diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 49712f80005..45ff3d461d7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2461,6 +2461,7 @@ export { isRuntimeExposureHmrPort, isAllowedRuntimeExposurePort, deriveViteHmrPort, + derivePaperclipViteHmrPort, buildRuntimeExposureUrl, buildRuntimeExposureHealthUrl, } from "./runtime-exposure/ports.js"; diff --git a/packages/shared/src/runtime-exposure/ports.test.ts b/packages/shared/src/runtime-exposure/ports.test.ts index 48b7b084850..de27b2c17db 100644 --- a/packages/shared/src/runtime-exposure/ports.test.ts +++ b/packages/shared/src/runtime-exposure/ports.test.ts @@ -8,6 +8,7 @@ import { buildRuntimeExposureHealthUrl, buildRuntimeExposureUrl, deriveViteHmrPort, + derivePaperclipViteHmrPort, isAllowedRuntimeExposurePort, isRuntimeExposureAppPort, isRuntimeExposureHmrPort, @@ -49,6 +50,13 @@ describe("runtime exposure port policy", () => { expect(() => deriveViteHmrPort(52000)).toThrow(RangeError); }); + it("shares the generic Paperclip HMR derivation with high-port overflow fallback", () => { + expect(derivePaperclipViteHmrPort(3_100)).toBe(13_100); + expect(derivePaperclipViteHmrPort(55_535)).toBe(65_535); + expect(derivePaperclipViteHmrPort(55_536)).toBe(45_536); + expect(() => derivePaperclipViteHmrPort(0)).toThrow(/valid TCP port/); + }); + it("builds https URLs on the non-standard port", () => { expect(buildRuntimeExposureUrl("paperclip-dev.tail29c1aa.ts.net", 42010)).toBe( "https://paperclip-dev.tail29c1aa.ts.net:42010", diff --git a/packages/shared/src/runtime-exposure/ports.ts b/packages/shared/src/runtime-exposure/ports.ts index c67bd4433c9..6566d780a36 100644 --- a/packages/shared/src/runtime-exposure/ports.ts +++ b/packages/shared/src/runtime-exposure/ports.ts @@ -68,7 +68,25 @@ export function deriveViteHmrPort(appPort: number): number { `[${RUNTIME_EXPOSURE_APP_PORT_MIN}, ${RUNTIME_EXPOSURE_APP_PORT_MAX}]`, ); } - return appPort + RUNTIME_EXPOSURE_HMR_PORT_OFFSET; + return derivePaperclipViteHmrPort(appPort); +} + +/** + * Derive Paperclip's Vite HMR companion port for any valid application port. + * + * Normal Paperclip instances can use ports outside the dedicated exposure + * range, while exposed branch runtimes are deliberately constrained to it. + * Keeping the generic derivation here prevents the app server and exposure + * allocator from drifting while preserving the historical overflow fallback. + */ +export function derivePaperclipViteHmrPort(serverPort: number): number { + if (!Number.isInteger(serverPort) || serverPort < 1 || serverPort > 65_535) { + throw new RangeError(`server port ${serverPort} is not a valid TCP port`); + } + if (serverPort <= 55_535) { + return serverPort + RUNTIME_EXPOSURE_HMR_PORT_OFFSET; + } + return Math.max(1_024, serverPort - RUNTIME_EXPOSURE_HMR_PORT_OFFSET); } /** diff --git a/packages/tailscale-https-broker/README.md b/packages/tailscale-https-broker/README.md index a1b4a9ac079..0bb0ed024dd 100644 --- a/packages/tailscale-https-broker/README.md +++ b/packages/tailscale-https-broker/README.md @@ -8,13 +8,32 @@ authority (see [PAP-16989](../../)) while still getting automatic trusted HTTPS previews per branch runtime. Design: [PAP-17049](../../) plan; security contract: [PAP-17050](../../) threat-model verdict. +Runtime services opt in explicitly; existing services and the primary `:443` +route are unchanged: + +```json +{ + "port": { "type": "auto", "envKey": "PORT" }, + "expose": { + "type": "tailscale_https", + "hostname": "auto", + "publicPort": "same", + "includePaperclipViteHmr": true, + "failurePolicy": "fail_closed" + } +} +``` + ## What it can and cannot do Supported operations (over a Unix socket, one runtime-service at a time): - `list` — the caller's own exposures (never returns lease handles). -- `expose` — add same-number HTTPS→loopback listeners for an allowlisted port, - returning an unguessable lease handle. +- `reserve` — atomically reserve an app/HMR pair before either backend binds, + returning an unguessable, short-lived lease handle bound to the caller, + runtime ID, ports, purposes, and generation. +- `expose` — redeem that reservation only after `/proc` proves both listeners + are loopback-only and owned by the configured managed-runtime UID. - `remove` — remove the caller's own listeners, proven by exact lease handle. Hard-denied, deny-by-default: Funnel, certificates, Tailscale Services, @@ -56,18 +75,30 @@ Tailscale-operator service account distinct from the Paperclip app account. Do **not** grant `--operator` to the Paperclip app/agent account (that grant was explicitly rejected in PAP-16989). -4. **Create state directories (root-owned, not writable by others).** +4. **Create state directories (not writable by the Paperclip app).** The + packaged unit creates these automatically; for a manual install use: ```sh - sudo install -d -o root -g root -m 0755 /run/paperclip-tailscale-broker - sudo install -d -o paperclip-tsbroker -g root -m 0700 /var/lib/paperclip-tailscale-broker - sudo install -d -o paperclip-tsbroker -g root -m 0700 /var/log/paperclip-tailscale-broker + sudo install -d -o paperclip-tsbroker -g paperclip-tsbroker-sock -m 0750 /run/paperclip-tailscale-broker + sudo install -d -o paperclip-tsbroker -g paperclip-tsbroker-sock -m 0700 /var/lib/paperclip-tailscale-broker + sudo install -d -o paperclip-tsbroker -g paperclip-tsbroker-sock -m 0700 /var/log/paperclip-tailscale-broker ``` The broker refuses to start if the registry path's parent is group/other writable. -5. **Install a systemd unit** (`/etc/systemd/system/paperclip-tailscale-broker.service`): +5. **Build and install the packaged systemd unit.** + + ```sh + pnpm --filter @paperclipai/tailscale-https-broker build + sudo install -D -m 0644 \ + packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service \ + /etc/systemd/system/paperclip-tailscale-https-broker.service + sudo install -d -m 0750 /etc/paperclip + sudoedit /etc/paperclip/tailscale-https-broker.env + ``` + + The packaged unit is equivalent to: ```ini [Unit] @@ -78,17 +109,10 @@ Tailscale-operator service account distinct from the Paperclip app account. [Service] Type=simple User=paperclip-tsbroker - # Socket must end up 0660 root:paperclip-tsbroker-sock. Set the group here and + # Socket must end up 0660 paperclip-tsbroker:paperclip-tsbroker-sock. Set the group here and # the broker chmods the socket to 0660 on bind. - SupplementaryGroups=paperclip-tsbroker-sock - Environment=BROKER_NODE_IDENTITY=%H - Environment=BROKER_SERVICE_UID= - Environment=BROKER_SERVICE_GID= - Environment=BROKER_RUNTIME_UID= - Environment=BROKER_TAILSCALE_BIN=/usr/bin/tailscale - # Only when SO_PEERCRED is not wired and the socket is confirmed 0660 - # root:. Prefer a native SO_PEERCRED reader when available. - Environment=BROKER_TRUST_SOCKET_PERMISSIONS=true + Group=paperclip-tsbroker-sock + EnvironmentFile=/etc/paperclip/tailscale-https-broker.env ExecStart=/usr/bin/node /opt/paperclip/packages/tailscale-https-broker/dist/main.js Restart=on-failure NoNewPrivileges=true @@ -99,6 +123,10 @@ Tailscale-operator service account distinct from the Paperclip app account. WantedBy=multi-user.target ``` + Put the `BROKER_*` values from the table below in the environment file. Set + `PAPERCLIP_TAILSCALE_BROKER_SOCKET=/run/paperclip-tailscale-broker/broker.sock` + on the Paperclip service only if overriding its default. + Environment variables (defaults in `src/config.ts`): | Var | Required | Default | Meaning | @@ -106,7 +134,7 @@ Tailscale-operator service account distinct from the Paperclip app account. | `BROKER_NODE_IDENTITY` | yes | — | hostname + boot id; a change forces quarantine + operator reconciliation | | `BROKER_SERVICE_UID` | yes | — | UID of the Paperclip **app** account allowed to connect | | `BROKER_SERVICE_GID` | yes | — | GID of the dedicated socket group (caller's primary GID) | - | `BROKER_RUNTIME_UID` | yes | — | UID of the dedicated managed-runtime account whose loopback listeners are eligible | + | `BROKER_RUNTIME_UID` | yes | — | UID that owns Paperclip-managed runtime processes (normally the Paperclip app service account); only its loopback listeners are eligible | | `BROKER_TAILSCALE_BIN` | no | `/usr/bin/tailscale` | absolute path to the Tailscale CLI | | `BROKER_SOCKET_PATH` | no | `/run/paperclip-tailscale-broker/broker.sock` | Unix socket path | | `BROKER_REGISTRY_PATH` | no | `/var/lib/paperclip-tailscale-broker/registry.json` | root-owned `0600` ownership registry | @@ -126,7 +154,8 @@ Tailscale-operator service account distinct from the Paperclip app account. node identity. Exit 0 = ready. It never mutates Serve state. 7. **Enable.** `sudo systemctl daemon-reload && sudo systemctl enable --now - paperclip-tailscale-broker`. Confirm the socket is `0660 root:`. + paperclip-tailscale-broker`. Confirm the socket is `0660 + paperclip-tsbroker:paperclip-tsbroker-sock`. ## Upgrade @@ -163,6 +192,7 @@ redacted. ## Tests ```sh -pnpm --filter @paperclipai/tailscale-https-broker test # 57 tests +pnpm --filter @paperclipai/tailscale-https-broker test # 60 tests pnpm --filter @paperclipai/tailscale-https-broker typecheck +pnpm --filter @paperclipai/tailscale-https-broker build ``` diff --git a/packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service b/packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service new file mode 100644 index 00000000000..3175d5e3d24 --- /dev/null +++ b/packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service @@ -0,0 +1,26 @@ +[Unit] +Description=Paperclip Tailscale HTTPS broker +After=tailscaled.service +Requires=tailscaled.service + +[Service] +Type=simple +User=paperclip-tsbroker +Group=paperclip-tsbroker-sock +EnvironmentFile=/etc/paperclip/tailscale-https-broker.env +ExecStart=/usr/bin/node /opt/paperclip/packages/tailscale-https-broker/dist/main.js +Restart=on-failure +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +RuntimeDirectory=paperclip-tailscale-broker +RuntimeDirectoryMode=0750 +StateDirectory=paperclip-tailscale-broker +StateDirectoryMode=0700 +LogsDirectory=paperclip-tailscale-broker +LogsDirectoryMode=0700 +ReadWritePaths=/run/paperclip-tailscale-broker /var/lib/paperclip-tailscale-broker /var/log/paperclip-tailscale-broker + +[Install] +WantedBy=multi-user.target diff --git a/packages/tailscale-https-broker/package.json b/packages/tailscale-https-broker/package.json index b30081f8c8f..07da09e04d5 100644 --- a/packages/tailscale-https-broker/package.json +++ b/packages/tailscale-https-broker/package.json @@ -10,9 +10,10 @@ "./*": "./src/*.ts" }, "bin": { - "paperclip-tailscale-https-broker": "./src/main.ts" + "paperclip-tailscale-https-broker": "./dist/main.js" }, "scripts": { + "build": "tsc", "typecheck": "tsc --noEmit", "test": "vitest run" }, diff --git a/packages/tailscale-https-broker/src/authorization.test.ts b/packages/tailscale-https-broker/src/authorization.test.ts index 94084c927ef..d184f882579 100644 --- a/packages/tailscale-https-broker/src/authorization.test.ts +++ b/packages/tailscale-https-broker/src/authorization.test.ts @@ -44,8 +44,10 @@ describe("authorizeRemoval (ownership binding)", () => { peerGid: 987, ports: [42010, 52010], purposes: ["app", "vite_hmr"], + state: "exposed", generation: 1, createdAtIso: "2026-08-11T00:00:00.000Z", + expiresAtIso: null, }; const peer = { uid: 999, gid: 987, pid: 42 }; diff --git a/packages/tailscale-https-broker/src/broker-core.test.ts b/packages/tailscale-https-broker/src/broker-core.test.ts index 7bada1422b3..85e5a946102 100644 --- a/packages/tailscale-https-broker/src/broker-core.test.ts +++ b/packages/tailscale-https-broker/src/broker-core.test.ts @@ -65,6 +65,7 @@ function makeCore( loopbackOnly: true, ownerUidMatches: true, }), + nowIso: () => string = () => "2026-08-11T00:00:00.000Z", ) { const audit = new MemoryAuditSink(); const core = new BrokerCore({ @@ -77,19 +78,30 @@ function makeCore( deps: { runTailscale: fake.run, verifyListenerOwnership: ownership, - nowIso: () => "2026-08-11T00:00:00.000Z", + nowIso, }, }); return { core, audit }; } -const exposeReq = (runtimeId = RUNTIME_A, port = 42010): BrokerRequest => ({ - op: "expose", +const reserveReq = (runtimeId = RUNTIME_A, port = 42010): BrokerRequest => ({ + op: "reserve", requestId: "req-e", runtimeId, listeners: [{ purpose: "app", port }], }); +async function reserveAndExpose(core: BrokerCore, runtimeId = RUNTIME_A, port = 42010, peer = PEER) { + const reserved = await core.handle(reserveReq(runtimeId, port), peer); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed"); + return await core.handle({ + op: "expose", + requestId: "req-x", + runtimeId, + handle: reserved.handle, + }, peer); +} + let dir: string; let registryPath: string; beforeEach(() => { @@ -101,10 +113,16 @@ afterEach(() => { }); describe("expose", () => { - it("exposes a same-number loopback listener and persists a lease", async () => { + it("reserves before bind, then exposes a same-number loopback listener and persists a lease", async () => { const fake = new FakeTailscale(); const { core } = makeCore(fake, registryPath); - const res = await core.handle(exposeReq(), PEER); + const reserved = await core.handle(reserveReq(), PEER); + expect(reserved.ok).toBe(true); + expect(fake.ports.has(42010)).toBe(false); + const beforeList = await core.handle({ op: "list", requestId: "before-list" }, PEER); + expect(beforeList).toMatchObject({ ok: true, listeners: [] }); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("expected reserve ok"); + const res = await core.handle({ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle }, PEER); expect(res.ok).toBe(true); if (!res.ok || res.op !== "expose") throw new Error("expected expose ok"); expect(res.publicPorts).toEqual([42010]); @@ -112,23 +130,46 @@ describe("expose", () => { expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010"); const registry = JSON.parse(readFileSync(registryPath, "utf8")); expect(registry.leases[0].ports).toEqual([42010]); + expect(registry.leases[0].state).toBe("exposed"); // Handle is persisted server-side but never appears in list output. }); it("is idempotent: re-exposing the same port does not double-apply", async () => { const fake = new FakeTailscale(); const { core } = makeCore(fake, registryPath); - await core.handle(exposeReq(), PEER); + const firstReservation = await core.handle(reserveReq(), PEER); + if (!firstReservation.ok || firstReservation.op !== "reserve") throw new Error("reserve failed"); + await core.handle({ op: "expose", requestId: "first", runtimeId: RUNTIME_A, handle: firstReservation.handle }, PEER); const before = fake.exposeCalls; - const res = await core.handle(exposeReq(), PEER); + const repeatedReservation = await core.handle(reserveReq(), PEER); + expect(repeatedReservation).toMatchObject({ ok: true, handle: firstReservation.handle }); + const res = await core.handle({ op: "expose", requestId: "second", runtimeId: RUNTIME_A, handle: firstReservation.handle }, PEER); expect(res.ok).toBe(true); expect(fake.exposeCalls).toBe(before); // no additional CLI mutation }); + it("rejects and releases an expired reservation before any Serve mutation", async () => { + const fake = new FakeTailscale(); + let now = "2026-08-11T00:00:00.000Z"; + const { core } = makeCore(fake, registryPath, undefined, () => now); + const reserved = await core.handle(reserveReq(), PEER); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed"); + now = "2026-08-11T00:06:00.000Z"; + const result = await core.handle({ + op: "expose", + requestId: "expired", + runtimeId: RUNTIME_A, + handle: reserved.handle, + }, PEER); + expect(result).toMatchObject({ ok: false, code: "reservation_expired" }); + expect(fake.exposeCalls).toBe(0); + expect(JSON.parse(readFileSync(registryPath, "utf8")).leases).toEqual([]); + }); + it("rejects an unauthorized peer without mutating serve", async () => { const fake = new FakeTailscale(); const { core } = makeCore(fake, registryPath); - const res = await core.handle(exposeReq(), { uid: 1000, gid: 987, pid: 1 }); + const res = await core.handle(reserveReq(), { uid: 1000, gid: 987, pid: 1 }); expect(res.ok).toBe(false); if (res.ok) throw new Error("unreachable"); expect(res.code).toBe("unauthorized_peer"); @@ -138,7 +179,7 @@ describe("expose", () => { it("rejects a port outside the dedicated allowlist", async () => { const fake = new FakeTailscale(); const { core } = makeCore(fake, registryPath); - const res = await core.handle(exposeReq(RUNTIME_A, 8080), PEER); + const res = await core.handle(reserveReq(RUNTIME_A, 8080), PEER); expect(res.ok).toBe(false); if (!res.ok) expect(res.code).toBe("port_not_allowlisted"); }); @@ -151,7 +192,7 @@ describe("expose", () => { ]) { const fake = new FakeTailscale(); const { core } = makeCore(fake, registryPath, () => bad); - const res = await core.handle(exposeReq(), PEER); + const res = await reserveAndExpose(core); expect(res.ok).toBe(false); if (!res.ok) expect(res.code).toBe("listener_ownership_mismatch"); expect(fake.ports.has(42010)).toBe(false); @@ -162,7 +203,7 @@ describe("expose", () => { const fake = new FakeTailscale(); fake.ports.set(42010, "http://127.0.0.1:5432"); // manual/unrelated service const { core } = makeCore(fake, registryPath); - const res = await core.handle(exposeReq(), PEER); + const res = await core.handle(reserveReq(), PEER); expect(res.ok).toBe(false); if (!res.ok) expect(res.code).toBe("manual_mapping_present"); expect(fake.ports.get(42010)).toBe("http://127.0.0.1:5432"); // unchanged @@ -172,7 +213,7 @@ describe("expose", () => { const fake = new FakeTailscale(); fake.retargetPrimaryOnExpose = true; const { core } = makeCore(fake, registryPath); - const res = await core.handle(exposeReq(), PEER); + const res = await reserveAndExpose(core); expect(res.ok).toBe(false); if (!res.ok) expect(res.code).toBe("primary_route_violation"); }); @@ -182,7 +223,7 @@ describe("expose", () => { fake.sideEffectOnExpose = 42011; // an unexpected extra entry appears fake.failRemove = true; // compensation cannot be proven -> quarantine const { core } = makeCore(fake, registryPath); - const res = await core.handle(exposeReq(), PEER); + const res = await reserveAndExpose(core); expect(res.ok).toBe(false); if (!res.ok) expect(res.code).toBe("unexpected_serve_diff"); const registry = JSON.parse(readFileSync(registryPath, "utf8")); @@ -190,7 +231,7 @@ describe("expose", () => { // A later expose of the quarantined port is refused. const fake2 = new FakeTailscale(); const { core: core2 } = makeCore(fake2, registryPath); - const res2 = await core2.handle(exposeReq(), PEER); + const res2 = await core2.handle(reserveReq(), PEER); expect(res2.ok).toBe(false); if (!res2.ok) expect(res2.code).toBe("quarantined"); }); @@ -198,7 +239,7 @@ describe("expose", () => { describe("remove", () => { async function exposeAndGetHandle(core: BrokerCore, port = 42010, runtime = RUNTIME_A) { - const res = await core.handle(exposeReq(runtime, port), PEER); + const res = await reserveAndExpose(core, runtime, port); if (!res.ok || res.op !== "expose") throw new Error("expose failed"); return res.handle; } @@ -235,13 +276,27 @@ describe("remove", () => { if (!cross.ok) expect(cross.code).toBe("listener_ownership_mismatch"); expect(fake.ports.has(42010)).toBe(true); // still there }); + + it("releases an unexposed reservation without mutating Serve", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + const reserved = await core.handle(reserveReq(), PEER); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed"); + const res = await core.handle( + { op: "remove", requestId: "req-r", runtimeId: RUNTIME_A, handle: reserved.handle }, + PEER, + ); + expect(res).toMatchObject({ ok: true, removedPorts: [] }); + expect(fake.exposeCalls).toBe(0); + expect(fake.removeCalls).toBe(0); + }); }); describe("list", () => { it("returns caller-owned ports and never lease handles", async () => { const fake = new FakeTailscale(); const { core } = makeCore(fake, registryPath); - await core.handle(exposeReq(), PEER); + await reserveAndExpose(core); const res = await core.handle({ op: "list", requestId: "req-l" }, PEER); expect(res.ok).toBe(true); if (res.ok && res.op === "list") { @@ -255,7 +310,7 @@ describe("node identity", () => { it("quarantines when the persisted node identity no longer matches", async () => { const fake = new FakeTailscale(); const { core } = makeCore(fake, registryPath); - await core.handle(exposeReq(), PEER); + await reserveAndExpose(core); // Rebuild a core with a different node identity against the same registry. const audit = new MemoryAuditSink(); const core2 = new BrokerCore({ @@ -271,7 +326,7 @@ describe("node identity", () => { nowIso: () => "2026-08-11T00:00:00.000Z", }, }); - const res = await core2.handle(exposeReq(RUNTIME_B, 42011), PEER); + const res = await core2.handle(reserveReq(RUNTIME_B, 42011), PEER); expect(res.ok).toBe(false); if (!res.ok) expect(res.code).toBe("quarantined"); }); diff --git a/packages/tailscale-https-broker/src/broker-core.ts b/packages/tailscale-https-broker/src/broker-core.ts index 39690bf1e5e..52d070610eb 100644 --- a/packages/tailscale-https-broker/src/broker-core.ts +++ b/packages/tailscale-https-broker/src/broker-core.ts @@ -36,6 +36,7 @@ import { isPortQuarantined, loadRegistry, nextGeneration, + pruneExpiredReservations, quarantinePort, removeLeaseByHandle, saveRegistry, @@ -44,10 +45,11 @@ import type { BrokerRegistry, BrokerRequest, BrokerResponse, - OwnedListener, PeerCredentials, } from "./types.js"; +const RESERVATION_TTL_MS = 5 * 60 * 1_000; + /** Result of running a tailscale CLI command (argv, shell:false). */ export interface CliResult { code: number; @@ -116,6 +118,8 @@ export class BrokerCore { switch (request.op) { case "list": return this.doList(request, peer); + case "reserve": + return this.doReserve(request, peer); case "expose": return this.doExpose(request, peer); case "remove": @@ -145,23 +149,23 @@ export class BrokerCore { } } - private loadRegistry(): BrokerRegistry { + private loadRegistry(pruneReservations = true): BrokerRegistry { const registry = loadRegistry(this.config.registryPath, this.config.nodeIdentity); // Boot/node identity change forces quarantine + operator reconciliation. if (registry.nodeIdentity !== this.config.nodeIdentity) { denied("quarantined", "node identity changed; operator reconciliation required"); } + if (pruneReservations && pruneExpiredReservations(registry, this.config.deps.nowIso()).length > 0) { + saveRegistry(this.config.registryPath, registry); + } return registry; } - private doExpose( - request: Extract, + private doReserve( + request: Extract, peer: PeerCredentials, ): BrokerResponse { const registry = this.loadRegistry(); - - // Pre-flight every requested port: allowlist, quarantine, and the - // immediately-before /proc ownership check (req #2). for (const listener of request.listeners) { if (!this.config.isAllowedPort(listener.port)) { denied("port_not_allowlisted", `port ${listener.port} is outside the dedicated range`); @@ -169,15 +173,109 @@ export class BrokerCore { if (isPortQuarantined(registry, listener.port)) { denied("quarantined", `port ${listener.port} is quarantined`); } - const ownership = this.config.deps.verifyListenerOwnership(listener.port); + } + + const requestedPorts = request.listeners.map((listener) => listener.port); + const requestedPurposes = request.listeners.map((listener) => listener.purpose); + const existingForRuntime = registry.leases.find((lease) => + lease.runtimeId === request.runtimeId + && lease.peerUid === peer.uid + && lease.peerGid === peer.gid + && sameNumbers(lease.ports, requestedPorts) + && sameStrings(lease.purposes, requestedPurposes)); + if (existingForRuntime) { + return { + ok: true, + op: "reserve", + requestId: request.requestId, + handle: existingForRuntime.handle, + reservedPorts: [...existingForRuntime.ports], + }; + } + + for (const lease of registry.leases) { + if (lease.ports.some((port) => requestedPorts.includes(port))) { + denied("reservation_conflict", "a requested port is reserved by another runtime"); + } + } + + const serve = this.readServe(); + assertPrimaryIntact(serve); + for (const port of requestedPorts) { + if (serve.entries.has(port)) { + denied("manual_mapping_present", `port ${port} already has a Serve mapping`); + } + } + + const createdAtIso = this.config.deps.nowIso(); + const handle = generateLeaseHandle(); + addLease(registry, { + handle, + runtimeId: request.runtimeId, + peerUid: peer.uid, + peerGid: peer.gid, + ports: requestedPorts, + purposes: requestedPurposes, + state: "reserved", + generation: nextGeneration(registry), + createdAtIso, + expiresAtIso: new Date(Date.parse(createdAtIso) + RESERVATION_TTL_MS).toISOString(), + }); + saveRegistry(this.config.registryPath, registry); + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op: "reserve", + runtimeId: request.runtimeId, + ports: requestedPorts, + requestId: request.requestId, + decision: "allow", + reasonCode: "ok", + reason: "reserved", + beforeDigest: primaryDigest(serve), + afterDigest: primaryDigest(serve), + cliExitCategory: "ok", + recovery: "none", + }); + return { ok: true, op: "reserve", requestId: request.requestId, handle, reservedPorts: requestedPorts }; + } + + private doExpose( + request: Extract, + peer: PeerCredentials, + ): BrokerResponse { + const registry = this.loadRegistry(false); + let lease; + try { + lease = authorizeRemoval(registry.leases, request, peer); + } catch (error) { + if (error instanceof AuthorizationError) denied(error.code, error.message); + throw error; + } + if (lease.state === "reserved" && lease.expiresAtIso && Date.parse(lease.expiresAtIso) <= Date.parse(this.config.deps.nowIso())) { + removeLeaseByHandle(registry, lease.handle); + saveRegistry(this.config.registryPath, registry); + denied("reservation_expired", "reservation expired before exposure"); + } + + // Pre-flight every requested port: allowlist, quarantine, and the + // immediately-before /proc ownership check (req #2). + for (const port of lease.ports) { + if (!this.config.isAllowedPort(port)) { + denied("port_not_allowlisted", `port ${port} is outside the dedicated range`); + } + if (isPortQuarantined(registry, port)) { + denied("quarantined", `port ${port} is quarantined`); + } + const ownership = this.config.deps.verifyListenerOwnership(port); if (!ownership.present) { - denied("listener_ownership_mismatch", `no loopback listener on port ${listener.port}`); + denied("listener_ownership_mismatch", `no loopback listener on port ${port}`); } if (!ownership.loopbackOnly) { - denied("listener_ownership_mismatch", `listener on ${listener.port} is not loopback-only`); + denied("listener_ownership_mismatch", `listener on ${port} is not loopback-only`); } if (!ownership.ownerUidMatches) { - denied("listener_ownership_mismatch", `listener on ${listener.port} not owned by managed runtime`); + denied("listener_ownership_mismatch", `listener on ${port} not owned by managed runtime`); } } @@ -187,26 +285,26 @@ export class BrokerCore { // Each target port must be absent or already exactly our same-number entry // (idempotent re-expose). A manual/unknown entry is never touched. - for (const listener of request.listeners) { - const entry = before.entries.get(listener.port); - if (entry && !isSameNumberLoopbackEntry(entry, listener.port)) { - denied("manual_mapping_present", `port ${listener.port} already has a non-Paperclip mapping`); + for (const port of lease.ports) { + const entry = before.entries.get(port); + if (entry && !isSameNumberLoopbackEntry(entry, port)) { + denied("manual_mapping_present", `port ${port} already has a non-Paperclip mapping`); } } const appliedPorts: number[] = []; try { - for (const listener of request.listeners) { - const already = before.entries.get(listener.port); - if (already && isSameNumberLoopbackEntry(already, listener.port)) { + for (const port of lease.ports) { + const already = before.entries.get(port); + if (already && isSameNumberLoopbackEntry(already, port)) { continue; // idempotent } const result = this.config.deps.runTailscale( - buildExposeArgv(this.config.tailscaleBinPath, listener.port), + buildExposeArgv(this.config.tailscaleBinPath, port), ); - if (result.timedOut) denied("cli_timeout", `expose ${listener.port} timed out`); - if (result.code !== 0) denied("cli_error", `expose ${listener.port} exited non-zero`); - appliedPorts.push(listener.port); + if (result.timedOut) denied("cli_timeout", `expose ${port} timed out`); + if (result.code !== 0) denied("cli_error", `expose ${port} exited non-zero`); + appliedPorts.push(port); } const after = this.readServe(); @@ -220,28 +318,18 @@ export class BrokerCore { // Only the intended ports may have changed, and each must now be an exact // same-number loopback listener. const diff = new Set(changedPorts(before, after)); - const intended = new Set(request.listeners.map((l) => l.port)); + const intended = new Set(lease.ports); for (const port of diff) { if (!intended.has(port)) denied("unexpected_serve_diff", `unexpected change on port ${port}`); } - for (const listener of request.listeners) { - if (!isSameNumberLoopbackEntry(after.entries.get(listener.port), listener.port)) { - denied("unexpected_serve_diff", `port ${listener.port} not exactly exposed`); + for (const port of lease.ports) { + if (!isSameNumberLoopbackEntry(after.entries.get(port), port)) { + denied("unexpected_serve_diff", `port ${port} not exactly exposed`); } } - const handle = generateLeaseHandle(); - const generation = nextGeneration(registry); - addLease(registry, { - handle, - runtimeId: request.runtimeId, - peerUid: peer.uid, - peerGid: peer.gid, - ports: request.listeners.map((l) => l.port), - purposes: request.listeners.map((l) => l.purpose) as OwnedListener["purpose"][], - generation, - createdAtIso: this.config.deps.nowIso(), - }); + lease.state = "exposed"; + lease.expiresAtIso = null; saveRegistry(this.config.registryPath, registry); this.config.auditSink.write({ @@ -249,7 +337,7 @@ export class BrokerCore { peer, op: "expose", runtimeId: request.runtimeId, - ports: request.listeners.map((l) => l.port), + ports: lease.ports, requestId: request.requestId, decision: "allow", reasonCode: "ok", @@ -264,8 +352,8 @@ export class BrokerCore { ok: true, op: "expose", requestId: request.requestId, - handle, - publicPorts: request.listeners.map((l) => l.port), + handle: lease.handle, + publicPorts: [...lease.ports], }; } catch (error) { // Partial success is not healthy: compensate by removing only the exact @@ -308,6 +396,25 @@ export class BrokerCore { throw error; } + if (lease.state === "reserved") { + removeLeaseByHandle(registry, lease.handle); + saveRegistry(this.config.registryPath, registry); + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op: "remove", + runtimeId: request.runtimeId, + ports: lease.ports, + requestId: request.requestId, + decision: "allow", + reasonCode: "ok", + reason: "reservation_released", + cliExitCategory: "ok", + recovery: "cleanup", + }); + return { ok: true, op: "remove", requestId: request.requestId, removedPorts: [] }; + } + const before = this.readServe(); assertPrimaryIntact(before); const beforePrimary = primaryDigest(before); @@ -371,7 +478,7 @@ export class BrokerCore { ): BrokerResponse { const registry = this.loadRegistry(); const listeners = registry.leases - .filter((lease) => lease.peerUid === peer.uid && lease.peerGid === peer.gid) + .filter((lease) => lease.state === "exposed" && lease.peerUid === peer.uid && lease.peerGid === peer.gid) .flatMap((lease) => lease.ports.map((port, index) => ({ runtimeId: lease.runtimeId, @@ -420,6 +527,14 @@ export class BrokerCore { } } +function sameNumbers(left: readonly number[], right: readonly number[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + function codeForError(error: unknown): string { if (error instanceof ServeParseError) return "serve_parse_error"; if (error instanceof AuthorizationError) return error.code; diff --git a/packages/tailscale-https-broker/src/peercred.ts b/packages/tailscale-https-broker/src/peercred.ts index 98f3c051529..50944c9a259 100644 --- a/packages/tailscale-https-broker/src/peercred.ts +++ b/packages/tailscale-https-broker/src/peercred.ts @@ -3,7 +3,7 @@ * * Node does not expose SO_PEERCRED through a public API. The broker's PRIMARY * access boundary is therefore the OS filesystem: the socket lives in a - * root-owned directory and is mode 0660 owned by `root:`, so + * broker-owned directory and is mode 0660 owned by the dedicated socket group, so * only the allowlisted Paperclip service identity can `connect()` at all (this * is exactly the dedicated-socket-group control in PAP-17050 verdict req #1). * @@ -21,7 +21,7 @@ export interface PeerResolverConfig { /** The single OS identity the socket permissions restrict connections to. */ serviceUid: number; serviceGid: number; - /** Operator assertion that the socket is 0660 root:. */ + /** Operator assertion that the socket is 0660 and dedicated-group-owned. */ trustSocketPermissions: boolean; /** Optional native SO_PEERCRED reader; returns null if unavailable. */ soPeercred?: (socket: Socket) => PeerCredentials | null; diff --git a/packages/tailscale-https-broker/src/protocol.test.ts b/packages/tailscale-https-broker/src/protocol.test.ts index 77f50ba5e34..32b3ba93de5 100644 --- a/packages/tailscale-https-broker/src/protocol.test.ts +++ b/packages/tailscale-https-broker/src/protocol.test.ts @@ -8,11 +8,11 @@ function frame(obj: unknown): string { } describe("decodeRequest", () => { - it("decodes a valid expose request", () => { + it("decodes a valid reserve request", () => { const req = decodeRequest( frame({ v: 1, - op: "expose", + op: "reserve", requestId: "req-1", runtimeId: RUNTIME, listeners: [ @@ -22,7 +22,7 @@ describe("decodeRequest", () => { }), ); expect(req).toEqual({ - op: "expose", + op: "reserve", requestId: "req-1", runtimeId: RUNTIME, listeners: [ @@ -32,7 +32,7 @@ describe("decodeRequest", () => { }); }); - it("decodes remove and list", () => { + it("decodes expose, remove, and list", () => { expect( decodeRequest(frame({ v: 1, op: "list", requestId: "r" })).op, ).toBe("list"); @@ -40,6 +40,10 @@ describe("decodeRequest", () => { frame({ v: 1, op: "remove", requestId: "r", runtimeId: RUNTIME, handle: "a".repeat(20) }), ); expect(rm.op).toBe("remove"); + const expose = decodeRequest( + frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, handle: "a".repeat(20) }), + ); + expect(expose.op).toBe("expose"); }); it("rejects unsupported protocol version", () => { @@ -64,22 +68,42 @@ describe("decodeRequest", () => { it("rejects a non-UUID runtimeId and out-of-canonical ports", () => { expect(() => - decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: "not-a-uuid", listeners: [{ purpose: "app", port: 42010 }] })), + decodeRequest(frame({ v: 1, op: "reserve", requestId: "r", runtimeId: "not-a-uuid", listeners: [{ purpose: "app", port: 42010 }] })), ).toThrow(/runtime/); expect(() => - decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: "42010" }] })), + decodeRequest(frame({ v: 1, op: "reserve", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: "42010" }] })), ).toThrow(); }); it("rejects duplicate ports/purposes and too many listeners", () => { expect(() => - decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "app", port: 42010 }] })), + decodeRequest(frame({ v: 1, op: "reserve", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "app", port: 42010 }] })), ).toThrow(/duplicate/); expect(() => - decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "vite_hmr", port: 52010 }, { purpose: "app", port: 42011 }] })), + decodeRequest(frame({ v: 1, op: "reserve", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "vite_hmr", port: 52010 }, { purpose: "app", port: 42011 }] })), ).toThrow(/too many/); }); + it("rejects HMR-only and mismatched app/HMR reservations", () => { + expect(() => decodeRequest(frame({ + v: 1, + op: "reserve", + requestId: "r", + runtimeId: RUNTIME, + listeners: [{ purpose: "vite_hmr", port: 52000 }], + }))).toThrow(/app listener/); + expect(() => decodeRequest(frame({ + v: 1, + op: "reserve", + requestId: "r", + runtimeId: RUNTIME, + listeners: [ + { purpose: "app", port: 42000 }, + { purpose: "vite_hmr", port: 52001 }, + ], + }))).toThrow(/companion/); + }); + it("rejects oversized frames", () => { const big = "x".repeat(MAX_REQUEST_BYTES + 1); expect(() => diff --git a/packages/tailscale-https-broker/src/protocol.ts b/packages/tailscale-https-broker/src/protocol.ts index e823e7b5312..cf21a3f43b8 100644 --- a/packages/tailscale-https-broker/src/protocol.ts +++ b/packages/tailscale-https-broker/src/protocol.ts @@ -5,6 +5,11 @@ * duplicate-key, and unknown-field requests are rejected without side effects. */ import { assertCanonicalPort } from "./integers.js"; +import { + DEFAULT_APP_PORT_MAX, + DEFAULT_APP_PORT_MIN, + DEFAULT_HMR_PORT_OFFSET, +} from "./port-policy.js"; import { parseJsonNoDuplicateKeys } from "./strict-json.js"; import { BROKER_PROTOCOL_VERSION, @@ -102,7 +107,7 @@ export function decodeRequest(frame: Buffer | string): BrokerRequest { const requestId = requireStringField(obj, "requestId", REQUEST_ID_RE, "malformed_request", null); const op = obj.op; - if (op !== "expose" && op !== "remove" && op !== "list") { + if (op !== "reserve" && op !== "expose" && op !== "remove" && op !== "list") { throw new ProtocolError("unknown_operation", `unknown operation: ${String(op)}`, requestId); } @@ -111,14 +116,14 @@ export function decodeRequest(frame: Buffer | string): BrokerRequest { return { op: "list", requestId }; } - if (op === "remove") { + if (op === "remove" || op === "expose") { assertExactKeys(obj, ["v", "op", "requestId", "runtimeId", "handle"], requestId); const runtimeId = requireStringField(obj, "runtimeId", UUID_RE, "invalid_runtime_id", requestId); const handle = requireStringField(obj, "handle", HANDLE_RE, "malformed_request", requestId); - return { op: "remove", requestId, runtimeId, handle }; + return { op, requestId, runtimeId, handle }; } - // expose + // reserve assertExactKeys(obj, ["v", "op", "requestId", "runtimeId", "listeners"], requestId); const runtimeId = requireStringField(obj, "runtimeId", UUID_RE, "invalid_runtime_id", requestId); const rawListeners = obj.listeners; @@ -151,7 +156,16 @@ export function decodeRequest(frame: Buffer | string): BrokerRequest { throw new ProtocolError("malformed_request", "duplicate listener port or purpose", requestId); } - return { op: "expose", requestId, runtimeId, listeners }; + const app = listeners.find((listener) => listener.purpose === "app"); + const hmr = listeners.find((listener) => listener.purpose === "vite_hmr"); + if (!app || app.port < DEFAULT_APP_PORT_MIN || app.port > DEFAULT_APP_PORT_MAX) { + throw new ProtocolError("invalid_port", "an app listener in the dedicated app range is required", requestId); + } + if (hmr && hmr.port !== app.port + DEFAULT_HMR_PORT_OFFSET) { + throw new ProtocolError("invalid_port", "vite_hmr must be the app port companion", requestId); + } + + return { op: "reserve", requestId, runtimeId, listeners }; } function assertExactKeys( diff --git a/packages/tailscale-https-broker/src/registry.ts b/packages/tailscale-https-broker/src/registry.ts index 62dc9f95590..3e6061e9a42 100644 --- a/packages/tailscale-https-broker/src/registry.ts +++ b/packages/tailscale-https-broker/src/registry.ts @@ -43,6 +43,13 @@ export function loadRegistry(path: string, nodeIdentity: string): BrokerRegistry if (parsed.version !== 1 || !Array.isArray(parsed.leases)) { throw new Error("registry is corrupt or of an unsupported version"); } + // Compatibility with the pre-reservation development build: an existing + // lease necessarily represented an exposed mapping. + parsed.leases = parsed.leases.map((lease) => ({ + ...lease, + state: lease.state === "reserved" ? "reserved" : "exposed", + expiresAtIso: typeof lease.expiresAtIso === "string" ? lease.expiresAtIso : null, + })); parsed.quarantinedPorts = Array.isArray(parsed.quarantinedPorts) ? parsed.quarantinedPorts : []; return parsed; } @@ -116,6 +123,21 @@ export function removeLeaseByHandle(registry: BrokerRegistry, handle: string): L return removed; } +/** Remove expired, never-exposed reservations and return their released ports. */ +export function pruneExpiredReservations(registry: BrokerRegistry, nowIso: string): number[] { + const nowMs = Date.parse(nowIso); + if (!Number.isFinite(nowMs)) return []; + const released: number[] = []; + registry.leases = registry.leases.filter((lease) => { + if (lease.state !== "reserved" || !lease.expiresAtIso) return true; + const expiresMs = Date.parse(lease.expiresAtIso); + if (!Number.isFinite(expiresMs) || expiresMs > nowMs) return true; + released.push(...lease.ports); + return false; + }); + return released; +} + export function isPortQuarantined(registry: BrokerRegistry, port: number): boolean { return registry.quarantinedPorts.includes(port); } diff --git a/packages/tailscale-https-broker/src/types.ts b/packages/tailscale-https-broker/src/types.ts index 298f1072f29..d157c9a808f 100644 --- a/packages/tailscale-https-broker/src/types.ts +++ b/packages/tailscale-https-broker/src/types.ts @@ -17,7 +17,7 @@ export const BROKER_PROTOCOL_VERSION = 1; export const PROTECTED_PRIMARY_PORT = 443; export const PROTECTED_PRIMARY_TARGET = "http://127.0.0.1:3100"; -export type BrokerOp = "expose" | "remove" | "list"; +export type BrokerOp = "reserve" | "expose" | "remove" | "list"; /** Peer credentials obtained from the OS for an accepted socket connection. */ export interface PeerCredentials { @@ -33,14 +33,20 @@ export interface OwnedListener { port: number; } -/** An expose request body (already schema-validated). */ +/** Reserve an app/HMR pair before the managed backend starts listening. */ +export interface ReserveRequest { + op: "reserve"; + requestId: string; + runtimeId: string; + listeners: OwnedListener[]; +} + +/** Expose the listeners bound to a broker-issued reservation handle. */ export interface ExposeRequest { op: "expose"; requestId: string; - /** Runtime-service UUID the caller claims to own. */ runtimeId: string; - /** Loopback target ports to expose; each becomes a same-number HTTPS listener. */ - listeners: OwnedListener[]; + handle: string; } /** A remove request body. Requires a prior expose lease handle. */ @@ -58,7 +64,16 @@ export interface ListRequest { requestId: string; } -export type BrokerRequest = ExposeRequest | RemoveRequest | ListRequest; +export type BrokerRequest = ReserveRequest | ExposeRequest | RemoveRequest | ListRequest; + +export interface ReserveResponseOk { + ok: true; + op: "reserve"; + requestId: string; + /** Unguessable handle bound to peer, runtime, ports, and generation. */ + handle: string; + reservedPorts: number[]; +} export interface ExposeResponseOk { ok: true; @@ -93,6 +108,7 @@ export interface BrokerErrorResponse { } export type BrokerResponse = + | ReserveResponseOk | ExposeResponseOk | RemoveResponseOk | ListResponseOk @@ -109,6 +125,8 @@ export type BrokerErrorCode = | "listener_not_owned" | "listener_ownership_mismatch" | "manual_mapping_present" + | "reservation_conflict" + | "reservation_expired" | "primary_route_violation" | "invalid_handle" | "serve_parse_error" @@ -129,9 +147,13 @@ export interface LeaseRecord { peerGid: number; ports: number[]; purposes: OwnedListener["purpose"][]; + /** Reserved before bind; exposed only after listener ownership is verified. */ + state: "reserved" | "exposed"; /** Monotonic generation to defeat ABA remove/re-expose confusion. */ generation: number; createdAtIso: string; + /** Bounds leaked reservations when a caller crashes before backend start. */ + expiresAtIso: string | null; } /** Registry persisted to a root-owned 0600 file via temp+fsync+rename. */ diff --git a/server/src/services/runtime-exposure/broker-client.test.ts b/server/src/services/runtime-exposure/broker-client.test.ts index fa31caaf501..a13e4478a79 100644 --- a/server/src/services/runtime-exposure/broker-client.test.ts +++ b/server/src/services/runtime-exposure/broker-client.test.ts @@ -72,26 +72,28 @@ afterEach(async () => { const RUNTIME_ID = "11111111-2222-4333-8444-555566667777"; describe("UnixBrokerClient", () => { - it("round-trips an expose request and parses handle + ports", async () => { + it("round-trips reservation and expose requests", async () => { let seen: Record | undefined; const socketPath = fake((request) => { seen = request; + if (request.op === "reserve") { + return { ok: true, op: "reserve", requestId: request.requestId, handle: "h".repeat(20), reservedPorts: [42000, 52000] }; + } return { ok: true, op: "expose", requestId: request.requestId, handle: "h".repeat(20), publicPorts: [42000, 52000] }; }); const client = new UnixBrokerClient({ socketPath }); - const result = await client.expose(RUNTIME_ID, [ + const reserved = await client.reserve(RUNTIME_ID, [ { purpose: "app", port: 42000 }, { purpose: "vite_hmr", port: 52000 }, ]); + expect(reserved).toEqual({ handle: "h".repeat(20), reservedPorts: [42000, 52000] }); + const result = await client.expose(RUNTIME_ID, reserved.handle); expect(result).toEqual({ handle: "h".repeat(20), publicPorts: [42000, 52000] }); expect(seen).toMatchObject({ v: 1, op: "expose", runtimeId: RUNTIME_ID, - listeners: [ - { purpose: "app", port: 42000 }, - { purpose: "vite_hmr", port: 52000 }, - ], + handle: "h".repeat(20), }); expect(typeof seen?.requestId).toBe("string"); }); @@ -128,7 +130,7 @@ describe("UnixBrokerClient", () => { message: "denied", })); const client = new UnixBrokerClient({ socketPath }); - await expect(client.expose(RUNTIME_ID, [{ purpose: "app", port: 42000 }])).rejects.toMatchObject({ + await expect(client.reserve(RUNTIME_ID, [{ purpose: "app", port: 42000 }])).rejects.toMatchObject({ name: "BrokerClientError", code: "port_not_allowlisted", }); @@ -143,7 +145,7 @@ describe("UnixBrokerClient", () => { it("rejects an expose response missing publicPorts", async () => { const socketPath = fake((request) => ({ ok: true, op: "expose", requestId: request.requestId, handle: "h".repeat(20) })); const client = new UnixBrokerClient({ socketPath }); - await expect(client.expose(RUNTIME_ID, [{ purpose: "app", port: 42000 }])).rejects.toMatchObject({ + await expect(client.expose(RUNTIME_ID, "h".repeat(20))).rejects.toMatchObject({ code: "malformed_response", }); }); diff --git a/server/src/services/runtime-exposure/broker-client.ts b/server/src/services/runtime-exposure/broker-client.ts index 91ce244fe9b..fa9cd28e477 100644 --- a/server/src/services/runtime-exposure/broker-client.ts +++ b/server/src/services/runtime-exposure/broker-client.ts @@ -45,6 +45,11 @@ export interface BrokerExposeResult { publicPorts: number[]; } +export interface BrokerReserveResult { + handle: string; + reservedPorts: number[]; +} + export interface BrokerRemoveResult { removedPorts: number[]; } @@ -75,7 +80,8 @@ export class BrokerClientError extends Error { * it as an interface lets tests inject a fake broker with zero sockets. */ export interface BrokerClient { - expose(runtimeId: string, listeners: BrokerListenerRequest[]): Promise; + reserve(runtimeId: string, listeners: BrokerListenerRequest[]): Promise; + expose(runtimeId: string, handle: string): Promise; remove(runtimeId: string, handle: string): Promise; list(): Promise; } @@ -116,16 +122,34 @@ export class UnixBrokerClient implements BrokerClient { this.connect = options.connect ?? ((p) => net.createConnection(p)); } - async expose( + async reserve( runtimeId: string, listeners: BrokerListenerRequest[], - ): Promise { + ): Promise { + const response = await this.roundTrip({ + v: BROKER_PROTOCOL_VERSION, + op: "reserve", + requestId: nextRequestId(), + runtimeId, + listeners: listeners.map((l) => ({ purpose: l.purpose, port: l.port })), + }); + if ( + typeof response.handle !== "string" || + !Array.isArray(response.reservedPorts) || + !response.reservedPorts.every((p) => Number.isInteger(p)) + ) { + throw new BrokerClientError("malformed_response", "reserve response missing handle/reservedPorts"); + } + return { handle: response.handle, reservedPorts: response.reservedPorts as number[] }; + } + + async expose(runtimeId: string, handle: string): Promise { const response = await this.roundTrip({ v: BROKER_PROTOCOL_VERSION, op: "expose", requestId: nextRequestId(), runtimeId, - listeners: listeners.map((l) => ({ purpose: l.purpose, port: l.port })), + handle, }); if ( typeof response.handle !== "string" || diff --git a/server/src/services/runtime-exposure/exposure-manager.test.ts b/server/src/services/runtime-exposure/exposure-manager.test.ts index cc3f4d13aa2..30904fd2f4e 100644 --- a/server/src/services/runtime-exposure/exposure-manager.test.ts +++ b/server/src/services/runtime-exposure/exposure-manager.test.ts @@ -8,12 +8,14 @@ import { type BrokerExposeResult, type BrokerListenerRequest, type BrokerOwnedListener, + type BrokerReserveResult, type BrokerRemoveResult, } from "./broker-client.js"; import { deprovisionExposure, provisionExposure, reconcileExposures, + reserveExposure, type ExposureManagerDeps, } from "./exposure-manager.js"; @@ -31,24 +33,31 @@ const CONFIG = { }; interface FakeBrokerScript { - expose?: (runtimeId: string, listeners: BrokerListenerRequest[]) => BrokerExposeResult; + reserve?: (runtimeId: string, listeners: BrokerListenerRequest[]) => BrokerReserveResult; + expose?: (runtimeId: string, handle: string) => BrokerExposeResult; remove?: (runtimeId: string, handle: string) => BrokerRemoveResult; list?: () => BrokerOwnedListener[]; } interface FakeBrokerCalls { - expose: Array<{ runtimeId: string; listeners: BrokerListenerRequest[] }>; + reserve: Array<{ runtimeId: string; listeners: BrokerListenerRequest[] }>; + expose: Array<{ runtimeId: string; handle: string }>; remove: Array<{ runtimeId: string; handle: string }>; list: number; } function fakeBroker(script: FakeBrokerScript): { broker: BrokerClient; calls: FakeBrokerCalls } { - const calls: FakeBrokerCalls = { expose: [], remove: [], list: 0 }; + const calls: FakeBrokerCalls = { reserve: [], expose: [], remove: [], list: 0 }; const broker: BrokerClient = { - async expose(runtimeId, listeners) { - calls.expose.push({ runtimeId, listeners }); + async reserve(runtimeId, listeners) { + calls.reserve.push({ runtimeId, listeners }); + if (!script.reserve) throw new BrokerClientError("internal_error", "no reserve script"); + return script.reserve(runtimeId, listeners); + }, + async expose(runtimeId, handle) { + calls.expose.push({ runtimeId, handle }); if (!script.expose) throw new BrokerClientError("internal_error", "no expose script"); - return script.expose(runtimeId, listeners); + return script.expose(runtimeId, handle); }, async remove(runtimeId, handle) { calls.remove.push({ runtimeId, handle }); @@ -63,6 +72,45 @@ function fakeBroker(script: FakeBrokerScript): { broker: BrokerClient; calls: Fa return { broker, calls }; } +const HANDLE = "handle-abcdef1234567890"; + +describe("reserveExposure", () => { + it("reserves the exact app + HMR pair before startup", async () => { + const { broker, calls } = fakeBroker({ + reserve: () => ({ handle: HANDLE, reservedPorts: [APP_PORT, HMR_PORT] }), + }); + const result = await reserveExposure(deps(broker, async () => true), { + runtimeId: RUNTIME_ID, + config: CONFIG, + appPort: APP_PORT, + }); + expect(calls.reserve).toEqual([{ + runtimeId: RUNTIME_ID, + listeners: [ + { purpose: "app", port: APP_PORT }, + { purpose: "vite_hmr", port: HMR_PORT }, + ], + }]); + expect(result.handle).toBe(HANDLE); + expect(result.status.state).toBe("pending"); + }); + + it("releases a malformed reservation response and fails closed", async () => { + const { broker, calls } = fakeBroker({ + reserve: () => ({ handle: HANDLE, reservedPorts: [APP_PORT] }), + remove: () => ({ removedPorts: [] }), + }); + const result = await reserveExposure(deps(broker, async () => true), { + runtimeId: RUNTIME_ID, + config: CONFIG, + appPort: APP_PORT, + }); + expect(result.status.state).toBe("failed"); + expect(result.handle).toBeNull(); + expect(calls.remove).toEqual([{ runtimeId: RUNTIME_ID, handle: HANDLE }]); + }); +}); + function deps(broker: BrokerClient, probeHealth: () => Promise): ExposureManagerDeps { return { broker, probeHealth, now: () => "2026-08-11T00:00:00.000Z" }; } @@ -75,15 +123,13 @@ describe("provisionExposure", () => { const { status, handle } = await provisionExposure(deps(broker, async () => true), { runtimeId: RUNTIME_ID, config: CONFIG, + handle: HANDLE, hostname: HOSTNAME, appPort: APP_PORT, }); expect(calls.expose).toHaveLength(1); - expect(calls.expose[0].listeners).toEqual([ - { purpose: "app", port: APP_PORT }, - { purpose: "vite_hmr", port: HMR_PORT }, - ]); + expect(calls.expose).toEqual([{ runtimeId: RUNTIME_ID, handle: HANDLE }]); expect(status.state).toBe("ready"); expect(status.publicUrl).toBe(`https://${HOSTNAME}:${APP_PORT}`); expect(status.hostname).toBe(HOSTNAME); @@ -100,10 +146,11 @@ describe("provisionExposure", () => { const { status } = await provisionExposure(deps(broker, async () => true), { runtimeId: RUNTIME_ID, config: { ...CONFIG, includePaperclipViteHmr: false }, + handle: HANDLE, hostname: HOSTNAME, appPort: APP_PORT, }); - expect(calls.expose[0].listeners).toEqual([{ purpose: "app", port: APP_PORT }]); + expect(calls.expose).toEqual([{ runtimeId: RUNTIME_ID, handle: HANDLE }]); expect(status.listeners).toEqual([{ purpose: "app", publicPort: APP_PORT, targetPort: APP_PORT }]); expect(status.state).toBe("ready"); }); @@ -113,12 +160,13 @@ describe("provisionExposure", () => { const { status, handle } = await provisionExposure(deps(broker, async () => true), { runtimeId: RUNTIME_ID, config: CONFIG, + handle: HANDLE, hostname: HOSTNAME, appPort: 3100, // primary app port — never eligible }); expect(calls.expose).toHaveLength(0); expect(status.state).toBe("failed"); - expect(handle).toBeNull(); + expect(handle).toBe(HANDLE); }); it("fails when the broker rejects the expose request", async () => { @@ -130,13 +178,14 @@ describe("provisionExposure", () => { const { status, handle } = await provisionExposure(deps(broker, async () => true), { runtimeId: RUNTIME_ID, config: CONFIG, + handle: HANDLE, hostname: HOSTNAME, appPort: APP_PORT, }); expect(status.state).toBe("failed"); expect(status.lastError).toBe("port_not_allowlisted"); - expect(status.listeners).toEqual([]); - expect(handle).toBeNull(); + expect(status.listeners).toHaveLength(2); + expect(handle).toBe(HANDLE); }); it("tears the mapping back down and fails when returned ports do not match", async () => { @@ -147,13 +196,14 @@ describe("provisionExposure", () => { const { status, handle } = await provisionExposure(deps(broker, async () => true), { runtimeId: RUNTIME_ID, config: CONFIG, + handle: HANDLE, hostname: HOSTNAME, appPort: APP_PORT, }); expect(status.state).toBe("failed"); expect(status.lastError).toMatch(/unexpected public ports/); expect(calls.remove).toHaveLength(1); // best-effort rollback - expect(handle).toBeNull(); + expect(handle).toBe(HANDLE); }); it("fail-closed: keeps the handle but reports failed when the HTTPS probe does not validate", async () => { @@ -163,6 +213,7 @@ describe("provisionExposure", () => { const { status, handle } = await provisionExposure(deps(broker, async () => false), { runtimeId: RUNTIME_ID, config: CONFIG, + handle: HANDLE, hostname: HOSTNAME, appPort: APP_PORT, }); @@ -182,7 +233,7 @@ describe("provisionExposure", () => { deps(broker, async () => { throw new Error("cert error"); }), - { runtimeId: RUNTIME_ID, config: CONFIG, hostname: HOSTNAME, appPort: APP_PORT }, + { runtimeId: RUNTIME_ID, config: CONFIG, handle: HANDLE, hostname: HOSTNAME, appPort: APP_PORT }, ); expect(status.state).toBe("failed"); }); diff --git a/server/src/services/runtime-exposure/exposure-manager.ts b/server/src/services/runtime-exposure/exposure-manager.ts index 8715f74ff0b..de093b1ae43 100644 --- a/server/src/services/runtime-exposure/exposure-manager.ts +++ b/server/src/services/runtime-exposure/exposure-manager.ts @@ -45,6 +45,8 @@ export interface ExposureManagerDeps { export interface ProvisionInput { runtimeId: string; config: RuntimeExposureConfigInput; + /** Broker-issued reservation handle persisted before the backend starts. */ + handle: string; /** Resolved Tailscale node DNS hostname (the manager does not resolve it). */ hostname: string; /** Loopback app port already allocated in the dedicated range. */ @@ -61,6 +63,48 @@ export interface ProvisionResult { handle: string | null; } +export interface ReserveInput { + runtimeId: string; + config: RuntimeExposureConfigInput; + appPort: number; +} + +/** Reserve the app/HMR pair before the backend binds either listener. */ +export async function reserveExposure( + deps: ExposureManagerDeps, + input: ReserveInput, +): Promise { + const status = baseStatus(deps.now()); + if (!isRuntimeExposureAppPort(input.appPort)) { + status.state = "failed"; + status.lastError = "app port outside dedicated runtime exposure range"; + return { status, handle: null }; + } + const requested = buildListenerRequests(input.config, input.appPort); + status.listeners = requested.map((listener) => ({ + purpose: listener.purpose, + publicPort: listener.port, + targetPort: listener.port, + })); + status.brokerRef = input.runtimeId; + try { + const result = await deps.broker.reserve(input.runtimeId, requested); + const requestedPorts = new Set(requested.map((listener) => listener.port)); + const returnedPorts = new Set(result.reservedPorts); + if (requestedPorts.size !== returnedPorts.size || ![...requestedPorts].every((port) => returnedPorts.has(port))) { + await safeRemove(deps.broker, input.runtimeId, result.handle); + status.state = "failed"; + status.lastError = "broker returned unexpected reserved ports"; + return { status, handle: null }; + } + return { status, handle: result.handle }; + } catch (error) { + status.state = "failed"; + status.lastError = brokerErrorCode(error); + return { status, handle: null }; + } +} + export interface DeprovisionInput { runtimeId: string; /** Server-persisted lease handle, or null if none was ever recorded. */ @@ -124,21 +168,27 @@ export async function provisionExposure( if (!isRuntimeExposureAppPort(input.appPort)) { status.state = "failed"; status.lastError = "app port outside dedicated runtime exposure range"; - return { status, handle: null }; + return { status, handle: input.handle }; } const requested = buildListenerRequests(input.config, input.appPort); + status.listeners = requested.map((listener) => ({ + purpose: listener.purpose, + publicPort: listener.port, + targetPort: listener.port, + })); + status.brokerRef = input.runtimeId; let handle: string; let publicPorts: number[]; try { - const result = await deps.broker.expose(input.runtimeId, requested); + const result = await deps.broker.expose(input.runtimeId, input.handle); handle = result.handle; publicPorts = result.publicPorts; } catch (error) { status.state = "failed"; status.lastError = brokerErrorCode(error); - return { status, handle: null }; + return { status, handle: input.handle }; } // The broker must return exactly the ports we asked for (same-number). A @@ -149,18 +199,11 @@ export async function provisionExposure( await safeRemove(deps.broker, input.runtimeId, handle); status.state = "failed"; status.lastError = "broker returned unexpected public ports"; - return { status, handle: null }; + return { status, handle }; } // Reflect the attempted mapping regardless of health so the UI can show what // was provisioned even while probing. - status.listeners = requested.map((l) => ({ - purpose: l.purpose, - publicPort: l.port, - targetPort: l.port, - })); - status.brokerRef = input.runtimeId; - // fail-closed: never report ready until an external, cert-validating probe // succeeds. Keep the handle so the caller can retry or clean up. const healthUrl = buildRuntimeExposureHealthUrl(input.hostname, input.appPort); diff --git a/server/src/services/runtime-exposure/tailscale-hostname.test.ts b/server/src/services/runtime-exposure/tailscale-hostname.test.ts new file mode 100644 index 00000000000..aef2b0d5378 --- /dev/null +++ b/server/src/services/runtime-exposure/tailscale-hostname.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { parseTailscaleDnsName } from "./tailscale-hostname.js"; + +describe("parseTailscaleDnsName", () => { + it("normalizes the local node MagicDNS hostname", () => { + expect(parseTailscaleDnsName({ Self: { DNSName: "Branch-Runner.tail123.ts.net." } })) + .toBe("branch-runner.tail123.ts.net"); + }); + + it("rejects missing, single-label, and injected hostnames", () => { + expect(() => parseTailscaleDnsName({})).toThrow(/Self\.DNSName/); + expect(() => parseTailscaleDnsName({ Self: { DNSName: "localhost" } })).toThrow(/invalid/); + expect(() => parseTailscaleDnsName({ Self: { DNSName: "host/evil.ts.net" } })).toThrow(/invalid/); + }); +}); diff --git a/server/src/services/runtime-exposure/tailscale-hostname.ts b/server/src/services/runtime-exposure/tailscale-hostname.ts new file mode 100644 index 00000000000..82a6c089206 --- /dev/null +++ b/server/src/services/runtime-exposure/tailscale-hostname.ts @@ -0,0 +1,45 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const DEFAULT_TAILSCALE_BIN = "/usr/bin/tailscale"; + +type TailscaleStatus = { + Self?: { DNSName?: unknown }; +}; + +/** Parse only this node's MagicDNS name from `tailscale status --json`. */ +export function parseTailscaleDnsName(value: unknown): string { + const dnsName = (value as TailscaleStatus | null)?.Self?.DNSName; + if (typeof dnsName !== "string") { + throw new Error("tailscale status did not include Self.DNSName"); + } + const normalized = dnsName.trim().replace(/\.$/, "").toLowerCase(); + if (!/^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/.test(normalized) || !normalized.includes(".")) { + throw new Error("tailscale status returned an invalid Self.DNSName"); + } + return normalized; +} + +/** + * Read-only hostname discovery. This does not require Tailscale operator + * authority; all Serve mutations remain isolated in the host broker. + */ +export async function resolveTailscaleDnsName(input?: { + tailscaleBinPath?: string; + timeoutMs?: number; +}): Promise { + const configured = process.env.PAPERCLIP_TAILSCALE_DNS_NAME?.trim(); + if (configured) return parseTailscaleDnsName({ Self: { DNSName: configured } }); + + const tailscaleBinPath = input?.tailscaleBinPath ?? DEFAULT_TAILSCALE_BIN; + if (!tailscaleBinPath.startsWith("/")) { + throw new Error("tailscale binary path must be absolute"); + } + const { stdout } = await execFileAsync(tailscaleBinPath, ["status", "--json"], { + encoding: "utf8", + timeout: input?.timeoutMs ?? 3_000, + maxBuffer: 1024 * 1024, + }); + return parseTailscaleDnsName(JSON.parse(stdout)); +}