From 00bb865175062abd2b1c0d2502c417e46ebd8130 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sat, 17 Jan 2026 00:27:31 -0500 Subject: [PATCH] feat: add API plugin for dynamic SSH routing New plugin that queries the unsandbox API to determine which pool a service is on, then routes to that pool's SSHPiper. This enables multi-pool SSH routing from a single edge SSHPiper. Usage: sshpiperd api --api-url http://cammy.foxhop.net:8080 \ --upstream-key /path/to/key \ --cammy-host cammy.foxhop.net:2222 \ --ai-host ai.foxhop.net:2222 --- plugin/api/api.go | 210 +++++++++++++++++++++++++++++++++++++++++++++ plugin/api/main.go | 59 +++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 plugin/api/api.go create mode 100644 plugin/api/main.go diff --git a/plugin/api/api.go b/plugin/api/api.go new file mode 100644 index 00000000..b517fac2 --- /dev/null +++ b/plugin/api/api.go @@ -0,0 +1,210 @@ +//go:build full || e2e + +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/tg123/sshpiper/libplugin" + "github.com/tg123/sshpiper/libplugin/skel" +) + +// ServiceResponse represents the API response for a service lookup +type ServiceResponse struct { + Service struct { + Name string `json:"name"` + ContainerIP string `json:"container_ip"` + SSHPort int `json:"ssh_port"` + State string `json:"state"` + Node string `json:"node"` // "cammy" or "ai" + } `json:"service"` + Error string `json:"error,omitempty"` +} + +type plugin struct { + APIURL string + UpstreamKey string + DefaultPool string + CammyHost string + AIHost string + + httpClient *http.Client +} + +func newAPIPlugin() *plugin { + return &plugin{ + httpClient: &http.Client{ + Timeout: 5 * time.Second, + }, + } +} + +// lookupService queries the API for service information +func (p *plugin) lookupService(serviceName string) (*ServiceResponse, error) { + url := fmt.Sprintf("%s/internal/services/by-name/%s", p.APIURL, serviceName) + + resp, err := p.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("API request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode == 404 { + return nil, fmt.Errorf("service not found: %s", serviceName) + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + var serviceResp ServiceResponse + if err := json.Unmarshal(body, &serviceResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &serviceResp, nil +} + +// getPoolHost returns the SSHPiper host for a given pool +func (p *plugin) getPoolHost(pool string) string { + switch pool { + case "ai": + return p.AIHost + case "cammy": + return p.CammyHost + default: + log.Warnf("Unknown pool %q, using default %q", pool, p.DefaultPool) + if p.DefaultPool == "ai" { + return p.AIHost + } + return p.CammyHost + } +} + +// apiPipe implements skel.SkelPipe for dynamic API-based routing +type apiPipe struct { + plugin *plugin + username string + host string +} + +// apiPipeFrom implements skel.SkelPipeFrom +type apiPipeFrom struct { + plugin *plugin + username string +} + +// apiPipeTo implements skel.SkelPipeTo +type apiPipeTo struct { + plugin *plugin + username string + host string +} + +func (p *apiPipe) From() []skel.SkelPipeFrom { + return []skel.SkelPipeFrom{ + &apiPipeFrom{ + plugin: p.plugin, + username: p.username, + }, + } +} + +// MatchConn checks if this pipe matches the connection +func (f *apiPipeFrom) MatchConn(conn libplugin.ConnMetadata) (skel.SkelPipeTo, error) { + username := conn.User() + + log.Infof("API lookup for service: %s", username) + + // Query the API for this service + serviceResp, err := f.plugin.lookupService(username) + if err != nil { + log.Warnf("API lookup failed for %s: %v, using default pool", username, err) + // Fall back to default pool + host := f.plugin.getPoolHost(f.plugin.DefaultPool) + return &apiPipeTo{ + plugin: f.plugin, + username: username, + host: host, + }, nil + } + + // Determine which pool SSHPiper to route to + pool := serviceResp.Service.Node + if pool == "" { + pool = f.plugin.DefaultPool + } + + host := f.plugin.getPoolHost(pool) + log.Infof("Routing %s to pool %s (%s)", username, pool, host) + + return &apiPipeTo{ + plugin: f.plugin, + username: username, + host: host, + }, nil +} + +// Implement SkelPipeFromPublicKey interface +func (f *apiPipeFrom) AuthorizedKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil +} + +func (f *apiPipeFrom) TrustedUserCAKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil +} + +// Implement SkelPipeFromPublicKeyAcceptAny interface - accept any key +func (f *apiPipeFrom) AcceptAnyKey() bool { + return true +} + +// apiPipeTo methods +func (t *apiPipeTo) User(conn libplugin.ConnMetadata) string { + return t.username +} + +func (t *apiPipeTo) Host(conn libplugin.ConnMetadata) string { + return t.host +} + +func (t *apiPipeTo) IgnoreHostKey(conn libplugin.ConnMetadata) bool { + return true +} + +func (t *apiPipeTo) KnownHosts(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil +} + +// Implement private key auth for upstream +func (t *apiPipeTo) PrivateKey(conn libplugin.ConnMetadata) ([]byte, []byte, error) { + keyData, err := os.ReadFile(t.plugin.UpstreamKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to read upstream key: %w", err) + } + return keyData, nil, nil +} + +// listPipe returns a catch-all pipe that handles all connections via API lookup +func (p *plugin) listPipe(conn libplugin.ConnMetadata) ([]skel.SkelPipe, error) { + // Return a single catch-all pipe + // The actual routing decision happens in MatchConn via API lookup + return []skel.SkelPipe{ + &apiPipe{ + plugin: p, + username: ".*", // catch-all + }, + }, nil +} diff --git a/plugin/api/main.go b/plugin/api/main.go new file mode 100644 index 00000000..58e6470d --- /dev/null +++ b/plugin/api/main.go @@ -0,0 +1,59 @@ +//go:build full || e2e + +package main + +import ( + "github.com/tg123/sshpiper/libplugin" + "github.com/tg123/sshpiper/libplugin/skel" + "github.com/urfave/cli/v2" +) + +func main() { + plugin := newAPIPlugin() + + libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ + Name: "api", + Usage: "sshpiperd api plugin - routes to pool SSHPiper based on API lookup", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "api-url", + Usage: "URL of the unsandbox API (e.g., http://cammy.foxhop.net:8080)", + Required: true, + EnvVars: []string{"SSHPIPERD_API_URL"}, + Destination: &plugin.APIURL, + }, + &cli.StringFlag{ + Name: "upstream-key", + Usage: "path to private key for upstream authentication", + Required: true, + EnvVars: []string{"SSHPIPERD_UPSTREAM_KEY"}, + Destination: &plugin.UpstreamKey, + }, + &cli.StringFlag{ + Name: "default-pool", + Usage: "default pool if API lookup fails (cammy or ai)", + Value: "cammy", + EnvVars: []string{"SSHPIPERD_DEFAULT_POOL"}, + Destination: &plugin.DefaultPool, + }, + &cli.StringFlag{ + Name: "cammy-host", + Usage: "host:port for cammy pool SSHPiper", + Value: "cammy.foxhop.net:2222", + EnvVars: []string{"SSHPIPERD_CAMMY_HOST"}, + Destination: &plugin.CammyHost, + }, + &cli.StringFlag{ + Name: "ai-host", + Usage: "host:port for ai pool SSHPiper", + Value: "ai.foxhop.net:2222", + EnvVars: []string{"SSHPIPERD_AI_HOST"}, + Destination: &plugin.AIHost, + }, + }, + CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { + skel := skel.NewSkelPlugin(plugin.listPipe) + return skel.CreateConfig(), nil + }, + }) +}