mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
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:
1 parent
fdf983dea0
commit
1696e17c67
20 files changed
+661
-142
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user