返回 DeepSeek-Reasonix
remote.go
根目录 / internal / remote / remote.go
1 // Package remote is the SSH transport for Reasonix's remote module: host
2 // resolution ([remote] config + ~/.ssh/config), authentication, host-key
3 // verification (system known_hosts read-only + a Reasonix-managed TOFU file),
4 // a supervised connection with keepalive and exponential-backoff reconnect,
5 // shared SFTP access, and port-forward lifecycle. The agent itself never runs
6 // through this package — remote workspaces are driven by a `reasonix serve`
7 // process bootstrapped on the remote host (internal/remote/bootstrap) and
8 // reached through a forwarded loopback port.
9 //
10 // The package is frontend-agnostic: all interactivity flows through callbacks
11 // (HostKeyPrompt, SecretPrompt) and status subscriptions, so the CLI, chat
12 // TUI, and the Wails desktop consume the same surface.
13 package remote
14
15 import (
16 "errors"
17 "strings"
18 "time"
19 )
20
21 // Status is the supervised connection state.
22 type Status int
23
24 const (
25 // StatusIdle: created, Start not yet called.
26 StatusIdle Status = iota
27 // StatusConnecting: first dial in progress.
28 StatusConnecting
29 // StatusConnected: SSH established, forwards attached.
30 StatusConnected
31 // StatusReconnecting: connection lost, supervisor is backing off/redialing.
32 StatusReconnecting
33 // StatusDegraded: connected, but at least one forward failed to attach.
34 StatusDegraded
35 // StatusStopped: Close was called, the context ended, or auth became
36 // unrecoverable. Terminal.
37 StatusStopped
38 )
39
40 func (s Status) String() string {
41 switch s {
42 case StatusIdle:
43 return "idle"
44 case StatusConnecting:
45 return "connecting"
46 case StatusConnected:
47 return "connected"
48 case StatusReconnecting:
49 return "reconnecting"
50 case StatusDegraded:
51 return "degraded"
52 case StatusStopped:
53 return "stopped"
54 default:
55 return "unknown"
56 }
57 }
58
59 // StatusEvent is one supervisor state transition, delivered to subscribers
60 // and returned by Client.Status.
61 type StatusEvent struct {
62 Host string // configured host name (or user@host target)
63 Status Status
64 Attempt int // reconnect attempt counter; 0 on the first connect
65 Err error // last error for Reconnecting/Degraded/Stopped; nil otherwise
66 At time.Time
67 }
68
69 // Typed errors surfaced by dial/auth/host-key verification and the client.
70 var (
71 // ErrNotConnected: the client is not currently connected (SSH/SFTP access
72 // while down, or Exec during a reconnect window).
73 ErrNotConnected = errors.New("remote: not connected")
74 // ErrAuthFailed: every configured auth method was rejected; reconnects
75 // stop rather than re-prompting in the background.
76 ErrAuthFailed = errors.New("remote: authentication failed")
77 // ErrHostKeyMismatch: the presented host key contradicts a recorded one.
78 // Never promptable — the user must inspect the named known_hosts line.
79 ErrHostKeyMismatch = errors.New("remote: host key mismatch")
80 // ErrHostKeyRejected: the user declined a first-seen (TOFU) fingerprint.
81 ErrHostKeyRejected = errors.New("remote: host key rejected")
82 // ErrDisconnected: a shared resource (SFTP handle) belongs to a previous
83 // connection generation; re-fetch it from the client.
84 ErrDisconnected = errors.New("remote: connection was re-established, re-fetch the handle")
85 )
86
87 // classifyDialError maps an ssh handshake error to a typed error where the
88 // distinction matters to the reconnect supervisor: authentication failures are
89 // unrecoverable (stop rather than loop re-prompting), everything else is a
90 // transient network error worth retrying.
91 func classifyDialError(err error) error {
92 if err == nil {
93 return nil
94 }
95 msg := strings.ToLower(err.Error())
96 if strings.Contains(msg, "unable to authenticate") ||
97 strings.Contains(msg, "no supported methods remain") ||
98 strings.Contains(msg, "permission denied") ||
99 strings.Contains(msg, "password required but no prompt available") ||
100 strings.Contains(msg, "key passphrase required but no prompt available") {
101 return errAuth{err}
102 }
103 return err
104 }
105
106 // errAuth wraps an unrecoverable authentication failure so the supervisor can
107 // detect it via errors.Is(err, ErrAuthFailed) while preserving the detail.
108 type errAuth struct{ err error }
109
110 func (e errAuth) Error() string { return e.err.Error() }
111 func (e errAuth) Unwrap() error { return e.err }
112 func (e errAuth) Is(target error) bool {
113 return target == ErrAuthFailed
114 }
115
116 // Clock is the test seam for keepalive and reconnect timing.
117 type Clock interface {
118 Now() time.Time
119 After(d time.Duration) <-chan time.Time
120 }
121
122 type realClock struct{}
123
124 func (realClock) Now() time.Time { return time.Now() }
125 func (realClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
126
127 // KeepalivePolicy controls liveness probing of an established connection.
128 type KeepalivePolicy struct {
129 Interval time.Duration // 0 => 30s; <0 disables keepalive
130 MaxMisses int // consecutive failures before declaring the link dead; 0 => 3
131 Timeout time.Duration // per-probe reply timeout; 0 => 10s
132 }
133
134 func (p KeepalivePolicy) interval() time.Duration {
135 if p.Interval < 0 {
136 return 0
137 }
138 if p.Interval == 0 {
139 return 30 * time.Second
140 }
141 return p.Interval
142 }
143
144 func (p KeepalivePolicy) maxMisses() int {
145 if p.MaxMisses <= 0 {
146 return 3
147 }
148 return p.MaxMisses
149 }
150
151 func (p KeepalivePolicy) timeout() time.Duration {
152 if p.Timeout <= 0 {
153 return 10 * time.Second
154 }
155 return p.Timeout
156 }
157
158 // BackoffPolicy controls reconnect pacing: full-jitter exponential backoff.
159 type BackoffPolicy struct {
160 Initial time.Duration // 0 => 1s
161 Factor float64 // 0 => 2
162 Max time.Duration // 0 => 60s
163 }
164
165 func (p BackoffPolicy) initial() time.Duration {
166 if p.Initial <= 0 {
167 return time.Second
168 }
169 return p.Initial
170 }
171
172 func (p BackoffPolicy) factor() float64 {
173 if p.Factor <= 1 {
174 return 2
175 }
176 return p.Factor
177 }
178
179 func (p BackoffPolicy) max() time.Duration {
180 if p.Max <= 0 {
181 return 60 * time.Second
182 }
183 return p.Max
184 }
185
186 // delay computes the ceiling for attempt n (0-based); the supervisor draws a
187 // full-jitter value in [0, delay] from its rng.
188 func (p BackoffPolicy) delay(attempt int) time.Duration {
189 d := float64(p.initial())
190 f := p.factor()
191 for i := 0; i < attempt; i++ {
192 d *= f
193 if d >= float64(p.max()) {
194 return p.max()
195 }
196 }
197 if d >= float64(p.max()) {
198 return p.max()
199 }
200 return time.Duration(d)
201 }
202
202 lines GO