feat(runtime): secure Tailscale exposure reservations (PAP-17051)

Add the bounded reserve/expose/remove broker handshake, paired-port contracts, hostname discovery, deployable systemd unit, and focused authorization/lifecycle tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-16 19:24:27 +00:00
1 parent fdf983dea0
commit 1696e17c67
20 files changed
+661 -142

No files matched your search

+1
View File
@@ -2461,6 +2461,7 @@ export {
isRuntimeExposureHmrPort,
isAllowedRuntimeExposurePort,
deriveViteHmrPort,
derivePaperclipViteHmrPort,
buildRuntimeExposureUrl,
buildRuntimeExposureHealthUrl,
} from "./runtime-exposure/ports.js";
@@ -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",
+19 -1
View File
@@ -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);
}
/**
+50 -20
View File
@@ -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=<uid of paperclip app account>
Environment=BROKER_SERVICE_GID=<gid of paperclip-tsbroker-sock>
Environment=BROKER_RUNTIME_UID=<uid of the dedicated managed-runtime account>
Environment=BROKER_TAILSCALE_BIN=/usr/bin/tailscale
# Only when SO_PEERCRED is not wired and the socket is confirmed 0660
# root:<group>. 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:<group>`.
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
```
@@ -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
+2 -1
View File
@@ -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"
},
@@ -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 };
@@ -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");
});
@@ -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<BrokerRequest, { op: "expose" }>,
private doReserve(
request: Extract<BrokerRequest, { op: "reserve" }>,
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<BrokerRequest, { op: "expose" }>,
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;
@@ -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:<dedicated-group>`, 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:<dedicated-group>. */
/** 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;
@@ -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(() =>
@@ -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(
@@ -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);
}
+28 -6
View File
@@ -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. */
@@ -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<string, unknown> | 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",
});
});
@@ -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<BrokerExposeResult>;
reserve(runtimeId: string, listeners: BrokerListenerRequest[]): Promise<BrokerReserveResult>;
expose(runtimeId: string, handle: string): Promise<BrokerExposeResult>;
remove(runtimeId: string, handle: string): Promise<BrokerRemoveResult>;
list(): Promise<BrokerOwnedListener[]>;
}
@@ -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<BrokerExposeResult> {
): Promise<BrokerReserveResult> {
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<BrokerExposeResult> {
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" ||
@@ -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<boolean>): 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");
});
@@ -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<ProvisionResult> {
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);
@@ -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/);
});
});
@@ -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<string> {
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));
}