Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/server/index/link-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
ownsListener(server: Server<T>): boolean;
start(ctx: LinkListenerStartContext<T>): void;
Expand Down
5 changes: 4 additions & 1 deletion src/server/index/optional-listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "./claude-intercept-lifecycle";
import {
createLinkListenerLifecycle,
linkListenerOwnsTarget,
linkRouteAllowed,
type LinkListenerDeps,
type LinkListenerLifecycle,
Expand Down Expand Up @@ -82,7 +83,9 @@ export function createOptionalListenerSet<T>(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();
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
supervisorStop = () => supervisor.stop();
claudeIntercept.start({
config: ctx.config,
Expand Down
1 change: 1 addition & 0 deletions src/server/management/link-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ async function issue(ctx: ManagementContext): Promise<Response> {
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 });
Expand Down
2 changes: 1 addition & 1 deletion structure/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The client tunnel pidfile is `<configDir>/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/<pid>/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/<pid>/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.

Expand Down
8 changes: 7 additions & 1 deletion tests/server/link-listener-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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") });
Expand Down
16 changes: 16 additions & 0 deletions tests/server/link-management-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading