| 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 |