// Package ssh implements ports.CommandExecutor over internal/actuator: // the dial pool, host-key handling, and streaming/combined execution. package ssh import ( "context" "fmt" "log/slog" "os" "sync" "time" cryptossh "golang.org/x/crypto/ssh" "github.com/dtoro/oikos/internal/actuator" "github.com/dtoro/oikos/internal/core/ports" ) const ( defaultExecTimeout = 10 * time.Minute defaultKeyPathEnv = "OIKOS_SSH_KEY_PATH" defaultKeyPath = "/etc/oikos/ssh_key" ) // SignerSource supplies the SSH signer used for all dials. The secrets // adapter provides one backed by Infisical/SOPS; tests inject a static one. type SignerSource func(ctx context.Context) (cryptossh.Signer, error) // FileSignerSource reads an OpenSSH private key from disk once and parses // it (path from env OIKOS_SSH_KEY_PATH, default /etc/oikos/ssh_key — the // same resolution the httpapi path used before the extraction). func FileSignerSource() SignerSource { var ( once sync.Once signer cryptossh.Signer err error ) return func(context.Context) (cryptossh.Signer, error) { once.Do(func() { path := os.Getenv(defaultKeyPathEnv) if path == "" { path = defaultKeyPath } key, rerr := os.ReadFile(path) if rerr != nil { err = fmt.Errorf("read ssh key %s: %w", path, rerr) return } signer, err = actuator.LoadSignerFromBytes(key) }) return signer, err } } // Executor runs commands over SSH through a dial pool. type Executor struct { signer SignerSource pool *actuator.DialPool } var _ ports.CommandExecutor = (*Executor)(nil) // NewExecutor builds an executor. The dial pool reuses connections per // host/user for the given TTL. func NewExecutor(signer SignerSource, poolTTL time.Duration) *Executor { return &Executor{ signer: signer, pool: actuator.NewDialPool(poolTTL), } } // Close releases pooled connections. func (e *Executor) Close() { e.pool.Close() } // Run dials the target (via the pool), wraps the command for transport when // the target needs it (pct/qm guests), executes with streaming output, and // maps the outcome onto ports.ExecResult. func (e *Executor) Run(ctx context.Context, target ports.Target, command string, opts ports.ExecOpts) ports.ExecResult { start := time.Now() signer, err := e.signer(ctx) if err != nil { return ports.ExecResult{Err: err, Duration: time.Since(start)} } client, err := e.pool.Get(ctx, actuator.DialOptions{ Host: target.Host, User: target.User, Signer: signer, }) if err != nil { return ports.ExecResult{Err: err, Duration: time.Since(start)} } // Pooled client: do not close here; the pool evicts on TTL. if target.Wrap != nil { command = target.Wrap(command) } timeout := opts.Timeout if timeout <= 0 { timeout = defaultExecTimeout } output, runErr := actuator.RunStreaming(ctx, client, command, opts.Sink, timeout) if runErr != nil { slog.Debug("ssh exec: command failed", "host", target.Host, "error", runErr) } return ports.ExecResult{ Output: output, Duration: time.Since(start), Err: runErr, } }