diff --git a/src/server/index/link-listener.ts b/src/server/index/link-listener.ts index c6d9c017c08..f3aeb2a7626 100644 --- a/src/server/index/link-listener.ts +++ b/src/server/index/link-listener.ts @@ -29,6 +29,10 @@ export type LinkListenerStatus = { reason: string | null; }; +export function linkListenerOwnsTarget(status: LinkListenerStatus): boolean { + return status.state === "listening" && status.port !== null; +} + export interface LinkListenerLifecycle { ownsListener(server: Server): boolean; start(ctx: LinkListenerStartContext): void; diff --git a/src/server/index/optional-listeners.ts b/src/server/index/optional-listeners.ts index 9542585bfa3..6b3d9e56e89 100644 --- a/src/server/index/optional-listeners.ts +++ b/src/server/index/optional-listeners.ts @@ -6,6 +6,7 @@ import { } from "./claude-intercept-lifecycle"; import { createLinkListenerLifecycle, + linkListenerOwnsTarget, linkRouteAllowed, type LinkListenerDeps, type LinkListenerLifecycle, @@ -82,7 +83,9 @@ export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): O activeConfig = ctx.config; linkListener.start({ dispatch: ctx.dispatch, maxRequestBodySize: ctx.maxRequestBodySize }); unregisterSupervisorAdmission ??= linkListener.onAuthenticatedCatalog(apiKeyId => supervisor.notifyAuthenticatedRequest?.(apiKeyId)); - supervisor.start(); + if (linkListenerOwnsTarget(linkListener.status())) { + supervisor.start(); + } supervisorStop = () => supervisor.stop(); claudeIntercept.start({ config: ctx.config, diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 12c6eb9bed2..fae3201f14f 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -362,6 +362,7 @@ async function issue(ctx: ManagementContext): Promise { failureCode = "listener_unavailable"; throw new Error("link listener unavailable"); } + await state.supervisor.ensureStarted(); const boundStore = readStoreFor(ctx); if (!port(boundStore.listenerPort)) throw new Error("link listener did not bind"); return Response.json({ linkId: id, apiKeyId: issued.id, key: issued.key, listenerPort: boundStore.listenerPort }); diff --git a/structure/remote-link.md b/structure/remote-link.md index 70e6022dcd5..661d1553eb5 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -26,7 +26,7 @@ The client tunnel pidfile is `/link/client-tunnel.pid` with `{ versio ## Tunnels, management and CLI -`src/link/ssh-runner.ts` runs every OpenSSH and `ssh-keygen` argv without a shell and caps captured output. `src/link/supervisor.ts` keeps one `ssh -R` child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned `link:` API keys at startup, and stops children before the hub-link listener on shutdown. It reaps a leftover tunnel only when Linux `/proc//cmdline` matches the recorded argv exactly; on other platforms a leftover is reported, never killed. `src/link/status-projection.ts` builds the status document the dashboard and `ocx link status` read, including persisted compensation failures, and `src/link/admission-wait.ts` waits for the first key-authenticated `/v1/catalog` read that proves a new link works. +`src/link/ssh-runner.ts` runs every OpenSSH and `ssh-keygen` argv without a shell and caps captured output. `src/link/supervisor.ts` keeps one `ssh -R` child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned `link:` API keys at startup, and stops children before the hub-link listener on shutdown. Automatic startup begins the supervisor only after the hub-link listener owns its socket; a bind failure must never leave a reverse forward targeting the persisted port. It reaps a leftover tunnel only when Linux `/proc//cmdline` matches the recorded argv exactly; on other platforms a leftover is reported, never killed. `src/link/status-projection.ts` builds the status document the dashboard and `ocx link status` read, including persisted compensation failures, and `src/link/admission-wait.ts` waits for the first key-authenticated `/v1/catalog` read that proves a new link works. Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link port|issue|revoke|status`. `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. diff --git a/tests/server/link-listener-lifecycle.test.ts b/tests/server/link-listener-lifecycle.test.ts index 1c6c5d4ee8c..99a108731db 100644 --- a/tests/server/link-listener-lifecycle.test.ts +++ b/tests/server/link-listener-lifecycle.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "bun"; -import { createLinkListenerLifecycle } from "../../src/server/index/link-listener"; +import { createLinkListenerLifecycle, linkListenerOwnsTarget } from "../../src/server/index/link-listener"; import { emptyLinkStore, type LinkStore } from "../../src/link/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -77,6 +77,12 @@ describe("hub-link listener lifecycle", () => { expect(lifecycle.status()).toEqual({ state: "off", port: null, reason: null }); }); + test("only a successfully bound listener owns a reverse-forward target", () => { + expect(linkListenerOwnsTarget({ state: "failed", port: null, reason: "bind" })).toBe(false); + expect(linkListenerOwnsTarget({ state: "off", port: null, reason: null })).toBe(false); + expect(linkListenerOwnsTarget({ state: "listening", port: 45678, reason: null })).toBe(true); + }); + test("closes the real link socket when listenerPort persistence fails", async () => { tempHome = mkdtempSync(join(tmpdir(), "ocx-link-write-")); const publicServer = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("public-ok") }); diff --git a/tests/server/link-management-routes.test.ts b/tests/server/link-management-routes.test.ts index 2cabc5d852c..100c31e7797 100644 --- a/tests/server/link-management-routes.test.ts +++ b/tests/server/link-management-routes.test.ts @@ -215,6 +215,22 @@ describe("link management routes", () => { expect(h.store.links).toHaveLength(1); }); + test("issue starts the supervisor once a recovered listener binds", async () => { + temp = mkdtempSync(join(tmpdir(), "ocx-link-issue-recover-")); + const h = harness(); + h.setListenerState("failed"); + const deps = { + ...h.deps, + linkListener: () => ({ + ...h.listener, + ensureStarted: async () => { h.events.push("listener"); h.setListenerState("listening"); }, + }), + }; + const response = await call("/api/link/issue", "POST", { alias: "home", tunnelPort: 2200 }, deps, "admin-token", true, null, true, h.config); + expect(response?.status).toBe(200); + expect(h.events.indexOf("listener")).toBeLessThan(h.events.indexOf("supervisor")); + }); + test("rejects issue when ensureStarted leaves the listener failed and compensates", async () => { temp = mkdtempSync(join(tmpdir(), "ocx-link-listener-failed-")); const h = harness();