sshpiper/plugin/api/api.go
russell@unturf.com 00bb865175 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
2026-01-17 00:27:31 -05:00

210 lines
4.8 KiB
Go

//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
}