// Package ssh provides impure SSH command execution. package ssh import ( "bytes" "context" "fmt" "log/slog" "net" "os" "time" gossh "golang.org/x/crypto/ssh" "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/pkg/tools" "github.com/enmanuel/agents/shell/logger" ) // Result holds the output of an SSH command execution. type Result struct { Stdout string Stderr string ExitCode int Err error } // Executor runs SSH commands against configured targets. type Executor struct { cfg config.SSHCfg logger *slog.Logger } // NewExecutor creates an Executor from the SSH config section. func NewExecutor(cfg config.SSHCfg, log *slog.Logger) *Executor { return &Executor{cfg: cfg, logger: log.With(logger.FieldComponent, "ssh")} } // Execute runs the SSH command described by spec. Impure. func (e *Executor) Execute(ctx context.Context, spec tools.SSHCommandSpec) Result { cmdPreview := spec.Command if len(cmdPreview) > 80 { cmdPreview = cmdPreview[:80] + "..." } e.logger.Info("ssh_exec_start", "target", spec.Target, "command", cmdPreview) start := time.Now() target, ok := e.cfg.Targets[spec.Target] if !ok { e.logger.Error("ssh_exec_error", "target", spec.Target, "err", "unknown target") return Result{Err: fmt.Errorf("unknown SSH target: %s", spec.Target)} } if len(target.Hosts) == 0 { e.logger.Error("ssh_exec_error", "target", spec.Target, "err", "no hosts") return Result{Err: fmt.Errorf("no hosts for target: %s", spec.Target)} } // Use first host (round-robin or load balancing can be added later) host := target.Hosts[0] user := target.User if user == "" { user = e.cfg.Defaults.User } port := target.Port if port == 0 { port = e.cfg.Defaults.Port } if port == 0 { port = 22 } keyEnv := target.KeyFileEnv if keyEnv == "" { keyEnv = e.cfg.Defaults.KeyFileEnv } signer, err := loadSigner(keyEnv) if err != nil { ms := time.Since(start).Milliseconds() e.logger.Error("ssh_exec_error", "target", spec.Target, logger.FieldDurationMS, ms, "err", err) return Result{Err: fmt.Errorf("load SSH key: %w", err)} } sshCfg := &gossh.ClientConfig{ User: user, Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)}, HostKeyCallback: gossh.InsecureIgnoreHostKey(), // TODO: use known_hosts Timeout: e.cfg.Defaults.Timeout, } if sshCfg.Timeout == 0 { sshCfg.Timeout = 10 * time.Second } addr := fmt.Sprintf("%s:%d", host, port) conn, err := gossh.Dial("tcp", addr, sshCfg) if err != nil { ms := time.Since(start).Milliseconds() e.logger.Error("ssh_exec_error", "target", spec.Target, "host", addr, logger.FieldDurationMS, ms, "err", err) return Result{Err: fmt.Errorf("ssh dial %s: %w", addr, err)} } defer conn.Close() session, err := conn.NewSession() if err != nil { ms := time.Since(start).Milliseconds() e.logger.Error("ssh_exec_error", "target", spec.Target, logger.FieldDurationMS, ms, "err", err) return Result{Err: fmt.Errorf("ssh session: %w", err)} } defer session.Close() var stdout, stderr bytes.Buffer session.Stdout = &stdout session.Stderr = &stderr // Respect context cancellation via a goroutine done := make(chan error, 1) go func() { done <- session.Run(spec.Command) }() select { case <-ctx.Done(): session.Signal(gossh.SIGTERM) ms := time.Since(start).Milliseconds() e.logger.Warn("ssh_exec_cancelled", "target", spec.Target, logger.FieldDurationMS, ms) return Result{Err: ctx.Err()} case err := <-done: ms := time.Since(start).Milliseconds() code := 0 if err != nil { var exitErr *gossh.ExitError if ok := asExitError(err, &exitErr); ok { code = exitErr.ExitStatus() } else { e.logger.Error("ssh_exec_error", "target", spec.Target, logger.FieldDurationMS, ms, "err", err) return Result{Err: err} } } e.logger.Info("ssh_exec_end", "target", spec.Target, "exit_code", code, logger.FieldDurationMS, ms) return Result{ Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: code, } } } func loadSigner(keyFileEnv string) (gossh.Signer, error) { keyPath := os.Getenv(keyFileEnv) if keyPath == "" { return nil, fmt.Errorf("env var %s not set", keyFileEnv) } raw, err := os.ReadFile(keyPath) if err != nil { return nil, err } return gossh.ParsePrivateKey(raw) } // asExitError is a helper for type-asserting ssh.ExitError. func asExitError(err error, target **gossh.ExitError) bool { e, ok := err.(*gossh.ExitError) if ok { *target = e } return ok } // Ensure net is used (for future jump host support) var _ = net.Dial