| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "os" |
| 12 | "os/exec" |
| 13 | "path" |
| 14 | "path/filepath" |
| 15 | "runtime" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "sync/atomic" |
| 19 | "time" |
| 20 | |
| 21 | "reasonix/internal/agent" |
| 22 | "reasonix/internal/config" |
| 23 | "reasonix/internal/netclient" |
| 24 | "reasonix/internal/remote" |
| 25 | "reasonix/internal/remote/bootstrap" |
| 26 | "reasonix/internal/remote/forward" |
| 27 | ) |
| 28 | |
| 29 | // ── View structs mirrored in frontend/src/lib/types.ts ── |
| 30 | |
| 31 | type RemoteHostView struct { |
| 32 | ID string `json:"id"` |
| 33 | Label string `json:"label"` |
| 34 | Host string `json:"host"` |
| 35 | Port int `json:"port"` |
| 36 | User string `json:"user"` |
| 37 | IdentityFile string `json:"identityFile"` |
| 38 | ProxyJump string `json:"proxyJump"` |
| 39 | DefaultWorkspace string `json:"defaultWorkspace"` |
| 40 | ServeInstall string `json:"serveInstall"` |
| 41 | CredentialMode string `json:"credentialMode"` |
| 42 | UseSSHConfig bool `json:"useSSHConfig"` |
| 43 | PasswordSet bool `json:"passwordSet,omitempty"` |
| 44 | KeyPassphraseSet bool `json:"keyPassphraseSet,omitempty"` |
| 45 | } |
| 46 | |
| 47 | type RemoteHostInput struct { |
| 48 | Label string `json:"label"` |
| 49 | Host string `json:"host"` |
| 50 | Port int `json:"port"` |
| 51 | User string `json:"user"` |
| 52 | IdentityFile string `json:"identityFile"` |
| 53 | ProxyJump string `json:"proxyJump"` |
| 54 | DefaultWorkspace string `json:"defaultWorkspace"` |
| 55 | ServeInstall string `json:"serveInstall"` |
| 56 | CredentialMode string `json:"credentialMode"` |
| 57 | UseSSHConfig bool `json:"useSSHConfig"` |
| 58 | Password string `json:"password,omitempty"` |
| 59 | KeyPassphrase string `json:"keyPassphrase,omitempty"` |
| 60 | ClearPassword bool `json:"clearPassword,omitempty"` |
| 61 | ClearPassphrase bool `json:"clearPassphrase,omitempty"` |
| 62 | PreserveExistingSettings bool `json:"preserveExistingSettings,omitempty"` |
| 63 | } |
| 64 | |
| 65 | type RemoteFingerprintView struct { |
| 66 | HostID string `json:"hostId"` |
| 67 | Address string `json:"address"` |
| 68 | KeyType string `json:"keyType"` |
| 69 | SHA256 string `json:"sha256"` |
| 70 | } |
| 71 | |
| 72 | type RemoteConnectionStatusView struct { |
| 73 | HostID string `json:"hostId"` |
| 74 | State string `json:"state"` |
| 75 | Error string `json:"error,omitempty"` |
| 76 | ErrorDetails *RemoteConnectionErrorDetailsView `json:"errorDetails,omitempty"` |
| 77 | Fingerprint *RemoteFingerprintView `json:"fingerprint,omitempty"` |
| 78 | SecretPrompt *RemoteSecretPromptView `json:"secretPrompt,omitempty"` |
| 79 | Attempt int `json:"attempt,omitempty"` |
| 80 | } |
| 81 | |
| 82 | // RemoteSecretPromptView contains prompt metadata only. Secret text travels |
| 83 | // one way through ConfirmRemoteSecret and is never emitted in status events. |
| 84 | type RemoteSecretPromptView struct { |
| 85 | PromptID string `json:"promptId"` |
| 86 | HostID string `json:"hostId"` |
| 87 | Host string `json:"host"` |
| 88 | Kind string `json:"kind"` // password | passphrase |
| 89 | Identity string `json:"identity,omitempty"` |
| 90 | } |
| 91 | |
| 92 | type RemoteKnownHostLocationView struct { |
| 93 | Path string `json:"path"` |
| 94 | Line int `json:"line"` |
| 95 | } |
| 96 | |
| 97 | type RemoteConnectionErrorDetailsView struct { |
| 98 | Code string `json:"code"` |
| 99 | PresentedSHA256 string `json:"presentedSha256,omitempty"` |
| 100 | KnownHostRecords []RemoteKnownHostLocationView `json:"knownHostRecords,omitempty"` |
| 101 | } |
| 102 | |
| 103 | type RemoteDirEntry struct { |
| 104 | Name string `json:"name"` |
| 105 | Path string `json:"path"` |
| 106 | IsDir bool `json:"isDir"` |
| 107 | Size int64 `json:"size"` |
| 108 | MtimeUnix int64 `json:"mtimeUnix"` |
| 109 | Symlink bool `json:"symlink"` |
| 110 | } |
| 111 | |
| 112 | type RemoteFilePreview struct { |
| 113 | Path string `json:"path"` |
| 114 | Body string `json:"body"` |
| 115 | Size int64 `json:"size"` |
| 116 | MtimeUnix int64 `json:"mtimeUnix"` |
| 117 | Truncated bool `json:"truncated"` |
| 118 | Binary bool `json:"binary"` |
| 119 | Err string `json:"err,omitempty"` |
| 120 | } |
| 121 | |
| 122 | type RemoteWriteResult struct { |
| 123 | OK bool `json:"ok"` |
| 124 | Conflict bool `json:"conflict"` |
| 125 | NewMtimeUnix int64 `json:"newMtimeUnix"` |
| 126 | } |
| 127 | |
| 128 | type RemoteForwardInput struct { |
| 129 | LocalPort int `json:"localPort"` |
| 130 | RemoteHost string `json:"remoteHost"` |
| 131 | RemotePort int `json:"remotePort"` |
| 132 | Label string `json:"label"` |
| 133 | } |
| 134 | |
| 135 | type RemoteForwardView struct { |
| 136 | ID string `json:"id"` |
| 137 | HostID string `json:"hostId"` |
| 138 | LocalPort int `json:"localPort"` |
| 139 | RemoteHost string `json:"remoteHost"` |
| 140 | RemotePort int `json:"remotePort"` |
| 141 | Label string `json:"label"` |
| 142 | State string `json:"state"` |
| 143 | Error string `json:"error,omitempty"` |
| 144 | } |
| 145 | |
| 146 | type RemoteServerView struct { |
| 147 | HostID string `json:"hostId"` |
| 148 | Workspace string `json:"workspace"` |
| 149 | State string `json:"state"` |
| 150 | Message string `json:"message,omitempty"` |
| 151 | LocalURL string `json:"localUrl,omitempty"` |
| 152 | InstanceID string `json:"instanceId,omitempty"` |
| 153 | Error string `json:"error,omitempty"` |
| 154 | } |
| 155 | |
| 156 | // ── Kernel seam ── |
| 157 | |
| 158 | // remoteKernel is the desktop's view of the remote subsystem. The concrete |
| 159 | // *desktopRemoteManager satisfies it; remote_app_test.go injects a fake. |
| 160 | type remoteKernel interface { |
| 161 | Hosts() ([]RemoteHostView, error) |
| 162 | AddHost(RemoteHostInput) (RemoteHostView, error) |
| 163 | UpdateHost(id string, in RemoteHostInput) (RemoteHostView, error) |
| 164 | RemoveHost(id string) error |
| 165 | ScanSSHConfig() ([]RemoteHostInput, error) |
| 166 | |
| 167 | Connect(hostID string) error |
| 168 | Disconnect(hostID string) error |
| 169 | Statuses() []RemoteConnectionStatusView |
| 170 | ResolveHostKey(hostID string, accept bool) error |
| 171 | ResolveSecret(hostID, promptID, secret string, accept bool) error |
| 172 | |
| 173 | ListDir(ctx context.Context, hostID, path string) ([]RemoteDirEntry, error) |
| 174 | ReadFile(ctx context.Context, hostID, path string) (RemoteFilePreview, error) |
| 175 | DownloadFile(ctx context.Context, hostID, path string, dst io.Writer) (int64, error) |
| 176 | WriteFile(ctx context.Context, hostID, path, body string, expectMtime int64) (RemoteWriteResult, error) |
| 177 | Mkdir(ctx context.Context, hostID, path string) error |
| 178 | Rename(ctx context.Context, hostID, oldPath, newPath string) error |
| 179 | Delete(ctx context.Context, hostID, path string, recursive bool) error |
| 180 | |
| 181 | Forwards(hostID string) []RemoteForwardView |
| 182 | AddForward(hostID string, in RemoteForwardInput) (RemoteForwardView, error) |
| 183 | RemoveForward(hostID, forwardID string) error |
| 184 | |
| 185 | EnsureServer(ctx context.Context, hostID, workspace string) (RemoteServerView, string, error) |
| 186 | SwitchCredentialProxyModel(ctx context.Context, hostID, workspace, currentRef, nextRef, expectedPath string) error |
| 187 | StopServer(hostID, workspace string) error |
| 188 | ServerStatus(hostID, workspace string) RemoteServerView |
| 189 | // ServeSnapshot is the read-only lookup for already-running serves; it |
| 190 | // must never contact the remote or wake anything up. |
| 191 | ServeSnapshot(hostID, workspace string) (RemoteServerView, string, bool) |
| 192 | ServerLogs(ctx context.Context, hostID, workspace string, tailLines int) (string, error) |
| 193 | CheckPlatform(ctx context.Context, hostID string) error |
| 194 | Close() error |
| 195 | } |
| 196 | |
| 197 | // remoteEventSink receives kernel status transitions for bridging to the |
| 198 | // frontend. All methods may be called from kernel goroutines. |
| 199 | type remoteEventSink interface { |
| 200 | onStatus(RemoteConnectionStatusView) |
| 201 | onForwards(hostID string, forwards []RemoteForwardView) |
| 202 | onServer(RemoteServerView) |
| 203 | } |
| 204 | |
| 205 | // ── App wiring ── |
| 206 | |
| 207 | func (a *App) remoteRT() (remoteKernel, error) { |
| 208 | a.remoteMu.Lock() |
| 209 | defer a.remoteMu.Unlock() |
| 210 | if a.remoteRuntime != nil { |
| 211 | return a.remoteRuntime, nil |
| 212 | } |
| 213 | mgr := newDesktopRemoteManager(a) |
| 214 | a.remoteRuntime = mgr |
| 215 | return mgr, nil |
| 216 | } |
| 217 | |
| 218 | func (a *App) stopRemoteRuntime() { |
| 219 | a.remoteMu.Lock() |
| 220 | rt := a.remoteRuntime |
| 221 | a.remoteRuntime = nil |
| 222 | a.remoteMu.Unlock() |
| 223 | if rt != nil { |
| 224 | _ = rt.Close() |
| 225 | } |
| 226 | a.closeRemoteBrokers() |
| 227 | } |
| 228 | |
| 229 | // remoteEventSink implementation on *App. |
| 230 | func (a *App) onStatus(s RemoteConnectionStatusView) { |
| 231 | a.emitRemoteEvent("remote:status", s) |
| 232 | a.remoteTabsHostStatus(s.HostID, s.State, s.Error) |
| 233 | // Close web windows after terminal SSH failures; transient reconnects keep |
| 234 | // them open while the status event explains the failure. |
| 235 | if s.State == "stopped" && s.Error != "" { |
| 236 | // Status callbacks may run while desktopRemoteManager.mu is held, so never |
| 237 | // wait on the host lifecycle mutex here. Capturing the generation before |
| 238 | // queueing makes a later explicit reconnect/open supersede this close. |
| 239 | op := a.beginRemoteWindowHostOperation(s.HostID) |
| 240 | a.goSafe("remoteWindowTerminalClose", func() { |
| 241 | _ = op.run(func(func() bool) error { |
| 242 | a.closeRemoteWindowForHost(s.HostID) |
| 243 | return nil |
| 244 | }) |
| 245 | }) |
| 246 | return |
| 247 | } |
| 248 | // After a reconnect the loopback tunnel rebinds to a new port. Re-point an |
| 249 | // open web window at the fresh Serve URL so it stays usable. |
| 250 | if s.State == "connected" && a.hasRemoteWindow(s.HostID) { |
| 251 | a.refreshRemoteWindowAfterReconnect(s.HostID) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // refreshRemoteWindowAfterReconnect re-establishes the Serve forward after an |
| 256 | // SSH reconnect and navigates the host's web window to the new loopback URL. |
| 257 | // The remote Serve process is reused, so this is a cheap state probe when the |
| 258 | // tunnel already rebinding — the window is kept regardless of failure, and the |
| 259 | // user can reopen it if the Serve itself went away. |
| 260 | func (a *App) refreshRemoteWindowAfterReconnect(hostID string) { |
| 261 | op := a.beginRemoteWindowHostOperation(hostID) |
| 262 | a.goSafe("remoteWindowReconnect", func() { |
| 263 | _ = op.run(func(current func() bool) error { |
| 264 | rt, err := a.remoteRT() |
| 265 | if err != nil { |
| 266 | return nil |
| 267 | } |
| 268 | ws := a.remoteWindowWorkspace(hostID) |
| 269 | if strings.TrimSpace(ws) == "" { |
| 270 | // Window opened before workspace tracking: fall back to the |
| 271 | // host's last workspace. |
| 272 | ws = a.RemoteLastWorkspace(hostID) |
| 273 | } |
| 274 | if strings.TrimSpace(ws) == "" { |
| 275 | return nil |
| 276 | } |
| 277 | status := rt.ServerStatus(hostID, ws) |
| 278 | if status.State != "ready" { |
| 279 | return nil |
| 280 | } |
| 281 | view, token, err := rt.EnsureServer(a.bootContext(), hostID, ws) |
| 282 | if err != nil || view.State != "ready" || view.LocalURL == "" || !current() { |
| 283 | return nil |
| 284 | } |
| 285 | if !a.hasRemoteWindow(hostID) { |
| 286 | return nil |
| 287 | } |
| 288 | _ = a.openRemoteWindowForHost(hostID, ws, serveURLWithToken(view.LocalURL, token)) |
| 289 | return nil |
| 290 | }) |
| 291 | }) |
| 292 | } |
| 293 | |
| 294 | func (a *App) onServer(s RemoteServerView) { a.emitRemoteEvent("remote:server", s) } |
| 295 | func (a *App) onForwards(hostID string, f []RemoteForwardView) { |
| 296 | a.emitRemoteEvent("remote:forwards", map[string]any{"hostId": hostID, "forwards": f}) |
| 297 | } |
| 298 | |
| 299 | // ── Bound methods ── |
| 300 | |
| 301 | func (a *App) RemoteHosts() ([]RemoteHostView, error) { |
| 302 | rt, err := a.remoteRT() |
| 303 | if err != nil { |
| 304 | return nil, err |
| 305 | } |
| 306 | return rt.Hosts() |
| 307 | } |
| 308 | |
| 309 | func (a *App) AddRemoteHost(in RemoteHostInput) (RemoteHostView, error) { |
| 310 | rt, err := a.remoteRT() |
| 311 | if err != nil { |
| 312 | return RemoteHostView{}, err |
| 313 | } |
| 314 | return rt.AddHost(in) |
| 315 | } |
| 316 | |
| 317 | func (a *App) UpdateRemoteHost(id string, in RemoteHostInput) (RemoteHostView, error) { |
| 318 | rt, err := a.remoteRT() |
| 319 | if err != nil { |
| 320 | return RemoteHostView{}, err |
| 321 | } |
| 322 | return rt.UpdateHost(id, in) |
| 323 | } |
| 324 | |
| 325 | func (a *App) RemoveRemoteHost(id string) error { |
| 326 | op := a.beginRemoteWindowHostOperation(id) |
| 327 | return op.run(func(func() bool) error { |
| 328 | rt, err := a.remoteRT() |
| 329 | if err != nil { |
| 330 | return err |
| 331 | } |
| 332 | if err := rt.RemoveHost(id); err != nil { |
| 333 | return err |
| 334 | } |
| 335 | if err := a.removeRemoteTabsForHost(id); err != nil { |
| 336 | return fmt.Errorf("replace tabs for removed remote host: %w", err) |
| 337 | } |
| 338 | a.closeRemoteWindowForHost(id) |
| 339 | return nil |
| 340 | }) |
| 341 | } |
| 342 | |
| 343 | func (a *App) ScanSSHConfig() ([]RemoteHostInput, error) { |
| 344 | rt, err := a.remoteRT() |
| 345 | if err != nil { |
| 346 | return nil, err |
| 347 | } |
| 348 | return rt.ScanSSHConfig() |
| 349 | } |
| 350 | |
| 351 | func (a *App) ConnectRemoteHost(id string) error { |
| 352 | rt, err := a.remoteRT() |
| 353 | if err != nil { |
| 354 | return err |
| 355 | } |
| 356 | if err := rt.Connect(id); err != nil { |
| 357 | view := RemoteConnectionStatusView{HostID: id, State: "stopped"} |
| 358 | applyRemoteConnectionError(&view, err) |
| 359 | a.onStatus(view) |
| 360 | return err |
| 361 | } |
| 362 | return nil |
| 363 | } |
| 364 | |
| 365 | func applyRemoteConnectionError(view *RemoteConnectionStatusView, err error) { |
| 366 | if err == nil { |
| 367 | return |
| 368 | } |
| 369 | view.Error = err.Error() |
| 370 | if view.State == "degraded" { |
| 371 | return |
| 372 | } |
| 373 | details := &RemoteConnectionErrorDetailsView{Code: "connection_failed"} |
| 374 | switch { |
| 375 | case errors.Is(err, remote.ErrHostKeyMismatch): |
| 376 | details.Code = "host_key_mismatch" |
| 377 | var mismatch *remote.HostKeyMismatchError |
| 378 | if errors.As(err, &mismatch) { |
| 379 | details.PresentedSHA256 = mismatch.PresentedFingerprint |
| 380 | details.KnownHostRecords = make([]RemoteKnownHostLocationView, 0, len(mismatch.Locations)) |
| 381 | for _, location := range mismatch.Locations { |
| 382 | details.KnownHostRecords = append(details.KnownHostRecords, RemoteKnownHostLocationView{ |
| 383 | Path: location.Filename, |
| 384 | Line: location.Line, |
| 385 | }) |
| 386 | } |
| 387 | } |
| 388 | case errors.Is(err, remote.ErrAuthFailed): |
| 389 | details.Code = "auth_failed" |
| 390 | case errors.Is(err, remote.ErrHostKeyRejected): |
| 391 | details.Code = "host_key_rejected" |
| 392 | } |
| 393 | view.ErrorDetails = details |
| 394 | } |
| 395 | |
| 396 | func (a *App) DisconnectRemoteHost(id string) error { |
| 397 | op := a.beginRemoteWindowHostOperation(id) |
| 398 | return op.run(func(func() bool) error { |
| 399 | rt, err := a.remoteRT() |
| 400 | if err != nil { |
| 401 | return err |
| 402 | } |
| 403 | if err := rt.Disconnect(id); err != nil { |
| 404 | return err |
| 405 | } |
| 406 | // An explicit disconnect kills the loopback tunnel; close the host's web |
| 407 | // window so the user is not left staring at a dead Serve page. The remote |
| 408 | // Serve itself stays resident. |
| 409 | a.closeRemoteWindowForHost(id) |
| 410 | return nil |
| 411 | }) |
| 412 | } |
| 413 | |
| 414 | func (a *App) RemoteConnectionStatuses() []RemoteConnectionStatusView { |
| 415 | rt, err := a.remoteRT() |
| 416 | if err != nil { |
| 417 | return nil |
| 418 | } |
| 419 | return rt.Statuses() |
| 420 | } |
| 421 | |
| 422 | func (a *App) ConfirmRemoteHostKey(hostID string, accept bool) error { |
| 423 | rt, err := a.remoteRT() |
| 424 | if err != nil { |
| 425 | return err |
| 426 | } |
| 427 | return rt.ResolveHostKey(hostID, accept) |
| 428 | } |
| 429 | |
| 430 | // ConfirmRemoteSecret resolves a one-shot interactive SSH credential prompt. |
| 431 | // The secret is retained only in the connection's in-memory reconnect cache; |
| 432 | // callers must use the host settings form when they explicitly want storage. |
| 433 | func (a *App) ConfirmRemoteSecret(hostID, promptID, secret string, accept bool) error { |
| 434 | rt, err := a.remoteRT() |
| 435 | if err != nil { |
| 436 | return err |
| 437 | } |
| 438 | return rt.ResolveSecret(hostID, promptID, secret, accept) |
| 439 | } |
| 440 | |
| 441 | func (a *App) ListRemoteDir(hostID, path string) ([]RemoteDirEntry, error) { |
| 442 | rt, err := a.remoteRT() |
| 443 | if err != nil { |
| 444 | return nil, err |
| 445 | } |
| 446 | return rt.ListDir(a.bootContext(), hostID, path) |
| 447 | } |
| 448 | |
| 449 | func (a *App) ReadRemoteFile(hostID, path string) (RemoteFilePreview, error) { |
| 450 | rt, err := a.remoteRT() |
| 451 | if err != nil { |
| 452 | return RemoteFilePreview{}, err |
| 453 | } |
| 454 | return rt.ReadFile(a.bootContext(), hostID, path) |
| 455 | } |
| 456 | |
| 457 | // SaveRemoteFileAs streams a remote file directly to a user-selected local |
| 458 | // destination. It never interprets the remote path as a local system path. |
| 459 | func (a *App) SaveRemoteFileAs(hostID, remotePath string) (string, error) { |
| 460 | return a.saveRemoteFileAs(hostID, remotePath) |
| 461 | } |
| 462 | |
| 463 | // SaveRemotePresentedFileAs requires the remote path to come from the trusted |
| 464 | // metadata of the matching built-in present call before it opens a local save |
| 465 | // dialog. The SSH host remains the source of bytes. |
| 466 | func (a *App) SaveRemotePresentedFileAs(tabID, hostID, toolCallID, remotePath string) (string, error) { |
| 467 | fence, err := a.requireRemotePresentedFiles(tabID, hostID) |
| 468 | if err != nil { |
| 469 | return "", err |
| 470 | } |
| 471 | snapshot, err := a.RemoteTabSnapshot(tabID) |
| 472 | if err != nil { |
| 473 | return "", err |
| 474 | } |
| 475 | if !a.remotePresentedFilesFenceCurrent(fence) { |
| 476 | return "", fmt.Errorf("remote file selection changed while validating the presented file") |
| 477 | } |
| 478 | if !remotePresentedFileDeclared(snapshot.History, toolCallID, remotePath) { |
| 479 | return "", os.ErrPermission |
| 480 | } |
| 481 | return a.saveRemoteFileAs(hostID, remotePath) |
| 482 | } |
| 483 | |
| 484 | // ResolveRemotePresentedPathForTab returns the absolute coordinate on the |
| 485 | // remote source host. The trusted history and route generation are checked; |
| 486 | // the resulting remote path is never passed to a local system-open API. |
| 487 | func (a *App) ResolveRemotePresentedPathForTab(tabID, hostID, toolCallID, remotePath string) (string, error) { |
| 488 | fence, err := a.requireRemotePresentedFiles(tabID, hostID) |
| 489 | if err != nil { |
| 490 | return "", err |
| 491 | } |
| 492 | snapshot, err := a.RemoteTabSnapshot(tabID) |
| 493 | if err != nil { |
| 494 | return "", err |
| 495 | } |
| 496 | if !a.remotePresentedFilesFenceCurrent(fence) { |
| 497 | return "", fmt.Errorf("remote file selection changed while validating the presented file") |
| 498 | } |
| 499 | if !remotePresentedFileDeclared(snapshot.History, toolCallID, remotePath) { |
| 500 | return "", os.ErrPermission |
| 501 | } |
| 502 | return remotePresentedAbsolutePath(fence.tab.ref.Workspace, remotePath), nil |
| 503 | } |
| 504 | |
| 505 | // ResolveRemoteWorkspacePathForTab binds a derived workspace artifact to the |
| 506 | // authenticated remote tab that produced it. Unlike a presented file this has |
| 507 | // no present-call grant, so only paths contained by the tab workspace resolve. |
| 508 | func (a *App) ResolveRemoteWorkspacePathForTab(tabID, hostID, toolCallID, remotePath string) (string, error) { |
| 509 | fence, err := a.requireRemoteWorkspaceArtifact(tabID, hostID) |
| 510 | if err != nil { |
| 511 | return "", err |
| 512 | } |
| 513 | snapshot, err := a.RemoteTabSnapshot(tabID) |
| 514 | if err != nil { |
| 515 | return "", err |
| 516 | } |
| 517 | if !a.remotePresentedFilesFenceCurrent(fence) || !remoteWorkspaceArtifactDeclared(snapshot.History, toolCallID, remotePath) { |
| 518 | return "", os.ErrPermission |
| 519 | } |
| 520 | return confinedRemoteWorkspacePath(fence.tab.ref.Workspace, remotePath) |
| 521 | } |
| 522 | |
| 523 | func confinedRemoteWorkspacePath(workspace, requested string) (string, error) { |
| 524 | workspace = strings.TrimSpace(workspace) |
| 525 | requested = strings.TrimSpace(requested) |
| 526 | if workspace == "" || requested == "" { |
| 527 | return "", os.ErrPermission |
| 528 | } |
| 529 | windows := isWindowsAbsolutePath(workspace) || strings.Contains(workspace, `\`) |
| 530 | normalize := func(value string) string { |
| 531 | clean := path.Clean(strings.ReplaceAll(value, `\`, "/")) |
| 532 | if windows { |
| 533 | clean = strings.ToLower(clean) |
| 534 | } |
| 535 | return strings.TrimRight(clean, "/") |
| 536 | } |
| 537 | root := normalize(workspace) |
| 538 | target := requested |
| 539 | if !path.IsAbs(requested) && !filepath.IsAbs(requested) && !isWindowsAbsolutePath(requested) { |
| 540 | target = strings.TrimRight(workspace, `/\`) + "/" + strings.TrimLeft(requested, `/\`) |
| 541 | } |
| 542 | canonical := normalize(target) |
| 543 | if canonical != root && !strings.HasPrefix(canonical, root+"/") { |
| 544 | return "", os.ErrPermission |
| 545 | } |
| 546 | if windows { |
| 547 | return strings.ReplaceAll(path.Clean(strings.ReplaceAll(target, `\`, "/")), "/", `\`), nil |
| 548 | } |
| 549 | return path.Clean(target), nil |
| 550 | } |
| 551 | |
| 552 | func remotePresentedAbsolutePath(workspace, presented string) string { |
| 553 | presented = strings.TrimSpace(presented) |
| 554 | workspace = strings.TrimSpace(workspace) |
| 555 | if path.IsAbs(presented) || filepath.IsAbs(presented) || isWindowsAbsolutePath(presented) { |
| 556 | return presented |
| 557 | } |
| 558 | if isWindowsAbsolutePath(workspace) || strings.Contains(workspace, `\`) { |
| 559 | return strings.TrimRight(workspace, `/\`) + `\` + strings.TrimLeft(strings.ReplaceAll(presented, "/", `\`), `/\`) |
| 560 | } |
| 561 | return path.Join(workspace, presented) |
| 562 | } |
| 563 | |
| 564 | func isWindowsAbsolutePath(value string) bool { |
| 565 | drive := len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && (value[2] == '\\' || value[2] == '/') |
| 566 | return drive || strings.HasPrefix(value, `\\`) |
| 567 | } |
| 568 | |
| 569 | type remotePresentedFilesFence struct { |
| 570 | tab *remoteTab |
| 571 | tabID string |
| 572 | hostID string |
| 573 | client *http.Client |
| 574 | base string |
| 575 | generation uint64 |
| 576 | selectionRevision uint64 |
| 577 | sessionPath string |
| 578 | } |
| 579 | |
| 580 | // requireRemotePresentedFiles binds an action to the same authenticated remote |
| 581 | // tab and host that produced the deliverable. A path from the renderer is only |
| 582 | // a locator; the trusted present record is re-read from remote history below. |
| 583 | func (a *App) requireRemotePresentedFiles(tabID, hostID string) (remotePresentedFilesFence, error) { |
| 584 | fence, err := a.requireRemoteWorkspaceArtifact(tabID, hostID) |
| 585 | if err != nil { |
| 586 | return remotePresentedFilesFence{}, err |
| 587 | } |
| 588 | if !fence.tab.capabilities["present-files-v1"] { |
| 589 | return remotePresentedFilesFence{}, fmt.Errorf("this remote Reasonix Serve does not support present-files-v1") |
| 590 | } |
| 591 | return fence, nil |
| 592 | } |
| 593 | |
| 594 | func (a *App) requireRemoteWorkspaceArtifact(tabID, hostID string) (remotePresentedFilesFence, error) { |
| 595 | a.remoteTabMu.Lock() |
| 596 | defer a.remoteTabMu.Unlock() |
| 597 | tab := a.remoteTabs[tabID] |
| 598 | valid := tab != nil && tab.ref.HostID == hostID && tab.client != nil && tab.state == "ready" && |
| 599 | strings.TrimSpace(tab.routing.currentPath) != "" && tab.routing.rehydratingPath == "" |
| 600 | if !valid { |
| 601 | return remotePresentedFilesFence{}, os.ErrPermission |
| 602 | } |
| 603 | return remotePresentedFilesFence{ |
| 604 | tab: tab, |
| 605 | tabID: tabID, |
| 606 | hostID: hostID, |
| 607 | client: tab.client, |
| 608 | base: tab.base, |
| 609 | generation: tab.gen, |
| 610 | selectionRevision: tab.selectionRevision, |
| 611 | sessionPath: tab.routing.currentPath, |
| 612 | }, nil |
| 613 | } |
| 614 | |
| 615 | func remoteWorkspaceArtifactDeclared(history json.RawMessage, toolCallID, requested string) bool { |
| 616 | if strings.TrimSpace(toolCallID) == "" || strings.TrimSpace(requested) == "" { |
| 617 | return false |
| 618 | } |
| 619 | var messages []struct { |
| 620 | Role string `json:"role"` |
| 621 | ToolCallID string `json:"toolCallId"` |
| 622 | ToolName string `json:"toolName"` |
| 623 | ToolError string `json:"toolResultError"` |
| 624 | ToolCalls []struct { |
| 625 | ID string `json:"id"` |
| 626 | Name string `json:"name"` |
| 627 | Arguments string `json:"arguments"` |
| 628 | } `json:"toolCalls"` |
| 629 | } |
| 630 | if json.Unmarshal(history, &messages) != nil { |
| 631 | return false |
| 632 | } |
| 633 | declared := false |
| 634 | toolName := "" |
| 635 | for _, message := range messages { |
| 636 | for _, call := range message.ToolCalls { |
| 637 | if call.ID != toolCallID { |
| 638 | continue |
| 639 | } |
| 640 | field := "path" |
| 641 | switch call.Name { |
| 642 | case "write_file", "edit_file", "multi_edit", "notebook_edit", "delete_range", "delete_symbol": |
| 643 | case "move_file": |
| 644 | field = "destination_path" |
| 645 | default: |
| 646 | continue |
| 647 | } |
| 648 | var args map[string]any |
| 649 | if json.Unmarshal([]byte(call.Arguments), &args) == nil && args[field] == requested { |
| 650 | declared, toolName = true, call.Name |
| 651 | } |
| 652 | } |
| 653 | } |
| 654 | if !declared { |
| 655 | return false |
| 656 | } |
| 657 | for _, message := range messages { |
| 658 | if message.Role == "tool" && message.ToolCallID == toolCallID && message.ToolName == toolName && message.ToolError == "" { |
| 659 | return true |
| 660 | } |
| 661 | } |
| 662 | return false |
| 663 | } |
| 664 | |
| 665 | func (a *App) remotePresentedFilesFenceCurrent(fence remotePresentedFilesFence) bool { |
| 666 | a.remoteTabMu.Lock() |
| 667 | defer a.remoteTabMu.Unlock() |
| 668 | current := a.remoteTabs[fence.tabID] |
| 669 | return current != nil && current == fence.tab && current.ref.HostID == fence.hostID && |
| 670 | current.client == fence.client && current.base == fence.base && current.gen == fence.generation && |
| 671 | current.selectionRevision == fence.selectionRevision && current.state == "ready" && |
| 672 | current.routing.rehydratingPath == "" && |
| 673 | agent.CanonicalSessionPath(current.routing.currentPath) == agent.CanonicalSessionPath(fence.sessionPath) |
| 674 | } |
| 675 | |
| 676 | func remotePresentedFileDeclared(history json.RawMessage, toolCallID, requested string) bool { |
| 677 | if strings.TrimSpace(toolCallID) == "" || strings.TrimSpace(requested) == "" { |
| 678 | return false |
| 679 | } |
| 680 | var messages []struct { |
| 681 | Role string `json:"role"` |
| 682 | ToolCallID string `json:"toolCallId"` |
| 683 | ToolName string `json:"toolName"` |
| 684 | PresentedFiles []struct { |
| 685 | Path string `json:"path"` |
| 686 | } `json:"presentedFiles"` |
| 687 | } |
| 688 | if json.Unmarshal(history, &messages) != nil { |
| 689 | return false |
| 690 | } |
| 691 | for _, message := range messages { |
| 692 | if message.Role != "tool" || message.ToolName != "present" || message.ToolCallID != toolCallID { |
| 693 | continue |
| 694 | } |
| 695 | for _, file := range message.PresentedFiles { |
| 696 | if file.Path == requested { |
| 697 | return true |
| 698 | } |
| 699 | } |
| 700 | } |
| 701 | return false |
| 702 | } |
| 703 | |
| 704 | func (a *App) saveRemoteFileAs(hostID, remotePath string) (string, error) { |
| 705 | if a.ctx == nil { |
| 706 | return "", nil |
| 707 | } |
| 708 | target, err := a.nativeHost().SaveFileDialog(a.ctx, nativeDialogOptions{ |
| 709 | Title: "Save remote file as", DefaultFilename: path.Base(remotePath), CanCreateDirectories: true, |
| 710 | }) |
| 711 | if err != nil || target == "" { |
| 712 | return "", err |
| 713 | } |
| 714 | rt, err := a.remoteRT() |
| 715 | if err != nil { |
| 716 | return "", err |
| 717 | } |
| 718 | tmp, err := os.CreateTemp(filepath.Dir(target), ".reasonix-remote-download-*") |
| 719 | if err != nil { |
| 720 | return "", err |
| 721 | } |
| 722 | tmpPath := tmp.Name() |
| 723 | defer os.Remove(tmpPath) |
| 724 | if _, err = rt.DownloadFile(a.bootContext(), hostID, remotePath, tmp); err != nil { |
| 725 | _ = tmp.Close() |
| 726 | return "", err |
| 727 | } |
| 728 | if err = tmp.Sync(); err == nil { |
| 729 | err = tmp.Close() |
| 730 | } else { |
| 731 | _ = tmp.Close() |
| 732 | } |
| 733 | if err != nil { |
| 734 | return "", err |
| 735 | } |
| 736 | if err = os.Rename(tmpPath, target); err != nil { |
| 737 | return "", err |
| 738 | } |
| 739 | return target, nil |
| 740 | } |
| 741 | |
| 742 | func (a *App) WriteRemoteFile(hostID, path, body string, expectMtimeUnix int64) (RemoteWriteResult, error) { |
| 743 | rt, err := a.remoteRT() |
| 744 | if err != nil { |
| 745 | return RemoteWriteResult{}, err |
| 746 | } |
| 747 | return rt.WriteFile(a.bootContext(), hostID, path, body, expectMtimeUnix) |
| 748 | } |
| 749 | |
| 750 | func (a *App) MkdirRemote(hostID, path string) error { |
| 751 | rt, err := a.remoteRT() |
| 752 | if err != nil { |
| 753 | return err |
| 754 | } |
| 755 | return rt.Mkdir(a.bootContext(), hostID, path) |
| 756 | } |
| 757 | |
| 758 | func (a *App) RenameRemotePath(hostID, oldPath, newPath string) error { |
| 759 | rt, err := a.remoteRT() |
| 760 | if err != nil { |
| 761 | return err |
| 762 | } |
| 763 | return rt.Rename(a.bootContext(), hostID, oldPath, newPath) |
| 764 | } |
| 765 | |
| 766 | func (a *App) DeleteRemotePath(hostID, path string, recursive bool) error { |
| 767 | rt, err := a.remoteRT() |
| 768 | if err != nil { |
| 769 | return err |
| 770 | } |
| 771 | return rt.Delete(a.bootContext(), hostID, path, recursive) |
| 772 | } |
| 773 | |
| 774 | func (a *App) RemoteForwards(hostID string) ([]RemoteForwardView, error) { |
| 775 | rt, err := a.remoteRT() |
| 776 | if err != nil { |
| 777 | return nil, err |
| 778 | } |
| 779 | return rt.Forwards(hostID), nil |
| 780 | } |
| 781 | |
| 782 | func (a *App) AddRemoteForward(hostID string, in RemoteForwardInput) (RemoteForwardView, error) { |
| 783 | rt, err := a.remoteRT() |
| 784 | if err != nil { |
| 785 | return RemoteForwardView{}, err |
| 786 | } |
| 787 | return rt.AddForward(hostID, in) |
| 788 | } |
| 789 | |
| 790 | func (a *App) RemoveRemoteForward(hostID, forwardID string) error { |
| 791 | rt, err := a.remoteRT() |
| 792 | if err != nil { |
| 793 | return err |
| 794 | } |
| 795 | return rt.RemoveForward(hostID, forwardID) |
| 796 | } |
| 797 | |
| 798 | // OpenRemoteWorkspace is the idempotent "open remote web" entry: it starts or |
| 799 | // reuses the target workspace's remote Serve, atomically replaces the loopback |
| 800 | // tunnel, then opens (or re-points) the host's web window. |
| 801 | // |
| 802 | // Two-phase switch contract: |
| 803 | // - If Serve/tunnel establishment fails, nothing is touched: the previous |
| 804 | // window and tunnel stay exactly as they were, and no workspace is saved. |
| 805 | // - Once the new Serve and tunnel are committed, the switch is final. A |
| 806 | // window-open failure (spawn error) surfaces to the caller while the Serve |
| 807 | // stays ready for the new workspace, and the recorded last workspace |
| 808 | // matches the running Serve so the next open reuses it. The previous |
| 809 | // window, if any, is left in place; it is re-pointed by the next |
| 810 | // successful open (or closed by an explicit disconnect/stop). |
| 811 | func (a *App) OpenRemoteWorkspace(hostID, workspace string) error { |
| 812 | op := a.beginRemoteWindowHostOperation(hostID) |
| 813 | return op.run(func(current func() bool) error { |
| 814 | rt, err := a.remoteRT() |
| 815 | if err != nil { |
| 816 | return err |
| 817 | } |
| 818 | view, token, err := rt.EnsureServer(a.bootContext(), hostID, workspace) |
| 819 | if err != nil { |
| 820 | return err |
| 821 | } |
| 822 | // A disconnect, stop, removal, or terminal SSH failure that began while |
| 823 | // EnsureServer was in flight owns the final state and must prevent a late |
| 824 | // child window from being spawned against its dead tunnel. |
| 825 | if !current() { |
| 826 | return nil |
| 827 | } |
| 828 | if view.LocalURL == "" { |
| 829 | return fmt.Errorf("remote serve did not report a local URL") |
| 830 | } |
| 831 | url := serveURLWithToken(view.LocalURL, token) |
| 832 | if err := a.saveLastRemoteWorkspace(hostID, workspace); err != nil { |
| 833 | return err |
| 834 | } |
| 835 | return a.openRemoteWindowForHost(hostID, workspace, url) |
| 836 | }) |
| 837 | } |
| 838 | |
| 839 | // serveURLWithToken appends the one-shot Serve token to the first-visit URL. |
| 840 | // The remote serve converts it to an HttpOnly cookie on the first request and |
| 841 | // redirects to a token-free URL. |
| 842 | func serveURLWithToken(localURL, token string) string { |
| 843 | if token != "" && !strings.Contains(localURL, "token=") { |
| 844 | return fmt.Sprintf("%s?token=%s", strings.TrimRight(localURL, "/"), token) |
| 845 | } |
| 846 | return localURL |
| 847 | } |
| 848 | |
| 849 | func (a *App) RemoteServerStatus(hostID, workspace string) (RemoteServerView, error) { |
| 850 | rt, err := a.remoteRT() |
| 851 | if err != nil { |
| 852 | return RemoteServerView{}, err |
| 853 | } |
| 854 | return rt.ServerStatus(hostID, workspace), nil |
| 855 | } |
| 856 | |
| 857 | func (a *App) RemoteServerLogs(hostID, workspace string, tailLines int) (string, error) { |
| 858 | rt, err := a.remoteRT() |
| 859 | if err != nil { |
| 860 | return "", err |
| 861 | } |
| 862 | return rt.ServerLogs(a.bootContext(), hostID, workspace, tailLines) |
| 863 | } |
| 864 | |
| 865 | // ── desktopRemoteManager: concrete remoteKernel ── |
| 866 | |
| 867 | type managedHost struct { |
| 868 | client desktopSSHClient |
| 869 | ctx context.Context |
| 870 | cancel context.CancelFunc |
| 871 | status RemoteConnectionStatusView |
| 872 | serves map[string]*serveEntry // per-workspace serve registry, keyed by the workspace string |
| 873 | fpAnswer chan bool // TOFU resolution channel; non-nil while pending |
| 874 | secretAnswer chan remoteSecretAnswer // one-shot credential channel; non-nil while pending |
| 875 | secretPromptID string // opaque ID prevents a stale dialog resolving a later prompt |
| 876 | verifiedPeer *RemoteFingerprintView // authenticated target key; retained after pending UI clears |
| 877 | serveMu sync.Mutex // serializes EnsureServer/StopServer for this host |
| 878 | // credPort is the last healed reverse-tunnel port; 0 means unknown. |
| 879 | // It is atomic because ensure and status dispatch use different locks. |
| 880 | credPort atomic.Int64 |
| 881 | // credFallbackAt throttles legacy-serve replacement per workspace. |
| 882 | credFallbackAt map[string]int64 |
| 883 | credWatch credentialWatchdog |
| 884 | } |
| 885 | |
| 886 | // serveEntry is one workspace's serve registration: the published view (with |
| 887 | // its loopback tunnel URL), the auth token for that serve, and the remote |
| 888 | // bind address the forward targets (kept so a ready entry can be revalidated |
| 889 | // without a fresh ensure round trip). |
| 890 | type serveEntry struct { |
| 891 | view RemoteServerView |
| 892 | token string |
| 893 | addr string |
| 894 | } |
| 895 | |
| 896 | type remoteSecretAnswer struct { |
| 897 | secret string |
| 898 | accept bool |
| 899 | } |
| 900 | |
| 901 | type desktopSSHClient interface { |
| 902 | bootstrap.Conn |
| 903 | Start(context.Context) error |
| 904 | Close() error |
| 905 | Subscribe(func(remote.StatusEvent)) func() |
| 906 | Forwards() *forward.Set |
| 907 | } |
| 908 | |
| 909 | type desktopRemoteManager struct { |
| 910 | sink remoteEventSink |
| 911 | |
| 912 | mu sync.Mutex |
| 913 | hosts map[string]*managedHost |
| 914 | |
| 915 | newClient func(remote.Options) (desktopSSHClient, error) |
| 916 | ensureServe func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) |
| 917 | stopServe func(context.Context, bootstrap.Conn, string) error |
| 918 | serveLogs func(context.Context, bootstrap.Conn, string, int, *strings.Builder) error |
| 919 | localBinary func() string |
| 920 | fetchRemoteBinary func(context.Context, string, string, string) ([]byte, error) |
| 921 | promptGate chan struct{} |
| 922 | promptSeq uint64 |
| 923 | } |
| 924 | |
| 925 | func newDesktopRemoteManager(sink remoteEventSink) *desktopRemoteManager { |
| 926 | return &desktopRemoteManager{ |
| 927 | sink: sink, |
| 928 | hosts: map[string]*managedHost{}, |
| 929 | newClient: func(opts remote.Options) (desktopSSHClient, error) { |
| 930 | return remote.New(opts) |
| 931 | }, |
| 932 | ensureServe: bootstrap.EnsureServe, |
| 933 | stopServe: bootstrap.Stop, |
| 934 | serveLogs: func(ctx context.Context, conn bootstrap.Conn, workspace string, n int, out *strings.Builder) error { |
| 935 | return bootstrap.Logs(ctx, conn, workspace, n, out) |
| 936 | }, |
| 937 | localBinary: desktopCLIBinaryPath, |
| 938 | fetchRemoteBinary: downloadRemoteCLIBinary, |
| 939 | promptGate: make(chan struct{}, 1), |
| 940 | } |
| 941 | } |
| 942 | |
| 943 | func (m *desktopRemoteManager) Hosts() ([]RemoteHostView, error) { |
| 944 | cfg, err := config.Load() |
| 945 | if err != nil { |
| 946 | return nil, err |
| 947 | } |
| 948 | out := make([]RemoteHostView, 0, len(cfg.Remote.Hosts)) |
| 949 | for _, h := range cfg.Remote.Hosts { |
| 950 | out = append(out, hostEntryToView(h)) |
| 951 | } |
| 952 | return out, nil |
| 953 | } |
| 954 | |
| 955 | func (m *desktopRemoteManager) AddHost(in RemoteHostInput) (RemoteHostView, error) { |
| 956 | var entry config.RemoteHostEntry |
| 957 | if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) { |
| 958 | entry = inputToHostEntry(in) |
| 959 | if existing, ok := c.RemoteHost(entry.Name); ok { |
| 960 | preserveRemoteHostHiddenFields(&entry, existing) |
| 961 | if in.PreserveExistingSettings { |
| 962 | preserveRemoteHostImportSettings(&entry, existing) |
| 963 | } |
| 964 | } |
| 965 | changes, removals := applyRemoteCredentialInput(&entry, in) |
| 966 | if err := c.UpsertRemoteHost(entry); err != nil { |
| 967 | return nil, err |
| 968 | } |
| 969 | return append(changes, config.UnusedGeneratedRemoteCredentialChanges(c, removals)...), nil |
| 970 | }); err != nil { |
| 971 | return RemoteHostView{}, err |
| 972 | } |
| 973 | return hostEntryToView(entry), nil |
| 974 | } |
| 975 | |
| 976 | func (m *desktopRemoteManager) UpdateHost(id string, in RemoteHostInput) (RemoteHostView, error) { |
| 977 | var merged config.RemoteHostEntry |
| 978 | if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) { |
| 979 | entry := inputToHostEntry(in) |
| 980 | entry.Name = id |
| 981 | if existing, ok := c.RemoteHost(id); ok { |
| 982 | preserveRemoteHostHiddenFields(&entry, existing) |
| 983 | } |
| 984 | changes, removals := applyRemoteCredentialInput(&entry, in) |
| 985 | merged = entry |
| 986 | if err := c.UpsertRemoteHost(entry); err != nil { |
| 987 | return nil, err |
| 988 | } |
| 989 | return append(changes, config.UnusedGeneratedRemoteCredentialChanges(c, removals)...), nil |
| 990 | }); err != nil { |
| 991 | return RemoteHostView{}, err |
| 992 | } |
| 993 | if !merged.CredentialProxyEnabled() { |
| 994 | m.mu.Lock() |
| 995 | mh := m.hosts[id] |
| 996 | m.mu.Unlock() |
| 997 | if mh != nil { |
| 998 | mh.credWatch.stop() |
| 999 | } |
| 1000 | } |
| 1001 | return hostEntryToView(merged), nil |
| 1002 | } |
| 1003 | |
| 1004 | func (m *desktopRemoteManager) RemoveHost(id string) error { |
| 1005 | _ = m.Disconnect(id) |
| 1006 | removed := false |
| 1007 | if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) { |
| 1008 | var removals []string |
| 1009 | if existing, ok := c.RemoteHost(id); ok { |
| 1010 | for _, key := range []string{existing.PasswordEnv, existing.PassphraseEnv} { |
| 1011 | if config.IsGeneratedRemoteCredential(id, key) { |
| 1012 | removals = append(removals, key) |
| 1013 | } |
| 1014 | } |
| 1015 | } |
| 1016 | removed = c.RemoveRemoteHost(id) |
| 1017 | return config.UnusedGeneratedRemoteCredentialChanges(c, removals), nil |
| 1018 | }); err != nil { |
| 1019 | return err |
| 1020 | } |
| 1021 | if !removed { |
| 1022 | return fmt.Errorf("no remote host named %q", id) |
| 1023 | } |
| 1024 | return nil |
| 1025 | } |
| 1026 | |
| 1027 | func (m *desktopRemoteManager) ScanSSHConfig() ([]RemoteHostInput, error) { |
| 1028 | src, err := remote.LoadUserSSHConfig() |
| 1029 | if err != nil { |
| 1030 | return nil, err |
| 1031 | } |
| 1032 | // Non-nil so Wails encodes an empty result as [] (not null), which the React |
| 1033 | // import page iterates safely. |
| 1034 | out := []RemoteHostInput{} |
| 1035 | for _, cand := range src.Aliases() { |
| 1036 | out = append(out, RemoteHostInput{ |
| 1037 | Label: cand.Alias, |
| 1038 | Host: cand.Alias, |
| 1039 | Port: 0, |
| 1040 | UseSSHConfig: true, |
| 1041 | PreserveExistingSettings: true, |
| 1042 | }) |
| 1043 | } |
| 1044 | return out, nil |
| 1045 | } |
| 1046 | |
| 1047 | func (m *desktopRemoteManager) Connect(hostID string) error { |
| 1048 | cfg, err := config.Load() |
| 1049 | if err != nil { |
| 1050 | return err |
| 1051 | } |
| 1052 | sshCfg, err := remote.LoadUserSSHConfig() |
| 1053 | if err != nil { |
| 1054 | return fmt.Errorf("load SSH config: %w", err) |
| 1055 | } |
| 1056 | host, err := remote.ResolveHost(cfg, hostID, sshCfg) |
| 1057 | if err != nil { |
| 1058 | return err |
| 1059 | } |
| 1060 | |
| 1061 | resolvedJumps, err := remote.ResolveJumpHosts(cfg, host.ProxyJump, sshCfg) |
| 1062 | if err != nil { |
| 1063 | return err |
| 1064 | } |
| 1065 | |
| 1066 | // Honor the user's proxy settings for the SSH dial, same as the CLI. |
| 1067 | dialer, derr := netclient.NewStreamDialer(cfg.NetworkProxySpec()) |
| 1068 | if derr != nil { |
| 1069 | return fmt.Errorf("remote: network proxy is misconfigured: %w", derr) |
| 1070 | } |
| 1071 | |
| 1072 | hostCtx, cancel := context.WithCancel(context.Background()) |
| 1073 | mh := &managedHost{ |
| 1074 | ctx: hostCtx, cancel: cancel, |
| 1075 | status: RemoteConnectionStatusView{HostID: hostID, State: "connecting"}, |
| 1076 | serves: map[string]*serveEntry{}, credFallbackAt: map[string]int64{}, |
| 1077 | } |
| 1078 | secretPrompt := m.secretPrompt(hostID, mh) |
| 1079 | auth := desktopAuthForHost(host, secretPrompt) |
| 1080 | jumpHosts := make([]remote.JumpHostOptions, 0, len(resolvedJumps)) |
| 1081 | for _, jump := range resolvedJumps { |
| 1082 | jumpHosts = append(jumpHosts, remote.JumpHostOptions{Host: jump, Auth: desktopAuthForHost(jump, secretPrompt)}) |
| 1083 | } |
| 1084 | policy := &remote.HostKeyPolicy{ |
| 1085 | Prompt: m.hostKeyPrompt(hostID, mh), |
| 1086 | Verified: func(q remote.HostKeyQuestion) { |
| 1087 | if q.Host != host.Label() { |
| 1088 | return // a ProxyJump identity is not the target identity |
| 1089 | } |
| 1090 | m.mu.Lock() |
| 1091 | if m.hosts[hostID] == mh { |
| 1092 | mh.verifiedPeer = &RemoteFingerprintView{ |
| 1093 | HostID: hostID, Address: q.Address, KeyType: q.KeyType, SHA256: q.Fingerprint, |
| 1094 | } |
| 1095 | } |
| 1096 | m.mu.Unlock() |
| 1097 | }, |
| 1098 | } |
| 1099 | client, err := m.newClient(remote.Options{ |
| 1100 | Host: host, Auth: auth, JumpHosts: jumpHosts, HostKeys: policy, Dialer: dialer, |
| 1101 | }) |
| 1102 | if err != nil { |
| 1103 | cancel() |
| 1104 | return err |
| 1105 | } |
| 1106 | mh.client = client |
| 1107 | |
| 1108 | // Insert a fully-populated generation atomically. A stopped generation is |
| 1109 | // replaceable; active/connecting generations make Connect idempotent. |
| 1110 | var replaced *managedHost |
| 1111 | m.mu.Lock() |
| 1112 | if existing := m.hosts[hostID]; existing != nil && existing.status.State != "stopped" { |
| 1113 | m.mu.Unlock() |
| 1114 | cancel() |
| 1115 | _ = client.Close() |
| 1116 | return nil // already connecting/connected |
| 1117 | } |
| 1118 | replaced = m.hosts[hostID] |
| 1119 | m.hosts[hostID] = mh |
| 1120 | m.mu.Unlock() |
| 1121 | closeManagedHost(replaced) |
| 1122 | |
| 1123 | client.Subscribe(func(ev remote.StatusEvent) { m.onClientStatus(hostID, mh, ev) }) |
| 1124 | |
| 1125 | go func() { |
| 1126 | if err := client.Start(hostCtx); err != nil { |
| 1127 | // Keep the stopped generation and its user-visible error. The next |
| 1128 | // Connect atomically replaces it with a fresh client. |
| 1129 | cancel() |
| 1130 | _ = client.Close() |
| 1131 | return |
| 1132 | } |
| 1133 | m.applyConfiguredForwards(hostID, mh, cfg) |
| 1134 | }() |
| 1135 | return nil |
| 1136 | } |
| 1137 | |
| 1138 | func desktopAuthForHost(host remote.ResolvedHost, prompt remote.SecretPrompt) remote.AuthOptions { |
| 1139 | auth := remote.AuthOptions{SecretPrompt: prompt} |
| 1140 | if host.PassphraseEnv != "" { |
| 1141 | env := host.PassphraseEnv |
| 1142 | auth.Passphrase = func() (string, error) { return config.ResolveCredential(env).Value, nil } |
| 1143 | } |
| 1144 | if host.PasswordEnv != "" { |
| 1145 | env := host.PasswordEnv |
| 1146 | auth.Password = func() (string, error) { return config.ResolveCredential(env).Value, nil } |
| 1147 | } |
| 1148 | return auth |
| 1149 | } |
| 1150 | |
| 1151 | func (m *desktopRemoteManager) applyConfiguredForwards(hostID string, mh *managedHost, cfg *config.Config) { |
| 1152 | entry, ok := cfg.RemoteHost(hostID) |
| 1153 | if !ok || !m.isCurrent(hostID, mh) { |
| 1154 | return |
| 1155 | } |
| 1156 | for _, f := range entry.Forwards { |
| 1157 | dir := forward.Local |
| 1158 | if strings.EqualFold(f.Type, "remote") { |
| 1159 | dir = forward.Remote |
| 1160 | } |
| 1161 | _, _ = mh.client.Forwards().Add(forward.Spec{Direction: dir, BindAddr: desktopNormalizeBind(f.Bind), TargetAddr: f.Target}) |
| 1162 | } |
| 1163 | m.emitForwardsFor(hostID, mh) |
| 1164 | } |
| 1165 | |
| 1166 | func (m *desktopRemoteManager) Disconnect(hostID string) error { |
| 1167 | m.mu.Lock() |
| 1168 | mh := m.hosts[hostID] |
| 1169 | delete(m.hosts, hostID) |
| 1170 | var answer chan bool |
| 1171 | var secretAnswer chan remoteSecretAnswer |
| 1172 | if mh != nil { |
| 1173 | answer = mh.fpAnswer |
| 1174 | mh.fpAnswer = nil |
| 1175 | secretAnswer = mh.secretAnswer |
| 1176 | mh.secretAnswer = nil |
| 1177 | mh.secretPromptID = "" |
| 1178 | } |
| 1179 | if mh != nil && m.sink != nil { |
| 1180 | m.sink.onStatus(RemoteConnectionStatusView{HostID: hostID, State: "stopped"}) |
| 1181 | } |
| 1182 | m.mu.Unlock() |
| 1183 | if mh == nil { |
| 1184 | return nil |
| 1185 | } |
| 1186 | if answer != nil { |
| 1187 | select { |
| 1188 | case answer <- false: |
| 1189 | default: |
| 1190 | } |
| 1191 | } |
| 1192 | if secretAnswer != nil { |
| 1193 | select { |
| 1194 | case secretAnswer <- remoteSecretAnswer{}: |
| 1195 | default: |
| 1196 | } |
| 1197 | } |
| 1198 | closeManagedHost(mh) |
| 1199 | return nil |
| 1200 | } |
| 1201 | |
| 1202 | func closeManagedHost(mh *managedHost) { |
| 1203 | if mh == nil { |
| 1204 | return |
| 1205 | } |
| 1206 | mh.credWatch.stop() |
| 1207 | if mh.cancel != nil { |
| 1208 | mh.cancel() |
| 1209 | } |
| 1210 | if mh.client != nil { |
| 1211 | _ = mh.client.Close() |
| 1212 | } |
| 1213 | } |
| 1214 | |
| 1215 | func (m *desktopRemoteManager) Statuses() []RemoteConnectionStatusView { |
| 1216 | m.mu.Lock() |
| 1217 | defer m.mu.Unlock() |
| 1218 | out := make([]RemoteConnectionStatusView, 0, len(m.hosts)) |
| 1219 | for _, mh := range m.hosts { |
| 1220 | out = append(out, mh.status) |
| 1221 | } |
| 1222 | return out |
| 1223 | } |
| 1224 | |
| 1225 | func (m *desktopRemoteManager) ResolveHostKey(hostID string, accept bool) error { |
| 1226 | m.mu.Lock() |
| 1227 | mh := m.hosts[hostID] |
| 1228 | var ch chan bool |
| 1229 | if mh != nil { |
| 1230 | ch = mh.fpAnswer |
| 1231 | } |
| 1232 | m.mu.Unlock() |
| 1233 | if ch == nil { |
| 1234 | return fmt.Errorf("no pending host key confirmation for %q", hostID) |
| 1235 | } |
| 1236 | select { |
| 1237 | case ch <- accept: |
| 1238 | return nil |
| 1239 | default: |
| 1240 | return fmt.Errorf("host key confirmation already resolved for %q", hostID) |
| 1241 | } |
| 1242 | } |
| 1243 | |
| 1244 | func (m *desktopRemoteManager) ResolveSecret(hostID, promptID, secret string, accept bool) error { |
| 1245 | m.mu.Lock() |
| 1246 | mh := m.hosts[hostID] |
| 1247 | var ch chan remoteSecretAnswer |
| 1248 | if mh != nil && mh.secretPromptID == promptID { |
| 1249 | ch = mh.secretAnswer |
| 1250 | } |
| 1251 | m.mu.Unlock() |
| 1252 | if ch == nil { |
| 1253 | return fmt.Errorf("no pending SSH credential prompt for %q", hostID) |
| 1254 | } |
| 1255 | select { |
| 1256 | case ch <- remoteSecretAnswer{secret: secret, accept: accept}: |
| 1257 | return nil |
| 1258 | default: |
| 1259 | return fmt.Errorf("SSH credential prompt already resolved for %q", hostID) |
| 1260 | } |
| 1261 | } |
| 1262 | |
| 1263 | // hostKeyPrompt returns a HostKeyPrompt that surfaces the fingerprint as a |
| 1264 | // pending_hostkey status and blocks on the answer channel until the UI calls |
| 1265 | // ConfirmRemoteHostKey. |
| 1266 | func (m *desktopRemoteManager) hostKeyPrompt(hostID string, generation *managedHost) remote.HostKeyPrompt { |
| 1267 | return func(ctx context.Context, q remote.HostKeyQuestion) (bool, error) { |
| 1268 | // The frontend presents one global TOFU dialog. Serialize prompts so two |
| 1269 | // simultaneous first-seen hosts cannot overwrite one another in the UI. |
| 1270 | select { |
| 1271 | case m.promptGate <- struct{}{}: |
| 1272 | defer func() { <-m.promptGate }() |
| 1273 | case <-ctx.Done(): |
| 1274 | return false, ctx.Err() |
| 1275 | } |
| 1276 | answer := make(chan bool, 1) |
| 1277 | m.mu.Lock() |
| 1278 | mh := m.hosts[hostID] |
| 1279 | if mh != generation { |
| 1280 | m.mu.Unlock() |
| 1281 | return false, fmt.Errorf("host %q connection was replaced", hostID) |
| 1282 | } |
| 1283 | mh.fpAnswer = answer |
| 1284 | fp := &RemoteFingerprintView{HostID: hostID, Address: q.Address, KeyType: q.KeyType, SHA256: q.Fingerprint} |
| 1285 | mh.status = RemoteConnectionStatusView{HostID: hostID, State: "pending_hostkey", Fingerprint: fp} |
| 1286 | status := mh.status |
| 1287 | if m.sink != nil { |
| 1288 | m.sink.onStatus(status) |
| 1289 | } |
| 1290 | m.mu.Unlock() |
| 1291 | defer func() { |
| 1292 | m.mu.Lock() |
| 1293 | if m.hosts[hostID] == generation && generation.fpAnswer == answer { |
| 1294 | generation.fpAnswer = nil |
| 1295 | } |
| 1296 | m.mu.Unlock() |
| 1297 | }() |
| 1298 | |
| 1299 | select { |
| 1300 | case ok := <-answer: |
| 1301 | return ok, nil |
| 1302 | case <-ctx.Done(): |
| 1303 | return false, ctx.Err() |
| 1304 | case <-time.After(2 * time.Minute): |
| 1305 | return false, fmt.Errorf("host key confirmation timed out") |
| 1306 | } |
| 1307 | } |
| 1308 | } |
| 1309 | |
| 1310 | // secretPrompt surfaces a password/passphrase request as a global desktop |
| 1311 | // dialog. Prompt metadata may be emitted, but the entered secret only crosses |
| 1312 | // the one-shot answer channel and AuthOptions' in-memory reconnect cache. |
| 1313 | func (m *desktopRemoteManager) secretPrompt(hostID string, generation *managedHost) remote.SecretPrompt { |
| 1314 | return func(ctx context.Context, kind remote.SecretKind, host, identityFile string) (string, error) { |
| 1315 | select { |
| 1316 | case m.promptGate <- struct{}{}: |
| 1317 | defer func() { <-m.promptGate }() |
| 1318 | case <-ctx.Done(): |
| 1319 | return "", ctx.Err() |
| 1320 | } |
| 1321 | |
| 1322 | answer := make(chan remoteSecretAnswer, 1) |
| 1323 | m.mu.Lock() |
| 1324 | mh := m.hosts[hostID] |
| 1325 | if mh != generation { |
| 1326 | m.mu.Unlock() |
| 1327 | return "", fmt.Errorf("host %q connection was replaced", hostID) |
| 1328 | } |
| 1329 | m.promptSeq++ |
| 1330 | promptID := fmt.Sprintf("ssh-secret-%d", m.promptSeq) |
| 1331 | mh.secretAnswer = answer |
| 1332 | mh.secretPromptID = promptID |
| 1333 | identity := "" |
| 1334 | if strings.TrimSpace(identityFile) != "" { |
| 1335 | identity = filepath.Base(identityFile) |
| 1336 | } |
| 1337 | prompt := &RemoteSecretPromptView{PromptID: promptID, HostID: hostID, Host: host, Kind: kind.String(), Identity: identity} |
| 1338 | mh.status = RemoteConnectionStatusView{HostID: hostID, State: "pending_secret", SecretPrompt: prompt} |
| 1339 | status := mh.status |
| 1340 | if m.sink != nil { |
| 1341 | m.sink.onStatus(status) |
| 1342 | } |
| 1343 | m.mu.Unlock() |
| 1344 | defer func() { |
| 1345 | m.mu.Lock() |
| 1346 | if m.hosts[hostID] == generation && generation.secretAnswer == answer { |
| 1347 | generation.secretAnswer = nil |
| 1348 | generation.secretPromptID = "" |
| 1349 | } |
| 1350 | m.mu.Unlock() |
| 1351 | }() |
| 1352 | |
| 1353 | select { |
| 1354 | case response := <-answer: |
| 1355 | if !response.accept { |
| 1356 | return "", fmt.Errorf("remote: %s prompt canceled", kind) |
| 1357 | } |
| 1358 | return response.secret, nil |
| 1359 | case <-ctx.Done(): |
| 1360 | return "", ctx.Err() |
| 1361 | case <-time.After(2 * time.Minute): |
| 1362 | return "", fmt.Errorf("remote: %s prompt timed out", kind) |
| 1363 | } |
| 1364 | } |
| 1365 | } |
| 1366 | |
| 1367 | func (m *desktopRemoteManager) onClientStatus(hostID string, generation *managedHost, ev remote.StatusEvent) { |
| 1368 | if ev.Status == remote.StatusIdle { |
| 1369 | return |
| 1370 | } |
| 1371 | view := RemoteConnectionStatusView{ |
| 1372 | HostID: hostID, |
| 1373 | State: statusString(ev.Status), |
| 1374 | Attempt: ev.Attempt, |
| 1375 | } |
| 1376 | if ev.Err != nil { |
| 1377 | applyRemoteConnectionError(&view, ev.Err) |
| 1378 | } |
| 1379 | m.mu.Lock() |
| 1380 | mh := m.hosts[hostID] |
| 1381 | if mh != generation { |
| 1382 | m.mu.Unlock() |
| 1383 | return |
| 1384 | } |
| 1385 | // Preserve a pending modal that a separate prompt goroutine set. |
| 1386 | if (mh.status.State == "pending_hostkey" || mh.status.State == "pending_secret") && view.State == "connecting" { |
| 1387 | m.mu.Unlock() |
| 1388 | return |
| 1389 | } |
| 1390 | mh.status = view |
| 1391 | if view.State == "connected" { |
| 1392 | // Reconnects rebind the reverse forward, so force the next ensure to |
| 1393 | // heal the new credential channel while m.mu is already held. |
| 1394 | mh.credPort.Store(0) |
| 1395 | } |
| 1396 | if m.sink != nil { |
| 1397 | m.sink.onStatus(view) |
| 1398 | } |
| 1399 | m.mu.Unlock() |
| 1400 | } |
| 1401 | |
| 1402 | func (m *desktopRemoteManager) isCurrent(hostID string, generation *managedHost) bool { |
| 1403 | m.mu.Lock() |
| 1404 | defer m.mu.Unlock() |
| 1405 | return m.hosts[hostID] == generation |
| 1406 | } |
| 1407 | |
| 1408 | func (m *desktopRemoteManager) client(hostID string) desktopSSHClient { |
| 1409 | m.mu.Lock() |
| 1410 | defer m.mu.Unlock() |
| 1411 | if mh := m.hosts[hostID]; mh != nil { |
| 1412 | return mh.client |
| 1413 | } |
| 1414 | return nil |
| 1415 | } |
| 1416 | |
| 1417 | func (m *desktopRemoteManager) fs(ctx context.Context, hostID string) (desktopSSHClient, error) { |
| 1418 | c := m.client(hostID) |
| 1419 | if c == nil { |
| 1420 | return nil, fmt.Errorf("host %q is not connected", hostID) |
| 1421 | } |
| 1422 | return c, nil |
| 1423 | } |
| 1424 | |
| 1425 | func (m *desktopRemoteManager) ListDir(ctx context.Context, hostID, path string) ([]RemoteDirEntry, error) { |
| 1426 | c, err := m.fs(ctx, hostID) |
| 1427 | if err != nil { |
| 1428 | return nil, err |
| 1429 | } |
| 1430 | fsys, err := c.SFTP() |
| 1431 | if err != nil { |
| 1432 | return nil, err |
| 1433 | } |
| 1434 | entries, err := fsys.List(ctx, path) |
| 1435 | if err != nil { |
| 1436 | return nil, err |
| 1437 | } |
| 1438 | out := make([]RemoteDirEntry, 0, len(entries)) |
| 1439 | for _, e := range entries { |
| 1440 | out = append(out, RemoteDirEntry{ |
| 1441 | Name: e.Name, Path: e.Path, IsDir: e.IsDir, |
| 1442 | Size: e.Size, MtimeUnix: e.ModTime, Symlink: e.Symlink, |
| 1443 | }) |
| 1444 | } |
| 1445 | return out, nil |
| 1446 | } |
| 1447 | |
| 1448 | func (m *desktopRemoteManager) ReadFile(ctx context.Context, hostID, path string) (RemoteFilePreview, error) { |
| 1449 | c, err := m.fs(ctx, hostID) |
| 1450 | if err != nil { |
| 1451 | return RemoteFilePreview{}, err |
| 1452 | } |
| 1453 | fsys, err := c.SFTP() |
| 1454 | if err != nil { |
| 1455 | return RemoteFilePreview{}, err |
| 1456 | } |
| 1457 | st, err := fsys.Stat(ctx, path) |
| 1458 | if err != nil { |
| 1459 | return RemoteFilePreview{Path: path, Err: err.Error()}, nil |
| 1460 | } |
| 1461 | data, truncated, kind, err := fsys.ReadFile(ctx, path, 0) |
| 1462 | if err != nil { |
| 1463 | return RemoteFilePreview{Path: path, Err: err.Error()}, nil |
| 1464 | } |
| 1465 | binary := kind != 0 // sftpfs.KindText == 0 |
| 1466 | prev := RemoteFilePreview{ |
| 1467 | Path: path, Size: st.Size, MtimeUnix: st.ModTime, |
| 1468 | Truncated: truncated, Binary: binary, |
| 1469 | } |
| 1470 | if !binary { |
| 1471 | prev.Body = string(data) |
| 1472 | } |
| 1473 | return prev, nil |
| 1474 | } |
| 1475 | |
| 1476 | func (m *desktopRemoteManager) DownloadFile(ctx context.Context, hostID, remotePath string, dst io.Writer) (int64, error) { |
| 1477 | c, err := m.fs(ctx, hostID) |
| 1478 | if err != nil { |
| 1479 | return 0, err |
| 1480 | } |
| 1481 | fsys, err := c.SFTP() |
| 1482 | if err != nil { |
| 1483 | return 0, err |
| 1484 | } |
| 1485 | return fsys.Download(ctx, remotePath, dst) |
| 1486 | } |
| 1487 | |
| 1488 | func (m *desktopRemoteManager) WriteFile(ctx context.Context, hostID, path, body string, expectMtime int64) (RemoteWriteResult, error) { |
| 1489 | c, err := m.fs(ctx, hostID) |
| 1490 | if err != nil { |
| 1491 | return RemoteWriteResult{}, err |
| 1492 | } |
| 1493 | fsys, err := c.SFTP() |
| 1494 | if err != nil { |
| 1495 | return RemoteWriteResult{}, err |
| 1496 | } |
| 1497 | // Optimistic-concurrency check: if the caller passed an expected mtime and |
| 1498 | // the remote file moved, report a conflict instead of overwriting. |
| 1499 | if expectMtime > 0 { |
| 1500 | if st, serr := fsys.Stat(ctx, path); serr == nil && st.ModTime != expectMtime { |
| 1501 | return RemoteWriteResult{Conflict: true}, nil |
| 1502 | } |
| 1503 | } |
| 1504 | if err := fsys.WriteFileAtomic(ctx, path, []byte(body), 0o644); err != nil { |
| 1505 | return RemoteWriteResult{}, err |
| 1506 | } |
| 1507 | st, _ := fsys.Stat(ctx, path) |
| 1508 | return RemoteWriteResult{OK: true, NewMtimeUnix: st.ModTime}, nil |
| 1509 | } |
| 1510 | |
| 1511 | func (m *desktopRemoteManager) Mkdir(ctx context.Context, hostID, path string) error { |
| 1512 | c, err := m.fs(ctx, hostID) |
| 1513 | if err != nil { |
| 1514 | return err |
| 1515 | } |
| 1516 | fsys, err := c.SFTP() |
| 1517 | if err != nil { |
| 1518 | return err |
| 1519 | } |
| 1520 | return fsys.MkdirAll(ctx, path) |
| 1521 | } |
| 1522 | |
| 1523 | func (m *desktopRemoteManager) Rename(ctx context.Context, hostID, oldPath, newPath string) error { |
| 1524 | c, err := m.fs(ctx, hostID) |
| 1525 | if err != nil { |
| 1526 | return err |
| 1527 | } |
| 1528 | fsys, err := c.SFTP() |
| 1529 | if err != nil { |
| 1530 | return err |
| 1531 | } |
| 1532 | return fsys.Rename(ctx, oldPath, newPath) |
| 1533 | } |
| 1534 | |
| 1535 | func (m *desktopRemoteManager) Delete(ctx context.Context, hostID, path string, recursive bool) error { |
| 1536 | c, err := m.fs(ctx, hostID) |
| 1537 | if err != nil { |
| 1538 | return err |
| 1539 | } |
| 1540 | fsys, err := c.SFTP() |
| 1541 | if err != nil { |
| 1542 | return err |
| 1543 | } |
| 1544 | return fsys.Remove(ctx, path, recursive) |
| 1545 | } |
| 1546 | |
| 1547 | func (m *desktopRemoteManager) Forwards(hostID string) []RemoteForwardView { |
| 1548 | c := m.client(hostID) |
| 1549 | if c == nil { |
| 1550 | return nil |
| 1551 | } |
| 1552 | return forwardEntriesToViews(hostID, c.Forwards().List()) |
| 1553 | } |
| 1554 | |
| 1555 | func (m *desktopRemoteManager) AddForward(hostID string, in RemoteForwardInput) (RemoteForwardView, error) { |
| 1556 | c := m.client(hostID) |
| 1557 | if c == nil { |
| 1558 | return RemoteForwardView{}, fmt.Errorf("host %q is not connected", hostID) |
| 1559 | } |
| 1560 | if in.LocalPort <= 0 || in.LocalPort > 65535 || in.RemotePort <= 0 || in.RemotePort > 65535 || strings.TrimSpace(in.RemoteHost) == "" { |
| 1561 | return RemoteForwardView{}, fmt.Errorf("forward requires a remote host and ports between 1 and 65535") |
| 1562 | } |
| 1563 | spec := forward.Spec{ |
| 1564 | Name: in.Label, |
| 1565 | Direction: forward.Local, |
| 1566 | BindAddr: net.JoinHostPort("127.0.0.1", fmt.Sprint(in.LocalPort)), |
| 1567 | TargetAddr: net.JoinHostPort(strings.TrimSpace(in.RemoteHost), fmt.Sprint(in.RemotePort)), |
| 1568 | } |
| 1569 | if _, err := c.Forwards().Add(spec); err != nil { |
| 1570 | return RemoteForwardView{}, err |
| 1571 | } |
| 1572 | m.emitForwards(hostID) |
| 1573 | view := RemoteForwardView{ |
| 1574 | ID: spec.DefaultName(), HostID: hostID, LocalPort: in.LocalPort, |
| 1575 | RemoteHost: in.RemoteHost, RemotePort: in.RemotePort, Label: in.Label, State: "active", |
| 1576 | } |
| 1577 | return view, nil |
| 1578 | } |
| 1579 | |
| 1580 | func (m *desktopRemoteManager) RemoveForward(hostID, forwardID string) error { |
| 1581 | c := m.client(hostID) |
| 1582 | if c == nil { |
| 1583 | return fmt.Errorf("host %q is not connected", hostID) |
| 1584 | } |
| 1585 | if err := c.Forwards().Remove(forwardID); err != nil { |
| 1586 | return err |
| 1587 | } |
| 1588 | m.emitForwards(hostID) |
| 1589 | return nil |
| 1590 | } |
| 1591 | |
| 1592 | func (m *desktopRemoteManager) emitForwards(hostID string) { |
| 1593 | mh := m.managed(hostID) |
| 1594 | if mh != nil { |
| 1595 | m.emitForwardsFor(hostID, mh) |
| 1596 | } |
| 1597 | } |
| 1598 | |
| 1599 | func (m *desktopRemoteManager) emitForwardsFor(hostID string, generation *managedHost) { |
| 1600 | entries := generation.client.Forwards().List() |
| 1601 | m.mu.Lock() |
| 1602 | defer m.mu.Unlock() |
| 1603 | if m.hosts[hostID] != generation { |
| 1604 | return |
| 1605 | } |
| 1606 | if m.sink != nil { |
| 1607 | m.sink.onForwards(hostID, forwardEntriesToViews(hostID, entries)) |
| 1608 | } |
| 1609 | } |
| 1610 | |
| 1611 | func (m *desktopRemoteManager) Close() error { |
| 1612 | m.mu.Lock() |
| 1613 | hosts := m.hosts |
| 1614 | m.hosts = map[string]*managedHost{} |
| 1615 | answers := make([]chan bool, 0, len(hosts)) |
| 1616 | secretAnswers := make([]chan remoteSecretAnswer, 0, len(hosts)) |
| 1617 | for _, mh := range hosts { |
| 1618 | if mh.fpAnswer != nil { |
| 1619 | answers = append(answers, mh.fpAnswer) |
| 1620 | mh.fpAnswer = nil |
| 1621 | } |
| 1622 | if mh.secretAnswer != nil { |
| 1623 | secretAnswers = append(secretAnswers, mh.secretAnswer) |
| 1624 | mh.secretAnswer = nil |
| 1625 | } |
| 1626 | } |
| 1627 | m.mu.Unlock() |
| 1628 | for _, answer := range answers { |
| 1629 | select { |
| 1630 | case answer <- false: |
| 1631 | default: |
| 1632 | } |
| 1633 | } |
| 1634 | for _, answer := range secretAnswers { |
| 1635 | select { |
| 1636 | case answer <- remoteSecretAnswer{}: |
| 1637 | default: |
| 1638 | } |
| 1639 | } |
| 1640 | for _, mh := range hosts { |
| 1641 | closeManagedHost(mh) |
| 1642 | } |
| 1643 | return nil |
| 1644 | } |
| 1645 | |
| 1646 | func managedOperationContext(parent context.Context, mh *managedHost) (context.Context, context.CancelFunc) { |
| 1647 | if parent == nil { |
| 1648 | parent = context.Background() |
| 1649 | } |
| 1650 | ctx, cancel := context.WithCancel(parent) |
| 1651 | stop := func() bool { return false } |
| 1652 | if mh != nil && mh.ctx != nil { |
| 1653 | stop = context.AfterFunc(mh.ctx, cancel) |
| 1654 | } |
| 1655 | return ctx, func() { |
| 1656 | stop() |
| 1657 | cancel() |
| 1658 | } |
| 1659 | } |
| 1660 | |
| 1661 | // publishFailedServeStart keeps host server ownership on a previous ready |
| 1662 | // Serve when a new Serve or its tunnel failed to establish. The previous |
| 1663 | // Serve is still running with its tunnel (forward Replace is atomic), so |
| 1664 | // Stop/Logs and reconnect refresh must keep operating on the workspace that |
| 1665 | // actually runs; the failure is delivered through the EnsureServer return |
| 1666 | // value and the caller's actionErr. When there is no previous ready Serve |
| 1667 | // (first start), the error view is published so the UI can show it. |
| 1668 | func (m *desktopRemoteManager) publishFailedServeStart(hostID string, generation *managedHost, previous RemoteServerView, previousToken, previousAddr string, failed RemoteServerView) { |
| 1669 | if previous.State == "ready" { |
| 1670 | m.publishServerIfCurrent(hostID, generation, previous, previousToken, previousAddr) |
| 1671 | return |
| 1672 | } |
| 1673 | m.publishServerIfCurrent(hostID, generation, failed, "", "") |
| 1674 | } |
| 1675 | |
| 1676 | func (m *desktopRemoteManager) publishServerIfCurrent(hostID string, generation *managedHost, view RemoteServerView, token, addr string) bool { |
| 1677 | m.mu.Lock() |
| 1678 | defer m.mu.Unlock() |
| 1679 | if m.hosts[hostID] != generation { |
| 1680 | return false |
| 1681 | } |
| 1682 | if generation.serves == nil { |
| 1683 | generation.serves = map[string]*serveEntry{} |
| 1684 | } |
| 1685 | generation.serves[view.Workspace] = &serveEntry{view: view, token: token, addr: addr} |
| 1686 | if m.sink != nil { |
| 1687 | m.sink.onServer(view) |
| 1688 | } |
| 1689 | return true |
| 1690 | } |
| 1691 | |
| 1692 | func hasUsableServeForward(entries []forward.Entry, name, targetAddr, localURL string) bool { |
| 1693 | for _, entry := range entries { |
| 1694 | if entry.Spec.Name == name && entry.Up && entry.Spec.TargetAddr == targetAddr && entry.BoundAddr != "" { |
| 1695 | return localURL == fmt.Sprintf("http://%s/", entry.BoundAddr) |
| 1696 | } |
| 1697 | } |
| 1698 | return false |
| 1699 | } |
| 1700 | |
| 1701 | func desktopCLIBinaryPath() string { |
| 1702 | packagedName, commandName := desktopCLIBinaryNames(runtime.GOOS) |
| 1703 | candidates := []string{} |
| 1704 | if exe, err := os.Executable(); err == nil { |
| 1705 | dir := filepath.Dir(exe) |
| 1706 | candidates = append(candidates, filepath.Join(dir, packagedName)) |
| 1707 | } |
| 1708 | if found, err := exec.LookPath(commandName); err == nil { |
| 1709 | candidates = append(candidates, found) |
| 1710 | } |
| 1711 | for _, candidate := range candidates { |
| 1712 | st, err := os.Stat(candidate) |
| 1713 | if err != nil || !st.Mode().IsRegular() { |
| 1714 | continue |
| 1715 | } |
| 1716 | if runtime.GOOS != "windows" && st.Mode().Perm()&0o111 == 0 { |
| 1717 | continue |
| 1718 | } |
| 1719 | return candidate |
| 1720 | } |
| 1721 | return "" |
| 1722 | } |
| 1723 | |
| 1724 | func desktopCLIBinaryNames(goos string) (packaged, command string) { |
| 1725 | if goos == "windows" { |
| 1726 | return "reasonix-cli.exe", "reasonix.exe" |
| 1727 | } |
| 1728 | return "reasonix", "reasonix" |
| 1729 | } |
| 1730 | |
| 1731 | func desktopNormalizeBind(bind string) string { |
| 1732 | bind = strings.TrimSpace(bind) |
| 1733 | if !strings.Contains(bind, ":") { |
| 1734 | return net.JoinHostPort("127.0.0.1", bind) |
| 1735 | } |
| 1736 | return bind |
| 1737 | } |
| 1738 | |
| 1739 | // ── helpers ── |
| 1740 | |
| 1741 | func preserveRemoteHostHiddenFields(entry *config.RemoteHostEntry, existing config.RemoteHostEntry) { |
| 1742 | entry.PassphraseEnv = existing.PassphraseEnv |
| 1743 | entry.PasswordEnv = existing.PasswordEnv |
| 1744 | entry.Forwards = append([]config.RemoteForwardEntry(nil), existing.Forwards...) |
| 1745 | } |
| 1746 | |
| 1747 | // Importing an already-managed SSH alias refreshes only its OpenSSH lookup |
| 1748 | // fields. Reasonix-specific workspace and bootstrap policy remain user-owned. |
| 1749 | func preserveRemoteHostImportSettings(entry *config.RemoteHostEntry, existing config.RemoteHostEntry) { |
| 1750 | entry.Workspace = existing.Workspace |
| 1751 | entry.ServeInstall = existing.ServeInstall |
| 1752 | entry.CredentialMode = existing.CredentialMode |
| 1753 | } |
| 1754 | |
| 1755 | // applyRemoteCredentialInput maps plaintext received from the one-shot Wails |
| 1756 | // call into Reasonix-owned credential slots. Blank fields preserve the current |
| 1757 | // reference; explicit clear flags remove only slots that this desktop created. |
| 1758 | func applyRemoteCredentialInput(entry *config.RemoteHostEntry, in RemoteHostInput) (changes []config.CredentialChange, removalCandidates []string) { |
| 1759 | if in.ClearPassword { |
| 1760 | if config.IsGeneratedRemoteCredential(entry.Name, entry.PasswordEnv) { |
| 1761 | removalCandidates = append(removalCandidates, entry.PasswordEnv) |
| 1762 | } |
| 1763 | entry.PasswordEnv = "" |
| 1764 | } |
| 1765 | if in.Password != "" { |
| 1766 | entry.PasswordEnv = config.RemotePasswordCredentialEnvName(entry.Name) |
| 1767 | changes = append(changes, config.CredentialChange{Key: entry.PasswordEnv, Value: in.Password}) |
| 1768 | } |
| 1769 | |
| 1770 | if in.ClearPassphrase { |
| 1771 | if config.IsGeneratedRemoteCredential(entry.Name, entry.PassphraseEnv) { |
| 1772 | removalCandidates = append(removalCandidates, entry.PassphraseEnv) |
| 1773 | } |
| 1774 | entry.PassphraseEnv = "" |
| 1775 | } |
| 1776 | if in.KeyPassphrase != "" { |
| 1777 | entry.PassphraseEnv = config.RemotePassphraseCredentialEnvName(entry.Name) |
| 1778 | changes = append(changes, config.CredentialChange{Key: entry.PassphraseEnv, Value: in.KeyPassphrase}) |
| 1779 | } |
| 1780 | return changes, removalCandidates |
| 1781 | } |
| 1782 | |
| 1783 | func hostEntryToView(h config.RemoteHostEntry) RemoteHostView { |
| 1784 | return RemoteHostView{ |
| 1785 | ID: h.Name, Label: h.Name, Host: h.Host, Port: h.Port, User: h.User, |
| 1786 | IdentityFile: h.IdentityFile, ProxyJump: h.ProxyJump, |
| 1787 | DefaultWorkspace: h.Workspace, ServeInstall: h.ServeInstallMode(), CredentialMode: credentialModeView(h), UseSSHConfig: h.UseSSHConfig, |
| 1788 | PasswordSet: config.ResolveCredential(h.PasswordEnv).Set, |
| 1789 | KeyPassphraseSet: config.ResolveCredential(h.PassphraseEnv).Set, |
| 1790 | } |
| 1791 | } |
| 1792 | |
| 1793 | func inputToHostEntry(in RemoteHostInput) config.RemoteHostEntry { |
| 1794 | name := strings.TrimSpace(in.Label) |
| 1795 | return config.RemoteHostEntry{ |
| 1796 | Name: name, Host: in.Host, Port: in.Port, User: in.User, |
| 1797 | IdentityFile: in.IdentityFile, ProxyJump: in.ProxyJump, |
| 1798 | Workspace: in.DefaultWorkspace, ServeInstall: in.ServeInstall, CredentialMode: normalizeCredentialMode(in.CredentialMode), UseSSHConfig: in.UseSSHConfig, |
| 1799 | } |
| 1800 | } |
| 1801 | |
| 1802 | func forwardEntriesToViews(hostID string, entries []forward.Entry) []RemoteForwardView { |
| 1803 | out := make([]RemoteForwardView, 0, len(entries)) |
| 1804 | for _, e := range entries { |
| 1805 | state := "active" |
| 1806 | if !e.Up { |
| 1807 | state = "error" |
| 1808 | } |
| 1809 | v := RemoteForwardView{ |
| 1810 | ID: e.Spec.Name, HostID: hostID, Label: e.Spec.Name, State: state, |
| 1811 | } |
| 1812 | if e.LastErr != nil { |
| 1813 | v.Error = e.LastErr.Error() |
| 1814 | } |
| 1815 | out = append(out, v) |
| 1816 | } |
| 1817 | return out |
| 1818 | } |
| 1819 | |
| 1820 | func statusString(s remote.Status) string { |
| 1821 | switch s { |
| 1822 | case remote.StatusConnecting: |
| 1823 | return "connecting" |
| 1824 | case remote.StatusConnected: |
| 1825 | return "connected" |
| 1826 | case remote.StatusReconnecting: |
| 1827 | return "reconnecting" |
| 1828 | case remote.StatusDegraded: |
| 1829 | return "degraded" |
| 1830 | case remote.StatusStopped: |
| 1831 | return "stopped" |
| 1832 | default: |
| 1833 | return "stopped" |
| 1834 | } |
| 1835 | } |
| 1836 |