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