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

@@ -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. */