| 1 | // Package transport is the SSH side-effect layer (PC1) for legatus. It |
| 2 | // implements legatus.Conn/legatus.Dialer over golang.org/x/crypto/ssh and |
| 3 | // github.com/pkg/sftp, with three hard guarantees: |
| 4 | // |
| 5 | // - IV2: remote command arguments and environment are passed STRUCTURALLY — |
| 6 | // every value is individually shell-quoted (see command.go) so the remote |
| 7 | // login shell, which parses the exec string, can never re-split or |
| 8 | // re-evaluate caller data. |
| 9 | // - IV5: no secret (key material, passphrase) is ever written to logs; this |
| 10 | // package logs nothing and folds key bytes only into the ssh library. |
| 11 | // - IV6: non-root commands are sudo-wrapped and non-root file uploads stage to |
| 12 | // a private /tmp path then `sudo mv` into place. |
| 13 | // |
| 14 | // Authentication is key/agent only (AS1): no password auth is offered. The core |
| 15 | // legatus package stays SSH-free; this package is exactly how legatus.Conn is |
| 16 | // satisfied (PC1). |
| 17 | package transport |
| 18 | |
| 19 | import ( |
| 20 | "context" |
| 21 | "net" |
| 22 | "os" |
| 23 | "path/filepath" |
| 24 | "strconv" |
| 25 | "strings" |
| 26 | "time" |
| 27 | |
| 28 | "github.com/kevinburke/ssh_config" |
| 29 | xssh "golang.org/x/crypto/ssh" |
| 30 | "golang.org/x/crypto/ssh/agent" |
| 31 | "golang.org/x/crypto/ssh/knownhosts" |
| 32 | |
| 33 | "go.bigb.es/auxilia/culpa" |
| 34 | "go.bigb.es/auxilia/legatus" |
| 35 | ) |
| 36 | |
| 37 | // defaultDialTimeout bounds the TCP connect plus SSH handshake when the caller |
| 38 | // does not set one via WithTimeout. |
| 39 | const defaultDialTimeout = 15 * time.Second |
| 40 | |
| 41 | // Option configures a Dialer. Auth methods accumulate in the order supplied and |
| 42 | // are tried in that order by the ssh handshake. |
| 43 | type Option func(*Dialer) |
| 44 | |
| 45 | // Dialer opens SSH connections, implementing legatus.Dialer. It is safe to |
| 46 | // reuse across hosts; each Dial produces an independent legatus.Conn. |
| 47 | type Dialer struct { |
| 48 | auths []xssh.AuthMethod |
| 49 | hostKeyCallback xssh.HostKeyCallback |
| 50 | timeout time.Duration |
| 51 | useSSHConfig bool |
| 52 | proxyJump string |
| 53 | // cfgGet resolves a ~/.ssh/config keyword for an alias. It defaults to the |
| 54 | // package-level ssh_config.Get and is overridable in tests so config and |
| 55 | // ProxyJump resolution are exercisable without a real ~/.ssh/config. |
| 56 | cfgGet func(alias, key string) string |
| 57 | } |
| 58 | |
| 59 | var _ legatus.Dialer = (*Dialer)(nil) |
| 60 | |
| 61 | // New builds a Dialer from the given options. At least one auth option |
| 62 | // (WithAgent / WithIdentityFile / WithSSHConfig) must be supplied; Dial fails |
| 63 | // loudly otherwise (no silent fallback, AS1). |
| 64 | func New(opts ...Option) *Dialer { |
| 65 | d := &Dialer{timeout: defaultDialTimeout, cfgGet: ssh_config.Get} |
| 66 | for _, o := range opts { |
| 67 | o(d) |
| 68 | } |
| 69 | return d |
| 70 | } |
| 71 | |
| 72 | // WithProxyJump routes the connection through one or more jump hosts, given as an |
| 73 | // ssh ProxyJump value (`[user@]host[:port]`, comma-separated, applied left to |
| 74 | // right). It overrides any ProxyJump in ~/.ssh/config for the target. |
| 75 | func WithProxyJump(spec string) Option { |
| 76 | return func(d *Dialer) { d.proxyJump = spec } |
| 77 | } |
| 78 | |
| 79 | // WithAgent authenticates via the ssh-agent at $SSH_AUTH_SOCK. The agent's |
| 80 | // signers are resolved lazily at Dial time so a Dialer can be constructed before |
| 81 | // the agent is reachable. Key material never leaves the agent (IV5). |
| 82 | func WithAgent() Option { |
| 83 | return func(d *Dialer) { |
| 84 | d.auths = append(d.auths, xssh.PublicKeysCallback(agentSigners)) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // WithIdentityFile authenticates with the private key at path (with a leading ~ |
| 89 | // expanded). The key is parsed at Dial time; an encrypted or missing key surfaces |
| 90 | // as a Dial error (AS1 — no password prompt). |
| 91 | func WithIdentityFile(path string) Option { |
| 92 | return func(d *Dialer) { |
| 93 | d.auths = append(d.auths, identityFileAuth(path)) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // WithSSHConfig resolves each host through ~/.ssh/config (HostName, Port, User, |
| 98 | // IdentityFile) at Dial time, mirroring how the system ssh client behaves. The |
| 99 | // host's own Addr/Port/User remain authoritative when set; config fills the gaps. |
| 100 | func WithSSHConfig() Option { |
| 101 | return func(d *Dialer) { d.useSSHConfig = true } |
| 102 | } |
| 103 | |
| 104 | // WithKnownHosts verifies host keys against the OpenSSH known_hosts file at path |
| 105 | // (with a leading ~ expanded). A key mismatch or unknown host fails the dial. |
| 106 | func WithKnownHosts(path string) Option { |
| 107 | return func(d *Dialer) { |
| 108 | // Resolve the callback eagerly so a missing/garbled known_hosts file is a |
| 109 | // construction-time error path rather than a silent ignore. We defer the |
| 110 | // error to Dial by storing a callback that returns it. |
| 111 | cb, err := knownhosts.New(expandTilde(path)) |
| 112 | if err != nil { |
| 113 | d.hostKeyCallback = func(string, net.Addr, xssh.PublicKey) error { |
| 114 | return culpa.WithCode( |
| 115 | culpa.Wrapf(err, "load known_hosts %q", path), |
| 116 | "SSH_KNOWN_HOSTS", |
| 117 | ) |
| 118 | } |
| 119 | return |
| 120 | } |
| 121 | d.hostKeyCallback = cb |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // WithHostKeyCallback sets an explicit host-key verification callback, overriding |
| 126 | // any WithKnownHosts. Callers that intentionally skip verification can pass |
| 127 | // xssh.InsecureIgnoreHostKey(). |
| 128 | func WithHostKeyCallback(cb xssh.HostKeyCallback) Option { |
| 129 | return func(d *Dialer) { d.hostKeyCallback = cb } |
| 130 | } |
| 131 | |
| 132 | // WithTimeout sets the TCP connect plus SSH handshake timeout. |
| 133 | func WithTimeout(timeout time.Duration) Option { |
| 134 | return func(d *Dialer) { d.timeout = timeout } |
| 135 | } |
| 136 | |
| 137 | // Dial opens an SSH connection to h, returning a legatus.Conn. It requires at |
| 138 | // least one auth method and a host-key callback; both missing are hard errors |
| 139 | // (no insecure default — the caller must opt into InsecureIgnoreHostKey via |
| 140 | // WithHostKeyCallback). Only key/agent auth is offered (AS1). |
| 141 | func (d *Dialer) Dial(ctx context.Context, h *legatus.Host) (legatus.Conn, error) { |
| 142 | if h == nil { |
| 143 | return nil, culpa.WithCode(culpa.New("ssh: nil host"), "SSH_DIAL") |
| 144 | } |
| 145 | |
| 146 | addr, port, user := h.Addr, h.Port, h.User |
| 147 | var identity xssh.AuthMethod |
| 148 | if d.useSSHConfig { |
| 149 | alias := h.Name |
| 150 | if alias == "" { |
| 151 | alias = h.Addr |
| 152 | } |
| 153 | addr, port, user, identity = d.resolveConfig(alias, addr, port, user) |
| 154 | } |
| 155 | if addr == "" { |
| 156 | addr = h.Name |
| 157 | } |
| 158 | if addr == "" { |
| 159 | return nil, culpa.WithCode( |
| 160 | culpa.Errorf("ssh: host %q has no address", h.Name), "SSH_DIAL") |
| 161 | } |
| 162 | if port == 0 { |
| 163 | port = 22 |
| 164 | } |
| 165 | if user == "" { |
| 166 | return nil, culpa.WithCode( |
| 167 | culpa.WithHint( |
| 168 | culpa.Errorf("ssh: host %q has no user", h.Name), |
| 169 | "set Host.User or provide it via ~/.ssh/config with WithSSHConfig", |
| 170 | ), "SSH_DIAL") |
| 171 | } |
| 172 | |
| 173 | if len(d.auths) == 0 && identity == nil { |
| 174 | return nil, culpa.WithCode( |
| 175 | culpa.WithHint( |
| 176 | culpa.New("ssh: no auth method configured"), |
| 177 | "add WithAgent, WithIdentityFile, or WithSSHConfig", |
| 178 | ), "SSH_AUTH") |
| 179 | } |
| 180 | if d.hostKeyCallback == nil { |
| 181 | return nil, culpa.WithCode( |
| 182 | culpa.WithHint( |
| 183 | culpa.New("ssh: no host-key verification configured"), |
| 184 | "add WithKnownHosts or WithHostKeyCallback (use InsecureIgnoreHostKey to skip)", |
| 185 | ), "SSH_HOSTKEY") |
| 186 | } |
| 187 | |
| 188 | chain, err := d.jumpChain(h) |
| 189 | if err != nil { |
| 190 | return nil, err |
| 191 | } |
| 192 | |
| 193 | // Dial each jump in order, tunnelling each subsequent hop through the |
| 194 | // previous one (IV1: every hop is fully authenticated and host-key-verified). |
| 195 | var jumps []*xssh.Client |
| 196 | var through *xssh.Client |
| 197 | for _, j := range chain { |
| 198 | jAddr, jPort, jUser, jID := d.resolveJump(j) |
| 199 | if jUser == "" { |
| 200 | closeAll(jumps) |
| 201 | return nil, culpa.WithCode( |
| 202 | culpa.WithHint( |
| 203 | culpa.Errorf("ssh: jump host %q has no user", j.host), |
| 204 | "set it in the ProxyJump spec (user@host) or ~/.ssh/config", |
| 205 | ), "SSH_DIAL") |
| 206 | } |
| 207 | jClient, err := d.dialHop(ctx, through, jAddr, jPort, d.clientConfig(jUser, jID)) |
| 208 | if err != nil { |
| 209 | closeAll(jumps) |
| 210 | return nil, err |
| 211 | } |
| 212 | jumps = append(jumps, jClient) |
| 213 | through = jClient |
| 214 | } |
| 215 | |
| 216 | client, err := d.dialHop(ctx, through, addr, port, d.clientConfig(user, identity)) |
| 217 | if err != nil { |
| 218 | closeAll(jumps) |
| 219 | return nil, err |
| 220 | } |
| 221 | |
| 222 | return &sshConn{client: client, host: addr, jumps: jumps}, nil |
| 223 | } |
| 224 | |
| 225 | // dialHop opens an SSH client to addr:port using cfg. With through==nil it dials |
| 226 | // TCP directly; otherwise it tunnels the connection through the jump client |
| 227 | // (ProxyJump). The returned client owns the underlying conn and is closed by the |
| 228 | // sshConn (jump clients) or directly (the target client). |
| 229 | func (d *Dialer) dialHop(ctx context.Context, through *xssh.Client, addr string, port int, cfg *xssh.ClientConfig) (*xssh.Client, error) { |
| 230 | target := net.JoinHostPort(addr, strconv.Itoa(port)) |
| 231 | |
| 232 | // Advertise the host key algorithms known_hosts already holds for this |
| 233 | // target, the way OpenSSH orders them. Set here rather than in clientConfig |
| 234 | // because `target` is the exact string handed to NewClientConn below, and so |
| 235 | // the exact string the host key callback checks — deriving both from one |
| 236 | // value keeps the algorithms and the verification in agreement by |
| 237 | // construction. Without this the server picks from the library default and a |
| 238 | // host recorded under one algorithm but negotiated under another fails as a |
| 239 | // spurious "key mismatch" (see hostkeyalgos.go). Nil for an unknown host |
| 240 | // leaves the library default, so it still fails in the callback rather than |
| 241 | // failing to negotiate. |
| 242 | if cfg.HostKeyAlgorithms == nil { |
| 243 | cfg.HostKeyAlgorithms = hostKeyAlgosFor(cfg.HostKeyCallback, target) |
| 244 | } |
| 245 | |
| 246 | var netConn net.Conn |
| 247 | var err error |
| 248 | if through == nil { |
| 249 | netConn, err = (&net.Dialer{Timeout: d.timeout}).DialContext(ctx, "tcp", target) |
| 250 | } else { |
| 251 | netConn, err = through.DialContext(ctx, "tcp", target) |
| 252 | } |
| 253 | if err != nil { |
| 254 | return nil, culpa.WithCode( |
| 255 | culpa.Wrapf(err, "ssh: dial %s@%s", cfg.User, target), "SSH_DIAL") |
| 256 | } |
| 257 | |
| 258 | clientConn, chans, reqs, err := xssh.NewClientConn(netConn, target, cfg) |
| 259 | if err != nil { |
| 260 | _ = netConn.Close() |
| 261 | return nil, culpa.WithCode( |
| 262 | culpa.Wrapf(err, "ssh: handshake %s@%s", cfg.User, target), "SSH_HANDSHAKE") |
| 263 | } |
| 264 | return xssh.NewClient(clientConn, chans, reqs), nil |
| 265 | } |
| 266 | |
| 267 | // clientConfig builds a per-dial ClientConfig. The auth list is composed freshly |
| 268 | // each call — d.auths plus any per-host identity — so a Dialer reused across |
| 269 | // hosts under concurrent fan-out never accumulates another host's identity (IV5). |
| 270 | // |
| 271 | // HostKeyAlgorithms is left unset here and filled in by dialHop, which owns the |
| 272 | // target address the host key callback will be checked against. |
| 273 | func (d *Dialer) clientConfig(user string, identity xssh.AuthMethod) *xssh.ClientConfig { |
| 274 | auths := make([]xssh.AuthMethod, 0, len(d.auths)+1) |
| 275 | auths = append(auths, d.auths...) |
| 276 | if identity != nil { |
| 277 | auths = append(auths, identity) |
| 278 | } |
| 279 | return &xssh.ClientConfig{ |
| 280 | User: user, |
| 281 | Auth: auths, |
| 282 | HostKeyCallback: d.hostKeyCallback, |
| 283 | Timeout: d.timeout, |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | // jumpChain returns the ordered jump hosts for h: an explicit WithProxyJump if |
| 288 | // set, else the target's ProxyJump from ~/.ssh/config when WithSSHConfig is on. |
| 289 | func (d *Dialer) jumpChain(h *legatus.Host) ([]jumpHost, error) { |
| 290 | if d.proxyJump != "" { |
| 291 | return parseJumpSpec(d.proxyJump) |
| 292 | } |
| 293 | if d.useSSHConfig { |
| 294 | return parseJumpSpec(d.cfgGet(h.Name, "ProxyJump")) |
| 295 | } |
| 296 | return nil, nil |
| 297 | } |
| 298 | |
| 299 | // resolveJump resolves a jump hop's address/port/user/identity, layering |
| 300 | // ~/.ssh/config under the hop's explicit fields and defaulting the port to 22. |
| 301 | func (d *Dialer) resolveJump(j jumpHost) (addr string, port int, user string, identity xssh.AuthMethod) { |
| 302 | addr, port, user = j.host, j.port, j.user |
| 303 | if d.useSSHConfig { |
| 304 | addr, port, user, identity = d.resolveConfig(j.host, addr, port, user) |
| 305 | } |
| 306 | if addr == "" { |
| 307 | addr = j.host |
| 308 | } |
| 309 | if port == 0 { |
| 310 | port = 22 |
| 311 | } |
| 312 | return addr, port, user, identity |
| 313 | } |
| 314 | |
| 315 | // closeAll closes a set of already-opened jump clients, used to unwind a chain |
| 316 | // when a later hop fails so no tunnel is leaked. |
| 317 | func closeAll(clients []*xssh.Client) { |
| 318 | for i := len(clients) - 1; i >= 0; i-- { |
| 319 | _ = clients[i].Close() |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // agentSigners returns the signers held by the ssh-agent at $SSH_AUTH_SOCK. It |
| 324 | // fails loudly when the agent is unset, unreachable, or empty — never falling |
| 325 | // back to another auth method (AS1). Key material stays inside the agent (IV5). |
| 326 | func agentSigners() ([]xssh.Signer, error) { |
| 327 | sock := os.Getenv("SSH_AUTH_SOCK") |
| 328 | if sock == "" { |
| 329 | return nil, culpa.WithCode( |
| 330 | culpa.WithHint( |
| 331 | culpa.New("ssh: SSH_AUTH_SOCK unset"), |
| 332 | "start ssh-agent and add your key with `ssh-add`", |
| 333 | ), "SSH_AGENT") |
| 334 | } |
| 335 | conn, err := net.Dial("unix", sock) |
| 336 | if err != nil { |
| 337 | return nil, culpa.WithCode( |
| 338 | culpa.Wrapf(err, "ssh: connect ssh-agent at %s", sock), "SSH_AGENT") |
| 339 | } |
| 340 | signers, err := agent.NewClient(conn).Signers() |
| 341 | if err != nil { |
| 342 | return nil, culpa.WithCode( |
| 343 | culpa.Wrap(err, "ssh: list ssh-agent signers"), "SSH_AGENT") |
| 344 | } |
| 345 | if len(signers) == 0 { |
| 346 | return nil, culpa.WithCode( |
| 347 | culpa.WithHint( |
| 348 | culpa.New("ssh: ssh-agent has no keys"), |
| 349 | "run `ssh-add` to load a key", |
| 350 | ), "SSH_AGENT") |
| 351 | } |
| 352 | return signers, nil |
| 353 | } |
| 354 | |
| 355 | // identityFileAuth builds an AuthMethod that reads and parses the private key at |
| 356 | // path on each handshake attempt. A missing key or one needing a passphrase is a |
| 357 | // hard error (AS1 — no interactive prompt). The raw key bytes are handed only to |
| 358 | // the ssh parser and never logged (IV5). |
| 359 | func identityFileAuth(path string) xssh.AuthMethod { |
| 360 | return xssh.PublicKeysCallback(func() ([]xssh.Signer, error) { |
| 361 | p := expandTilde(path) |
| 362 | data, err := os.ReadFile(p) |
| 363 | if err != nil { |
| 364 | return nil, culpa.WithCode( |
| 365 | culpa.Wrapf(err, "ssh: read identity file %q", path), "SSH_IDENTITY") |
| 366 | } |
| 367 | signer, err := xssh.ParsePrivateKey(data) |
| 368 | if err != nil { |
| 369 | return nil, culpa.WithCode( |
| 370 | culpa.WithHint( |
| 371 | culpa.Wrapf(err, "ssh: parse identity file %q", path), |
| 372 | "key may be encrypted; add it to ssh-agent and use WithAgent", |
| 373 | ), "SSH_IDENTITY") |
| 374 | } |
| 375 | return []xssh.Signer{signer}, nil |
| 376 | }) |
| 377 | } |
| 378 | |
| 379 | // resolveConfig resolves an alias through ~/.ssh/config, layering config values |
| 380 | // UNDER the caller's explicit fields: a non-empty addr/port/user always wins. |
| 381 | // An empty alias leaves everything untouched. The resolved IdentityFile, if any, |
| 382 | // is RETURNED as an auth method — never appended onto the shared Dialer — so a |
| 383 | // Dialer reused across hosts under concurrent fan-out resolves each host |
| 384 | // independently (IV5). |
| 385 | func (d *Dialer) resolveConfig(alias, addr string, port int, user string) (string, int, string, xssh.AuthMethod) { |
| 386 | if alias == "" { |
| 387 | return addr, port, user, nil |
| 388 | } |
| 389 | if addr == "" { |
| 390 | addr = strings.TrimSpace(d.cfgGet(alias, "HostName")) |
| 391 | } |
| 392 | if port == 0 { |
| 393 | if ps := strings.TrimSpace(d.cfgGet(alias, "Port")); ps != "" { |
| 394 | if pv, err := strconv.Atoi(ps); err == nil { |
| 395 | port = pv |
| 396 | } |
| 397 | } |
| 398 | } |
| 399 | if user == "" { |
| 400 | user = strings.TrimSpace(d.cfgGet(alias, "User")) |
| 401 | } |
| 402 | var identity xssh.AuthMethod |
| 403 | if idf := strings.TrimSpace(d.cfgGet(alias, "IdentityFile")); idf != "" { |
| 404 | identity = identityFileAuth(idf) |
| 405 | } |
| 406 | return addr, port, user, identity |
| 407 | } |
| 408 | |
| 409 | // expandTilde expands a leading ~/ (or bare ~) to the user's home directory. A |
| 410 | // failure to resolve home leaves the path unchanged so the subsequent open |
| 411 | // raises a concrete file error rather than a swallowed one. |
| 412 | func expandTilde(p string) string { |
| 413 | if p == "~" || strings.HasPrefix(p, "~/") { |
| 414 | if home, err := os.UserHomeDir(); err == nil { |
| 415 | return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/")) |
| 416 | } |
| 417 | } |
| 418 | return p |
| 419 | } |
| 420 | |