| 1 | package remote |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net" |
| 7 | "time" |
| 8 | |
| 9 | "golang.org/x/crypto/ssh" |
| 10 | |
| 11 | "reasonix/internal/netclient" |
| 12 | ) |
| 13 | |
| 14 | // dialConfig carries everything a single dial (or one hop of a jump chain) |
| 15 | // needs. It is assembled by Client.Start from Options. |
| 16 | type dialConfig struct { |
| 17 | host ResolvedHost |
| 18 | auth *AuthOptions // target auth (holds the target's credentials + cache) |
| 19 | resolveHop func(string) (ResolvedHost, *AuthOptions, error) |
| 20 | hostKeys *HostKeyPolicy |
| 21 | dialer netclient.StreamDialer // first-hop transport; nil => direct |
| 22 | dialTimeout time.Duration |
| 23 | } |
| 24 | |
| 25 | // hopAuthFor returns the auth to use for a jump host. It never carries the |
| 26 | // target's Password/Passphrase closures: a jump host must not be authenticated |
| 27 | // with the target's stored credentials. |
| 28 | func (cfg dialConfig) hopAuthFor(hop ResolvedHost) *AuthOptions { |
| 29 | return &AuthOptions{SecretPrompt: cfg.auth.SecretPrompt, DisableAgent: cfg.auth.DisableAgent} |
| 30 | } |
| 31 | |
| 32 | func (cfg dialConfig) resolvedHop(raw string) (ResolvedHost, *AuthOptions, error) { |
| 33 | if cfg.resolveHop != nil { |
| 34 | return cfg.resolveHop(raw) |
| 35 | } |
| 36 | userName, hostName, port, err := ParseTarget(raw) |
| 37 | if err != nil { |
| 38 | return ResolvedHost{}, nil, err |
| 39 | } |
| 40 | hop := ResolvedHost{Name: raw, HostName: hostName, Port: port, User: userName} |
| 41 | applyHostDefaults(&hop) |
| 42 | return hop, cfg.hopAuthFor(hop), nil |
| 43 | } |
| 44 | |
| 45 | // dialSSH establishes an *ssh.Client to cfg.host, walking any ProxyJump chain |
| 46 | // left-to-right. The netclient proxy (cfg.dialer) applies only to the first |
| 47 | // hop, matching OpenSSH semantics; subsequent hops are dialed through the |
| 48 | // preceding hop's SSH connection. Each hop's host key is verified. |
| 49 | // |
| 50 | // It returns the target client and the ordered list of intermediary clients |
| 51 | // (jump hosts) so the caller can close them when the target connection ends. |
| 52 | func dialSSH(ctx context.Context, cfg dialConfig) (*ssh.Client, []*ssh.Client, error) { |
| 53 | timeout := cfg.dialTimeout |
| 54 | if timeout <= 0 { |
| 55 | timeout = 15 * time.Second |
| 56 | } |
| 57 | base := cfg.dialer |
| 58 | if base == nil { |
| 59 | base = netclient.DialerFunc((&net.Dialer{Timeout: timeout}).DialContext) |
| 60 | } |
| 61 | |
| 62 | var hops []*ssh.Client |
| 63 | // dialThrough dials addr using either the base transport (first hop) or the |
| 64 | // previous SSH hop's context-aware Dial. |
| 65 | dialThrough := func(prev *ssh.Client, addr string) (net.Conn, error) { |
| 66 | dctx, cancel := context.WithTimeout(ctx, timeout) |
| 67 | defer cancel() |
| 68 | if prev == nil { |
| 69 | return base.DialContext(dctx, "tcp", addr) |
| 70 | } |
| 71 | return prev.DialContext(dctx, "tcp", addr) |
| 72 | } |
| 73 | |
| 74 | var prev *ssh.Client |
| 75 | // Resolve and connect each jump host in order. |
| 76 | for i, jump := range cfg.host.ProxyJump { |
| 77 | hop, hopAuth, err := cfg.resolvedHop(jump) |
| 78 | if err != nil { |
| 79 | closeAll(hops) |
| 80 | return nil, nil, fmt.Errorf("proxy jump %q: %w", jump, err) |
| 81 | } |
| 82 | conn, derr := dialThrough(prev, hop.Addr()) |
| 83 | if derr != nil { |
| 84 | closeAll(hops) |
| 85 | return nil, nil, fmt.Errorf("proxy jump %d (%s): %w", i+1, hop.Label(), derr) |
| 86 | } |
| 87 | // Each jump host authenticates with its own credential-free auth, so the |
| 88 | // target's password_env is never sent upstream to a jump host. |
| 89 | client, cerr := newSSHClient(ctx, conn, hop, hopAuth, cfg.hostKeys, timeout) |
| 90 | if cerr != nil { |
| 91 | closeAll(hops) |
| 92 | return nil, nil, fmt.Errorf("proxy jump %d (%s): %w", i+1, hop.Label(), cerr) |
| 93 | } |
| 94 | hops = append(hops, client) |
| 95 | prev = client |
| 96 | } |
| 97 | |
| 98 | conn, err := dialThrough(prev, cfg.host.Addr()) |
| 99 | if err != nil { |
| 100 | closeAll(hops) |
| 101 | return nil, nil, fmt.Errorf("dial %s: %w", cfg.host.Label(), err) |
| 102 | } |
| 103 | target, err := newSSHClient(ctx, conn, cfg.host, cfg.auth, cfg.hostKeys, timeout) |
| 104 | if err != nil { |
| 105 | closeAll(hops) |
| 106 | return nil, nil, err |
| 107 | } |
| 108 | return target, hops, nil |
| 109 | } |
| 110 | |
| 111 | // newSSHClient performs the SSH handshake over an established conn. It bounds |
| 112 | // the handshake with a deadline (ssh.ClientConfig.Timeout only covers the TCP |
| 113 | // dial, not the version/key exchange, so a host that accepts TCP but never |
| 114 | // sends a banner would otherwise hang NewClientConn — and Close — forever). |
| 115 | func newSSHClient(ctx context.Context, conn net.Conn, host ResolvedHost, auth *AuthOptions, hostKeys *HostKeyPolicy, timeout time.Duration) (*ssh.Client, error) { |
| 116 | methods, authCallback, cleanupAuth, err := buildAuthMethods(ctx, host, auth) |
| 117 | if err != nil { |
| 118 | conn.Close() |
| 119 | return nil, err |
| 120 | } |
| 121 | defer cleanupAuth() |
| 122 | hkCallback, err := hostKeys.Callback(ctx, host.Label()) |
| 123 | if err != nil { |
| 124 | conn.Close() |
| 125 | return nil, err |
| 126 | } |
| 127 | hostKeyAlgorithms, err := hostKeys.HostKeyAlgorithms(host.Addr(), conn.RemoteAddr()) |
| 128 | if err != nil { |
| 129 | conn.Close() |
| 130 | return nil, err |
| 131 | } |
| 132 | clientCfg := &ssh.ClientConfig{ |
| 133 | User: host.User, |
| 134 | Auth: methods, |
| 135 | AuthCallback: authCallback, |
| 136 | HostKeyCallback: hkCallback, |
| 137 | HostKeyAlgorithms: hostKeyAlgorithms, |
| 138 | Timeout: timeout, |
| 139 | } |
| 140 | // Bound the handshake even for ProxyJump channel connections, whose |
| 141 | // SetDeadline method returns "deadline not supported". A watcher closes the |
| 142 | // connection on timeout/cancellation; the acknowledgement prevents a late |
| 143 | // watcher from closing a successfully established client. |
| 144 | hsCtx, cancel := context.WithTimeout(ctx, handshakeTimeout(timeout)) |
| 145 | stopWatch := make(chan struct{}) |
| 146 | watchDone := make(chan struct{}) |
| 147 | go func() { |
| 148 | defer close(watchDone) |
| 149 | select { |
| 150 | case <-hsCtx.Done(): |
| 151 | _ = conn.Close() |
| 152 | case <-stopWatch: |
| 153 | } |
| 154 | }() |
| 155 | if deadline, ok := hsCtx.Deadline(); ok { |
| 156 | _ = conn.SetDeadline(deadline) |
| 157 | } |
| 158 | c, chans, reqs, err := ssh.NewClientConn(conn, host.Addr(), clientCfg) |
| 159 | close(stopWatch) |
| 160 | <-watchDone |
| 161 | cancel() |
| 162 | if err != nil { |
| 163 | conn.Close() |
| 164 | return nil, classifyDialError(err) |
| 165 | } |
| 166 | _ = conn.SetDeadline(time.Time{}) |
| 167 | return ssh.NewClient(c, chans, reqs), nil |
| 168 | } |
| 169 | |
| 170 | func handshakeTimeout(dialTimeout time.Duration) time.Duration { |
| 171 | if dialTimeout <= 0 { |
| 172 | return 15 * time.Second |
| 173 | } |
| 174 | return dialTimeout |
| 175 | } |
| 176 | |
| 177 | func closeAll(clients []*ssh.Client) { |
| 178 | for i := len(clients) - 1; i >= 0; i-- { |
| 179 | _ = clients[i].Close() |
| 180 | } |
| 181 | } |
| 182 |