paperclip/packages/tailscale-https-broker/src/protected-ports.test.ts

387 lines
16 KiB
TypeScript

/**
* PAP-17285 regression coverage.
*
* Two Serve mappings on `42000/52000` that operators had declared must-preserve
* were destroyed by a fully authorized managed removal. Reconstructed cause: the
* broker had itself created those mappings for a since-retired canary lane, so
* its registry still held an `exposed` lease for them. Every existing guard
* therefore passed — the peer was authorized, the handle matched, the entries
* were shape-valid same-number loopback listeners, `:443` was untouched, and the
* before/after diff saw changes only on the lease's own ports. The pre-existing
* "unknown/manual entries are never modified" invariant never applied, because
* the entries were never unknown *to the broker*.
*
* These tests pin both halves of the repair:
* - a genuinely unrelated unknown/manual pair survives every lifecycle path,
* including the failed/compensated ones (the pre-existing guarantee), and
* - an operator-protected pair survives even when a valid lease names it (the
* new guarantee), with a negative control proving the guard is what does it.
*/
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { MemoryAuditSink } from "./audit.js";
import { BrokerCore, type CliResult, type ListenerOwnership } from "./broker-core.js";
import { buildExposeArgv, buildRemoveArgv } from "./argv.js";
import { defaultIsAllowedPort, parseProtectedPorts } from "./port-policy.js";
import { changedProtectedPorts, parseServeStatus } from "./serve-config.js";
import { saveRegistry } from "./registry.js";
import type { BrokerRequest, PeerCredentials } from "./types.js";
const HOST = "paperclip-dev.tail29c1aa.ts.net";
const BIN = "/usr/bin/tailscale";
const RUNTIME_A = "2af79bb1-ecc5-4410-8438-091be135a921";
const PEER: PeerCredentials = { uid: 999, gid: 987, pid: 4242 };
/** The pair the incident lost. Same-number loopback, so shape-indistinguishable. */
const PROTECTED_APP = 42000;
const PROTECTED_HMR = 52000;
/**
* An unrelated unknown/manual pair the broker never created. Deliberately NOT
* same-number (`42500 -> 5432`) so it is genuinely unknown by shape, which is
* the population the original invariant protects.
*/
const MANUAL_APP = 42500;
const MANUAL_HMR = 52500;
const MANUAL_APP_TARGET = "http://127.0.0.1:5432";
const MANUAL_HMR_TARGET = "http://127.0.0.1:5433";
class FakeTailscale {
ports = new Map<number, string>([
[443, "http://127.0.0.1:3100"],
[MANUAL_APP, MANUAL_APP_TARGET],
[MANUAL_HMR, MANUAL_HMR_TARGET],
[PROTECTED_APP, `http://127.0.0.1:${PROTECTED_APP}`],
[PROTECTED_HMR, `http://127.0.0.1:${PROTECTED_HMR}`],
]);
/** Funnel stays null for the whole suite; the broker has no Funnel verb. */
funnel: unknown = null;
failExposePort: number | null = null;
/** Simulate a CLI that clobbers an unrelated port as a side effect. */
strayOnRemove: number | null = null;
exposeCalls = 0;
removeCalls = 0;
removedPorts: number[] = [];
run = (argv: string[]): CliResult => {
const [, sub, a2, a3] = argv;
if (sub === "serve" && a2 === "status") {
return { code: 0, stdout: this.statusJson(), stderr: "", timedOut: false };
}
if (sub === "serve" && a2 === "--bg") {
this.exposeCalls += 1;
const port = Number(a3.replace("--https=", ""));
if (this.failExposePort === port) {
return { code: 1, stdout: "", stderr: "boom", timedOut: false };
}
this.ports.set(port, `http://127.0.0.1:${port}`);
return { code: 0, stdout: "", stderr: "", timedOut: false };
}
if (sub === "serve" && a2.startsWith("--https=") && a3 === "off") {
this.removeCalls += 1;
const port = Number(a2.replace("--https=", ""));
this.removedPorts.push(port);
this.ports.delete(port);
if (this.strayOnRemove !== null) this.ports.delete(this.strayOnRemove);
return { code: 0, stdout: "", stderr: "", timedOut: false };
}
return { code: 2, stdout: "", stderr: "unknown", timedOut: false };
};
private statusJson(): string {
const TCP: Record<string, unknown> = {};
const Web: Record<string, unknown> = {};
for (const [port, proxy] of this.ports) {
TCP[String(port)] = { HTTPS: true };
Web[`${HOST}:${port}`] = { Handlers: { "/": { Proxy: proxy } } };
}
return JSON.stringify({ TCP, Web, AllowFunnel: this.funnel });
}
/** Exactly the entries that must never move, as a comparable snapshot. */
preservedSnapshot() {
return {
manualApp: this.ports.get(MANUAL_APP),
manualHmr: this.ports.get(MANUAL_HMR),
protectedApp: this.ports.get(PROTECTED_APP),
protectedHmr: this.ports.get(PROTECTED_HMR),
primary: this.ports.get(443),
funnel: this.funnel,
};
}
}
const PRESERVED_INTACT = {
manualApp: MANUAL_APP_TARGET,
manualHmr: MANUAL_HMR_TARGET,
protectedApp: `http://127.0.0.1:${PROTECTED_APP}`,
protectedHmr: `http://127.0.0.1:${PROTECTED_HMR}`,
primary: "http://127.0.0.1:3100",
funnel: null,
};
function makeCore(
fake: FakeTailscale,
registryPath: string,
protectedPorts: readonly number[] = [PROTECTED_APP, PROTECTED_HMR],
ownership: (port: number) => ListenerOwnership = () => ({
present: true,
loopbackOnly: true,
ownerUidMatches: true,
inodes: ["5001"],
}),
) {
const audit = new MemoryAuditSink();
const core = new BrokerCore({
tailscaleBinPath: BIN,
registryPath,
auditSink: audit,
peerPolicy: { allowedUids: new Set([999]), allowedGids: new Set([987]) },
nodeIdentity: "node-1",
isAllowedPort: (port) => defaultIsAllowedPort(port) && !protectedPorts.includes(port),
protectedPorts,
deps: {
runTailscale: fake.run,
verifyListenerOwnership: ownership,
nowIso: () => "2026-08-14T00:00:00.000Z",
},
});
return { core, audit };
}
/**
* Plant the exact registry state that caused the incident: a live `exposed`
* lease the broker itself issued for the now-retired lane, still naming the
* ports operators later declared must-preserve.
*/
function plantRetiredLaneLease(registryPath: string, ports: number[]) {
const handle = "retired-lane-handle-000000000000";
saveRegistry(registryPath, {
version: 1,
nodeIdentity: "node-1",
generationCounter: 7,
leases: [{
handle,
runtimeId: RUNTIME_A,
peerUid: PEER.uid,
peerGid: PEER.gid,
ports,
purposes: ports.map((_, index) => (index === 0 ? "app" : "vite_hmr")),
state: "exposed",
generation: 7,
createdAtIso: "2026-08-11T12:07:18.000Z",
expiresAtIso: null,
}],
quarantinedPorts: [],
});
return handle;
}
const reserveReq = (ports: number[], runtimeId = RUNTIME_A): BrokerRequest => ({
op: "reserve",
requestId: "req-r",
runtimeId,
listeners: ports.map((port, index) => ({
purpose: index === 0 ? "app" : "vite_hmr",
port,
})),
});
let registryPath: string;
beforeEach(() => {
registryPath = join(mkdtempSync(join(tmpdir(), "broker-protected-")), "registry.json");
});
describe("operator-protected ports (PAP-17285)", () => {
it("refuses to remove a protected pair even when a valid broker lease names it", async () => {
// The incident, reproduced: the lease is real, the handle matches, the peer
// is authorized, and the entries are shape-valid same-number listeners. The
// ONLY thing that can save them is the operator declaration.
const fake = new FakeTailscale();
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP, PROTECTED_HMR]);
const { core, audit } = makeCore(fake, registryPath);
const res = await core.handle(
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle },
PEER,
);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("protected_port");
// Fail-closed: denial precedes every Serve read and mutation.
expect(fake.removeCalls).toBe(0);
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
const denial = audit.events.find((event) => event.decision === "deny");
expect(denial?.reasonCode).toBe("protected_port");
expect(denial?.op).toBe("remove");
});
it("NEGATIVE CONTROL: the identical removal succeeds when the ports are not protected", async () => {
// Proves the assertion above is carried by the new guard and not by some
// unrelated precondition — without this, that test could pass vacuously.
const fake = new FakeTailscale();
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP, PROTECTED_HMR]);
const { core } = makeCore(fake, registryPath, []); // no protected ports
const res = await core.handle(
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle },
PEER,
);
expect(res.ok).toBe(true);
if (res.ok && res.op === "remove") {
expect(res.removedPorts).toEqual([PROTECTED_APP, PROTECTED_HMR]);
}
// This is precisely the production loss, reproduced on demand.
expect(fake.ports.has(PROTECTED_APP)).toBe(false);
expect(fake.ports.has(PROTECTED_HMR)).toBe(false);
// Even here the unrelated unknown/manual pair and the primary are untouched.
expect(fake.ports.get(MANUAL_APP)).toBe(MANUAL_APP_TARGET);
expect(fake.ports.get(MANUAL_HMR)).toBe(MANUAL_HMR_TARGET);
expect(fake.ports.get(443)).toBe("http://127.0.0.1:3100");
expect(fake.funnel).toBeNull();
});
it("refuses to reserve or expose a protected port, so no lane can acquire one", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const reserved = await core.handle(reserveReq([PROTECTED_APP, PROTECTED_HMR]), PEER);
expect(reserved.ok).toBe(false);
if (!reserved.ok) expect(reserved.code).toBe("protected_port");
// Also unreachable via a lease forged straight into the registry.
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP]);
const exposed = await core.handle(
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle },
PEER,
);
expect(exposed.ok).toBe(false);
if (!exposed.ok) expect(exposed.code).toBe("protected_port");
expect(fake.exposeCalls).toBe(0);
expect(fake.removeCalls).toBe(0);
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
});
it("preserves both pairs across a full healthy reserve/expose/remove lifecycle", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const lanePorts = [42010, 52010];
const reserved = await core.handle(reserveReq(lanePorts), PEER);
expect(reserved.ok).toBe(true);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
const exposed = await core.handle(
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(exposed.ok).toBe(true);
expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010");
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
const removed = await core.handle(
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(removed.ok).toBe(true);
expect(fake.ports.has(42010)).toBe(false);
expect(fake.ports.has(52010)).toBe(false);
// The whole point: only the lane's own ports moved.
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
});
it("preserves both pairs when expose fails midway and is compensated", async () => {
const fake = new FakeTailscale();
fake.failExposePort = 52010; // second port fails, first is already applied
const { core, audit } = makeCore(fake, registryPath);
const reserved = await core.handle(reserveReq([42010, 52010]), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
const exposed = await core.handle(
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
expect(exposed.ok).toBe(false);
if (!exposed.ok) expect(exposed.code).toBe("cli_error");
// Compensation rolled back the partial application...
expect(fake.ports.has(42010)).toBe(false);
// ...and touched nothing it did not apply.
expect(fake.removedPorts).toEqual([42010]);
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
// Compensation is a Serve mutation, so it must leave a durable record
// (req #6). Before this change it emitted none at all.
const compensation = audit.events.find((event) => event.recovery === "cleanup" && event.op === "expose");
expect(compensation?.reason).toBe("expose compensated");
});
it("detects and quarantines when compensation collaterally changes an unrelated entry", async () => {
// Requirement #3: any unrelated Serve change must be DETECTED. This path
// previously trusted the rollback exit code and re-read nothing.
const fake = new FakeTailscale();
fake.failExposePort = 52010;
fake.strayOnRemove = MANUAL_APP; // rollback clobbers an unknown/manual entry
const { core, audit } = makeCore(fake, registryPath);
const reserved = await core.handle(reserveReq([42010, 52010]), PEER);
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
const exposed = await core.handle(
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
PEER,
);
// The original failure is still the reported error — never masked.
expect(exposed.ok).toBe(false);
if (!exposed.ok) expect(exposed.code).toBe("cli_error");
// The collateral damage is detected, recorded, and the port quarantined so
// it is never silently reused. Recoverability preserved, loss surfaced.
const denial = audit.events.find((event) => event.reason.startsWith("expose compensation unverified"));
expect(denial).toBeDefined();
expect(denial?.reason).toContain(`unexpected_serve_diff:${MANUAL_APP}`);
expect(denial?.recovery).toBe("quarantine");
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
expect(registry.quarantinedPorts).toContain(42010);
});
it("detects a protected entry that DISAPPEARS, not just one that is retargeted", () => {
// The incident was a deletion. `entryDigest(undefined)` is the `"absent"`
// sentinel precisely so removal is as loud as retargeting.
const withBoth = parseServeStatus(JSON.parse(new FakeTailscale().run([BIN, "serve", "status", "--json"]).stdout));
const missing = new FakeTailscale();
missing.ports.delete(PROTECTED_APP);
const withoutOne = parseServeStatus(JSON.parse(missing.run([BIN, "serve", "status", "--json"]).stdout));
expect(changedProtectedPorts(withBoth, withoutOne, [PROTECTED_APP, PROTECTED_HMR])).toEqual([PROTECTED_APP]);
expect(changedProtectedPorts(withBoth, withBoth, [PROTECTED_APP, PROTECTED_HMR])).toEqual([]);
});
it("refuses to build a mutating argv for a protected port", () => {
const guarded = [PROTECTED_APP, PROTECTED_HMR];
expect(() => buildExposeArgv(BIN, PROTECTED_APP, guarded)).toThrow(/operator-protected/);
expect(() => buildRemoveArgv(BIN, PROTECTED_HMR, guarded)).toThrow(/operator-protected/);
// Unprotected ports in the dedicated range still build exactly as before.
expect(buildRemoveArgv(BIN, 42010, guarded)).toEqual([BIN, "serve", "--https=42010", "off"]);
expect(buildExposeArgv(BIN, 42010, guarded)).toEqual([
BIN, "serve", "--bg", "--https=42010", "http://127.0.0.1:42010",
]);
});
it("parses BROKER_PROTECTED_PORTS fail-closed", () => {
expect(parseProtectedPorts(undefined)).toEqual([]);
expect(parseProtectedPorts("")).toEqual([]);
expect(parseProtectedPorts("52000,42000")).toEqual([42000, 52000]);
expect(parseProtectedPorts("42000 52000")).toEqual([42000, 52000]);
expect(parseProtectedPorts("42000,42000")).toEqual([42000]);
// A malformed list must stop the broker, not silently protect nothing.
expect(() => parseProtectedPorts("42000,abc")).toThrow(/non-numeric/);
expect(() => parseProtectedPorts("70000")).toThrow(/out-of-range/);
expect(() => parseProtectedPorts("443")).toThrow(/must not list 443/);
});
});