返回 DeepSeek-Reasonix
remote_app.go
根目录 / desktop / remote_app.go
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
1681 lines GO