返回 DeepSeek-Reasonix
client.go
根目录 / internal / remote / client.go
1 package remote
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "math/rand"
9 "sync"
10 "time"
11
12 "golang.org/x/crypto/ssh"
13
14 "reasonix/internal/netclient"
15 "reasonix/internal/remote/forward"
16 "reasonix/internal/remote/sftpfs"
17 )
18
19 // Options configures a Client. Host, Auth, and HostKeys are required; the rest
20 // default sensibly.
21 type Options struct {
22 Host ResolvedHost
23 Auth AuthOptions
24 JumpHosts []JumpHostOptions // resolved ProxyJump hosts in chain order
25 HostKeys *HostKeyPolicy
26 Dialer netclient.StreamDialer // first-hop transport; nil => direct
27 DialTimeout time.Duration // default 15s
28 Keepalive KeepalivePolicy
29 Backoff BackoffPolicy
30 Clock Clock // nil => real clock
31 Rand *rand.Rand
32 }
33
34 // JumpHostOptions binds one resolved ProxyJump host to credentials owned by
35 // that hop. Target credentials are never inherited implicitly.
36 type JumpHostOptions struct {
37 Host ResolvedHost
38 Auth AuthOptions
39 }
40
41 // Client is a supervised SSH connection: it dials, verifies the host key,
42 // attaches forwards, keeps the link alive, and reconnects with backoff. It
43 // shares one SFTP session and one forward.Set across its lifetime.
44 type Client struct {
45 opts Options
46 clock Clock
47 rng *rand.Rand
48 hub *statusHub
49 forwards *forward.Set
50
51 mu sync.Mutex
52 ssh *ssh.Client
53 hops []*ssh.Client
54 sftp *sftpfs.FS
55 generation uint64 // bumps on every (re)connect; SFTP handles carry it
56 status Status
57 closed bool
58 hopHosts map[string]ResolvedHost
59 hopAuths map[string]*AuthOptions // fallback auth cache, keyed by user+addr
60 hopRawAuths map[string]*AuthOptions // configured auth by alias; aliases may share an endpoint
61
62 cancel context.CancelFunc
63 done chan struct{}
64 }
65
66 // hopAuthFor returns a persistent AuthOptions for a jump host. It deliberately
67 // omits the target's Password/Passphrase closures and gives each jump host its
68 // own secret cache, so the target's password_env is never sent to a jump host
69 // and one hop's typed secret is never reused for another. The instance persists
70 // for the Client's lifetime so reconnects do not re-prompt for jump secrets.
71 func (c *Client) hopAuthFor(hop ResolvedHost) *AuthOptions {
72 c.mu.Lock()
73 defer c.mu.Unlock()
74 if c.hopAuths == nil {
75 c.hopAuths = map[string]*AuthOptions{}
76 }
77 key := hopAuthKey(hop)
78 if a, ok := c.hopAuths[key]; ok {
79 return a
80 }
81 a := &AuthOptions{
82 SecretPrompt: c.opts.Auth.SecretPrompt,
83 DisableAgent: c.opts.Auth.DisableAgent,
84 }
85 c.hopAuths[key] = a
86 return a
87 }
88
89 func hopAuthKey(hop ResolvedHost) string { return hop.User + "\x00" + hop.Addr() }
90
91 // resolveHop returns the pre-resolved config/ssh_config host when the assembly
92 // layer supplied one, with a conservative ad-hoc fallback for low-level users.
93 func (c *Client) resolveHop(raw string) (ResolvedHost, *AuthOptions, error) {
94 c.mu.Lock()
95 hop, ok := c.hopHosts[raw]
96 auth := c.hopRawAuths[raw]
97 c.mu.Unlock()
98 if ok {
99 return hop, auth, nil
100 }
101 userName, hostName, port, err := ParseTarget(raw)
102 if err != nil {
103 return ResolvedHost{}, nil, err
104 }
105 hop = ResolvedHost{Name: raw, HostName: hostName, Port: port, User: userName}
106 applyHostDefaults(&hop)
107 return hop, c.hopAuthFor(hop), nil
108 }
109
110 // New creates a Client. It does not dial; call Start.
111 func New(opts Options) (*Client, error) {
112 if opts.Host.HostName == "" {
113 return nil, errors.New("remote: Options.Host has no hostname")
114 }
115 if opts.HostKeys == nil {
116 opts.HostKeys = &HostKeyPolicy{}
117 }
118 clock := opts.Clock
119 if clock == nil {
120 clock = realClock{}
121 }
122 rng := opts.Rand
123 if rng == nil {
124 rng = rand.New(rand.NewSource(time.Now().UnixNano()))
125 }
126 c := &Client{
127 opts: opts,
128 clock: clock,
129 rng: rng,
130 hub: newStatusHub(),
131 status: StatusIdle,
132 done: make(chan struct{}),
133 hopHosts: map[string]ResolvedHost{},
134 hopAuths: map[string]*AuthOptions{},
135 hopRawAuths: map[string]*AuthOptions{},
136 }
137 if len(opts.JumpHosts) > 0 && len(opts.JumpHosts) != len(opts.Host.ProxyJump) {
138 return nil, fmt.Errorf("remote: %d resolved jump hosts for %d ProxyJump entries", len(opts.JumpHosts), len(opts.Host.ProxyJump))
139 }
140 for i, jump := range opts.JumpHosts {
141 if jump.Host.HostName == "" {
142 return nil, fmt.Errorf("remote: ProxyJump %d has no hostname", i+1)
143 }
144 raw := opts.Host.ProxyJump[i]
145 auth := jump.Auth
146 c.hopHosts[raw] = jump.Host
147 c.hopRawAuths[raw] = &auth
148 }
149 c.forwards = forward.NewSet(nil)
150 return c, nil
151 }
152
153 // Subscribe registers a status callback; it receives the current event
154 // immediately and every subsequent transition. Callbacks must not block.
155 func (c *Client) Subscribe(fn func(StatusEvent)) (cancel func()) {
156 return c.hub.subscribe(fn)
157 }
158
159 // Status returns the last published status event.
160 func (c *Client) Status() StatusEvent { return c.hub.current() }
161
162 // Forwards returns the port-forward registry bound to this client's lifecycle.
163 func (c *Client) Forwards() *forward.Set { return c.forwards }
164
165 // SSH returns the current ssh client, or ErrNotConnected while down.
166 func (c *Client) SSH() (*ssh.Client, error) {
167 c.mu.Lock()
168 defer c.mu.Unlock()
169 if c.ssh == nil {
170 return nil, ErrNotConnected
171 }
172 return c.ssh, nil
173 }
174
175 // SFTP returns the shared SFTP handle, lazily created for the current
176 // connection. Handles obtained before a reconnect return ErrDisconnected on
177 // use; re-fetch via SFTP.
178 func (c *Client) SFTP() (*sftpfs.FS, error) {
179 c.mu.Lock()
180 defer c.mu.Unlock()
181 if c.ssh == nil {
182 return nil, ErrNotConnected
183 }
184 if c.sftp != nil {
185 return c.sftp, nil
186 }
187 fsys, err := sftpfs.New(c.ssh)
188 if err != nil {
189 return nil, err
190 }
191 c.sftp = fsys
192 return c.sftp, nil
193 }
194
195 // ExecResult is the outcome of a one-shot remote command.
196 type ExecResult struct {
197 Stdout []byte
198 Stderr []byte
199 ExitCode int
200 }
201
202 // Exec runs cmd via `sh -c` on a fresh session and collects its output.
203 func (c *Client) Exec(ctx context.Context, cmd string) (ExecResult, error) {
204 cl, err := c.SSH()
205 if err != nil {
206 return ExecResult{}, err
207 }
208 type res struct {
209 out ExecResult
210 err error
211 }
212 ch := make(chan res, 1)
213 go func() {
214 sess, serr := cl.NewSession()
215 if serr != nil {
216 ch <- res{err: serr}
217 return
218 }
219 defer sess.Close()
220 var stdout, stderr bytes.Buffer
221 sess.Stdout = &stdout
222 sess.Stderr = &stderr
223 runErr := sess.Run(cmd)
224 out := ExecResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
225 if runErr != nil {
226 var ee *ssh.ExitError
227 if errors.As(runErr, &ee) {
228 out.ExitCode = ee.ExitStatus()
229 ch <- res{out: out}
230 return
231 }
232 ch <- res{out: out, err: runErr}
233 return
234 }
235 ch <- res{out: out}
236 }()
237 select {
238 case <-ctx.Done():
239 return ExecResult{}, ctx.Err()
240 case r := <-ch:
241 return r.out, r.err
242 }
243 }
244
245 // Start dials and blocks until the first Connected (returns nil) or an
246 // unrecoverable error / ctx cancellation (returns the error). The supervisor
247 // keeps running after a successful Start; call Close to stop it.
248 func (c *Client) Start(ctx context.Context) error {
249 c.mu.Lock()
250 if c.closed {
251 c.mu.Unlock()
252 return errors.New("remote: client closed")
253 }
254 superCtx, cancel := context.WithCancel(context.Background())
255 c.cancel = cancel
256 c.mu.Unlock()
257
258 firstResult := make(chan error, 1)
259 go c.supervise(superCtx, firstResult)
260
261 select {
262 case <-ctx.Done():
263 cancel()
264 return ctx.Err()
265 case err := <-firstResult:
266 return err
267 }
268 }
269
270 // Close stops the supervisor and releases the connection, forwards, and SFTP.
271 func (c *Client) Close() error {
272 c.mu.Lock()
273 if c.closed {
274 c.mu.Unlock()
275 return nil
276 }
277 c.closed = true
278 cancel := c.cancel
279 c.mu.Unlock()
280
281 if cancel != nil {
282 cancel()
283 <-c.done
284 } else {
285 c.teardownConn()
286 c.publish(StatusStopped, 0, nil)
287 }
288 c.forwards.Close()
289 return nil
290 }
291
292 // supervise is the single goroutine that owns the connection lifecycle.
293 func (c *Client) supervise(ctx context.Context, firstResult chan<- error) {
294 defer close(c.done)
295 firstDone := false
296 sendFirst := func(err error) {
297 if !firstDone {
298 firstDone = true
299 firstResult <- err
300 }
301 }
302
303 attempt := 0
304 for {
305 if attempt == 0 {
306 c.publish(StatusConnecting, 0, nil)
307 } else {
308 c.publish(StatusReconnecting, attempt, nil)
309 }
310
311 cl, hops, err := dialSSH(ctx, dialConfig{
312 host: c.opts.Host,
313 auth: &c.opts.Auth,
314 resolveHop: c.resolveHop,
315 hostKeys: c.opts.HostKeys,
316 dialer: c.opts.Dialer,
317 dialTimeout: c.opts.DialTimeout,
318 })
319 if err != nil {
320 if ctx.Err() != nil {
321 c.publish(StatusStopped, attempt, ctx.Err())
322 sendFirst(ctx.Err())
323 return
324 }
325 if errors.Is(err, ErrAuthFailed) || errors.Is(err, ErrHostKeyMismatch) || errors.Is(err, ErrHostKeyRejected) {
326 // Unrecoverable: stop rather than loop.
327 c.publish(StatusStopped, attempt, err)
328 sendFirst(err)
329 return
330 }
331 if !firstDone {
332 // The very first connect failed on a transient error; report it
333 // so callers get immediate feedback instead of a silent retry.
334 c.publish(StatusStopped, attempt, err)
335 sendFirst(err)
336 return
337 }
338 attempt++
339 if !c.sleepBackoff(ctx, attempt) {
340 c.publish(StatusStopped, attempt, ctx.Err())
341 return
342 }
343 continue
344 }
345
346 // Connected. Install the connection and attach forwards.
347 c.installConn(cl, hops)
348 if attachErr := c.forwards.Attach(cl); attachErr != nil {
349 c.publish(StatusDegraded, attempt, attachErr)
350 } else {
351 c.publish(StatusConnected, attempt, nil)
352 }
353 sendFirst(nil)
354
355 // Block until the connection dies, ctx ends, or Close.
356 reason := c.watch(ctx, cl)
357 c.forwards.Detach()
358 c.teardownConn()
359
360 if ctx.Err() != nil || reason == watchClosed {
361 c.publish(StatusStopped, attempt, ctx.Err())
362 return
363 }
364 // Connection dropped: reconnect with backoff.
365 attempt++
366 if !c.sleepBackoff(ctx, attempt) {
367 c.publish(StatusStopped, attempt, ctx.Err())
368 return
369 }
370 }
371 }
372
373 type watchReason int
374
375 const (
376 watchConnLost watchReason = iota
377 watchClosed
378 )
379
380 // watch runs the keepalive loop and returns when the connection dies or ctx
381 // ends.
382 func (c *Client) watch(ctx context.Context, cl *ssh.Client) watchReason {
383 closed := make(chan struct{})
384 go func() {
385 _ = cl.Wait() // always non-nil at disconnect; the signal is the return itself
386 close(closed)
387 }()
388
389 interval := c.opts.Keepalive.interval()
390 misses := 0
391 for {
392 var tick <-chan time.Time
393 if interval > 0 {
394 tick = c.clock.After(interval)
395 }
396 select {
397 case <-ctx.Done():
398 return watchClosed
399 case <-closed:
400 return watchConnLost
401 case <-tick:
402 if c.keepaliveOK(cl) {
403 misses = 0
404 continue
405 }
406 misses++
407 if misses >= c.opts.Keepalive.maxMisses() {
408 return watchConnLost
409 }
410 }
411 }
412 }
413
414 func (c *Client) keepaliveOK(cl *ssh.Client) bool {
415 type res struct{ err error }
416 ch := make(chan res, 1)
417 go func() {
418 _, _, err := cl.SendRequest("keepalive@openssh.com", true, nil)
419 ch <- res{err}
420 }()
421 select {
422 case <-c.clock.After(c.opts.Keepalive.timeout()):
423 return false
424 case r := <-ch:
425 return r.err == nil
426 }
427 }
428
429 // sleepBackoff waits a full-jitter backoff for attempt, returning false if ctx
430 // ended during the wait.
431 func (c *Client) sleepBackoff(ctx context.Context, attempt int) bool {
432 ceil := c.opts.Backoff.delay(attempt - 1)
433 d := time.Duration(c.rng.Int63n(int64(ceil) + 1))
434 select {
435 case <-ctx.Done():
436 return false
437 case <-c.clock.After(d):
438 return true
439 }
440 }
441
442 func (c *Client) installConn(cl *ssh.Client, hops []*ssh.Client) {
443 c.mu.Lock()
444 c.ssh = cl
445 c.hops = hops
446 c.sftp = nil
447 c.generation++
448 c.mu.Unlock()
449 }
450
451 func (c *Client) teardownConn() {
452 c.mu.Lock()
453 sftpH, cl, hops := c.sftp, c.ssh, c.hops
454 c.sftp, c.ssh, c.hops = nil, nil, nil
455 c.mu.Unlock()
456 if sftpH != nil {
457 _ = sftpH.Close()
458 }
459 if cl != nil {
460 _ = cl.Close()
461 }
462 closeAll(hops)
463 }
464
465 func (c *Client) publish(s Status, attempt int, err error) {
466 c.mu.Lock()
467 c.status = s
468 c.mu.Unlock()
469 c.hub.publish(StatusEvent{
470 Host: c.opts.Host.Name,
471 Status: s,
472 Attempt: attempt,
473 Err: err,
474 At: c.clock.Now(),
475 })
476 }
477
477 lines GO