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