返回 DeepSeek-Reasonix
remote_serve.go
根目录 / desktop / remote_serve.go
1 package main
2
3 import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "log"
10 "net"
11 "net/http"
12 "net/http/cookiejar"
13 "runtime"
14 "sort"
15 "strconv"
16 "strings"
17 "time"
18
19 "golang.org/x/crypto/ssh"
20
21 "reasonix/internal/config"
22 "reasonix/internal/jobs"
23 "reasonix/internal/remote/bootstrap"
24 "reasonix/internal/remote/forward"
25 "reasonix/internal/store"
26 )
27
28 const remoteProviderReloadTimeout = jobs.DefaultTeardownGrace + 15*time.Second
29
30 // SwitchCredentialProxyModel stages an immutable desktop proxy route and a
31 // a complete resolver bundle, then asks a capable Serve to publish it at the
32 // existing active-work/session boundary. An unknown outcome is read back; the
33 // write is never replayed or rolled back against a possibly newer controller.
34 func (m *desktopRemoteManager) SwitchCredentialProxyModel(ctx context.Context, hostID, workspace, currentRef, nextRef, expectedPath string) error {
35 hostID, workspace = strings.TrimSpace(hostID), strings.TrimSpace(workspace)
36 currentRef, nextRef = strings.TrimSpace(currentRef), strings.TrimSpace(nextRef)
37 if hostID == "" || workspace == "" || currentRef == "" || nextRef == "" {
38 return fmt.Errorf("credential proxy model switch: host, workspace, current model, and next model are required")
39 }
40 if ctx == nil {
41 ctx = context.Background()
42 }
43 mh := m.managed(hostID)
44 if mh == nil || mh.client == nil {
45 return fmt.Errorf("host %q is not connected", hostID)
46 }
47 mh.serveMu.Lock()
48 defer mh.serveMu.Unlock()
49 if !m.isCurrent(hostID, mh) {
50 return fmt.Errorf("host %q connection was replaced", hostID)
51 }
52 m.mu.Lock()
53 serve := mh.serves[workspace]
54 m.mu.Unlock()
55 if serve == nil || serve.view.State != "ready" || serve.view.LocalURL == "" || serve.token == "" {
56 return fmt.Errorf("workspace %q has no ready Reasonix Serve", workspace)
57 }
58 app, ok := m.sink.(*App)
59 if !ok || app == nil {
60 return fmt.Errorf("credential proxy: app unavailable")
61 }
62 client, err := newServeHTTPClient(serve.view.LocalURL)
63 if err != nil {
64 return err
65 }
66 if err := serveHandshake(ctx, client, serve.view.LocalURL, serve.token); err != nil {
67 return err
68 }
69 status, err := remoteModelSettingsRequest(ctx, client, serve.view.LocalURL, expectedPath, nil)
70 if err != nil {
71 return err
72 }
73 cfg, err := config.LoadModelRuntimeSnapshot(".")
74 if err != nil {
75 return err
76 }
77 port, err := app.credentialProxyPort()
78 if err != nil {
79 return err
80 }
81 if !m.pinModelSettingsOwnership(app, hostID, workspace, mh, status) {
82 return fmt.Errorf("remote Serve must support ordered model settings ownership")
83 }
84 remotePort, err := ensureCredentialProxyForward(mh.client, hostID, port)
85 if err != nil {
86 return fmt.Errorf("credential proxy: reverse tunnel: %w", err)
87 }
88 bundle, remoteRef, err := app.buildRemoteModelSettings(hostID, workspace, nextRef, remotePort, cfg)
89 if err != nil {
90 return err
91 }
92 _, err = app.installRemoteModelSettingsSnapshot(ctx, client, serve.view.LocalURL, hostID, workspace, expectedPath, remoteRef, bundle, status)
93 if err != nil {
94 return err
95 }
96 if !m.isCurrent(hostID, mh) {
97 return fmt.Errorf("remote connection changed while applying model settings")
98 }
99 app.remoteTabMu.Lock()
100 for _, tab := range app.remoteTabs {
101 if tab != nil && tab.ref.HostID == hostID && tab.ref.Workspace == workspace && tab.routing.currentPath == expectedPath {
102 tab.settings.revision = bundle.Revision
103 tab.settings.failure = ""
104 tab.settings.generation = tab.gen
105 tab.settings.sessionPath = expectedPath
106 }
107 }
108 app.remoteTabMu.Unlock()
109 return nil
110 }
111
112 func credentialProxyBootstrapOptions(workspace string, remotePort int, info credentialProxyRouteInfo) *bootstrap.CredentialProxyOptions {
113 slug := store.RemoteWorkspaceSlug(workspace)
114 suffix := slug[len(slug)-16:]
115 if info.revision != "" {
116 suffix += "-" + info.revision[:16]
117 }
118 return &bootstrap.CredentialProxyOptions{
119 BaseURL: fmt.Sprintf("http://127.0.0.1:%d", remotePort),
120 Token: info.token,
121 TokenEnv: "REASONIX_PROXY_TOKEN_" + strings.ToUpper(strings.ReplaceAll(suffix, "-", "_")),
122 Provider: credentialProxyProviderName + "-" + suffix,
123 Model: info.model,
124 Kind: info.kind,
125 }
126 }
127
128 // serveForwardName derives the per-workspace local tunnel name from the same
129 // collision-proof slug the remote state files use (store.RemoteWorkspaceSlug),
130 // so one host holds one independent forward per workspace.
131 func serveForwardName(workspace string) string {
132 return "serve-" + store.RemoteWorkspaceSlug(workspace)
133 }
134
135 func (m *desktopRemoteManager) EnsureServer(ctx context.Context, hostID, workspace string) (RemoteServerView, string, error) {
136 hostID, workspace = strings.TrimSpace(hostID), strings.TrimSpace(workspace)
137 if hostID == "" || workspace == "" {
138 return RemoteServerView{}, "", fmt.Errorf("remote serve: host and workspace are required")
139 }
140 if ctx == nil {
141 ctx = context.Background()
142 }
143 mh := m.managed(hostID)
144 if mh == nil || mh.client == nil {
145 return RemoteServerView{}, "", fmt.Errorf("host %q is not connected", hostID)
146 }
147 // Serialize per-host so two concurrent EnsureServer calls cannot both miss
148 // the state and launch duplicate/orphan serve processes.
149 mh.serveMu.Lock()
150 defer mh.serveMu.Unlock()
151 m.mu.Lock()
152 if m.hosts[hostID] != mh {
153 m.mu.Unlock()
154 return RemoteServerView{}, "", fmt.Errorf("host %q connection was replaced", hostID)
155 }
156 var previousServer RemoteServerView
157 previousToken := ""
158 previousAddr := ""
159 if e := mh.serves[workspace]; e != nil {
160 previousServer, previousToken, previousAddr = e.view, e.token, e.addr
161 }
162 m.mu.Unlock()
163 c := mh.client
164 if m.readyServeReusable(ctx, c, mh, hostID, workspace, previousServer, previousToken, previousAddr) {
165 m.startCredentialWatchdogIfEnabled(mh, hostID, workspace)
166 return previousServer, previousToken, nil
167 }
168 opCtx, cancel := managedOperationContext(ctx, mh)
169 defer cancel()
170
171 entry, err := configuredRemoteHost(hostID)
172 if err != nil {
173 return RemoteServerView{}, "", err
174 }
175 starting := RemoteServerView{HostID: hostID, Workspace: workspace, State: "starting"}
176 if !m.publishServerIfCurrent(hostID, mh, starting, "", "") {
177 return RemoteServerView{}, "", fmt.Errorf("host %q connection was replaced", hostID)
178 }
179 // Local-proxy credential mode: start the desktop key holder, open the
180 // reverse tunnel, and hand the bootstrap the virtual token + provider
181 // entry to install on the remote. The real key never leaves this machine.
182 credOpts, err := m.credentialOptions(c, hostID, workspace, entry)
183 if err != nil {
184 view := RemoteServerView{HostID: hostID, Workspace: workspace, State: "error", Error: err.Error()}
185 m.publishFailedServeStart(hostID, mh, previousServer, previousToken, previousAddr, view)
186 return view, "", err
187 }
188 res, err := m.ensureServe(opCtx, c, bootstrap.Options{
189 Workspace: workspace,
190 Install: entry.ServeInstallMode(),
191 LocalBinary: m.localBinary(),
192 LocalGOOS: runtime.GOOS,
193 LocalGOARCH: runtime.GOARCH,
194 ProductVersion: version,
195 FetchBinary: m.fetchRemoteBinary,
196 MinVersion: bootstrap.MinServeVersion,
197 CredentialProxy: credOpts,
198 BrowserBroker: m.browserBrokerCallback(c, hostID, mh),
199 Progress: func(step, detail string) {
200 view := RemoteServerView{HostID: hostID, Workspace: workspace, State: step, Message: detail}
201 m.publishServerIfCurrent(hostID, mh, view, "", "")
202 },
203 })
204 if err != nil {
205 view := RemoteServerView{HostID: hostID, Workspace: workspace, State: "error", Error: err.Error()}
206 m.publishFailedServeStart(hostID, mh, previousServer, previousToken, previousAddr, view)
207 return view, "", err
208 }
209 if !m.isCurrent(hostID, mh) {
210 return RemoteServerView{}, "", fmt.Errorf("host %q connection was replaced", hostID)
211 }
212 if res.Reused && previousServer.State == "ready" && previousServer.Workspace == workspace &&
213 hasUsableServeForward(c.Forwards().List(), serveForwardName(workspace), res.State.Addr, previousServer.LocalURL) {
214 previousServer.InstanceID = remoteServeInstanceID(res.State)
215 if !m.publishServerIfCurrent(hostID, mh, previousServer, res.Token, res.State.Addr) {
216 return RemoteServerView{}, "", fmt.Errorf("host %q connection was replaced", hostID)
217 }
218 // A reused process still carries the previous generation's broker
219 // environment; rotate the route and rebind it to this connection.
220 m.rebindBrowserBrokerBestEffort(opCtx, c, mh, hostID, previousServer, res.Token)
221 return m.finishCredentialServe(opCtx, c, mh, hostID, workspace, previousServer, res.Token, res, entry.CredentialProxyEnabled())
222 }
223 // Start the replacement before retiring the old tunnel. If binding fails,
224 // the previous ready server stays usable instead of leaving a dead gap.
225 bound, ferr := c.Forwards().Replace(forward.Spec{
226 Name: serveForwardName(workspace), Direction: forward.Local, BindAddr: "127.0.0.1:0", TargetAddr: res.State.Addr,
227 })
228 if ferr != nil {
229 if !res.Reused {
230 cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second)
231 _ = m.stopServe(cleanupCtx, c, workspace)
232 cleanupCancel()
233 }
234 view := RemoteServerView{HostID: hostID, Workspace: workspace, State: "error", Error: ferr.Error()}
235 m.publishFailedServeStart(hostID, mh, previousServer, previousToken, previousAddr, view)
236 return view, "", ferr
237 }
238 localURL := fmt.Sprintf("http://%s/", bound)
239 view := RemoteServerView{HostID: hostID, Workspace: workspace, State: "ready", LocalURL: localURL, InstanceID: remoteServeInstanceID(res.State)}
240 if !m.publishServerIfCurrent(hostID, mh, view, res.Token, res.State.Addr) {
241 _ = c.Forwards().Remove(serveForwardName(workspace))
242 return RemoteServerView{}, "", fmt.Errorf("host %q connection was replaced", hostID)
243 }
244 return m.finishCredentialServe(opCtx, c, mh, hostID, workspace, view, res.Token, res, entry.CredentialProxyEnabled())
245 }
246
247 func remoteServeInstanceID(state bootstrap.ServeState) string {
248 if state.PID == 0 && state.StartedAt == 0 && strings.TrimSpace(state.Addr) == "" {
249 return ""
250 }
251 return fmt.Sprintf("%d:%d:%s", state.PID, state.StartedAt, strings.TrimSpace(state.Addr))
252 }
253
254 func configuredRemoteHost(hostID string) (config.RemoteHostEntry, error) {
255 cfg, err := config.Load()
256 if err != nil {
257 return config.RemoteHostEntry{}, err
258 }
259 entry, ok := cfg.RemoteHost(hostID)
260 if !ok {
261 return config.RemoteHostEntry{}, fmt.Errorf("remote host %q is no longer configured", hostID)
262 }
263 return entry, nil
264 }
265
266 func (m *desktopRemoteManager) credentialOptions(c desktopSSHClient, hostID, workspace string, entry config.RemoteHostEntry) (*bootstrap.CredentialProxyOptions, error) {
267 if !entry.CredentialProxyEnabled() {
268 return nil, nil
269 }
270 return m.credentialProxySetup(c, hostID, workspace)
271 }
272
273 // readyServeReusable validates the serve forward and credential channel.
274 // Config read failures fail closed into the full ensure path.
275 func (m *desktopRemoteManager) readyServeReusable(ctx context.Context, c desktopSSHClient, mh *managedHost, hostID, workspace string, view RemoteServerView, token, addr string) bool {
276 if view.State != "ready" || view.LocalURL == "" || token == "" || addr == "" ||
277 !hasUsableServeForward(c.Forwards().List(), serveForwardName(workspace), addr, view.LocalURL) ||
278 !serveTunnelAlive(ctx, view.LocalURL) {
279 return false
280 }
281 cfg, err := config.Load()
282 if err != nil {
283 return false
284 }
285 host, ok := cfg.RemoteHost(hostID)
286 if !ok || !host.CredentialProxyEnabled() {
287 return true
288 }
289 port, hasForward := credentialForwardPort(c, hostID)
290 healedPort := int(mh.credPort.Load())
291 if !hasForward || healedPort != port || probeReverseTunnel(c, port) != nil {
292 log.Printf("[remote] EnsureServer: FAST-REUSE blocked (credential channel) host=%s ws=%s port=%d has=%v healedPort=%d", hostID, workspace, port, hasForward, healedPort)
293 return false
294 }
295 return true
296 }
297
298 // healCredentialChannel runs after ensure when the forward is live. A reconnect
299 // can move its remote port, so heal every tracked workspace config before any
300 // Serve reloads providers, then verify the channel before recording the port.
301 func (m *desktopRemoteManager) healCredentialChannel(ctx context.Context, c desktopSSHClient, mh *managedHost, hostID, workspace, base, token string, res bootstrap.Result) error {
302 port, has := credentialForwardPort(c, hostID)
303 if !has {
304 return fmt.Errorf("credential proxy: reverse tunnel is not available")
305 }
306 // The tunnel secret rotates on every SSH reconnection even when the remote
307 // port is reused, and a running serve keeps validating the previous token
308 // until its providers are rebuilt from the healed disk config. So heal the
309 // tracked configs and reload unconditionally: both steps are idempotent,
310 // and skipping the config heal on a same-port rebind leaves every model
311 // call failing with "invalid credential proxy token".
312 if int(mh.credPort.Load()) != port || res.CredentialConfigChanged {
313 log.Printf("[remote] EnsureServer: cred port drift host=%s old=%d new=%d configChanged=%v -> healing serve credentials", hostID, mh.credPort.Load(), port, res.CredentialConfigChanged)
314 } else {
315 log.Printf("[remote] EnsureServer: same cred port %d -> healing serve credentials (fresh tunnel secret)", port)
316 }
317 workspaces := m.trackedCredentialWorkspaces(hostID, workspace)
318 // base+token covers the Serve ensured by this round even if it has not
319 // reached the registry yet; tracked peers are reloaded alongside it.
320 if err := healCredentialConfigsBeforeReload(ctx, workspaces,
321 func(workspace string) (*bootstrap.CredentialProxyOptions, error) {
322 return m.credentialProxySetup(c, hostID, workspace)
323 },
324 func(ctx context.Context, opts *bootstrap.CredentialProxyOptions) error {
325 _, err := bootstrap.HealCredentialProvider(ctx, c, opts)
326 return err
327 },
328 func() bool { return m.reloadServeProviders(ctx, mh, hostID, workspace, base, token) },
329 ); err != nil {
330 return err
331 }
332 if perr := probeReverseTunnel(c, port); perr != nil {
333 log.Printf("[remote] EnsureServer: reverse probe FAILED host=%s ws=%s port=%d err=%v", hostID, workspace, port, perr)
334 return fmt.Errorf("credential proxy: reverse tunnel health check: %w", perr)
335 }
336 m.mu.Lock()
337 defer m.mu.Unlock()
338 if m.hosts[hostID] != mh {
339 return fmt.Errorf("host %q connection was replaced", hostID)
340 }
341 mh.credPort.Store(int64(port))
342 return nil
343 }
344
345 // serveTunnelAlive probes the local serve forward: any HTTP response —
346 // the remote serve behind it are answering.
347 func serveTunnelAlive(ctx context.Context, localURL string) bool {
348 probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
349 defer cancel()
350 req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, serveURL(localURL, "/"), nil)
351 if err != nil {
352 return false
353 }
354 resp, err := http.DefaultClient.Do(req)
355 if err != nil {
356 return false
357 }
358 _ = resp.Body.Close()
359 return true
360 }
361
362 func (m *desktopRemoteManager) StopServer(hostID, workspace string) error {
363 mh := m.managed(hostID)
364 if mh == nil || mh.client == nil {
365 return fmt.Errorf("host %q is not connected", hostID)
366 }
367 mh.serveMu.Lock()
368 defer mh.serveMu.Unlock()
369 if !m.isCurrent(hostID, mh) {
370 return fmt.Errorf("host %q connection was replaced", hostID)
371 }
372 c := mh.client
373 m.mu.Lock()
374 _, tracked := mh.serves[workspace]
375 m.mu.Unlock()
376 if !tracked {
377 return fmt.Errorf("host %q has no managed server for workspace %q", hostID, workspace)
378 }
379 opCtx, cancel := managedOperationContext(context.Background(), mh)
380 defer cancel()
381 if err := m.stopServe(opCtx, c, workspace); err != nil {
382 return err
383 }
384 // Tear down the local serve tunnel so a stale forward can't linger.
385 _ = c.Forwards().Remove(serveForwardName(workspace))
386 // A stopped serve keeps its broker environment; drop the host's route so
387 // the token dies with the process that held it.
388 m.revokeBrowserBroker(hostID)
389 view := RemoteServerView{HostID: hostID, Workspace: workspace, State: "stopped"}
390 m.publishServerIfCurrent(hostID, mh, view, "", "")
391 return nil
392 }
393
394 // managed returns the managed host record for hostID, or nil.
395 func (m *desktopRemoteManager) managed(hostID string) *managedHost {
396 m.mu.Lock()
397 defer m.mu.Unlock()
398 return m.hosts[hostID]
399 }
400
401 func (m *desktopRemoteManager) ServerStatus(hostID, workspace string) RemoteServerView {
402 m.mu.Lock()
403 defer m.mu.Unlock()
404 if mh := m.hosts[hostID]; mh != nil {
405 if e := mh.serves[workspace]; e != nil {
406 return e.view
407 }
408 }
409 return RemoteServerView{HostID: hostID, Workspace: workspace, State: "stopped"}
410 }
411
412 // ServeSnapshot is the read-only resolution for callers that want to talk to
413 // an already-running serve without waking one: it returns the registry's view
414 // and token only when the recorded state is ready with a usable URL. Query
415 // paths (session listing) must go through this — a full EnsureServer from a
416 // poll serializes behind tab bootstraps on the per-host lock and starves them.
417 func (m *desktopRemoteManager) ServeSnapshot(hostID, workspace string) (RemoteServerView, string, bool) {
418 m.mu.Lock()
419 defer m.mu.Unlock()
420 mh := m.hosts[hostID]
421 if mh == nil {
422 return RemoteServerView{}, "", false
423 }
424 e := mh.serves[workspace]
425 if e == nil || e.view.State != "ready" || e.view.LocalURL == "" || e.token == "" {
426 return RemoteServerView{}, "", false
427 }
428 return e.view, e.token, true
429 }
430
431 func (m *desktopRemoteManager) ServerLogs(ctx context.Context, hostID, workspace string, tailLines int) (string, error) {
432 m.mu.Lock()
433 mh := m.hosts[hostID]
434 tracked := false
435 if mh != nil {
436 _, tracked = mh.serves[workspace]
437 }
438 m.mu.Unlock()
439 if mh == nil || mh.client == nil {
440 return "", fmt.Errorf("host %q is not connected", hostID)
441 }
442 if !tracked {
443 return "", fmt.Errorf("host %q has no managed server for workspace %q", hostID, workspace)
444 }
445 opCtx, cancel := managedOperationContext(ctx, mh)
446 defer cancel()
447 var sb strings.Builder
448 if err := m.serveLogs(opCtx, mh.client, workspace, tailLines, &sb); err != nil {
449 return "", err
450 }
451 if !m.isCurrent(hostID, mh) {
452 return "", fmt.Errorf("host %q connection was replaced", hostID)
453 }
454 return sb.String(), nil
455 }
456
457 // credentialProxySetup prepares local-proxy credential mode for one
458 // workspace: starts the desktop key holder, registers every tracked workspace
459 // against the desktop's current default model, and opens the reverse tunnel the
460 // remote serve will call through. The returned options are ready to hand to
461 // the bootstrap (BaseURL points at the tunnel's remote loopback port).
462 func (m *desktopRemoteManager) credentialProxySetup(c desktopSSHClient, hostID, workspace string) (*bootstrap.CredentialProxyOptions, error) {
463 app, ok := m.sink.(*App)
464 if !ok || app == nil {
465 return nil, fmt.Errorf("credential proxy: app unavailable")
466 }
467 info, err := m.registerTrackedCredentialRoutes(app, hostID, workspace)
468 if err != nil {
469 return nil, err
470 }
471 remotePort, err := ensureCredentialProxyForward(c, hostID, info.port)
472 if err != nil {
473 return nil, fmt.Errorf("credential proxy: reverse tunnel: %w", err)
474 }
475 return credentialProxyBootstrapOptions(workspace, remotePort, info), nil
476 }
477
478 func (m *desktopRemoteManager) registerTrackedCredentialRoutes(app *App, hostID, workspace string) (credentialProxyRouteInfo, error) {
479 workspaces := m.trackedCredentialWorkspaces(hostID, workspace)
480 var info credentialProxyRouteInfo
481 for index, trackedWorkspace := range workspaces {
482 registered, err := app.registerCredentialProxyRoute(hostID, trackedWorkspace)
483 if err != nil {
484 return credentialProxyRouteInfo{}, err
485 }
486 if index == 0 {
487 info = registered
488 }
489 }
490 return info, nil
491 }
492
493 func (m *desktopRemoteManager) trackedCredentialWorkspaces(hostID, workspace string) []string {
494 workspaces := []string{workspace}
495 m.mu.Lock()
496 if managed := m.hosts[hostID]; managed != nil {
497 peers := make([]string, 0, len(managed.serves))
498 for peer := range managed.serves {
499 if peer != workspace {
500 peers = append(peers, peer)
501 }
502 }
503 sort.Strings(peers)
504 workspaces = append(workspaces, peers...)
505 }
506 m.mu.Unlock()
507 return workspaces
508 }
509
510 // ensureCredentialProxyForward opens (idempotently) the reverse tunnel: the
511 // REMOTE binds an ephemeral loopback port (avoids conflicts with stale
512 // listeners from half-dead sessions) and forwards back through SSH to the
513 // desktop proxy. Returns the actually bound remote port.
514 func ensureCredentialProxyForward(c desktopSSHClient, hostID string, desktopPort int) (int, error) {
515 name := "cred-proxy:" + hostID
516 target := fmt.Sprintf("127.0.0.1:%d", desktopPort)
517 for _, f := range c.Forwards().List() {
518 if f.Spec.Name == name && f.Spec.TargetAddr == target && f.Up {
519 if port, ok := portOfAddr(f.BoundAddr); ok {
520 return port, nil
521 }
522 }
523 }
524 bound, err := c.Forwards().Replace(forward.Spec{
525 Name: name,
526 Direction: forward.Remote,
527 BindAddr: "127.0.0.1:0",
528 TargetAddr: target,
529 })
530 if err != nil {
531 return 0, err
532 }
533 port, ok := portOfAddr(bound)
534 if !ok {
535 return 0, fmt.Errorf("reverse tunnel bound unexpected address %q", bound)
536 }
537 return port, nil
538 }
539
540 // credentialForwardPort reports the remote-side port of the host's reverse
541 // credential forward, when one is up. This is the port the remote serve's
542 // provider config must point at.
543 func credentialForwardPort(c desktopSSHClient, hostID string) (int, bool) {
544 name := "cred-proxy:" + hostID
545 for _, f := range c.Forwards().List() {
546 if f.Spec.Name == name && f.Up {
547 if port, ok := portOfAddr(f.BoundAddr); ok {
548 return port, true
549 }
550 }
551 }
552 return 0, false
553 }
554
555 // probeReverseTunnel verifies the reverse credential channel end to end. The
556 // desktop cannot dial the remote-side loopback listener itself, so the probe
557 // rides the same SSH connection as a direct-tcpip channel — the remote sshd
558 // connects to its own 127.0.0.1:<port>, which forwards back through the
559 // tunnel to the desktop credential proxy. Any HTTP response to /healthz
560 // proves the whole chain; a refused dial or timeout means the channel the
561 // serve depends on is dead.
562 func probeReverseTunnel(c desktopSSHClient, port int) error {
563 type sshDialer interface{ SSH() (*ssh.Client, error) }
564 d, ok := c.(sshDialer)
565 if !ok {
566 return errors.New("reverse probe: ssh client does not expose a raw connection")
567 }
568 cl, err := d.SSH()
569 if err != nil {
570 return fmt.Errorf("reverse probe: no ssh connection: %w", err)
571 }
572 conn, err := cl.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
573 if err != nil {
574 return fmt.Errorf("reverse probe: remote dial 127.0.0.1:%d: %w", port, err)
575 }
576 defer conn.Close()
577 _ = conn.SetDeadline(time.Now().Add(5 * time.Second))
578 if _, err := conn.Write([]byte("GET /healthz HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")); err != nil {
579 return fmt.Errorf("reverse probe: write: %w", err)
580 }
581 statusLine, err := bufio.NewReader(conn).ReadString('\n')
582 if err != nil {
583 return fmt.Errorf("reverse probe: read: %w", err)
584 }
585 if !strings.Contains(statusLine, "HTTP/") {
586 return fmt.Errorf("reverse probe: non-HTTP response %q", strings.TrimSpace(statusLine))
587 }
588 return nil
589 }
590
591 // reloadServeProviders asks every running serve on the host to rebuild its
592 // providers: POST /providers/reload rebinds the controller with the CURRENT
593 // model, re-reading the healed config. Needed after a reverse credential
594 // tunnel rebind — a reused serve otherwise keeps dialing the dead old port.
595 // workspace+extraBase+extraToken target the serve this ensure round just
596 // produced (the registry may not list it yet); registry entries cover the
597 // host's other workspaces, which share the same healed config. Returns false
598 // when any serve could not reload (busy turn, or a serve too old to know the
599 // endpoint): callers keep their heal gate closed so the next ensure retries.
600 func (m *desktopRemoteManager) reloadServeProviders(ctx context.Context, generation *managedHost, hostID, workspace, extraBase, extraToken string) bool {
601 type target struct{ base, token string }
602 m.mu.Lock()
603 mh := m.hosts[hostID]
604 if mh == nil || mh != generation {
605 m.mu.Unlock()
606 return false
607 }
608 targets := make(map[string]target, len(mh.serves)+1)
609 for ws, e := range mh.serves {
610 if e != nil && e.view.LocalURL != "" && e.token != "" {
611 targets[ws] = target{e.view.LocalURL, e.token}
612 }
613 }
614 m.mu.Unlock()
615 if extraBase != "" && extraToken != "" && workspace != "" {
616 // The just-ensured serve takes precedence under its REAL workspace
617 // key; drop any registry entry pointing at the same base so it is
618 // not reloaded twice.
619 targets[workspace] = target{extraBase, extraToken}
620 for ws, t := range targets {
621 if ws != workspace && t.base == extraBase {
622 delete(targets, ws)
623 }
624 }
625 }
626 allOK := true
627 for ws, t := range targets {
628 jar, err := cookiejar.New(nil)
629 if err != nil {
630 allOK = false
631 continue
632 }
633 client := &http.Client{Jar: jar}
634 callCtx, cancel := context.WithTimeout(ctx, remoteProviderReloadTimeout)
635 err = serveHandshake(callCtx, client, t.base, t.token)
636 if err == nil {
637 err = servePost(callCtx, client, serveURL(t.base, "/providers/reload"), nil)
638 }
639 cancel()
640 if err != nil && strings.Contains(err.Error(), "status 409") {
641 err = m.cancelThenReload(ctx, client, t.base, t.token)
642 }
643 if err != nil {
644 log.Printf("[remote] reloadServeProviders: FAILED host=%s ws=%s err=%v", hostID, ws, err)
645 allOK = false
646 // Legacy serves lack this route; replace them asynchronously because
647 // EnsureServer currently holds serveMu.
648 if (strings.Contains(err.Error(), "status 404") || strings.Contains(err.Error(), "status 405")) && m.markCredFallback(hostID, ws) {
649 log.Printf("[remote] reloadServeProviders: legacy serve -> replacing host=%s ws=%s", hostID, ws)
650 go func(hostID, ws string) {
651 if err := m.StopServer(hostID, ws); err != nil {
652 log.Printf("[remote] reloadServeProviders: stop legacy serve failed host=%s ws=%s err=%v", hostID, ws, err)
653 }
654 if _, _, err := m.EnsureServer(context.Background(), hostID, ws); err != nil {
655 // EnsureServer errors can carry credential-configuration context.
656 // Keep logs useful for correlation without persisting that detail.
657 log.Printf("[remote] reloadServeProviders: restart legacy serve failed host=%s ws=%s", hostID, ws)
658 }
659 }(hostID, ws)
660 }
661 continue
662 }
663 }
664 return allOK
665 }
666
667 // cancelThenReload breaks the credential-heal deadlock: after tunnel drift a
668 // busy turn is already doomed against the stale port, so cancel it and retry
669 // the provider rebuild with bounded backoff.
670 func (m *desktopRemoteManager) cancelThenReload(ctx context.Context, client *http.Client, base, token string) error {
671 log.Printf("[remote] reloadServeProviders: busy turn blocks reload -> canceling turn base=%s", base)
672 cancelCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
673 cancelErr := servePost(cancelCtx, client, serveURL(base, "/cancel"), nil)
674 cancel()
675 if cancelErr != nil {
676 log.Printf("[remote] reloadServeProviders: cancel busy turn FAILED err=%v", cancelErr)
677 }
678 jobsCtx, jobsCancel := context.WithTimeout(ctx, 5*time.Second)
679 jobsErr := cancelServeBackgroundJobs(jobsCtx, client, base)
680 jobsCancel()
681 if jobsErr != nil {
682 log.Printf("[remote] reloadServeProviders: cancel background jobs FAILED err=%v", jobsErr)
683 }
684 var err error
685 for attempt := 1; attempt <= 3; attempt++ {
686 timer := time.NewTimer(time.Duration(attempt) * time.Second)
687 select {
688 case <-ctx.Done():
689 if !timer.Stop() {
690 <-timer.C
691 }
692 return ctx.Err()
693 case <-timer.C:
694 }
695 callCtx, callCancel := context.WithTimeout(ctx, remoteProviderReloadTimeout)
696 err = serveHandshake(callCtx, client, base, token)
697 if err == nil {
698 err = servePost(callCtx, client, serveURL(base, "/providers/reload"), nil)
699 }
700 callCancel()
701 if err == nil || !strings.Contains(err.Error(), "status 409") {
702 return err
703 }
704 }
705 return err
706 }
707
708 func cancelServeBackgroundJobs(ctx context.Context, client *http.Client, base string) error {
709 status, err := serveGet(ctx, client, serveURL(base, "/status?runtime=1"))
710 if err != nil {
711 return err
712 }
713 var payload struct {
714 Jobs []struct {
715 ID string `json:"id"`
716 } `json:"jobs"`
717 }
718 if err := json.Unmarshal(status, &payload); err != nil {
719 return fmt.Errorf("decode serve jobs: %w", err)
720 }
721 ids := make([]string, 0, len(payload.Jobs))
722 for _, job := range payload.Jobs {
723 if id := strings.TrimSpace(job.ID); id != "" {
724 ids = append(ids, id)
725 }
726 }
727 if len(ids) == 0 {
728 return nil
729 }
730 body, err := json.Marshal(map[string]any{"ids": ids})
731 if err != nil {
732 return err
733 }
734 return servePost(ctx, client, serveURL(base, "/jobs/cancel"), body)
735 }
736
737 // markCredFallback rate-limits the legacy-serve replacement to at most one
738 // attempt every two minutes per host and workspace.
739 func (m *desktopRemoteManager) markCredFallback(hostID, workspace string) bool {
740 m.mu.Lock()
741 defer m.mu.Unlock()
742 mh := m.hosts[hostID]
743 if mh == nil {
744 return false
745 }
746 now := time.Now().Unix()
747 if mh.credFallbackAt == nil {
748 mh.credFallbackAt = map[string]int64{}
749 }
750 last := mh.credFallbackAt[workspace]
751 if last != 0 && now-last < 120 {
752 return false
753 }
754 mh.credFallbackAt[workspace] = now
755 return true
756 }
757
758 func portOfAddr(addr string) (int, bool) {
759 _, portStr, err := net.SplitHostPort(addr)
760 if err != nil {
761 return 0, false
762 }
763 port, err := strconv.Atoi(portStr)
764 if err != nil || port <= 0 {
765 return 0, false
766 }
767 return port, true
768 }
769
769 lines GO