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
210 lines
4.8 KiB
Go
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
|
|
}
|