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