diff --git a/Dockerfile b/Dockerfile index 2769078b66..a449c77942 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ COPY packages/google-sheets-mcp-server/package.json packages/google-sheets-mcp-s COPY packages/kv-demo-mcp-server/package.json packages/kv-demo-mcp-server/ COPY packages/mcp-server/package.json packages/mcp-server/ COPY packages/skills-catalog/package.json packages/skills-catalog/ +COPY packages/tailscale-https-broker/package.json packages/tailscale-https-broker/ COPY packages/teams-catalog/package.json packages/teams-catalog/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ diff --git a/packages/db/src/migrations/0219_runtime_service_exposure.sql b/packages/db/src/migrations/0219_runtime_service_exposure.sql new file mode 100644 index 0000000000..3a3b464f64 --- /dev/null +++ b/packages/db/src/migrations/0219_runtime_service_exposure.sql @@ -0,0 +1,3 @@ +ALTER TABLE "workspace_runtime_services" ADD COLUMN "exposure" jsonb; +ALTER TABLE "workspace_runtime_services" ADD COLUMN "exposure_handle" text; +ALTER TABLE "workspace_runtime_services" ADD COLUMN "backend_url" text; diff --git a/packages/db/src/migrations/0220_execution_workspace_runtime_leases.sql b/packages/db/src/migrations/0220_execution_workspace_runtime_leases.sql new file mode 100644 index 0000000000..3b73121120 --- /dev/null +++ b/packages/db/src/migrations/0220_execution_workspace_runtime_leases.sql @@ -0,0 +1,26 @@ +CREATE TABLE "execution_workspace_runtime_leases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "execution_workspace_id" uuid NOT NULL, + "owner_key" text NOT NULL, + "owner_issue_id" uuid, + "owner_run_id" uuid, + "owner_agent_id" uuid, + "last_action" text NOT NULL, + "claimed_at" timestamp with time zone DEFAULT now() NOT NULL, + "renewed_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "execution_workspace_runtime_leases_execution_workspace_id_unique" UNIQUE("execution_workspace_id") +); +--> statement-breakpoint +ALTER TABLE "execution_workspace_runtime_leases" ADD CONSTRAINT "execution_workspace_runtime_leases_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "execution_workspace_runtime_leases" ADD CONSTRAINT "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "execution_workspace_runtime_leases" ADD CONSTRAINT "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk" FOREIGN KEY ("owner_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "execution_workspace_runtime_leases" ADD CONSTRAINT "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("owner_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "execution_workspace_runtime_leases" ADD CONSTRAINT "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "execution_workspace_runtime_leases_company_workspace_idx" ON "execution_workspace_runtime_leases" USING btree ("company_id","execution_workspace_id");--> statement-breakpoint +CREATE INDEX "execution_workspace_runtime_leases_company_owner_idx" ON "execution_workspace_runtime_leases" USING btree ("company_id","owner_key");--> statement-breakpoint +CREATE INDEX "execution_workspace_runtime_leases_expires_at_idx" ON "execution_workspace_runtime_leases" USING btree ("expires_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index e8ee2e3ee3..ee901e2db3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1520,6 +1520,20 @@ "when": 1786711898729, "tag": "0218_mushy_jack_murdock", "breakpoints": true + }, + { + "idx": 219, + "version": "7", + "when": 1786711898730, + "tag": "0219_runtime_service_exposure", + "breakpoints": true + }, + { + "idx": 220, + "version": "7", + "when": 1786711898731, + "tag": "0220_execution_workspace_runtime_leases", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/execution_workspace_runtime_leases.ts b/packages/db/src/schema/execution_workspace_runtime_leases.ts new file mode 100644 index 0000000000..56d8f3039a --- /dev/null +++ b/packages/db/src/schema/execution_workspace_runtime_leases.ts @@ -0,0 +1,45 @@ +import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { executionWorkspaces } from "./execution_workspaces.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issues } from "./issues.js"; + +// One durable exclusivity row per execution workspace. The unique constraint on +// execution_workspace_id is the atomicity anchor: concurrent claims from +// different server processes serialize on it instead of on per-process state. +export const executionWorkspaceRuntimeLeases = pgTable( + "execution_workspace_runtime_leases", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + executionWorkspaceId: uuid("execution_workspace_id") + .notNull() + .unique() + .references(() => executionWorkspaces.id, { onDelete: "cascade" }), + // Durable owner identity (`issue:` or `run:`). Kept as text so the + // lease still identifies its owner after the FK columns are nulled out. + ownerKey: text("owner_key").notNull(), + ownerIssueId: uuid("owner_issue_id").references(() => issues.id, { onDelete: "set null" }), + ownerRunId: uuid("owner_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }), + lastAction: text("last_action").notNull(), + claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(), + renewedAt: timestamp("renewed_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyWorkspaceIdx: index("execution_workspace_runtime_leases_company_workspace_idx").on( + table.companyId, + table.executionWorkspaceId, + ), + companyOwnerIdx: index("execution_workspace_runtime_leases_company_owner_idx").on( + table.companyId, + table.ownerKey, + ), + expiresAtIdx: index("execution_workspace_runtime_leases_expires_at_idx").on(table.expiresAt), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 66114d8d04..ad878e2e83 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -29,6 +29,7 @@ export { projectMemberships } from "./project_memberships.js"; export { documentMemberships } from "./document_memberships.js"; export { projectWorkspaces } from "./project_workspaces.js"; export { executionWorkspaces } from "./execution_workspaces.js"; +export { executionWorkspaceRuntimeLeases } from "./execution_workspace_runtime_leases.js"; export { environments } from "./environments.js"; export { environmentLeases } from "./environment_leases.js"; export { environmentCustomImageTemplates } from "./environment_custom_image_templates.js"; diff --git a/packages/db/src/schema/workspace_runtime_services.ts b/packages/db/src/schema/workspace_runtime_services.ts index 150c332dec..eda40ce356 100644 --- a/packages/db/src/schema/workspace_runtime_services.ts +++ b/packages/db/src/schema/workspace_runtime_services.ts @@ -7,6 +7,7 @@ import { timestamp, uuid, } from "drizzle-orm/pg-core"; +import type { RuntimeExposureStatus } from "@paperclipai/shared"; import { companies } from "./companies.js"; import { projects } from "./projects.js"; import { projectWorkspaces } from "./project_workspaces.js"; @@ -42,6 +43,15 @@ export const workspaceRuntimeServices = pgTable( startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), stoppedAt: timestamp("stopped_at", { withTimezone: true }), stopPolicy: jsonb("stop_policy").$type>(), + // Structured opt-in `tailscale_https` exposure state (PAP-17049/17050). + // Null when the service is not configured for HTTPS exposure. Modelled + // independently of process `status`/`healthStatus` so HTTPS provisioning / + // failure / cleanup surface separately from backend process health. + exposure: jsonb("exposure").$type(), + // Server-private reservation/lease handle and backend readiness URL. These + // are deliberately never serialized to API clients. + exposureHandle: text("exposure_handle"), + backendUrl: text("backend_url"), healthStatus: text("health_status").notNull().default("unknown"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b98c0db9de..da182e227b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2425,3 +2425,54 @@ export { type FeatureTier, type InstanceFeatureKey, } from "./feature-catalog.js"; + +// --- Runtime exposure (opt-in Tailscale HTTPS for managed branch runtimes) --- +// PAP-17049 plan, PAP-17050 threat-model verdict. Contract shared across DB, +// server, UI, runtime, and the least-privilege host broker. +export type { + RuntimeExposureProvider, + RuntimeExposureFailurePolicy, + RuntimeExposureConfig, + RuntimeExposureState, + RuntimeExposureListenerPurpose, + RuntimeExposureListener, + RuntimeExposureStatus, +} from "./types/runtime-exposure.js"; +export { + runtimeExposureProviderSchema, + runtimeExposureFailurePolicySchema, + runtimeExposureConfigSchema, + runtimeExposureStateSchema, + runtimeExposureListenerPurposeSchema, + runtimeExposureListenerSchema, + runtimeExposureStatusSchema, + parseRuntimeExposureConfig, + readRuntimeExposureIntent, + resolveDeclaredRuntimeExposureConfig, + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + type RuntimeExposureConfigInput, + type RuntimeExposureIntent, + type RuntimeExposureStatusInput, +} from "./validators/runtime-exposure.js"; +export { + RUNTIME_EXPOSURE_APP_PORT_MIN, + RUNTIME_EXPOSURE_APP_PORT_MAX, + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + RUNTIME_EXPOSURE_HMR_PORT_MIN, + RUNTIME_EXPOSURE_HMR_PORT_MAX, + isRuntimeExposureAppPort, + isRuntimeExposureHmrPort, + isAllowedRuntimeExposurePort, + deriveViteHmrPort, + derivePaperclipViteHmrPort, + buildRuntimeExposureUrl, + buildRuntimeExposureHealthUrl, +} from "./runtime-exposure/ports.js"; +export { + RUNTIME_EXPOSURE_BIND_MODE, + RUNTIME_EXPOSURE_BIND_HOST, + commandSelectsBindMode, + forceLoopbackBindInCommand, + isPaperclipDevRunnerCommand, + rewriteUrlHostToLoopback, +} from "./runtime-exposure/loopback-bind.js"; diff --git a/packages/shared/src/runtime-exposure/loopback-bind.test.ts b/packages/shared/src/runtime-exposure/loopback-bind.test.ts new file mode 100644 index 0000000000..2f5d188276 --- /dev/null +++ b/packages/shared/src/runtime-exposure/loopback-bind.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; + +import { + RUNTIME_EXPOSURE_BIND_HOST, + commandSelectsBindMode, + forceLoopbackBindInCommand, + isPaperclipDevRunnerCommand, + rewriteUrlHostToLoopback, +} from "./loopback-bind.js"; + +describe("forceLoopbackBindInCommand", () => { + it("replaces the exact command the failing managed lanes ran", () => { + // PAP-17256 reproduction: this is verbatim what `workspace_runtime_services` + // recorded for every lane the broker denied. + expect(forceLoopbackBindInCommand("pnpm dev --bind lan")).toBe( + "pnpm dev --bind custom --bind-host 127.0.0.1", + ); + }); + + it("replaces an existing loopback-adjacent bind rather than duplicating it", () => { + expect(forceLoopbackBindInCommand("pnpm dev --bind tailnet")).toBe( + "pnpm dev --bind custom --bind-host 127.0.0.1", + ); + expect(forceLoopbackBindInCommand("pnpm dev --bind custom --bind-host 10.0.0.4")).toBe( + "pnpm dev --bind custom --bind-host 127.0.0.1", + ); + }); + + it("is idempotent, so a re-start of an already-rewritten service is stable", () => { + const once = forceLoopbackBindInCommand("pnpm dev --bind lan"); + expect(forceLoopbackBindInCommand(once)).toBe(once); + }); + + it("handles the =-separated spelling", () => { + expect(forceLoopbackBindInCommand("pnpm dev --bind=lan --bind-host=0.0.0.0")).toBe( + "pnpm dev --bind custom --bind-host 127.0.0.1", + ); + }); + + it("adds an explicit loopback bind when the command selects none", () => { + // A bare `pnpm dev` lets an old guest runner infer its bind from HOST, so the + // flags must be added rather than assumed. + expect(forceLoopbackBindInCommand("pnpm dev")).toBe( + "pnpm dev --bind custom --bind-host 127.0.0.1", + ); + }); + + it("keeps the legacy lan aliases so dev-service detection still matches", () => { + // `isPaperclipDevRuntimeService` matches `--tailscale-auth` as a substring; + // an explicit `--bind` already beats the alias in every dev-runner version. + expect(forceLoopbackBindInCommand("pnpm dev:once --tailscale-auth")).toBe( + "pnpm dev:once --tailscale-auth --bind custom --bind-host 127.0.0.1", + ); + }); + + it("never strips a value that is actually the next flag", () => { + expect(forceLoopbackBindInCommand("pnpm dev --bind --verbose")).toBe( + "pnpm dev --bind --verbose --bind custom --bind-host 127.0.0.1", + ); + }); + + it("does not touch a --bind occurrence that is not a flag boundary", () => { + expect(forceLoopbackBindInCommand("pnpm dev --no--bind lan")).toBe( + "pnpm dev --no--bind lan --bind custom --bind-host 127.0.0.1", + ); + }); + + it("leaves a command that does not parse these flags untouched", () => { + // The HTTPS probe canaries: rewriting this would produce nonsense, and + // appending flags to a command that cannot parse them makes it exit at boot. + const canary = 'python3 -m http.server "$PORT" --bind 127.0.0.1'; + expect(forceLoopbackBindInCommand(canary)).toBe(canary); + const fixture = `node -e 'require("http").createServer().listen(1)'`; + expect(forceLoopbackBindInCommand(fixture)).toBe(fixture); + }); + + it("uses the loopback host constant", () => { + expect(forceLoopbackBindInCommand("pnpm dev")).toContain(RUNTIME_EXPOSURE_BIND_HOST); + }); +}); + +describe("commandSelectsBindMode", () => { + it("detects both spellings and the legacy aliases", () => { + expect(commandSelectsBindMode("pnpm dev --bind lan")).toBe(true); + expect(commandSelectsBindMode("pnpm dev --bind=lan")).toBe(true); + expect(commandSelectsBindMode("pnpm dev --bind-host 10.0.0.1")).toBe(true); + expect(commandSelectsBindMode("pnpm dev:once --tailscale-auth")).toBe(true); + expect(commandSelectsBindMode("pnpm dev:once --authenticated-private")).toBe(true); + }); + + it("is false for a command with no bind selection", () => { + expect(commandSelectsBindMode("pnpm dev")).toBe(false); + expect(commandSelectsBindMode('python3 -m http.server "$PORT"')).toBe(false); + }); +}); + +describe("isPaperclipDevRunnerCommand", () => { + it("matches the real managed dev commands", () => { + expect(isPaperclipDevRunnerCommand("pnpm dev --bind lan")).toBe(true); + expect(isPaperclipDevRunnerCommand("pnpm dev")).toBe(true); + expect(isPaperclipDevRunnerCommand("pnpm dev:once --tailscale-auth")).toBe(true); + expect(isPaperclipDevRunnerCommand("pnpm dev:watch")).toBe(true); + expect(isPaperclipDevRunnerCommand("npm run dev --bind lan")).toBe(true); + expect(isPaperclipDevRunnerCommand("yarn dev")).toBe(true); + expect(isPaperclipDevRunnerCommand("node /tmp/x/dev-runner.mjs --bind lan")).toBe(true); + expect(isPaperclipDevRunnerCommand("tsx ../scripts/dev-runner.ts watch")).toBe(true); + }); + + it("does not match unrelated commands that merely use --bind", () => { + expect(isPaperclipDevRunnerCommand('python3 -m http.server "$PORT" --bind 127.0.0.1')).toBe(false); + expect(isPaperclipDevRunnerCommand("node -e 'listen()'")).toBe(false); + expect(isPaperclipDevRunnerCommand("pnpm build")).toBe(false); + expect(isPaperclipDevRunnerCommand("pnpm develop")).toBe(false); + expect(isPaperclipDevRunnerCommand("./my-dev-runnerish --bind lan")).toBe(false); + }); +}); + +describe("rewriteUrlHostToLoopback", () => { + it("redirects a MagicDNS probe target to loopback, keeping port and path", () => { + expect(rewriteUrlHostToLoopback("http://paperclip-dev:42003/api/health")).toBe( + "http://127.0.0.1:42003/api/health", + ); + expect(rewriteUrlHostToLoopback("http://paperclip-dev:42003")).toBe("http://127.0.0.1:42003/"); + }); + + it("leaves an already-loopback target alone", () => { + expect(rewriteUrlHostToLoopback("http://127.0.0.1:42003/api/health")).toBe( + "http://127.0.0.1:42003/api/health", + ); + }); + + it("passes through null and unparseable values", () => { + expect(rewriteUrlHostToLoopback(null)).toBeNull(); + expect(rewriteUrlHostToLoopback("not a url")).toBe("not a url"); + }); +}); diff --git a/packages/shared/src/runtime-exposure/loopback-bind.ts b/packages/shared/src/runtime-exposure/loopback-bind.ts new file mode 100644 index 0000000000..61ff7e4c17 --- /dev/null +++ b/packages/shared/src/runtime-exposure/loopback-bind.ts @@ -0,0 +1,98 @@ +/** + * Forcing a managed runtime's listeners onto loopback through argv (PAP-17256). + * + * The broker only exposes a port whose listener /proc proves to be loopback-only, + * so an exposed Paperclip dev runtime MUST bind `127.0.0.1`. The server used to + * request that with env vars alone (`PAPERCLIP_BIND` / `PAPERCLIP_BIND_HOST`), + * which is not sufficient: the process that has to honour them is the *guest + * checkout's* `scripts/dev-runner.ts`, and a checkout that predates managed + * exposure overwrites `PAPERCLIP_BIND` from its own `--bind` argv and deletes + * `PAPERCLIP_BIND_HOST` outright. A branch pinned at such a commit therefore + * bound `0.0.0.0` and every expose was correctly denied with + * `listener_ownership_mismatch`. + * + * argv is the one channel every dev-runner version honours, because each of them + * derives its bind mode from `--bind` / `--bind-host` *before* writing the child + * env. Rewriting the command is what actually makes the loopback bind binding. + * + * Pure string functions only — no I/O, no process state. + */ + +/** The only address an exposed managed runtime may bind. */ +export const RUNTIME_EXPOSURE_BIND_MODE = "custom"; +export const RUNTIME_EXPOSURE_BIND_HOST = "127.0.0.1"; + +/** + * `--bind ` / `--bind-host `, in both the space- and `=`-separated + * spellings. Each takes exactly one value, and a value is never another flag. + */ +const BIND_SELECTING_ARG = + /(?:^|\s)--bind(?:-host)?(?:=|\s+)(?!--)[^\s]+/g; + +/** Legacy aliases for `--bind lan`; they select a non-loopback bind. */ +const LEGACY_LAN_ALIASES = /(?:^|\s)--(?:tailscale-auth|authenticated-private)(?=\s|$)/g; + +/** True when the command carries an explicit bind selection of any kind. */ +export function commandSelectsBindMode(command: string): boolean { + return new RegExp(BIND_SELECTING_ARG.source).test(command) + || new RegExp(LEGACY_LAN_ALIASES.source).test(command); +} + +/** + * A Paperclip dev-runner invocation — the only shape that understands + * `--bind` / `--bind-host`. + * + * Deliberately keyed on the *command*, not the service name. `--bind` means + * something entirely different to an unrelated process (the HTTPS probe canaries + * pass it to `python3 -m http.server`), and appending flags a command does not + * parse turns a working service into one that exits on startup. + */ +const PAPERCLIP_DEV_RUNNER_COMMAND = + /(?:^|[\s;&|])(?:(?:pnpm|npm|yarn|bun)(?:\s+run)?\s+dev(?::once|:watch|:server)?(?=\s|$)|[^\s]*dev-runner(?:\.[cm]?[jt]s)?(?=\s|$))/; + +export function isPaperclipDevRunnerCommand(command: string): boolean { + return PAPERCLIP_DEV_RUNNER_COMMAND.test(command); +} + +/** + * Rewrite a Paperclip dev-runner command so it explicitly requests the loopback + * bind, replacing whatever bind selection it carried. + * + * A command that is not a dev-runner invocation is returned untouched — see + * {@link isPaperclipDevRunnerCommand} for why that guard is not optional. + * + * The legacy `--tailscale-auth` / `--authenticated-private` aliases are + * deliberately *left in place*: an explicit `--bind` already wins over them in + * every dev-runner version, they still correctly select the authenticated + * deployment mode an exposed lane wants, and `isPaperclipDevRuntimeService` + * matches on `--tailscale-auth` as a substring, so stripping it would silently + * change readiness handling. + */ +export function forceLoopbackBindInCommand(command: string): string { + if (!isPaperclipDevRunnerCommand(command)) return command; + const stripped = command.replace(BIND_SELECTING_ARG, "").trim(); + if (stripped.length === 0) return command; + return `${stripped} --bind ${RUNTIME_EXPOSURE_BIND_MODE} --bind-host ${RUNTIME_EXPOSURE_BIND_HOST}`; +} + +/** + * Point a probe URL at loopback, keeping its scheme, port, and path. + * + * An exposed runtime's listener is loopback-only by construction, so probing it + * on any other host cannot work. The live config happens to declare a loopback + * readiness URL, but the fallback target is the service's display URL — a + * MagicDNS name like `http://paperclip-dev:42003` — which only ever answered + * because the guest was wrongly bound to the wildcard. Normalising here keeps + * the loopback fix from turning that latent mismatch into a readiness timeout. + */ +export function rewriteUrlHostToLoopback(url: string | null): string | null { + if (!url) return url; + try { + const parsed = new URL(url); + parsed.hostname = RUNTIME_EXPOSURE_BIND_HOST; + return parsed.toString(); + } catch { + // Not a URL we can reason about; leave it for the caller's own handling. + return url; + } +} diff --git a/packages/shared/src/runtime-exposure/ports.test.ts b/packages/shared/src/runtime-exposure/ports.test.ts new file mode 100644 index 0000000000..de27b2c17d --- /dev/null +++ b/packages/shared/src/runtime-exposure/ports.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + RUNTIME_EXPOSURE_APP_PORT_MAX, + RUNTIME_EXPOSURE_APP_PORT_MIN, + RUNTIME_EXPOSURE_HMR_PORT_MAX, + RUNTIME_EXPOSURE_HMR_PORT_MIN, + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + buildRuntimeExposureHealthUrl, + buildRuntimeExposureUrl, + deriveViteHmrPort, + derivePaperclipViteHmrPort, + isAllowedRuntimeExposurePort, + isRuntimeExposureAppPort, + isRuntimeExposureHmrPort, +} from "./ports.js"; + +describe("runtime exposure port policy", () => { + it("accepts app ports only inside the dedicated range", () => { + expect(isRuntimeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MIN)).toBe(true); + expect(isRuntimeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MAX)).toBe(true); + expect(isRuntimeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MIN - 1)).toBe(false); + expect(isRuntimeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MAX + 1)).toBe(false); + }); + + it("rejects privileged, reserved, and non-integer ports", () => { + expect(isAllowedRuntimeExposurePort(443)).toBe(false); + expect(isAllowedRuntimeExposurePort(22)).toBe(false); + expect(isAllowedRuntimeExposurePort(3100)).toBe(false); + expect(isAllowedRuntimeExposurePort(0)).toBe(false); + expect(isAllowedRuntimeExposurePort(65536)).toBe(false); + expect(isAllowedRuntimeExposurePort(42000.5)).toBe(false); + expect(isAllowedRuntimeExposurePort(Number.NaN)).toBe(false); + }); + + it("keeps the HMR range in sync with the offset", () => { + expect(RUNTIME_EXPOSURE_HMR_PORT_MIN).toBe( + RUNTIME_EXPOSURE_APP_PORT_MIN + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + ); + expect(RUNTIME_EXPOSURE_HMR_PORT_MAX).toBe( + RUNTIME_EXPOSURE_APP_PORT_MAX + RUNTIME_EXPOSURE_HMR_PORT_OFFSET, + ); + expect(isRuntimeExposureHmrPort(RUNTIME_EXPOSURE_HMR_PORT_MIN)).toBe(true); + expect(isAllowedRuntimeExposurePort(RUNTIME_EXPOSURE_HMR_PORT_MIN)).toBe(true); + }); + + it("derives a deterministic HMR companion port and rejects out-of-range apps", () => { + expect(deriveViteHmrPort(42000)).toBe(52000); + expect(deriveViteHmrPort(42123)).toBe(52123); + expect(() => deriveViteHmrPort(3100)).toThrow(RangeError); + 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", + ); + expect(buildRuntimeExposureHealthUrl("paperclip-dev.tail29c1aa.ts.net", 42010)).toBe( + "https://paperclip-dev.tail29c1aa.ts.net:42010/api/health", + ); + }); + + it("rejects empty hostnames and out-of-range ports when building URLs", () => { + expect(() => buildRuntimeExposureUrl("", 42010)).toThrow(); + expect(() => buildRuntimeExposureUrl("host", 443)).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/runtime-exposure/ports.ts b/packages/shared/src/runtime-exposure/ports.ts new file mode 100644 index 0000000000..6566d780a3 --- /dev/null +++ b/packages/shared/src/runtime-exposure/ports.ts @@ -0,0 +1,113 @@ +/** + * Deterministic, shared port + URL derivation for the `tailscale_https` + * exposure mode. Extracted so runtime allocation, server startup, the broker, + * and tests all agree on the dedicated Paperclip runtime port range and the + * app/HMR pairing (PAP-17049 plan, PAP-17050 verdict requirement #2). + * + * Pure functions only — no I/O, no process state. + */ + +/** + * Dedicated, allowlisted port range for managed Paperclip runtime APP + * listeners. Ports outside this range are never eligible for exposure, which + * bounds the SSRF blast radius: a compromised caller cannot ask the broker to + * publish an arbitrary existing loopback service. + */ +export const RUNTIME_EXPOSURE_APP_PORT_MIN = 42000; +export const RUNTIME_EXPOSURE_APP_PORT_MAX = 42999; + +/** + * Fixed offset between an app port and its Paperclip Vite HMR companion port. + * Matches Paperclip dev mode's `server port + offset` HMR convention and keeps + * the two listeners deterministically paired. + */ +export const RUNTIME_EXPOSURE_HMR_PORT_OFFSET = 10000; + +/** Derived HMR companion port range, kept in sync with the offset above. */ +export const RUNTIME_EXPOSURE_HMR_PORT_MIN = + RUNTIME_EXPOSURE_APP_PORT_MIN + RUNTIME_EXPOSURE_HMR_PORT_OFFSET; +export const RUNTIME_EXPOSURE_HMR_PORT_MAX = + RUNTIME_EXPOSURE_APP_PORT_MAX + RUNTIME_EXPOSURE_HMR_PORT_OFFSET; + +/** True only for integers strictly inside the dedicated app port range. */ +export function isRuntimeExposureAppPort(port: number): boolean { + return ( + Number.isInteger(port) && + port >= RUNTIME_EXPOSURE_APP_PORT_MIN && + port <= RUNTIME_EXPOSURE_APP_PORT_MAX + ); +} + +/** True only for integers strictly inside the derived HMR companion range. */ +export function isRuntimeExposureHmrPort(port: number): boolean { + return ( + Number.isInteger(port) && + port >= RUNTIME_EXPOSURE_HMR_PORT_MIN && + port <= RUNTIME_EXPOSURE_HMR_PORT_MAX + ); +} + +/** + * True for any port the broker is allowed to expose (app OR HMR companion). + * Everything else — privileged/reserved ports, `443`, ephemeral, unknown — is + * deny-by-default. + */ +export function isAllowedRuntimeExposurePort(port: number): boolean { + return isRuntimeExposureAppPort(port) || isRuntimeExposureHmrPort(port); +} + +/** + * Derive the Vite HMR companion port for an allocated app port. Throws if the + * app port is not a valid dedicated app port so callers cannot silently pair a + * port outside the allowlist. + */ +export function deriveViteHmrPort(appPort: number): number { + if (!isRuntimeExposureAppPort(appPort)) { + throw new RangeError( + `app port ${appPort} is outside the dedicated runtime exposure range ` + + `[${RUNTIME_EXPOSURE_APP_PORT_MIN}, ${RUNTIME_EXPOSURE_APP_PORT_MAX}]`, + ); + } + 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); +} + +/** + * Build the canonical browser URL for an exposed app port on a resolved + * Tailscale node hostname. Uses the non-standard HTTPS port explicitly. + */ +export function buildRuntimeExposureUrl(hostname: string, appPort: number): string { + const trimmed = hostname.trim(); + if (trimmed.length === 0) { + throw new Error("hostname is required to build an exposure URL"); + } + if (!isRuntimeExposureAppPort(appPort)) { + throw new RangeError(`app port ${appPort} is outside the dedicated runtime exposure range`); + } + return `https://${trimmed}:${appPort}`; +} + +/** + * Build the external HTTPS health-probe URL used to verify normal certificate + * validation before a runtime is reported healthy. + */ +export function buildRuntimeExposureHealthUrl(hostname: string, appPort: number): string { + return `${buildRuntimeExposureUrl(hostname, appPort)}/api/health`; +} diff --git a/packages/shared/src/types/runtime-exposure.ts b/packages/shared/src/types/runtime-exposure.ts new file mode 100644 index 0000000000..785f053a7a --- /dev/null +++ b/packages/shared/src/types/runtime-exposure.ts @@ -0,0 +1,85 @@ +/** + * Contract shared across DB, server, UI, runtime, and the least-privilege + * Tailscale HTTPS host broker for the opt-in `tailscale_https` exposure mode on + * managed workspace runtime services. + * + * This file is the single source of truth for the exposure config and exposure + * state shapes so the database column, shared validators, server serializers, + * UI clients, and the broker wire protocol cannot drift. See PAP-17049 (plan) + * and PAP-17050 (threat-model verdict). + */ + +/** Exposure provider. Only Tailscale HTTPS is defined today. */ +export type RuntimeExposureProvider = "tailscale_https"; + +/** + * How a configured exposure reports health when provisioning fails. + * `fail_closed` is the only supported value: a configured HTTPS preview is + * never reported healthy with a plain-HTTP fallback. + */ +export type RuntimeExposureFailurePolicy = "fail_closed"; + +/** + * Opt-in exposure request declared on a managed workspace runtime service. + * Deliberately narrow: no arbitrary target URL, path, hostname suffix, or port + * is accepted from the app. `hostname: "auto"` is resolved from the local + * Tailscale node; `publicPort: "same"` reuses the allocated app port. + */ +export interface RuntimeExposureConfig { + type: RuntimeExposureProvider; + /** Only "auto" is supported; the node DNS name is resolved at runtime. */ + hostname: "auto"; + /** Only "same" is supported; the public port equals the loopback app port. */ + publicPort: "same"; + /** Provision the Paperclip Vite HMR companion listener alongside the app. */ + includePaperclipViteHmr: boolean; + failurePolicy: RuntimeExposureFailurePolicy; +} + +/** + * Exposure lifecycle, modelled independently of the backend process lifecycle. + * A backend can be `running`/healthy while its HTTPS exposure is still + * `pending`, `failed`, or `cleanup_pending`. + */ +export type RuntimeExposureState = + | "pending" + | "ready" + | "failed" + | "cleanup_pending" + | "removed"; + +/** Purpose of a single owned HTTPS-to-loopback listener. */ +export type RuntimeExposureListenerPurpose = "app" | "vite_hmr"; + +/** + * One HTTPS-to-loopback mapping owned by this runtime service. `publicPort` + * always equals `targetPort` (same-number invariant); the target is loopback. + */ +export interface RuntimeExposureListener { + purpose: RuntimeExposureListenerPurpose; + publicPort: number; + targetPort: number; +} + +/** + * Structured exposure state persisted on the runtime-service record and echoed + * through server serializers to the UI. Never contains privileged commands, + * lease handles, host paths, or raw broker/CLI output. + */ +export interface RuntimeExposureStatus { + provider: RuntimeExposureProvider; + state: RuntimeExposureState; + /** Canonical browser URL once `ready`; null while pending/failed/removed. */ + publicUrl: string | null; + /** Resolved node DNS hostname once known; null otherwise. */ + hostname: string | null; + listeners: RuntimeExposureListener[]; + /** + * Opaque broker reference for reconciliation/audit correlation. Not a lease + * handle and not secret; safe to serialize to the UI. + */ + brokerRef: string | null; + /** Last non-sensitive, bounded error string for actionable UI remediation. */ + lastError: string | null; + updatedAt: string | null; +} diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index f2b79f8981..fcde0ef0fc 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -1,4 +1,5 @@ import type { TrustAuthorizationPolicy } from "../trust-policy.js"; +import type { RuntimeExposureStatus } from "./runtime-exposure.js"; export type ExecutionWorkspaceStrategyType = | "project_primary" @@ -211,6 +212,8 @@ export interface WorkspaceOverviewPrimaryService { url: string | null; port: number | null; healthStatus: WorkspaceRuntimeService["healthStatus"]; + /** HTTPS exposure state, surfaced separately from process `healthStatus`. */ + exposure?: RuntimeExposureStatus | null; updatedAt: Date; } @@ -305,6 +308,12 @@ export interface WorkspaceRuntimeService { stoppedAt: Date | null; stopPolicy: Record | null; healthStatus: "unknown" | "healthy" | "unhealthy"; + /** + * Structured HTTPS exposure state for the opt-in `tailscale_https` mode, + * modelled independently of `healthStatus` (process health). Null when the + * service does not opt into exposure. See PAP-17049 / PAP-17050. + */ + exposure?: RuntimeExposureStatus | null; configIndex?: number | null; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/validators/runtime-exposure.test.ts b/packages/shared/src/validators/runtime-exposure.test.ts new file mode 100644 index 0000000000..31175388db --- /dev/null +++ b/packages/shared/src/validators/runtime-exposure.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + parseRuntimeExposureConfig, + readRuntimeExposureIntent, + resolveDeclaredRuntimeExposureConfig, + runtimeExposureConfigSchema, + runtimeExposureListenerSchema, + runtimeExposureStatusSchema, +} from "./runtime-exposure.js"; + +describe("runtimeExposureConfigSchema", () => { + it("accepts the default fail-closed config", () => { + expect(() => runtimeExposureConfigSchema.parse(DEFAULT_TAILSCALE_HTTPS_EXPOSURE)).not.toThrow(); + }); + + it("rejects unknown fields (no smuggled target/path/hostname suffix)", () => { + expect(() => + runtimeExposureConfigSchema.parse({ + ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + target: "http://127.0.0.1:5432", + }), + ).toThrow(); + }); + + it("rejects arbitrary hostname / publicPort / provider / failure policy", () => { + expect(() => + runtimeExposureConfigSchema.parse({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, hostname: "evil.example" }), + ).toThrow(); + expect(() => + runtimeExposureConfigSchema.parse({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, publicPort: 443 }), + ).toThrow(); + expect(() => + runtimeExposureConfigSchema.parse({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, type: "funnel" }), + ).toThrow(); + expect(() => + runtimeExposureConfigSchema.parse({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, failurePolicy: "fail_open" }), + ).toThrow(); + }); + + it("parseRuntimeExposureConfig returns null when absent and throws when malformed", () => { + expect(parseRuntimeExposureConfig(undefined)).toBeNull(); + expect(parseRuntimeExposureConfig(null)).toBeNull(); + expect(parseRuntimeExposureConfig(DEFAULT_TAILSCALE_HTTPS_EXPOSURE)).toEqual( + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + ); + expect(() => parseRuntimeExposureConfig({ type: "tailscale_https" })).toThrow(); + }); +}); + +describe("readRuntimeExposureIntent", () => { + it("treats a legacy expose block with no exposure fields as unset", () => { + // The pre-feature Paperclip App template shape: an `expose` block that only + // describes the backend URL. This must be defaultable, not opted out. + expect(readRuntimeExposureIntent({ urlTemplate: "http://paperclip-dev:{{port}}" })).toBe("unset"); + expect(readRuntimeExposureIntent(undefined)).toBe("unset"); + expect(readRuntimeExposureIntent(null)).toBe("unset"); + expect(readRuntimeExposureIntent({})).toBe("unset"); + expect(readRuntimeExposureIntent([])).toBe("unset"); + expect(readRuntimeExposureIntent("tailscale_https")).toBe("unset"); + }); + + it("reads explicit opt-in", () => { + expect(readRuntimeExposureIntent({ type: "tailscale_https" })).toBe("enabled"); + expect(readRuntimeExposureIntent(DEFAULT_TAILSCALE_HTTPS_EXPOSURE)).toBe("enabled"); + expect(readRuntimeExposureIntent({ tailscaleHttps: true })).toBe("enabled"); + }); + + it("reads deliberate opt-out", () => { + expect(readRuntimeExposureIntent({ tailscaleHttps: false })).toBe("disabled"); + expect(readRuntimeExposureIntent({ type: "none" })).toBe("disabled"); + }); + + it("lets an explicit negative win over a stale positive in the same block", () => { + expect(readRuntimeExposureIntent({ type: "tailscale_https", tailscaleHttps: false })).toBe("disabled"); + }); +}); + +describe("resolveDeclaredRuntimeExposureConfig", () => { + it("returns null unless the block explicitly opts in", () => { + expect(resolveDeclaredRuntimeExposureConfig(undefined)).toBeNull(); + expect(resolveDeclaredRuntimeExposureConfig({ urlTemplate: "http://paperclip-dev:{{port}}" })).toBeNull(); + expect(resolveDeclaredRuntimeExposureConfig({ tailscaleHttps: false })).toBeNull(); + }); + + it("completes a bare declaration from the fail-closed defaults", () => { + expect(resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https" })).toEqual( + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + ); + // Shorthand normalizes to the provider literal. + expect(resolveDeclaredRuntimeExposureConfig({ tailscaleHttps: true })).toEqual( + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + ); + }); + + it("keeps the backend urlTemplate alongside an opt-in without leaking it into the config", () => { + expect( + resolveDeclaredRuntimeExposureConfig({ + type: "tailscale_https", + urlTemplate: "http://127.0.0.1:{{port}}", + }), + ).toEqual(DEFAULT_TAILSCALE_HTTPS_EXPOSURE); + }); + + it("honors an explicit sub-field override", () => { + expect( + resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https", includePaperclipViteHmr: false }), + ).toEqual({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, includePaperclipViteHmr: false }); + }); + + it("still rejects smuggled fields and invalid sub-field values", () => { + expect(() => + resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https", target: "http://127.0.0.1:5432" }), + ).toThrow(/Unsupported expose field/); + expect(() => + resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https", hostname: "evil.example" }), + ).toThrow(); + expect(() => + resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https", failurePolicy: "fail_open" }), + ).toThrow(); + }); +}); + +describe("runtimeExposureListenerSchema", () => { + it("enforces the same-number invariant", () => { + expect(() => + runtimeExposureListenerSchema.parse({ purpose: "app", publicPort: 42010, targetPort: 42010 }), + ).not.toThrow(); + expect(() => + runtimeExposureListenerSchema.parse({ purpose: "app", publicPort: 42010, targetPort: 5432 }), + ).toThrow(); + }); +}); + +describe("runtimeExposureStatusSchema", () => { + it("accepts a well-formed ready status", () => { + expect(() => + runtimeExposureStatusSchema.parse({ + provider: "tailscale_https", + state: "ready", + publicUrl: "https://paperclip-dev.tail29c1aa.ts.net:42010", + hostname: "paperclip-dev.tail29c1aa.ts.net", + listeners: [ + { purpose: "app", publicPort: 42010, targetPort: 42010 }, + { purpose: "vite_hmr", publicPort: 52010, targetPort: 52010 }, + ], + brokerRef: "rs-1", + lastError: null, + updatedAt: "2026-08-11T00:00:00.000Z", + }), + ).not.toThrow(); + }); + + it("rejects unknown fields in serialized status", () => { + expect(() => + runtimeExposureStatusSchema.parse({ + provider: "tailscale_https", + state: "ready", + publicUrl: null, + hostname: null, + listeners: [], + brokerRef: null, + lastError: null, + updatedAt: null, + leaseHandle: "secret", + }), + ).toThrow(); + }); +}); diff --git a/packages/shared/src/validators/runtime-exposure.ts b/packages/shared/src/validators/runtime-exposure.ts new file mode 100644 index 0000000000..362cfc4721 --- /dev/null +++ b/packages/shared/src/validators/runtime-exposure.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; + +/** + * Validators for the opt-in `tailscale_https` runtime exposure contract. + * Strict-by-default: unknown fields are rejected so a malformed or injected + * config cannot smuggle an arbitrary target, path, or hostname suffix through + * the runtime configuration into the broker (PAP-17050 verdict). + */ + +export const runtimeExposureProviderSchema = z.literal("tailscale_https"); + +export const runtimeExposureFailurePolicySchema = z.literal("fail_closed"); + +export const runtimeExposureConfigSchema = z + .object({ + type: runtimeExposureProviderSchema, + hostname: z.literal("auto"), + publicPort: z.literal("same"), + includePaperclipViteHmr: z.boolean(), + failurePolicy: runtimeExposureFailurePolicySchema, + }) + .strict(); + +export const runtimeExposureStateSchema = z.enum([ + "pending", + "ready", + "failed", + "cleanup_pending", + "removed", +]); + +export const runtimeExposureListenerPurposeSchema = z.enum(["app", "vite_hmr"]); + +export const runtimeExposureListenerSchema = z + .object({ + purpose: runtimeExposureListenerPurposeSchema, + publicPort: z.number().int().positive(), + targetPort: z.number().int().positive(), + }) + .strict() + // Same-number invariant: the public HTTPS port must equal the loopback + // target port. The broker enforces this too, but rejecting here keeps + // persisted/serialized state honest. + .refine((listener) => listener.publicPort === listener.targetPort, { + message: "publicPort must equal targetPort (same-number exposure)", + path: ["publicPort"], + }); + +export const runtimeExposureStatusSchema = z + .object({ + provider: runtimeExposureProviderSchema, + state: runtimeExposureStateSchema, + publicUrl: z.string().url().nullable(), + hostname: z.string().min(1).nullable(), + listeners: z.array(runtimeExposureListenerSchema), + brokerRef: z.string().min(1).nullable(), + lastError: z.string().nullable(), + updatedAt: z.string().nullable(), + }) + .strict(); + +export type RuntimeExposureConfigInput = z.infer; +export type RuntimeExposureStatusInput = z.infer; + +/** + * Default exposure config used when a project/workspace enables HTTPS exposure + * without overriding sub-fields. Fail-closed and HMR-inclusive by default so + * dev-mode hot reload works over the exposed HTTPS origin. + */ +export const DEFAULT_TAILSCALE_HTTPS_EXPOSURE: RuntimeExposureConfigInput = { + type: "tailscale_https", + hostname: "auto", + publicPort: "same", + includePaperclipViteHmr: true, + failurePolicy: "fail_closed", +}; + +/** + * Parse an untrusted `expose` block off a workspace runtime service config. + * Returns null when exposure is absent; throws (via zod) on a malformed block + * so misconfiguration fails closed rather than silently exposing nothing. + */ +export function parseRuntimeExposureConfig( + value: unknown, +): RuntimeExposureConfigInput | null { + if (value === undefined || value === null) return null; + return runtimeExposureConfigSchema.parse(value); +} + +/** + * What an `expose` block says about HTTPS exposure, independent of whether the + * runtime is eligible for it. + * + * `unset` is the common case: legacy templates only declare + * `{ type: "url", urlTemplate }` for the backend readiness URL and say nothing + * about exposure. Those runtimes are defaulted to `tailscale_https` by the + * server start path when they are eligible (PAP-17158), so no project template + * or UI caller has to supply an exposure block. + */ +export type RuntimeExposureIntent = "enabled" | "disabled" | "unset"; + +/** + * Keys of the exposure contract itself. Anything a declared `tailscale_https` + * block sets outside this list (and {@link RUNTIME_EXPOSURE_TRANSPORT_KEYS}) is + * rejected, so a malformed or injected config still cannot smuggle an arbitrary + * target, path, or hostname suffix through to the broker (PAP-17050 verdict). + */ +const RUNTIME_EXPOSURE_CONFIG_KEYS = [ + "type", + "hostname", + "publicPort", + "includePaperclipViteHmr", + "failurePolicy", +] as const; + +/** + * Pre-existing `expose` keys that describe the *backend* URL rather than the + * exposure, and may legitimately sit next to a `tailscale_https` declaration. + */ +const RUNTIME_EXPOSURE_TRANSPORT_KEYS = ["urlTemplate", "tailscaleHttps"] as const; + +/** + * Read the declared intent off an `expose` block. + * + * An explicit negative always wins over an explicit positive so a deliberate + * opt-out can never be re-enabled by a stale `type` left in the same block. + */ +export function readRuntimeExposureIntent(value: unknown): RuntimeExposureIntent { + if (!value || typeof value !== "object" || Array.isArray(value)) return "unset"; + const expose = value as Record; + // Deliberate opt-outs, checked first. + if (expose.tailscaleHttps === false) return "disabled"; + if (expose.type === "none") return "disabled"; + if (expose.type === "tailscale_https" || expose.tailscaleHttps === true) return "enabled"; + return "unset"; +} + +/** + * Resolve the exposure config a service *declared* (as opposed to the one it + * inherits by default). Returns null unless the block explicitly opts in. + * + * Recognized sub-fields fall back to {@link DEFAULT_TAILSCALE_HTTPS_EXPOSURE}, + * so `{ type: "tailscale_https" }` is a complete declaration; unknown keys still + * throw rather than being silently dropped. + */ +export function resolveDeclaredRuntimeExposureConfig( + value: unknown, +): RuntimeExposureConfigInput | null { + if (readRuntimeExposureIntent(value) !== "enabled") return null; + const expose = value as Record; + const known = new Set([...RUNTIME_EXPOSURE_CONFIG_KEYS, ...RUNTIME_EXPOSURE_TRANSPORT_KEYS]); + const unknownKeys = Object.keys(expose).filter((key) => !known.has(key)); + if (unknownKeys.length > 0) { + throw new Error(`Unsupported expose field(s): ${unknownKeys.sort().join(", ")}`); + } + const merged: Record = { ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE }; + for (const key of RUNTIME_EXPOSURE_CONFIG_KEYS) { + if (expose[key] !== undefined) merged[key] = expose[key]; + } + // `tailscaleHttps: true` is shorthand for the provider; normalize it away. + merged.type = "tailscale_https"; + return runtimeExposureConfigSchema.parse(merged); +} diff --git a/packages/tailscale-https-broker/README.md b/packages/tailscale-https-broker/README.md new file mode 100644 index 0000000000..51ab1d9e47 --- /dev/null +++ b/packages/tailscale-https-broker/README.md @@ -0,0 +1,249 @@ +# Paperclip Tailscale HTTPS broker + +Least-privilege host broker that manages **only** Paperclip-owned, tailnet-only, +same-number HTTPS-to-loopback listeners for managed branch runtimes. + +It exists so the Paperclip app/agent account never gains Tailscale operator +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). +- `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, +`serve reset` / `set-config`, path handlers, arbitrary targets, non-loopback or +wildcard/dual-stack backends, port `443`, privileged/reserved ports, ports +outside the dedicated runtime range, unknown fields, and removal of any mapping +not matching an exact registry + lease + live Serve entry. The primary +`:443 → 127.0.0.1:3100` route is verified structurally **before and after every +mutation** and is never modified. + +The socket transport reads Linux `SO_PEERCRED` before admission, admits at most +8 concurrent sockets per resolved UID, and reserves 4 of its 32 global slots +for the configured Paperclip service UID. Connection deadlines destroy the +socket so timed-out peers cannot retain kernel-level connection slots. Missing +or invalid native credentials fail closed; socket permissions are not used as +a substitute identity. + +## One-time host installation (`paperclip-dev`) + +These steps require **root** and must be run by CloudOps/host owner, not the +Paperclip agent account. They install the broker as a dedicated +Tailscale-operator service account distinct from the Paperclip app account. + +1. **Preconditions.** Tailscale is installed and up on the node, the node has an + HTTPS-capable trusted cert (MagicDNS + HTTPS enabled), and the existing + `:443 → 127.0.0.1:3100` Serve mapping is present. + +2. **Create the dedicated operator account and socket group.** + + ```sh + sudo useradd --system --home /var/lib/paperclip-tailscale-broker \ + --shell /usr/sbin/nologin paperclip-tsbroker + sudo groupadd --system paperclip-tsbroker-sock + # The Paperclip *app* service account must have this as its PRIMARY group so + # its SO_PEERCRED gid matches the socket group (supplemental membership is + # intentionally NOT accepted). + sudo usermod -g paperclip-tsbroker-sock + ``` + +3. **Grant Tailscale operator authority to the broker account only.** + + ```sh + sudo tailscale set --operator=paperclip-tsbroker + ``` + + Do **not** grant `--operator` to the Paperclip app/agent account (that grant + was explicitly rejected in PAP-16989). + +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 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. **Build, install the package under `/opt/paperclip`, and install the + packaged systemd unit.** The unit's `ExecStart` (and the doctor command + below) run the build output from + `/opt/paperclip/packages/tailscale-https-broker/dist`, so copy it there + explicitly. The Linux build requires a C compiler and Node.js headers to + compile the dependency-free N-API `SO_PEERCRED` addon. The output is + self-contained (Node builtins plus the compiled addon; no `node_modules` + needed). + + ```sh + pnpm --filter @paperclipai/tailscale-https-broker build + sudo install -d -m 0755 /opt/paperclip/packages/tailscale-https-broker + sudo cp -r packages/tailscale-https-broker/dist \ + /opt/paperclip/packages/tailscale-https-broker/ + 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] + Description=Paperclip Tailscale HTTPS broker + After=tailscaled.service + Requires=tailscaled.service + + [Service] + Type=simple + User=paperclip-tsbroker + # Socket must end up 0660 paperclip-tsbroker:paperclip-tsbroker-sock. Set the group here and + # the broker chmods the socket to 0660 on bind. + 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 + ProtectSystem=strict + ReadWritePaths=/run/paperclip-tailscale-broker /var/lib/paperclip-tailscale-broker /var/log/paperclip-tailscale-broker + + [Install] + 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 | + |-----|----------|---------|---------| + | `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 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 | + | `BROKER_AUDIT_PATH` | no | `/var/log/paperclip-tailscale-broker/audit.log` | append-only security audit log | + | `BROKER_PROTECTED_PORTS` | no | *(empty)* | comma/space separated ports the broker must **never** create, remove, or reclaim — even when its own registry holds a valid lease for them (see below) | + + ### `BROKER_PROTECTED_PORTS` — operator-declared preservation (PAP-17285) + + The long-standing "unknown/manual entries are never modified" invariant is + *provenance-blind*: it protects only entries the broker has no lease for. It + therefore could not protect the `42000/52000` mappings, because the broker had + itself created them for a canary lane that was later retired — so its registry + still called them owned, while operators had reclassified them as + must-preserve after failing to attribute them to any live lane. Both views were + internally consistent, they disagreed, and a fully authorized, shape-valid, + `:443`-preserving removal destroyed them with no guard able to object. + + A protected port is an **operator assertion that outranks the broker's own + ownership record**. Enforcement is fail-closed and layered: refused during argv + construction, denied in `reserve`/`expose`/`remove` with `protected_port`, + excluded from the allocatable allowlist so no lane can acquire one, and + asserted byte-unchanged across every before/after snapshot + (`protected_entry_violation`). A malformed list makes the broker refuse to + start rather than silently protect nothing; `443` is rejected because the + primary route already has a stronger, non-optional invariant. + + ``` + BROKER_PROTECTED_PORTS=42000,52000 + ``` + + Confirm it took effect before trusting it — `--doctor` echoes the parsed set: + + ```sh + sudo -u paperclip-tsbroker \ + env $(cat /etc/paperclip/tailscale-https-broker.env | xargs) \ + node /opt/paperclip/packages/tailscale-https-broker/dist/main.js --doctor + ``` + +6. **Preflight (read-only, no mutation).** + + ```sh + sudo -u paperclip-tsbroker \ + BROKER_NODE_IDENTITY=$(hostname) BROKER_SERVICE_UID=... BROKER_SERVICE_GID=... BROKER_RUNTIME_UID=... \ + node /opt/paperclip/packages/tailscale-https-broker/dist/main.js --doctor + ``` + + Verifies: supported Tailscale CLI version, Serve status is readable, the + primary `:443` route is intact, the registry path is safe, and prints the + node identity. Exit 0 = ready. It never mutates Serve state. + +7. **Enable.** `sudo systemctl daemon-reload && sudo systemctl enable --now + paperclip-tailscale-https-broker`. Confirm the socket is `0660 + paperclip-tsbroker:paperclip-tsbroker-sock`. + +## Upgrade + +Deploy new package output to +`/opt/paperclip/packages/tailscale-https-broker/dist`, then +`sudo systemctl restart paperclip-tailscale-https-broker`. On +restart the broker re-reads its root-owned registry and adopts only exact-lease +matches; a changed `BROKER_NODE_IDENTITY` (host reimage / boot-id change) forces +quarantine and operator reconciliation rather than silently re-adopting. + +## Uninstall / rollback / opt-out + +Rollback disables new exposure and removes only broker-owned listeners; it never +resets Serve or changes the primary route. + +1. Disable the exposure flag on the project runtime (Paperclip stops requesting + `expose`). Existing previews drain on runtime stop. +2. Drain owned listeners: stop each managed runtime so Paperclip issues `remove` + for its own leases (proven by handle). +3. `sudo systemctl disable --now paperclip-tailscale-https-broker`. +4. Optional cleanup: remove the state dirs and `sudo tailscale set --operator=` + to drop the operator grant. Do **not** run `tailscale serve reset` — remove + only the specific per-port Serve entries if any remain. + +## Recovery + +If a mutation fails partway, the broker removes only the exact listeners it +applied; if exact cleanup cannot be proven it quarantines the affected ports and +reports `cleanup_pending` (partial app+HMR exposure is never reported healthy). +Quarantined ports are not reused until an operator clears them. The append-only +audit log at `BROKER_AUDIT_PATH` records every allow/deny and mutation outcome +(peer UID/GID/PID, operation, runtime UUID, ports, decision reason, before/after +state digests, quarantine/recovery) with lease handles and raw CLI output +redacted. + +## Tests + +```sh +pnpm --filter @paperclipai/tailscale-https-broker test # 72 tests +pnpm --filter @paperclipai/tailscale-https-broker typecheck +pnpm --filter @paperclipai/tailscale-https-broker build +``` diff --git a/packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service b/packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service new file mode 100644 index 0000000000..3175d5e3d2 --- /dev/null +++ b/packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service @@ -0,0 +1,26 @@ +[Unit] +Description=Paperclip Tailscale HTTPS broker +After=tailscaled.service +Requires=tailscaled.service + +[Service] +Type=simple +User=paperclip-tsbroker +Group=paperclip-tsbroker-sock +EnvironmentFile=/etc/paperclip/tailscale-https-broker.env +ExecStart=/usr/bin/node /opt/paperclip/packages/tailscale-https-broker/dist/main.js +Restart=on-failure +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +RuntimeDirectory=paperclip-tailscale-broker +RuntimeDirectoryMode=0750 +StateDirectory=paperclip-tailscale-broker +StateDirectoryMode=0700 +LogsDirectory=paperclip-tailscale-broker +LogsDirectoryMode=0700 +ReadWritePaths=/run/paperclip-tailscale-broker /var/lib/paperclip-tailscale-broker /var/log/paperclip-tailscale-broker + +[Install] +WantedBy=multi-user.target diff --git a/packages/tailscale-https-broker/package.json b/packages/tailscale-https-broker/package.json new file mode 100644 index 0000000000..715a7c6fa9 --- /dev/null +++ b/packages/tailscale-https-broker/package.json @@ -0,0 +1,25 @@ +{ + "name": "@paperclipai/tailscale-https-broker", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "description": "Least-privilege host broker that manages only Paperclip-owned, same-number Tailscale HTTPS-to-loopback listeners for managed branch runtimes.", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "bin": { + "paperclip-tailscale-https-broker": "./dist/main.js" + }, + "scripts": { + "build": "tsc && node scripts/build-native.mjs", + "typecheck": "tsc --noEmit", + "test": "node scripts/build-native.mjs && vitest run" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "typescript": "^5.7.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/tailscale-https-broker/scripts/build-native.mjs b/packages/tailscale-https-broker/scripts/build-native.mjs new file mode 100644 index 0000000000..5072e88630 --- /dev/null +++ b/packages/tailscale-https-broker/scripts/build-native.mjs @@ -0,0 +1,51 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +if (process.platform !== "linux") { + process.stdout.write("[tailscale-https-broker] skipping Linux SO_PEERCRED addon build\n"); + process.exit(0); +} + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outputDir = join(packageRoot, "dist"); +const source = join(packageRoot, "src", "peercred-native.c"); +const output = join(outputDir, "peercred-native.node"); +const nodePrefix = process.config.variables.node_prefix; +const headerCandidates = [ + typeof nodePrefix === "string" ? join(nodePrefix, "include", "node") : "", + resolve(dirname(process.execPath), "..", "include", "node"), + "/usr/include/node", + "/usr/local/include/node", +].filter(Boolean); +const headerDir = headerCandidates.find((candidate) => existsSync(join(candidate, "node_api.h"))); + +if (!headerDir) { + throw new Error("cannot build SO_PEERCRED addon: Node.js headers were not found"); +} + +mkdirSync(outputDir, { recursive: true }); +const compiler = process.env.CC || "cc"; +const result = spawnSync( + compiler, + [ + "-std=c11", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-shared", + "-fPIC", + `-I${headerDir}`, + source, + "-o", + output, + ], + { stdio: "inherit" }, +); + +if (result.error) throw result.error; +if (result.status !== 0) { + throw new Error(`SO_PEERCRED addon compiler exited with status ${result.status}`); +} diff --git a/packages/tailscale-https-broker/src/argv.test.ts b/packages/tailscale-https-broker/src/argv.test.ts new file mode 100644 index 0000000000..26f44dd219 --- /dev/null +++ b/packages/tailscale-https-broker/src/argv.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { buildExposeArgv, buildRemoveArgv, buildStatusArgv } from "./argv.js"; + +const BIN = "/usr/bin/tailscale"; + +describe("argv construction", () => { + it("builds an exact status argv with no shell tokens", () => { + expect(buildStatusArgv(BIN)).toEqual([BIN, "serve", "status", "--json"]); + }); + + it("builds exact same-number expose/remove argv vectors", () => { + expect(buildExposeArgv(BIN, 42010)).toEqual([ + BIN, + "serve", + "--bg", + "--https=42010", + "http://127.0.0.1:42010", + ]); + expect(buildRemoveArgv(BIN, 42010)).toEqual([BIN, "serve", "--https=42010", "off"]); + }); + + it("refuses the protected primary port 443 and privileged ports", () => { + expect(() => buildExposeArgv(BIN, 443)).toThrow(/443/); + expect(() => buildRemoveArgv(BIN, 443)).toThrow(/443/); + expect(() => buildExposeArgv(BIN, 80)).toThrow(/privileged/); + expect(() => buildExposeArgv(BIN, 1023)).toThrow(/privileged/); + }); + + it("refuses non-canonical ports (string, float, overflow)", () => { + expect(() => buildExposeArgv(BIN, "42010" as unknown as number)).toThrow(); + expect(() => buildExposeArgv(BIN, 42010.5)).toThrow(); + expect(() => buildExposeArgv(BIN, 70000)).toThrow(); + }); + + it("refuses a non-absolute or shell-metacharacter binary path", () => { + expect(() => buildStatusArgv("tailscale")).toThrow(/absolute/); + expect(() => buildStatusArgv("/usr/bin/tailscale; rm -rf /")).toThrow(); + expect(() => buildStatusArgv("/usr/bin/tail scale")).toThrow(); + }); +}); diff --git a/packages/tailscale-https-broker/src/argv.ts b/packages/tailscale-https-broker/src/argv.ts new file mode 100644 index 0000000000..b500717d64 --- /dev/null +++ b/packages/tailscale-https-broker/src/argv.ts @@ -0,0 +1,76 @@ +/** + * Tailscale CLI argv construction. Every command is a fixed token vector with a + * single validated integer interpolated; there is never a shell, an arbitrary + * target URL, a path handler, Funnel, cert, Service, reset, or set-config + * operation (PAP-17049 plan; PAP-17050 verdict requirement #4 + invariants). + * + * The caller must pass an absolute, root-owned tailscale binary path. Callers + * spawn with shell:false and a minimal environment. + */ +import { assertCanonicalPort } from "./integers.js"; +import { PROTECTED_PRIMARY_PORT } from "./types.js"; + +/** The loopback target is always same-number and always plain-http loopback. */ +export function loopbackTarget(port: number): string { + assertCanonicalPort(port); + return `http://127.0.0.1:${port}`; +} + +function assertMutablePort(port: number, protectedPorts: readonly number[] = []): number { + const p = assertCanonicalPort(port); + if (p === PROTECTED_PRIMARY_PORT) { + throw new Error("refusing to operate on the protected primary port 443"); + } + if (p < 1024) { + throw new Error("refusing to operate on a privileged/reserved port (<1024)"); + } + // Innermost refusal for operator-declared protected ports (PAP-17285). Higher + // layers deny first with a typed code; this exists so no caller can construct + // a mutating argv for a protected port even by mistake, and so the guarantee + // does not depend on every future call site remembering to check. + if (protectedPorts.includes(p)) { + throw new Error(`refusing to operate on operator-protected port ${p}`); + } + return p; +} + +/** `tailscale serve status --json` — read-only. */ +export function buildStatusArgv(binPath: string): string[] { + assertAbsolute(binPath); + return [binPath, "serve", "status", "--json"]; +} + +/** + * Add one same-number HTTPS-to-loopback listener in the background without + * disturbing other Serve entries. + */ +export function buildExposeArgv( + binPath: string, + port: number, + protectedPorts: readonly number[] = [], +): string[] { + assertAbsolute(binPath); + const p = assertMutablePort(port, protectedPorts); + return [binPath, "serve", "--bg", `--https=${p}`, loopbackTarget(p)]; +} + +/** Remove exactly one HTTPS listener by port. Never `reset`, never `off` all. */ +export function buildRemoveArgv( + binPath: string, + port: number, + protectedPorts: readonly number[] = [], +): string[] { + assertAbsolute(binPath); + const p = assertMutablePort(port, protectedPorts); + return [binPath, "serve", `--https=${p}`, "off"]; +} + +function assertAbsolute(binPath: string): void { + if (typeof binPath !== "string" || !binPath.startsWith("/")) { + throw new Error("tailscale binary path must be absolute"); + } + // No shell metacharacters, whitespace, or NUL in the pinned binary path. + if (/[\s;&|`$<>(){}\\"'*?\0]/.test(binPath)) { + throw new Error("tailscale binary path contains disallowed characters"); + } +} diff --git a/packages/tailscale-https-broker/src/audit.ts b/packages/tailscale-https-broker/src/audit.ts new file mode 100644 index 0000000000..f78225ccd6 --- /dev/null +++ b/packages/tailscale-https-broker/src/audit.ts @@ -0,0 +1,90 @@ +/** + * Append-only, bounded, redacted security audit (PAP-17050 verdict req #6). + * + * Every allow/deny and mutation outcome emits one event. Lease handles, + * bearer/session material, full environments, and raw unbounded CLI output are + * NEVER logged. All string fields are control-character-sanitized and length + * bounded to defeat log forging. + */ +import { appendFileSync } from "node:fs"; +import type { BrokerErrorCode, PeerCredentials } from "./types.js"; + +const MAX_FIELD_LEN = 256; + +export interface AuditEvent { + timestampIso: string; + peer: PeerCredentials | null; + op: string; + runtimeId: string | null; + ports: number[]; + requestId: string | null; + decision: "allow" | "deny"; + reasonCode: BrokerErrorCode | "ok"; + reason: string; + beforeDigest?: string; + afterDigest?: string; + cliExitCategory?: "ok" | "error" | "timeout" | "none"; + recovery?: "none" | "quarantine" | "cleanup"; +} + +/** Strip control chars (defeats newline/log-forging) and bound length. */ +export function sanitizeField(value: string): string { + // eslint-disable-next-line no-control-regex + const stripped = value.replace(/[\x00-\x1f\x7f]/g, " "); + return stripped.length > MAX_FIELD_LEN ? `${stripped.slice(0, MAX_FIELD_LEN)}…` : stripped; +} + +export function formatAuditLine(event: AuditEvent): string { + const safe = { + ts: event.timestampIso, + uid: event.peer?.uid ?? null, + gid: event.peer?.gid ?? null, + pid: event.peer?.pid ?? null, + op: sanitizeField(event.op), + runtimeId: event.runtimeId ? sanitizeField(event.runtimeId) : null, + ports: event.ports.filter((p) => Number.isInteger(p)).slice(0, 8), + requestId: event.requestId ? sanitizeField(event.requestId) : null, + decision: event.decision, + reasonCode: event.reasonCode, + reason: sanitizeField(event.reason), + beforeDigest: event.beforeDigest ? sanitizeField(event.beforeDigest) : undefined, + afterDigest: event.afterDigest ? sanitizeField(event.afterDigest) : undefined, + cliExitCategory: event.cliExitCategory, + recovery: event.recovery, + }; + return JSON.stringify(safe); +} + +export interface AuditSink { + write(event: AuditEvent): void; +} + +/** + * Durable file audit sink. If the recommended `blockOnFailure` is set, a write + * failure throws so the caller can fail the mutation closed rather than mutate + * without a durable record. + */ +export class FileAuditSink implements AuditSink { + constructor( + private readonly path: string, + private readonly blockOnFailure = true, + ) {} + + write(event: AuditEvent): void { + try { + appendFileSync(this.path, `${formatAuditLine(event)}\n`, { mode: 0o600 }); + } catch (error) { + if (this.blockOnFailure) { + throw new Error(`audit sink write failed: ${(error as Error).message}`); + } + } + } +} + +/** In-memory sink for tests. */ +export class MemoryAuditSink implements AuditSink { + readonly events: AuditEvent[] = []; + write(event: AuditEvent): void { + this.events.push(event); + } +} diff --git a/packages/tailscale-https-broker/src/authorization.test.ts b/packages/tailscale-https-broker/src/authorization.test.ts new file mode 100644 index 0000000000..d184f88257 --- /dev/null +++ b/packages/tailscale-https-broker/src/authorization.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + AuthorizationError, + authorizePeer, + authorizeRemoval, + generateLeaseHandle, + handlesEqual, +} from "./authorization.js"; +import type { LeaseRecord } from "./types.js"; + +const policy = { + allowedUids: new Set([999]), + allowedGids: new Set([987]), +}; + +describe("authorizePeer (complete mediation)", () => { + it("accepts the exact allowlisted identity", () => { + expect(() => authorizePeer({ uid: 999, gid: 987, pid: 1 }, policy)).not.toThrow(); + }); + + it("rejects wrong uid, wrong gid, and missing creds", () => { + expect(() => authorizePeer({ uid: 1000, gid: 987, pid: 1 }, policy)).toThrow(AuthorizationError); + expect(() => authorizePeer({ uid: 999, gid: 100, pid: 1 }, policy)).toThrow(/gid/); + expect(() => authorizePeer({ uid: Number.NaN, gid: 987, pid: 1 }, policy)).toThrow(); + }); +}); + +describe("lease handles", () => { + it("generates unguessable, distinct, url-safe handles", () => { + const a = generateLeaseHandle(); + const b = generateLeaseHandle(); + expect(a).not.toBe(b); + expect(a).toMatch(/^[A-Za-z0-9_-]{16,}$/); + expect(handlesEqual(a, a)).toBe(true); + expect(handlesEqual(a, b)).toBe(false); + }); +}); + +describe("authorizeRemoval (ownership binding)", () => { + const lease: LeaseRecord = { + handle: "handle-aaaaaaaaaaaaaaaa", + runtimeId: "2af79bb1-ecc5-4410-8438-091be135a921", + peerUid: 999, + 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 }; + + it("authorizes an exact handle + runtime + peer match", () => { + expect(authorizeRemoval([lease], { runtimeId: lease.runtimeId, handle: lease.handle }, peer)).toBe(lease); + }); + + it("rejects random/stale handles", () => { + expect(() => + authorizeRemoval([lease], { runtimeId: lease.runtimeId, handle: "random-handle-xxxxxx" }, peer), + ).toThrow(/invalid_handle|stale/); + }); + + it("rejects runtime A removing runtime B's lease", () => { + expect(() => + authorizeRemoval([lease], { runtimeId: "00000000-0000-0000-0000-000000000000", handle: lease.handle }, peer), + ).toThrow(/runtime/); + }); + + it("rejects a wrong peer identity even with the right handle", () => { + expect(() => + authorizeRemoval([lease], { runtimeId: lease.runtimeId, handle: lease.handle }, { uid: 1000, gid: 987, pid: 7 }), + ).toThrow(/peer/); + }); +}); diff --git a/packages/tailscale-https-broker/src/authorization.ts b/packages/tailscale-https-broker/src/authorization.ts new file mode 100644 index 0000000000..10970afcfb --- /dev/null +++ b/packages/tailscale-https-broker/src/authorization.ts @@ -0,0 +1,87 @@ +/** + * Peer authorization and lease-handle ownership (PAP-17050 verdict req #1). + * + * SO_PEERCRED authenticates an OS principal; a caller-supplied runtime ID does + * NOT prove ownership. Authorization therefore has two layers: + * 1. An exact UID/GID allowlist checked with peer credentials on EVERY + * accepted connection (complete mediation). + * 2. Ownership defined as a broker-issued, unguessable lease handle returned + * by `expose` and required by `remove`, bound to peer identity, runtime + * UUID, ports, and generation. `list` never returns handles. + */ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import type { LeaseRecord, PeerCredentials } from "./types.js"; + +export interface PeerPolicy { + /** Exact set of allowed peer UIDs (the Paperclip service identity). */ + allowedUids: ReadonlySet; + /** Exact set of allowed peer GIDs (the dedicated broker socket group). */ + allowedGids: ReadonlySet; +} + +export class AuthorizationError extends Error { + constructor( + readonly code: + | "unauthorized_peer" + | "invalid_handle" + | "listener_ownership_mismatch", + message: string, + ) { + super(message); + this.name = "AuthorizationError"; + } +} + +/** + * Complete-mediation check run on every accepted connection before any request + * is even decoded. Throws AuthorizationError("unauthorized_peer") on any + * mismatch. Supplemental-group-only membership does not satisfy the GID check + * because peer.gid is the process's primary GID from SO_PEERCRED. + */ +export function authorizePeer(peer: PeerCredentials, policy: PeerPolicy): void { + if (!Number.isInteger(peer.uid) || !Number.isInteger(peer.gid)) { + throw new AuthorizationError("unauthorized_peer", "missing peer credentials"); + } + if (!policy.allowedUids.has(peer.uid)) { + throw new AuthorizationError("unauthorized_peer", `uid ${peer.uid} not allowlisted`); + } + if (!policy.allowedGids.has(peer.gid)) { + throw new AuthorizationError("unauthorized_peer", `gid ${peer.gid} not allowlisted`); + } +} + +/** Generate an unguessable lease handle (256 bits, url-safe). */ +export function generateLeaseHandle(): string { + return randomBytes(32).toString("base64url"); +} + +/** Constant-time handle comparison to avoid timing oracles. */ +export function handlesEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, "utf8"); + const bb = Buffer.from(b, "utf8"); + if (ab.byteLength !== bb.byteLength) return false; + return timingSafeEqual(ab, bb); +} + +/** + * Resolve the lease a `remove` request is authorized to act on. Requires an + * exact handle match AND that the requesting peer + runtime UUID match the + * lease bound at expose time. Runtime A can never remove runtime B's listener. + */ +export function authorizeRemoval( + leases: readonly LeaseRecord[], + request: { runtimeId: string; handle: string }, + peer: PeerCredentials, +): LeaseRecord { + const lease = leases.find((entry) => handlesEqual(entry.handle, request.handle)); + if (!lease) { + throw new AuthorizationError("invalid_handle", "unknown or stale lease handle"); + } + if (lease.runtimeId !== request.runtimeId) { + throw new AuthorizationError("listener_ownership_mismatch", "runtime id does not match lease"); + } + if (lease.peerUid !== peer.uid || lease.peerGid !== peer.gid) { + throw new AuthorizationError("listener_ownership_mismatch", "peer identity does not match lease"); + } + return lease; +} diff --git a/packages/tailscale-https-broker/src/broker-core.test.ts b/packages/tailscale-https-broker/src/broker-core.test.ts new file mode 100644 index 0000000000..11dbe4a887 --- /dev/null +++ b/packages/tailscale-https-broker/src/broker-core.test.ts @@ -0,0 +1,593 @@ +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MemoryAuditSink } from "./audit.js"; +import { BrokerCore, type CliResult, type ListenerOwnership } from "./broker-core.js"; +import { defaultIsAllowedPort } from "./port-policy.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 RUNTIME_B = "3108ef8e-5ed0-41d9-b561-6b41c41b8545"; +const PEER: PeerCredentials = { uid: 999, gid: 987, pid: 4242 }; + +/** In-memory tailscale serve fake driven by exact argv vectors. */ +class FakeTailscale { + ports = new Map([[443, "http://127.0.0.1:3100"]]); + failExposePort: number | null = null; + failRemove = false; + sideEffectOnExpose: number | null = null; + retargetPrimaryOnExpose = false; + exposeCalls = 0; + removeCalls = 0; + statusCalls = 0; + + run = (argv: string[]): CliResult => { + const [, sub, a2, a3] = argv; + if (sub === "serve" && a2 === "status") { + this.statusCalls += 1; + 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}`); + if (this.sideEffectOnExpose) this.ports.set(this.sideEffectOnExpose, `http://127.0.0.1:${this.sideEffectOnExpose}`); + if (this.retargetPrimaryOnExpose) this.ports.set(443, "http://127.0.0.1:9999"); + return { code: 0, stdout: "", stderr: "", timedOut: false }; + } + if (sub === "serve" && a2.startsWith("--https=") && a3 === "off") { + this.removeCalls += 1; + if (this.failRemove) return { code: 1, stdout: "", stderr: "no", timedOut: false }; + this.ports.delete(Number(a2.replace("--https=", ""))); + return { code: 0, stdout: "", stderr: "", timedOut: false }; + } + return { code: 2, stdout: "", stderr: "unknown", timedOut: false }; + }; + + private statusJson(): string { + const TCP: Record = {}; + const Web: Record = {}; + for (const [port, proxy] of this.ports) { + TCP[String(port)] = { HTTPS: true }; + Web[`${HOST}:${port}`] = { Handlers: { "/": { Proxy: proxy } } }; + } + return JSON.stringify({ TCP, Web }); + } +} + +function makeCore( + fake: FakeTailscale, + registryPath: string, + ownership: (port: number) => ListenerOwnership = () => ({ + present: true, + loopbackOnly: true, + ownerUidMatches: true, + inodes: ["5001"], + }), + nowIso: () => string = () => "2026-08-11T00:00:00.000Z", +) { + 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: defaultIsAllowedPort, + deps: { + runTailscale: fake.run, + verifyListenerOwnership: ownership, + nowIso, + }, + }); + return { core, audit }; +} + +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(() => { + dir = mkdtempSync(join(tmpdir(), "broker-test-")); + registryPath = join(dir, "registry.json"); +}); +afterEach(() => { + // tmp dir is left for the OS to reap; tests use unique dirs. +}); + +describe("expose", () => { + 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 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]); + expect(res.handle).toMatch(/^[A-Za-z0-9_-]{16,}$/); + 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); + 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 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(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"); + expect(fake.ports.has(42010)).toBe(false); + }); + + it("rejects a port outside the dedicated allowlist", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + 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"); + }); + + it("names which listener predicate failed, and every one of them still denies (SSRF guard)", async () => { + // One code per predicate, so a denial is attributable from the client reply + // alone: `safeMessage()` returns the bare code and the discriminating reason + // reaches only the root-owned audit file, which the calling account cannot + // read. Distinguishing them relaxes nothing — all three deny below. + for (const { ownership, code } of [ + { ownership: { present: false, loopbackOnly: true, ownerUidMatches: true, inodes: [] }, code: "listener_absent" }, + { ownership: { present: true, loopbackOnly: false, ownerUidMatches: true, inodes: ["5001"] }, code: "listener_not_loopback" }, + { ownership: { present: true, loopbackOnly: true, ownerUidMatches: false, inodes: ["5001"] }, code: "listener_ownership_mismatch" }, + ]) { + const fake = new FakeTailscale(); + const { core, audit } = makeCore(fake, registryPath, () => ownership); + const res = await reserveAndExpose(core); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe(code); + + // Fail-closed: the denial precedes every Serve read and mutation, so the + // requested port is never published and the primary route is untouched. + expect(fake.exposeCalls).toBe(0); + expect(fake.removeCalls).toBe(0); + expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]); + + // The discriminating detail must still reach the audit trail. + const denial = audit.events.find((event) => event.decision === "deny"); + expect(denial?.reasonCode).toBe(code); + expect(denial?.op).toBe("expose"); + } + }); + + it("denies a listener that is present and correctly owned but cannot be named", async () => { + // Present-but-unattributable is not permission. Without a socket identity + // the broker cannot prove the socket it publishes is the socket it verified, + // so it must refuse rather than fall through to "the predicates passed". + const fake = new FakeTailscale(); + const { core, audit } = makeCore(fake, registryPath, () => ({ + present: true, + loopbackOnly: true, + ownerUidMatches: true, + inodes: [], + })); + const res = await reserveAndExpose(core); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("listener_unattributable"); + expect(fake.exposeCalls).toBe(0); + expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]); + expect(audit.events.find((event) => event.decision === "deny")?.reasonCode).toBe( + "listener_unattributable", + ); + }); + + it("denies before publishing when the listener is substituted during the Serve status read", async () => { + // The window Greptile found: pre-flight verifies the listener, then + // `readServe()` runs a `tailscale` subprocess. A process under the same + // managed-runtime UID can close the verified socket and take the port during + // that subprocess. The three boolean predicates cannot see it — the new + // process satisfies all of them — so only the socket identity changes. + // Publishing then maps tailnet HTTPS at a service that was never authorized. + const fake = new FakeTailscale(); + // Armed relative to the start of expose, because reserve already reads Serve + // status once. The swap therefore lands on expose's own status subprocess — + // after its pre-flight verification, before it publishes anything. + let statusReadsBeforeExpose = 0; + const { core, audit } = makeCore(fake, registryPath, () => ({ + present: true, + loopbackOnly: true, + ownerUidMatches: true, + inodes: [fake.statusCalls > statusReadsBeforeExpose ? "9999" : "5001"], + })); + + const reserved = await core.handle(reserveReq(), PEER); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed"); + statusReadsBeforeExpose = fake.statusCalls; + + const res = await core.handle( + { op: "expose", requestId: "req-sub", runtimeId: RUNTIME_A, handle: reserved.handle }, + PEER, + ); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("listener_substituted"); + + // Fail closed *before* the mutation: the substituted service is never + // published, so only the pre-existing primary route remains. + expect(fake.exposeCalls).toBe(0); + expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]); + expect(audit.events.find((event) => event.decision === "deny")?.reasonCode).toBe( + "listener_substituted", + ); + }); + + it("withdraws the mapping when substitution happens after the port is published", async () => { + // If the swap lands after the mutation instead, the post-publication re-proof + // denies inside the try, so compensation removes what was applied rather + // than leaving a substituted service reachable over HTTPS. + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath, () => ({ + present: true, + loopbackOnly: true, + ownerUidMatches: true, + inodes: [fake.exposeCalls >= 1 ? "9999" : "5001"], + })); + + const res = await reserveAndExpose(core); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("listener_substituted"); + + // The port was published, then withdrawn by compensation. + expect(fake.exposeCalls).toBeGreaterThan(0); + expect(fake.removeCalls).toBeGreaterThan(0); + expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]); + }); + + it("withdraws an idempotently-skipped mapping whose listener was substituted", async () => { + // The gap a skipped mapping opens: on a re-expose the port already carries + // our exact same-number entry, so the apply loop skips it and it never + // enters `appliedPorts`. Denying alone would leave that pre-existing mapping + // active and now pointing at the replacement service, so a rejected request + // would still publish something unauthorized. + const fake = new FakeTailscale(); + // Swap armed relative to the start of the *second* expose, so it lands after + // that request's pre-flight verification — the same window as before, but on + // a port the apply loop will skip instead of publish. + let statusReadsBeforeReExpose = Number.POSITIVE_INFINITY; + const { core } = makeCore(fake, registryPath, () => ({ + present: true, + loopbackOnly: true, + ownerUidMatches: true, + inodes: [fake.statusCalls > statusReadsBeforeReExpose ? "9999" : "5001"], + })); + + // First expose publishes the port for real. + const first = await reserveAndExpose(core); + expect(first.ok).toBe(true); + expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010"); + const exposeCallsAfterFirst = fake.exposeCalls; + + // Re-expose the same port. The mapping already matches, so the apply loop + // takes the idempotent path and publishes nothing. + const reserved = await core.handle(reserveReq(), PEER); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed"); + statusReadsBeforeReExpose = fake.statusCalls; + + const second = await core.handle( + { op: "expose", requestId: "req-again", runtimeId: RUNTIME_A, handle: reserved.handle }, + PEER, + ); + expect(second.ok).toBe(false); + if (!second.ok) expect(second.code).toBe("listener_substituted"); + expect(fake.exposeCalls).toBe(exposeCallsAfterFirst); // nothing re-published + + // The substituted mapping must not survive the rejected request. + expect(fake.removeCalls).toBeGreaterThan(0); + expect(fake.ports.has(42010)).toBe(false); + expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]); + }); + + it("still withdraws a substituted idempotent mapping when a reserved lease published nothing", async () => { + // A `reserved` lease has published nothing, so a substitution detected on it + // must deny without withdrawing anything: there is no mapping of ours to + // remove, and removing by port number would delete someone else's. + const fake = new FakeTailscale(); + let statusReadsBeforeExpose = Number.POSITIVE_INFINITY; + const { core } = makeCore(fake, registryPath, () => ({ + present: true, + loopbackOnly: true, + ownerUidMatches: true, + inodes: [fake.statusCalls > statusReadsBeforeExpose ? "9999" : "5001"], + })); + + const reserved = await core.handle(reserveReq(), PEER); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed"); + statusReadsBeforeExpose = fake.statusCalls; + + const res = await core.handle( + { op: "expose", requestId: "req-res", runtimeId: RUNTIME_A, handle: reserved.handle }, + PEER, + ); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("listener_substituted"); + // Nothing of ours was published, so nothing is published now. + expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]); + }); + + it("withdraws a substituted idempotent mapping without depending on a readable Serve status", async () => { + // The withdrawal decision must not hinge on a live status read. If it did, a + // status read that fails at exactly the wrong moment would make the broker + // return `listener_substituted` while leaving the replacement service + // exposed. Provenance comes from the `exposed` lease instead, so the remove + // is still issued. + const fake = new FakeTailscale(); + let statusReadsBeforeReExpose = Number.POSITIVE_INFINITY; + const { core } = makeCore(fake, registryPath, () => ({ + present: true, + loopbackOnly: true, + ownerUidMatches: true, + inodes: [fake.statusCalls > statusReadsBeforeReExpose ? "9999" : "5001"], + })); + + const first = await reserveAndExpose(core); + expect(first.ok).toBe(true); + const removeCallsAfterFirst = fake.removeCalls; + + const reserved = await core.handle(reserveReq(), PEER); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed"); + statusReadsBeforeReExpose = fake.statusCalls; + + const second = await core.handle( + { op: "expose", requestId: "req-noread", runtimeId: RUNTIME_A, handle: reserved.handle }, + PEER, + ); + expect(second.ok).toBe(false); + if (!second.ok) expect(second.code).toBe("listener_substituted"); + + // The remove was issued for the substituted port even though this request + // published nothing itself. + expect(fake.removeCalls).toBeGreaterThan(removeCallsAfterFirst); + expect(fake.ports.has(42010)).toBe(false); + }); + + it("withdraws a published mapping when the listener disappears entirely", async () => { + // Absent is not "unchanged". A closed listener leaves the mapping pointing at + // nothing, which a later process on that port would inherit, so it must be + // withdrawn like any other substitution. + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath, () => ({ + present: fake.exposeCalls === 0, + loopbackOnly: true, + ownerUidMatches: true, + inodes: fake.exposeCalls === 0 ? ["5001"] : [], + })); + + const res = await reserveAndExpose(core); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("listener_substituted"); + expect(fake.removeCalls).toBeGreaterThan(0); + expect([...fake.ports]).toEqual([[443, "http://127.0.0.1:3100"]]); + }); + + it("denies an absent listener without consuming the reservation or touching Serve", async () => { + // A denial is pure: it neither publishes a mapping nor burns the lease. No + // production caller retries an exposure today (deliberately — a retry needs + // its own reproduced requirement and wiring); this pins the broker-side + // state purity that any such caller, or a plain operator re-run, relies on. + const fake = new FakeTailscale(); + let bound = false; + const { core } = makeCore(fake, registryPath, () => ({ + present: bound, + loopbackOnly: true, + ownerUidMatches: true, + inodes: bound ? ["5001"] : [], + })); + const reserved = await core.handle(reserveReq(), PEER); + if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed"); + + const denied = await core.handle( + { op: "expose", requestId: "req-x1", runtimeId: RUNTIME_A, handle: reserved.handle }, + PEER, + ); + expect(denied.ok).toBe(false); + if (!denied.ok) expect(denied.code).toBe("listener_absent"); + expect(fake.exposeCalls).toBe(0); + expect(fake.ports.has(42010)).toBe(false); + + // Once the child is actually bound, the very same reservation still exposes. + bound = true; + const exposed = await core.handle( + { op: "expose", requestId: "req-x2", runtimeId: RUNTIME_A, handle: reserved.handle }, + PEER, + ); + expect(exposed.ok).toBe(true); + expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010"); + }); + + it("never touches a pre-existing manual mapping on the target port", async () => { + 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(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 + }); + + it("fails closed and preserves :443 if a mutation retargets the primary route", async () => { + const fake = new FakeTailscale(); + fake.retargetPrimaryOnExpose = true; + const { core } = makeCore(fake, registryPath); + const res = await reserveAndExpose(core); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("primary_route_violation"); + }); + + it("compensates and quarantines on an unexpected serve diff", async () => { + const fake = new FakeTailscale(); + fake.sideEffectOnExpose = 42011; // an unexpected extra entry appears + fake.failRemove = true; // compensation cannot be proven -> quarantine + const { core } = makeCore(fake, registryPath); + 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")); + expect(registry.quarantinedPorts).toContain(42010); + // A later expose of the quarantined port is refused. + const fake2 = new FakeTailscale(); + const { core: core2 } = makeCore(fake2, registryPath); + const res2 = await core2.handle(reserveReq(), PEER); + expect(res2.ok).toBe(false); + if (!res2.ok) expect(res2.code).toBe("quarantined"); + }); +}); + +describe("remove", () => { + async function exposeAndGetHandle(core: BrokerCore, port = 42010, runtime = RUNTIME_A) { + const res = await reserveAndExpose(core, runtime, port); + if (!res.ok || res.op !== "expose") throw new Error("expose failed"); + return res.handle; + } + + it("removes only the owned listener with an exact lease match", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + const handle = await exposeAndGetHandle(core); + const res = await core.handle( + { op: "remove", requestId: "req-r", runtimeId: RUNTIME_A, handle }, + PEER, + ); + expect(res.ok).toBe(true); + if (res.ok && res.op === "remove") expect(res.removedPorts).toEqual([42010]); + expect(fake.ports.has(42010)).toBe(false); + expect(fake.ports.get(443)).toBe("http://127.0.0.1:3100"); // primary intact + }); + + it("rejects a random handle and a cross-runtime removal", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + const handle = await exposeAndGetHandle(core); + const bad = await core.handle( + { op: "remove", requestId: "r", runtimeId: RUNTIME_A, handle: "random-handle-not-real-xxxx" }, + PEER, + ); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.code).toBe("invalid_handle"); + const cross = await core.handle( + { op: "remove", requestId: "r", runtimeId: RUNTIME_B, handle }, + PEER, + ); + expect(cross.ok).toBe(false); + 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 reserveAndExpose(core); + const res = await core.handle({ op: "list", requestId: "req-l" }, PEER); + expect(res.ok).toBe(true); + if (res.ok && res.op === "list") { + expect(res.listeners).toEqual([{ runtimeId: RUNTIME_A, port: 42010, purpose: "app" }]); + expect(JSON.stringify(res)).not.toMatch(/handle/); + } + }); +}); + +describe("node identity", () => { + it("quarantines when the persisted node identity no longer matches", async () => { + const fake = new FakeTailscale(); + const { core } = makeCore(fake, registryPath); + await reserveAndExpose(core); + // Rebuild a core with a different node identity against the same registry. + const audit = new MemoryAuditSink(); + const core2 = new BrokerCore({ + tailscaleBinPath: BIN, + registryPath, + auditSink: audit, + peerPolicy: { allowedUids: new Set([999]), allowedGids: new Set([987]) }, + nodeIdentity: "node-CHANGED", + isAllowedPort: defaultIsAllowedPort, + deps: { + runTailscale: fake.run, + verifyListenerOwnership: () => ({ present: true, loopbackOnly: true, ownerUidMatches: true, inodes: ["5001"] }), + nowIso: () => "2026-08-11T00:00:00.000Z", + }, + }); + const res = await core2.handle(reserveReq(RUNTIME_B, 42011), PEER); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("quarantined"); + }); +}); diff --git a/packages/tailscale-https-broker/src/broker-core.ts b/packages/tailscale-https-broker/src/broker-core.ts new file mode 100644 index 0000000000..3353f99828 --- /dev/null +++ b/packages/tailscale-https-broker/src/broker-core.ts @@ -0,0 +1,827 @@ +/** + * Broker transaction core. One serialized, fail-closed transaction per + * mutation implements every PAP-17050 verdict requirement and invariant: + * + * - Complete peer mediation + lease-handle ownership (req #1). + * - Dedicated-range + immediately-before /proc listener-ownership check + * defeats SSRF-equivalent publication of unrelated loopback services (#2). + * - Read → strict-parse → verify absence-or-exact-lease-match → verify + * protected :443 → one fixed per-port op → reread → require only the + * intended entry changed and :443 identical → atomic registry commit; any + * ambiguity quarantines and fails closed (#3). + * - Argv-only, shell:false CLI via an injected runner (#4). + * - Bounded, serialized mutation queue (#5). + * - One audit event per allow/deny/outcome (#6). + */ +import { + AuthorizationError, + authorizePeer, + authorizeRemoval, + generateLeaseHandle, + type PeerPolicy, +} from "./authorization.js"; +import { buildExposeArgv, buildRemoveArgv, buildStatusArgv } from "./argv.js"; +import type { AuditSink } from "./audit.js"; +import { + ParsedServe, + ServeParseError, + assertPrimaryIntact, + changedPorts, + changedProtectedPorts, + isSameNumberLoopbackEntry, + parseServeStatus, + primaryDigest, +} from "./serve-config.js"; +import { + addLease, + isPortQuarantined, + loadRegistry, + nextGeneration, + pruneExpiredReservations, + quarantinePort, + removeLeaseByHandle, + saveRegistry, +} from "./registry.js"; +import type { + BrokerRegistry, + BrokerRequest, + BrokerResponse, + 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; + stdout: string; + stderr: string; + timedOut: boolean; +} + +/** + * Immediately-before-mutation ownership facts about a loopback listener, + * derived from /proc (req #2). `loopbackOnly` must be true (reject wildcard, + * IPv6-wildcard, dual-stack off-loopback); `ownerUidMatches` binds the listener + * to the expected managed-runtime identity; `present` guards the swap race. + */ +export interface ListenerOwnership { + present: boolean; + loopbackOnly: boolean; + ownerUidMatches: boolean; + /** + * Socket inodes of the listening sockets on the port, sorted. This is the + * listener's *identity*, and it is required rather than optional: a caller + * that cannot name the socket must fail closed, not fall through to + * "permitted". `present: true` with an empty array is contradictory and is + * refused as `listener_unattributable`. + * + * The three booleans above only describe "something acceptable is on this + * port". They are re-read but cannot detect substitution, because a different + * process under the same managed-runtime UID satisfies all three. Comparing + * inode sets across the reserve/expose window is what proves the socket the + * broker verified is the socket it publishes. + */ + inodes: string[]; +} + +export interface BrokerDeps { + runTailscale(argv: string[]): CliResult; + /** Inspect a loopback listener immediately before mutation (req #2). */ + verifyListenerOwnership(port: number): ListenerOwnership; + nowIso(): string; +} + +export interface BrokerCoreConfig { + tailscaleBinPath: string; + registryPath: string; + auditSink: AuditSink; + peerPolicy: PeerPolicy; + /** hostname + boot id; a change forces quarantine + operator reconciliation. */ + nodeIdentity: string; + /** Deny-by-default port allowlist. Defaults to the dedicated runtime range. */ + isAllowedPort(port: number): boolean; + /** + * Operator-declared ports that must never be created, removed, or reclaimed + * (PAP-17285). Outranks the broker's own ownership record: a protected port is + * denied even when a valid lease names it, which is exactly the case that + * destroyed `42000/52000`. Defaults to none. + */ + protectedPorts?: readonly number[]; + deps: BrokerDeps; +} + +function denied(code: string, message: string): never { + const err = new Error(message) as Error & { brokerCode: string }; + err.brokerCode = code; + throw err; +} + +export class BrokerCore { + private queue: Promise = Promise.resolve(); + + constructor(private readonly config: BrokerCoreConfig) {} + + private get protectedPorts(): readonly number[] { + return this.config.protectedPorts ?? []; + } + + /** + * Deny any request naming an operator-protected port, before anything reads or + * mutates Serve (PAP-17285). Deliberately checked ahead of the lease/ownership + * logic: the protection must hold *because* the operator declared it, not + * because the broker happens to lack a lease for the port. + */ + private assertNoProtectedPort(ports: readonly number[]): void { + for (const port of ports) { + if (this.protectedPorts.includes(port)) { + denied("protected_port", `port ${port} is operator-protected and may not be mutated`); + } + } + } + + /** + * Assert every protected entry is byte-identical across a mutation. Any change + * — including disappearance — fails closed. Callers must run this on the same + * `before`/`after` snapshots used for the primary-route check. + */ + private assertProtectedIntact(before: ParsedServe, after: ParsedServe): void { + const changed = changedProtectedPorts(before, after, this.protectedPorts); + if (changed.length > 0) { + denied( + "protected_entry_violation", + `operator-protected entries changed during mutation: ${changed.join(",")}`, + ); + } + } + + /** Public entry point. Serializes all requests through one mutation queue. */ + async handle(request: BrokerRequest, peer: PeerCredentials): Promise { + const run = this.queue.then(() => this.dispatch(request, peer)); + // Keep the chain alive even if this request rejects. + this.queue = run.catch(() => undefined); + return run; + } + + private async dispatch(request: BrokerRequest, peer: PeerCredentials): Promise { + try { + authorizePeer(peer, this.config.peerPolicy); + } catch (error) { + return this.fail(request.requestId, peer, request.op, "unauthorized_peer", error); + } + try { + 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": + return this.doRemove(request, peer); + } + } catch (error) { + const code = (error as { brokerCode?: string }).brokerCode ?? codeForError(error); + return this.fail(request.requestId, peer, request.op, code, error); + } + } + + private readServe(): ParsedServe { + const result = this.config.deps.runTailscale(buildStatusArgv(this.config.tailscaleBinPath)); + if (result.timedOut) denied("cli_timeout", "serve status timed out"); + if (result.code !== 0) denied("cli_error", "serve status exited non-zero"); + let json: unknown; + try { + json = JSON.parse(result.stdout); + } catch { + denied("serve_parse_error", "serve status returned invalid JSON"); + } + try { + return parseServeStatus(json); + } catch (error) { + if (error instanceof ServeParseError) denied("serve_parse_error", error.message); + throw error; + } + } + + 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 doReserve( + request: Extract, + peer: PeerCredentials, + ): BrokerResponse { + const registry = this.loadRegistry(); + // Protected ports are never allocatable, so a lane can never acquire a lease + // on one and no later lifecycle op can reach it (PAP-17285). + this.assertNoProtectedPort(request.listeners.map((listener) => listener.port)); + for (const listener of request.listeners) { + if (!this.config.isAllowedPort(listener.port)) { + denied("port_not_allowlisted", `port ${listener.port} is outside the dedicated range`); + } + if (isPortQuarantined(registry, listener.port)) { + denied("quarantined", `port ${listener.port} is quarantined`); + } + } + + 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 }; + } + + /** + * Prove the port carries an acceptable, attributable managed listener, or + * deny. Each predicate keeps its own reason code so a deployed failure can be + * attributed to a missing listener, an off-loopback bind, a foreign owner, or + * a socket the broker could not name. + */ + private verifyListener(port: number): ListenerOwnership { + const ownership = this.config.deps.verifyListenerOwnership(port); + if (!ownership.present) { + denied("listener_absent", `no loopback listener on port ${port}`); + } + if (!ownership.loopbackOnly) { + denied("listener_not_loopback", `listener on ${port} is not loopback-only`); + } + if (!ownership.ownerUidMatches) { + denied("listener_ownership_mismatch", `listener on ${port} not owned by managed runtime`); + } + // Present but unnameable is not permission. Without a socket identity the + // broker cannot prove that the socket it publishes is the socket it + // verified, so it refuses rather than publishing on trust. + if (ownership.inodes.length === 0) { + denied("listener_unattributable", `listener on ${port} has no identifiable socket`); + } + return ownership; + } + + /** + * Re-prove that `port` still carries the exact socket verified earlier. A + * changed identity means the listener was substituted inside the window, so + * publishing would expose a service the broker never authorized. + */ + private assertListenerUnchanged(port: number, expected: string | undefined): void { + if (expected === undefined) { + denied("listener_substituted", `port ${port} was not verified before mutation`); + } + const current = listenerIdentity(this.verifyListener(port)); + if (current !== expected) { + denied( + "listener_substituted", + `listener on ${port} changed after verification; refusing to expose a substituted service`, + ); + } + } + + /** + * Current socket identity without denying on a failed predicate. Used by the + * post-publication sweep, which must classify *every* port before it throws: + * an absent or unnameable listener is as much a substitution as a swapped one, + * and each case still needs the mapping withdrawn. + * + * SCOPE. Every check here is point-in-time, and it bounds the transaction only. + * A same-UID process can still replace a listener *after* a successful expose + * returns, while the Serve mapping persists. No check inside this transaction + * can close that, because the mapping outlives the transaction; the broker has + * no way to pin a Serve entry to a socket. That case is a lifecycle concern and + * is handled above the broker: the server re-verifies listener ownership on + * every readiness and health check, and reconciliation fails closed when a + * reserved pair is held by a different execution workspace. What this + * transaction guarantees is narrower and worth stating plainly — a *successful* + * expose published the socket it verified, and a failed one leaves nothing of + * ours published. + */ + private currentListenerIdentity(port: number): string { + try { + return listenerIdentity(this.config.deps.verifyListenerOwnership(port)); + } catch { + return ""; + } + } + + private doExpose( + request: Extract, + peer: PeerCredentials, + ): BrokerResponse { + const registry = this.loadRegistry(false); + let lease; + try { + lease = authorizeRemoval(registry.leases, request, peer); + } catch (error) { + if (error instanceof AuthorizationError) denied(error.code, error.message); + throw error; + } + if (lease.state === "reserved" && lease.expiresAtIso && Date.parse(lease.expiresAtIso) <= Date.parse(this.config.deps.nowIso())) { + removeLeaseByHandle(registry, lease.handle); + saveRegistry(this.config.registryPath, registry); + denied("reservation_expired", "reservation expired before exposure"); + } + + // A protected port must be refused even when a previously-issued lease names + // it, so an operator declaration made *after* a lease existed still holds. + this.assertNoProtectedPort(lease.ports); + + // Captured before the transaction can promote the lease: true means a prior + // expose already published these ports, so the broker owns whatever mapping + // is on them. This is the provenance the withdrawal path uses. + const leaseWasExposed = lease.state === "exposed"; + + // Pre-flight every requested port: allowlist, quarantine, and the + // immediately-before /proc ownership check (req #2). + // + // The verified socket identity per port is retained, because the checks + // below are not the last thing to happen before Serve is mutated: reading + // Serve status runs a `tailscale` subprocess, which is unbounded wall-clock + // time during which the verified listener can close and another process can + // take the port. Every mutation therefore re-proves this identity. + const verifiedIdentities = new Map(); + 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`); + } + // Each predicate gets its own code. Enforcement is unchanged — every + // branch below still denies, before anything reads or mutates Serve — but + // the caller can now tell which predicate failed. Previously all three + // returned `listener_ownership_mismatch` and only the root-owned audit + // file carried the reason, so a deployed failure could not be attributed + // to a missing listener, an off-loopback bind, or a foreign owner. + const ownership = this.verifyListener(port); + verifiedIdentities.set(port, listenerIdentity(ownership)); + } + + const before = this.readServe(); + assertPrimaryIntact(before); + const beforePrimary = primaryDigest(before); + + // 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 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 port of lease.ports) { + const already = before.entries.get(port); + if (already && isSameNumberLoopbackEntry(already, port)) { + continue; // idempotent + } + // Immediately before this port's mutation, and again after the whole + // batch below. Checking only once before `readServe()` left the verified + // socket free to be replaced during that subprocess. + this.assertListenerUnchanged(port, verifiedIdentities.get(port)); + const result = this.config.deps.runTailscale( + buildExposeArgv(this.config.tailscaleBinPath, port, this.protectedPorts), + ); + 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(); + // Digest equality (vs the known-good `before`) is the strongest primary + // check: any retarget, removal, or structural change fails closed here as + // a primary_route_violation before any weaker classification runs. + if (primaryDigest(after) !== beforePrimary) { + denied("primary_route_violation", "protected :443 route changed during expose"); + } + assertPrimaryIntact(after); + this.assertProtectedIntact(before, after); + // 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(lease.ports); + for (const port of diff) { + if (!intended.has(port)) denied("unexpected_serve_diff", `unexpected change on port ${port}`); + } + for (const port of lease.ports) { + if (!isSameNumberLoopbackEntry(after.entries.get(port), port)) { + denied("unexpected_serve_diff", `port ${port} not exactly exposed`); + } + } + + // Final proof, after the last mutation and the status read that follows + // it: every published port must still carry the socket that was verified. + // + // Classify all ports before throwing. A port skipped above as idempotent + // was never added to `appliedPorts`, so denying alone would leave its + // pre-existing mapping active and now pointing at the replacement service. + // Any port whose identity no longer matches is therefore made eligible for + // withdrawal, whether this request published it or found it already + // correct. Which of those ports are ours to withdraw is decided below from + // the registry, not from the shape of the Serve entry. + const substituted = lease.ports.filter( + (port) => this.currentListenerIdentity(port) !== verifiedIdentities.get(port), + ); + if (substituted.length > 0) { + // Provenance for a withdrawal comes from the broker's own registry, not + // from the shape of a Serve entry. An already-`exposed` lease means the + // broker published these ports itself, so they are ours to withdraw — and + // that holds even if Serve cannot be read at this moment, which is why + // this does not depend on a live status read that might fail and silently + // preserve the substituted mapping. A still-`reserved` lease published + // nothing, so there is nothing of ours to withdraw. + // + // Shape alone would be the wrong test in both directions: it cannot prove + // an identically-shaped entry is ours, and it cannot be evaluated at all + // when the status read fails. An operator who needs a mapping to survive + // managed lifecycle declares the port protected, which is refused far + // above this point. + if (leaseWasExposed) { + for (const port of substituted) { + if (!appliedPorts.includes(port)) appliedPorts.push(port); + } + } + denied( + "listener_substituted", + `listener on ${substituted.join(",")} changed after verification; withdrawing the mapping`, + ); + } + + lease.state = "exposed"; + lease.expiresAtIso = null; + saveRegistry(this.config.registryPath, registry); + + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op: "expose", + runtimeId: request.runtimeId, + ports: lease.ports, + requestId: request.requestId, + decision: "allow", + reasonCode: "ok", + reason: "exposed", + beforeDigest: beforePrimary, + afterDigest: primaryDigest(after), + cliExitCategory: "ok", + recovery: "none", + }); + + return { + ok: true, + op: "expose", + requestId: request.requestId, + handle: lease.handle, + publicPorts: [...lease.ports], + }; + } catch (error) { + // Partial success is not healthy: compensate by removing only the exact + // entries we applied; if compensation cannot be proven, quarantine. + this.compensateExpose(appliedPorts, registry, before, peer, request); + throw error; + } + } + + /** + * Roll back a partially-applied expose. + * + * This path used to mutate Serve with no verification and no audit event at + * all: it fired one `--https= off` per applied port, trusted the exit + * code, and returned. That made it the one broker mutation where an unrelated + * Serve change was structurally undetectable, and where a mutation left no + * durable record (PAP-17285 requirement #3, and req #6 which mandates one + * audit event per mutation outcome). + * + * It now re-reads Serve and proves three things against the pre-mutation + * snapshot: the primary route is intact, every protected entry is unchanged, + * and nothing outside the compensated set changed. Any port it cannot prove + * clean is quarantined, so recoverability is preserved rather than traded away. + * Compensation never throws — the original failure is the caller's error and + * must not be masked — but it can no longer fail silently either. + */ + private compensateExpose( + appliedPorts: number[], + registry: BrokerRegistry, + before: ParsedServe, + peer: PeerCredentials, + request: Extract, + ): void { + const unproven: number[] = []; + for (const port of appliedPorts) { + let cleaned = false; + try { + const result = this.config.deps.runTailscale( + buildRemoveArgv(this.config.tailscaleBinPath, port, this.protectedPorts), + ); + cleaned = !result.timedOut && result.code === 0; + } catch { + cleaned = false; + } + if (!cleaned) { + quarantinePort(registry, port); + unproven.push(port); + } + } + + // Verify the rollback actually restored the pre-mutation state. A failure + // here is a containment failure, not a cleanup detail, so every port we + // touched is quarantined even if its own `off` reported success. + let verifyFailure: string | null = null; + try { + const after = this.readServe(); + if (primaryDigest(after) !== primaryDigest(before)) { + verifyFailure = "primary_route_violation"; + } else { + const protectedChanged = changedProtectedPorts(before, after, this.protectedPorts); + if (protectedChanged.length > 0) { + verifyFailure = `protected_entry_violation:${protectedChanged.join(",")}`; + } else { + const compensated = new Set(appliedPorts); + const stray = changedPorts(before, after).filter((port) => !compensated.has(port)); + if (stray.length > 0) verifyFailure = `unexpected_serve_diff:${stray.join(",")}`; + } + } + } catch (error) { + // Could not even read Serve back — treat as unproven, never as clean. + verifyFailure = `unverifiable:${(error as { brokerCode?: string }).brokerCode ?? "read_failed"}`; + } + if (verifyFailure) { + for (const port of appliedPorts) { + quarantinePort(registry, port); + if (!unproven.includes(port)) unproven.push(port); + } + } + + try { + saveRegistry(this.config.registryPath, registry); + } catch { + /* best effort; registry may already reflect quarantine on next load */ + } + + try { + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op: "expose", + runtimeId: request.runtimeId, + ports: appliedPorts, + requestId: request.requestId, + decision: verifyFailure || unproven.length > 0 ? "deny" : "allow", + reasonCode: verifyFailure ? "unexpected_serve_diff" : "ok", + reason: verifyFailure + ? `expose compensation unverified: ${verifyFailure}` + : unproven.length > 0 + ? `expose compensation could not clean ports: ${unproven.join(",")}` + : "expose compensated", + cliExitCategory: verifyFailure || unproven.length > 0 ? "error" : "ok", + recovery: unproven.length > 0 ? "quarantine" : "cleanup", + }); + } catch { + /* never let an audit failure mask the original expose error */ + } + } + + private doRemove( + request: Extract, + peer: PeerCredentials, + ): BrokerResponse { + const registry = this.loadRegistry(); + let lease; + try { + lease = authorizeRemoval(registry.leases, request, peer); + } catch (error) { + if (error instanceof AuthorizationError) denied(error.code, error.message); + throw error; + } + + // Refuse before any Serve read or mutation. This is the exact clause whose + // absence let an authorized, shape-valid removal destroy `42000/52000`: the + // lease was genuinely the broker's own, so nothing else in this path could + // object (PAP-17285). + this.assertNoProtectedPort(lease.ports); + + 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); + + const removedPorts: number[] = []; + for (const port of lease.ports) { + const entry = before.entries.get(port); + if (!entry) continue; // already gone; idempotent + if (!isSameNumberLoopbackEntry(entry, port)) { + // Unknown/manual/mismatched entry — never modify; cannot prove cleanup. + quarantinePort(registry, port); + saveRegistry(this.config.registryPath, registry); + denied("listener_ownership_mismatch", `port ${port} does not match the owned lease entry`); + } + const result = this.config.deps.runTailscale( + buildRemoveArgv(this.config.tailscaleBinPath, port, this.protectedPorts), + ); + if (result.timedOut) denied("cli_timeout", `remove ${port} timed out`); + if (result.code !== 0) denied("cli_error", `remove ${port} exited non-zero`); + removedPorts.push(port); + } + + const after = this.readServe(); + if (primaryDigest(after) !== beforePrimary) { + denied("primary_route_violation", "protected :443 route changed during remove"); + } + assertPrimaryIntact(after); + this.assertProtectedIntact(before, after); + const diff = new Set(changedPorts(before, after)); + for (const port of diff) { + if (!lease.ports.includes(port)) denied("unexpected_serve_diff", `unexpected change on port ${port}`); + } + for (const port of removedPorts) { + if (after.entries.get(port)) denied("unexpected_serve_diff", `port ${port} still present after remove`); + } + + 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: "removed", + beforeDigest: beforePrimary, + afterDigest: primaryDigest(after), + cliExitCategory: "ok", + recovery: "cleanup", + }); + + return { ok: true, op: "remove", requestId: request.requestId, removedPorts }; + } + + private doList( + request: Extract, + peer: PeerCredentials, + ): BrokerResponse { + const registry = this.loadRegistry(); + const listeners = registry.leases + .filter((lease) => lease.state === "exposed" && lease.peerUid === peer.uid && lease.peerGid === peer.gid) + .flatMap((lease) => + lease.ports.map((port, index) => ({ + runtimeId: lease.runtimeId, + port, + purpose: lease.purposes[index] ?? "app", + })), + ); + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op: "list", + runtimeId: null, + ports: listeners.map((l) => l.port), + requestId: request.requestId, + decision: "allow", + reasonCode: "ok", + reason: "listed", + }); + return { ok: true, op: "list", requestId: request.requestId, listeners }; + } + + private fail( + requestId: string | null, + peer: PeerCredentials | null, + op: string, + code: string, + error: unknown, + ): BrokerResponse { + const message = error instanceof Error ? error.message : String(error); + try { + this.config.auditSink.write({ + timestampIso: this.config.deps.nowIso(), + peer, + op, + runtimeId: null, + ports: [], + requestId, + decision: "deny", + reasonCode: code as never, + reason: message, + }); + } catch { + /* never let an audit failure mask the denial response */ + } + return { ok: false, requestId, code: code as never, message: safeMessage(code) }; + } +} + +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]); +} + +/** + * Stable, comparable identity for the listening socket(s) on a port. Inodes are + * already sorted by the verifier; joining them makes two snapshots comparable + * with a single equality check. + */ +function listenerIdentity(ownership: ListenerOwnership): string { + return ownership.inodes.join(","); +} + +function codeForError(error: unknown): string { + if (error instanceof ServeParseError) return "serve_parse_error"; + if (error instanceof AuthorizationError) return error.code; + return "internal_error"; +} + +/** Client-facing message is a stable label; never echoes host/command detail. */ +function safeMessage(code: string): string { + return code; +} diff --git a/packages/tailscale-https-broker/src/config.ts b/packages/tailscale-https-broker/src/config.ts new file mode 100644 index 0000000000..bf40219682 --- /dev/null +++ b/packages/tailscale-https-broker/src/config.ts @@ -0,0 +1,61 @@ +/** + * Environment-driven configuration for the broker host service. All values are + * validated; unsafe or missing required values make the broker refuse to start + * (fail closed). + */ +import { parseProtectedPorts } from "./port-policy.js"; +import { registryPathUnsafeReason } from "./registry.js"; + +export interface BrokerHostConfig { + socketPath: string; + registryPath: string; + auditPath: string; + tailscaleBinPath: string; + nodeIdentity: string; + serviceUid: number; + serviceGid: number; + runtimeUid: number; + /** + * Operator-declared ports the broker must never mutate, parsed from + * `BROKER_PROTECTED_PORTS` (PAP-17285). Empty when unset. + */ + protectedPorts: number[]; +} + +function requireEnv(env: NodeJS.ProcessEnv, key: string): string { + const value = env[key]; + if (!value || value.trim().length === 0) { + throw new Error(`missing required env: ${key}`); + } + return value; +} + +function requireUid(env: NodeJS.ProcessEnv, key: string): number { + const raw = requireEnv(env, key); + if (!/^[0-9]+$/.test(raw)) throw new Error(`${key} must be a non-negative integer`); + return Number(raw); +} + +export function loadHostConfig(env: NodeJS.ProcessEnv): BrokerHostConfig { + const config: BrokerHostConfig = { + socketPath: env.BROKER_SOCKET_PATH ?? "/run/paperclip-tailscale-broker/broker.sock", + registryPath: env.BROKER_REGISTRY_PATH ?? "/var/lib/paperclip-tailscale-broker/registry.json", + auditPath: env.BROKER_AUDIT_PATH ?? "/var/log/paperclip-tailscale-broker/audit.log", + tailscaleBinPath: env.BROKER_TAILSCALE_BIN ?? "/usr/bin/tailscale", + nodeIdentity: requireEnv(env, "BROKER_NODE_IDENTITY"), + serviceUid: requireUid(env, "BROKER_SERVICE_UID"), + serviceGid: requireUid(env, "BROKER_SERVICE_GID"), + runtimeUid: requireUid(env, "BROKER_RUNTIME_UID"), + // Throws on a malformed list so the broker refuses to start rather than + // starting up silently protecting nothing (PAP-17285). + protectedPorts: parseProtectedPorts(env.BROKER_PROTECTED_PORTS), + }; + if (!config.tailscaleBinPath.startsWith("/")) { + throw new Error("BROKER_TAILSCALE_BIN must be an absolute path"); + } + const unsafe = registryPathUnsafeReason(config.registryPath); + if (unsafe) { + throw new Error(`refusing to start: ${unsafe} (${config.registryPath})`); + } + return config; +} diff --git a/packages/tailscale-https-broker/src/index.ts b/packages/tailscale-https-broker/src/index.ts new file mode 100644 index 0000000000..b37b28ce65 --- /dev/null +++ b/packages/tailscale-https-broker/src/index.ts @@ -0,0 +1,11 @@ +export * from "./types.js"; +export * from "./integers.js"; +export * from "./strict-json.js"; +export * from "./protocol.js"; +export * from "./argv.js"; +export * from "./serve-config.js"; +export * from "./authorization.js"; +export * from "./registry.js"; +export * from "./audit.js"; +export * from "./port-policy.js"; +export * from "./broker-core.js"; diff --git a/packages/tailscale-https-broker/src/integers.test.ts b/packages/tailscale-https-broker/src/integers.test.ts new file mode 100644 index 0000000000..76000bbf40 --- /dev/null +++ b/packages/tailscale-https-broker/src/integers.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { assertCanonicalPort, parseCanonicalIntegerString } from "./integers.js"; + +describe("assertCanonicalPort", () => { + it("accepts in-range integers", () => { + expect(assertCanonicalPort(1)).toBe(1); + expect(assertCanonicalPort(42010)).toBe(42010); + expect(assertCanonicalPort(65535)).toBe(65535); + }); + + it("rejects non-numbers, non-integers, and out-of-range", () => { + expect(() => assertCanonicalPort("42010")).toThrow(); + expect(() => assertCanonicalPort(42010.5)).toThrow(); + expect(() => assertCanonicalPort(0)).toThrow(); + expect(() => assertCanonicalPort(-1)).toThrow(); + expect(() => assertCanonicalPort(65536)).toThrow(); + expect(() => assertCanonicalPort(Number.NaN)).toThrow(); + expect(() => assertCanonicalPort(Infinity)).toThrow(); + expect(() => assertCanonicalPort(null)).toThrow(); + }); +}); + +describe("parseCanonicalIntegerString", () => { + it("accepts canonical decimal strings", () => { + expect(parseCanonicalIntegerString("0")).toBe(0); + expect(parseCanonicalIntegerString("42010")).toBe(42010); + }); + + it("rejects signs, whitespace, leading zeros, decimals, and exponents", () => { + expect(() => parseCanonicalIntegerString("+42010")).toThrow(); + expect(() => parseCanonicalIntegerString("-1")).toThrow(); + expect(() => parseCanonicalIntegerString(" 42010")).toThrow(); + expect(() => parseCanonicalIntegerString("42010 ")).toThrow(); + expect(() => parseCanonicalIntegerString("007")).toThrow(); + expect(() => parseCanonicalIntegerString("00")).toThrow(); + expect(() => parseCanonicalIntegerString("4.2")).toThrow(); + expect(() => parseCanonicalIntegerString("4e2")).toThrow(); + }); + + it("rejects Unicode digits and non-string input", () => { + // Arabic-Indic digits for 42010. + expect(() => parseCanonicalIntegerString("٤٢٠١٠")).toThrow(); + // Fullwidth digits. + expect(() => parseCanonicalIntegerString("42010")).toThrow(); + expect(() => parseCanonicalIntegerString(42010 as unknown as string)).toThrow(); + }); + + it("rejects integer overflow beyond safe range", () => { + expect(() => parseCanonicalIntegerString("99999999999999999999")).toThrow(); + }); +}); diff --git a/packages/tailscale-https-broker/src/integers.ts b/packages/tailscale-https-broker/src/integers.ts new file mode 100644 index 0000000000..9bfb972fa5 --- /dev/null +++ b/packages/tailscale-https-broker/src/integers.ts @@ -0,0 +1,59 @@ +/** + * Canonical integer parsing for the broker. Commands and Serve mutations are + * generated only from integers that pass these checks, never from raw strings, + * URLs, or unvalidated JSON (PAP-17050 verdict requirement #4). + */ + +/** Ports are constrained to the valid TCP range. */ +export const MIN_PORT = 1; +export const MAX_PORT = 65535; + +/** + * Assert a JSON-decoded value is a canonical, safe, in-range port number. + * Rejects non-numbers, non-integers, NaN/Infinity, negatives, and out-of-range + * values. Returns the number so call sites read as validated. + */ +export function assertCanonicalPort(value: unknown): number { + if (typeof value !== "number") { + throw new Error("port must be a JSON number"); + } + if (!Number.isInteger(value)) { + throw new Error("port must be an integer"); + } + if (value < MIN_PORT || value > MAX_PORT) { + throw new Error(`port out of range [${MIN_PORT}, ${MAX_PORT}]`); + } + return value; +} + +/** + * Parse an integer from an untrusted STRING with strict canonical rules. + * Rejects: empty, whitespace, signs, leading zeros (non-canonical), decimals, + * exponents, thousands separators, Unicode digits, and anything that does not + * round-trip exactly back to its canonical decimal form. Used at any boundary + * where a numeric value could arrive as text. + */ +export function parseCanonicalIntegerString(raw: unknown): number { + if (typeof raw !== "string") { + throw new Error("expected a string integer"); + } + // Only ASCII digits, at least one, no sign, no whitespace. `^[0-9]+$` with a + // JS regex still matches only ASCII 0-9 (it does not match Unicode digits + // unless the `u` + property-escape form is used), which is what we want. + if (!/^[0-9]+$/.test(raw)) { + throw new Error("integer contains non-canonical characters"); + } + // Reject non-canonical leading zeros ("007", "00"). + if (raw.length > 1 && raw[0] === "0") { + throw new Error("integer has non-canonical leading zero"); + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed)) { + throw new Error("integer overflow / not a safe integer"); + } + // Round-trip guard against any residual ambiguity. + if (String(parsed) !== raw) { + throw new Error("integer did not round-trip canonically"); + } + return parsed; +} diff --git a/packages/tailscale-https-broker/src/main.ts b/packages/tailscale-https-broker/src/main.ts new file mode 100644 index 0000000000..993522e1b9 --- /dev/null +++ b/packages/tailscale-https-broker/src/main.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Broker host entrypoint. Wires the tested BrokerCore to the live Tailscale + * CLI, /proc listener-ownership check, file registry, file audit sink, and the + * unix-socket transport. Runs as a dedicated, Tailscale-operator service + * account — NOT as the Paperclip app/agent account. + * + * Usage: + * paperclip-tailscale-https-broker # run the broker + * paperclip-tailscale-https-broker --doctor # read-only preflight, no mutation + */ +import { FileAuditSink } from "./audit.js"; +import { BrokerCore } from "./broker-core.js"; +import { loadHostConfig } from "./config.js"; +import { createNativePeerCredentialReader } from "./native-peercred.js"; +import { createPeerResolver } from "./peercred.js"; +import { defaultIsAllowedPort } from "./port-policy.js"; +import { createProcListenerVerifier } from "./proc-listener.js"; +import { startSocketServer } from "./socket-server.js"; +import { buildStatusArgv } from "./argv.js"; +import { assertPrimaryIntact, parseServeStatus } from "./serve-config.js"; +import { createTailscaleRunner, isSupportedTailscaleVersion } from "./tailscale-cli.js"; + +function runDoctor(): number { + const config = loadHostConfig(process.env); + const runner = createTailscaleRunner(); + const version = runner([config.tailscaleBinPath, "version"]); + const versionOk = version.code === 0 && isSupportedTailscaleVersion(version.stdout); + const status = runner(buildStatusArgv(config.tailscaleBinPath)); + let primaryOk = false; + try { + assertPrimaryIntact(parseServeStatus(JSON.parse(status.stdout))); + primaryOk = true; + } catch { + primaryOk = false; + } + const checks = { + tailscaleVersionSupported: versionOk, + serveStatusReadable: status.code === 0, + primaryRouteIntact: primaryOk, + registryPathSafe: true, // loadHostConfig already threw otherwise + nodeIdentity: config.nodeIdentity, + // Echo the parsed protected set so an operator can confirm the preservation + // list actually took effect before trusting it (PAP-17285). + protectedPorts: config.protectedPorts, + }; + process.stdout.write(`${JSON.stringify(checks, null, 2)}\n`); + return versionOk && status.code === 0 && primaryOk ? 0 : 1; +} + +function main(): void { + if (process.argv.includes("--doctor")) { + process.exit(runDoctor()); + } + + const config = loadHostConfig(process.env); + const core = new BrokerCore({ + tailscaleBinPath: config.tailscaleBinPath, + registryPath: config.registryPath, + auditSink: new FileAuditSink(config.auditPath, true), + peerPolicy: { + allowedUids: new Set([config.serviceUid]), + allowedGids: new Set([config.serviceGid]), + }, + nodeIdentity: config.nodeIdentity, + // Protected ports are excluded from the allocatable allowlist as well as + // denied per-op, so a lane can never even reserve one (PAP-17285). + isAllowedPort: (port) => defaultIsAllowedPort(port) && !config.protectedPorts.includes(port), + protectedPorts: config.protectedPorts, + deps: { + runTailscale: createTailscaleRunner(), + verifyListenerOwnership: createProcListenerVerifier(config.runtimeUid), + nowIso: () => new Date().toISOString(), + }, + }); + + const server = startSocketServer({ + socketPath: config.socketPath, + core, + serviceUid: config.serviceUid, + resolvePeer: createPeerResolver({ + soPeercred: createNativePeerCredentialReader(), + }), + }); + + const shutdown = () => { + server.close(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + process.stderr.write(`[broker] listening on ${config.socketPath}\n`); +} + +main(); diff --git a/packages/tailscale-https-broker/src/native-peercred.ts b/packages/tailscale-https-broker/src/native-peercred.ts new file mode 100644 index 0000000000..37a008fd5b --- /dev/null +++ b/packages/tailscale-https-broker/src/native-peercred.ts @@ -0,0 +1,36 @@ +/** + * Linux SO_PEERCRED bridge. + * + * Node's public net.Socket API does not expose peer credentials. The package + * build compiles a dependency-free N-API addon next to this module's emitted + * JavaScript. The native call is synchronous and runs once per accepted local + * socket, before the connection enters the admission pool. + */ +import { createRequire } from "node:module"; +import type { Socket } from "node:net"; +import type { PeerCredentials } from "./types.js"; + +interface NativePeercredBinding { + getPeerCredentials(fd: number): PeerCredentials; +} + +interface SocketWithHandle extends Socket { + _handle?: { fd?: unknown }; +} + +export function createNativePeerCredentialReader(): (socket: Socket) => PeerCredentials { + if (process.platform !== "linux") { + throw new Error("native SO_PEERCRED is supported only on Linux"); + } + + const require = createRequire(import.meta.url); + const binding = require("./peercred-native.node") as NativePeercredBinding; + + return (socket: Socket): PeerCredentials => { + const fd = (socket as SocketWithHandle)._handle?.fd; + if (!Number.isInteger(fd) || (fd as number) < 0) { + throw new Error("accepted socket has no valid file descriptor"); + } + return binding.getPeerCredentials(fd as number); + }; +} diff --git a/packages/tailscale-https-broker/src/peercred-native.c b/packages/tailscale-https-broker/src/peercred-native.c new file mode 100644 index 0000000000..a71a6440e9 --- /dev/null +++ b/packages/tailscale-https-broker/src/peercred-native.c @@ -0,0 +1,64 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +static napi_value throw_last_error(napi_env env, const char *operation) { + char message[256]; + (void)snprintf(message, sizeof(message), "%s failed: %s", operation, strerror(errno)); + napi_throw_error(env, NULL, message); + return NULL; +} + +static napi_value get_peer_credentials(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + int32_t fd; + + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc != 1 || + napi_get_value_int32(env, argv[0], &fd) != napi_ok || fd < 0) { + napi_throw_type_error(env, NULL, "getPeerCredentials requires a non-negative file descriptor"); + return NULL; + } + + struct ucred credentials; + socklen_t length = sizeof(credentials); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &credentials, &length) != 0) { + return throw_last_error(env, "getsockopt(SO_PEERCRED)"); + } + if (length != sizeof(credentials) || credentials.pid < 0) { + napi_throw_error(env, NULL, "getsockopt(SO_PEERCRED) returned invalid credentials"); + return NULL; + } + + napi_value result; + napi_value pid; + napi_value uid; + napi_value gid; + if (napi_create_object(env, &result) != napi_ok || + napi_create_int32(env, credentials.pid, &pid) != napi_ok || + napi_create_uint32(env, credentials.uid, &uid) != napi_ok || + napi_create_uint32(env, credentials.gid, &gid) != napi_ok || + napi_set_named_property(env, result, "pid", pid) != napi_ok || + napi_set_named_property(env, result, "uid", uid) != napi_ok || + napi_set_named_property(env, result, "gid", gid) != napi_ok) { + napi_throw_error(env, NULL, "failed to create SO_PEERCRED result"); + return NULL; + } + return result; +} + +static napi_value initialize(napi_env env, napi_value exports) { + napi_value function; + if (napi_create_function(env, "getPeerCredentials", NAPI_AUTO_LENGTH, + get_peer_credentials, NULL, &function) != napi_ok || + napi_set_named_property(env, exports, "getPeerCredentials", function) != napi_ok) { + napi_throw_error(env, NULL, "failed to initialize peercred native binding"); + return NULL; + } + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, initialize) diff --git a/packages/tailscale-https-broker/src/peercred.test.ts b/packages/tailscale-https-broker/src/peercred.test.ts new file mode 100644 index 0000000000..3e48051567 --- /dev/null +++ b/packages/tailscale-https-broker/src/peercred.test.ts @@ -0,0 +1,29 @@ +import type { Socket } from "node:net"; +import { describe, expect, it } from "vitest"; +import { PeerResolutionError, createPeerResolver } from "./peercred.js"; + +const socket = {} as Socket; + +describe("createPeerResolver", () => { + it("returns the actual native identity for admission before authorization", () => { + const resolve = createPeerResolver({ + soPeercred: () => ({ uid: 1000, gid: 987, pid: 42 }), + }); + + expect(resolve(socket)).toEqual({ uid: 1000, gid: 987, pid: 42 }); + }); + + it("fails closed when native credentials are unavailable", () => { + const resolve = createPeerResolver({ soPeercred: () => null }); + + expect(() => resolve(socket)).toThrow(PeerResolutionError); + }); + + it("fails closed on malformed native credentials", () => { + const resolve = createPeerResolver({ + soPeercred: () => ({ uid: Number.NaN, gid: 987, pid: 42 }), + }); + + expect(() => resolve(socket)).toThrow(/invalid/); + }); +}); diff --git a/packages/tailscale-https-broker/src/peercred.ts b/packages/tailscale-https-broker/src/peercred.ts new file mode 100644 index 0000000000..7d7305c2df --- /dev/null +++ b/packages/tailscale-https-broker/src/peercred.ts @@ -0,0 +1,37 @@ +/** + * Peer-credential resolution for accepted socket connections. + * + * Node does not expose SO_PEERCRED through a public API. The broker's primary + * access boundary is still the OS filesystem: the socket lives in a + * broker-owned directory and is mode 0660 owned by the dedicated socket group. + * Every member of that group can connect, so the shipped entrypoint wires a + * small native SO_PEERCRED reader to distinguish those peers before transport + * admission. BrokerCore applies the service UID/GID authorization policy after + * a request is framed. + * + * `createPeerResolver` returns the identity to bind leases to. A native + * SO_PEERCRED mechanism is required and its result is the authoritative peer + * identity. Missing or invalid credentials fail closed. + */ +import type { Socket } from "node:net"; +import type { PeerCredentials } from "./types.js"; + +export interface PeerResolverConfig { + /** Native SO_PEERCRED reader; returns null only when credentials are unavailable. */ + soPeercred: (socket: Socket) => PeerCredentials | null; +} + +export class PeerResolutionError extends Error {} + +export function createPeerResolver(config: PeerResolverConfig) { + return (socket: Socket): PeerCredentials => { + const native = config.soPeercred(socket); + if (native) { + if (![native.uid, native.gid, native.pid].every((value) => Number.isInteger(value) && value >= 0)) { + throw new PeerResolutionError("native peer credentials are invalid"); + } + return native; + } + throw new PeerResolutionError("native peer credentials are unavailable"); + }; +} diff --git a/packages/tailscale-https-broker/src/port-policy.ts b/packages/tailscale-https-broker/src/port-policy.ts new file mode 100644 index 0000000000..2ac44bcbd5 --- /dev/null +++ b/packages/tailscale-https-broker/src/port-policy.ts @@ -0,0 +1,48 @@ +/** + * Default dedicated port allowlist for the broker. Kept numerically in sync + * with `@paperclipai/shared` `runtime-exposure/ports` so the broker and the + * runtime allocator agree, but inlined here so the broker stays deployable as a + * standalone host service without a workspace dependency graph. + */ +export const DEFAULT_APP_PORT_MIN = 42000; +export const DEFAULT_APP_PORT_MAX = 42999; +export const DEFAULT_HMR_PORT_OFFSET = 10000; +export const DEFAULT_HMR_PORT_MIN = DEFAULT_APP_PORT_MIN + DEFAULT_HMR_PORT_OFFSET; +export const DEFAULT_HMR_PORT_MAX = DEFAULT_APP_PORT_MAX + DEFAULT_HMR_PORT_OFFSET; + +export function defaultIsAllowedPort(port: number): boolean { + if (!Number.isInteger(port)) return false; + const inApp = port >= DEFAULT_APP_PORT_MIN && port <= DEFAULT_APP_PORT_MAX; + const inHmr = port >= DEFAULT_HMR_PORT_MIN && port <= DEFAULT_HMR_PORT_MAX; + return inApp || inHmr; +} + +/** + * Parse `BROKER_PROTECTED_PORTS` (comma/space separated) into a sorted, deduped + * set of operator-protected ports (PAP-17285). + * + * Fails closed: a malformed list throws so the broker refuses to start rather + * than silently protecting nothing. Protecting a port the broker cannot mutate + * anyway is harmless, so no range restriction is applied — but `443` is rejected + * because the primary route has its own stronger, non-optional invariant and + * listing it here would imply it were opt-in. + */ +export function parseProtectedPorts(raw: string | undefined): number[] { + if (raw === undefined) return []; + const tokens = raw.split(/[,\s]+/).filter((token) => token.length > 0); + const ports = new Set(); + for (const token of tokens) { + if (!/^[0-9]{1,5}$/.test(token)) { + throw new Error(`BROKER_PROTECTED_PORTS contains a non-numeric entry: ${JSON.stringify(token)}`); + } + const port = Number(token); + if (port < 1 || port > 65535) { + throw new Error(`BROKER_PROTECTED_PORTS contains an out-of-range port: ${token}`); + } + if (port === 443) { + throw new Error("BROKER_PROTECTED_PORTS must not list 443; the primary route is always protected"); + } + ports.add(port); + } + return [...ports].sort((a, b) => a - b); +} diff --git a/packages/tailscale-https-broker/src/proc-listener.test.ts b/packages/tailscale-https-broker/src/proc-listener.test.ts new file mode 100644 index 0000000000..456d0426ec --- /dev/null +++ b/packages/tailscale-https-broker/src/proc-listener.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + isLoopbackIpv4Hex, + isLoopbackIpv6Hex, + isWildcardHex, + listenerFactsForPort, + parseProcNetTable, +} from "./proc-listener.js"; + +// 42010 == 0xA41A. /proc stores IPv4 little-endian: 127.0.0.1 -> 0100007F. +const LOOPBACK_ROW = " 0: 0100007F:A41A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 999 0 12345 1"; +const WILDCARD_ROW = " 1: 00000000:A41A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 999 0 12346 1"; +const OTHERUID_ROW = " 2: 0100007F:A41A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12347 1"; +const HEADER = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode"; + +describe("proc listener parsing", () => { + it("classifies loopback vs wildcard addresses", () => { + expect(isLoopbackIpv4Hex("0100007F")).toBe(true); + expect(isLoopbackIpv4Hex("0101A8C0")).toBe(false); // 192.168.1.1 + expect(isWildcardHex("00000000")).toBe(true); + // /proc/net/tcp6 stores ::1 little-endian per word: final word 0x1 -> "01000000". + expect(isLoopbackIpv6Hex("00000000000000000000000001000000")).toBe(true); + // The big-endian spelling must NOT match, or a non-loopback bind could slip through. + expect(isLoopbackIpv6Hex("00000000000000000000000000000001")).toBe(false); + expect(isLoopbackIpv6Hex("00000000000000000000000000000000")).toBe(false); // :: + }); + + it("reports a loopback listener owned by the expected uid", () => { + const rows = parseProcNetTable(`${HEADER}\n${LOOPBACK_ROW}`); + const facts = listenerFactsForPort(rows, [], 42010); + expect(facts.present).toBe(true); + expect(facts.loopbackOnly).toBe(true); + expect(facts.uids).toEqual([999]); + // The socket inode is the listener's identity. It is what lets the broker + // tell "the socket I verified" from "some socket on this port owned by the + // same uid", so it must survive the reduction to facts. + expect(facts.inodes).toEqual(["12345"]); + }); + + it("distinguishes two different sockets on the same port and uid", () => { + // Same port, same uid, different socket: only the inode changes. This is the + // substitution the broker must be able to detect. + const first = listenerFactsForPort(parseProcNetTable(`${HEADER}\n${LOOPBACK_ROW}`), [], 42010); + const replaced = parseProcNetTable(`${HEADER}\n${LOOPBACK_ROW.replace(" 12345 ", " 55555 ")}`); + const second = listenerFactsForPort(replaced, [], 42010); + expect(second.uids).toEqual(first.uids); + expect(second.loopbackOnly).toBe(first.loopbackOnly); + expect(second.inodes).not.toEqual(first.inodes); + }); + + it("returns inodes in a stable order so two snapshots compare equal", () => { + // Dual-stack listeners can be reported in either table order. Identity must + // not depend on that, or a legitimate listener would look substituted. + const v6 = ` 3: ${"0".repeat(31)}1:A41A 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 999 0 12300 1`; + const rows = parseProcNetTable(`${HEADER}\n${LOOPBACK_ROW}`); + const rows6 = parseProcNetTable(`${HEADER}\n${v6}`); + expect(listenerFactsForPort(rows, rows6, 42010).inodes).toEqual( + listenerFactsForPort(rows, rows6, 42010).inodes, + ); + expect(listenerFactsForPort(rows, rows6, 42010).inodes).toEqual(["12300", "12345"]); + }); + + it("flags a wildcard bind as not loopback-only", () => { + const rows = parseProcNetTable(`${HEADER}\n${WILDCARD_ROW}`); + const facts = listenerFactsForPort(rows, [], 42010); + expect(facts.present).toBe(true); + expect(facts.loopbackOnly).toBe(false); + }); + + it("surfaces a foreign uid so ownership fails", () => { + const rows = parseProcNetTable(`${HEADER}\n${OTHERUID_ROW}`); + const facts = listenerFactsForPort(rows, [], 42010); + expect(facts.uids).toEqual([1000]); + }); + + it("returns absent when no listener matches the port", () => { + const rows = parseProcNetTable(`${HEADER}\n${LOOPBACK_ROW}`); + expect(listenerFactsForPort(rows, [], 40000).present).toBe(false); + }); +}); diff --git a/packages/tailscale-https-broker/src/proc-listener.ts b/packages/tailscale-https-broker/src/proc-listener.ts new file mode 100644 index 0000000000..ae3f734002 --- /dev/null +++ b/packages/tailscale-https-broker/src/proc-listener.ts @@ -0,0 +1,142 @@ +/** + * /proc-based loopback listener ownership check (PAP-17050 verdict req #2). + * + * Immediately before a mutation the broker proves the target port has a + * listener that is (a) loopback-only — not a wildcard/IPv6-wildcard/dual-stack + * bind reachable off-loopback — and (b) owned by the expected managed-runtime + * UID. A mere caller assertion or health response is insufficient. + * + * Pure parsing helpers are exported for tests; `createProcListenerVerifier` + * wires them to the live /proc filesystem. + */ +import { readFileSync } from "node:fs"; +import type { ListenerOwnership } from "./broker-core.js"; + +export interface ProcListenerRow { + localAddressHex: string; + localPortHex: string; + state: string; + uid: number; + inode: string; +} + +const TCP_STATE_LISTEN = "0A"; + +/** Parse one `/proc/net/tcp{,6}` table into structured listening rows. */ +export function parseProcNetTable(content: string): ProcListenerRow[] { + const rows: ProcListenerRow[] = []; + const lines = content.split("\n").slice(1); // drop header + for (const line of lines) { + const cols = line.trim().split(/\s+/); + if (cols.length < 10) continue; + const [local, , stateHex] = [cols[1], cols[2], cols[3]]; + const uid = Number.parseInt(cols[7], 10); + const inode = cols[9]; + const [addr, port] = local.split(":"); + rows.push({ localAddressHex: addr, localPortHex: port, state: stateHex, uid, inode }); + } + return rows; +} + +/** True when a hex IPv4 address is loopback (127.0.0.0/8). */ +export function isLoopbackIpv4Hex(addrHex: string): boolean { + // /proc stores little-endian; low byte is the last pair. + if (addrHex.length !== 8) return false; + const b0 = parseInt(addrHex.slice(6, 8), 16); // first octet + return b0 === 127; +} + +/** + * `/proc/net/tcp6` rendering of ::1. Like the IPv4 table, the address is stored + * as 32-bit words in host (little-endian) byte order, so the final word 0x00000001 + * is byte-swapped to "01000000" — NOT the big-endian "...0001". Matching the + * wrong endianness would misclassify a real loopback listener. + */ +const LOOPBACK_IPV6_PROC_HEX = "00000000000000000000000001000000"; + +/** True when a hex IPv6 address is ::1 loopback (in /proc little-endian form). */ +export function isLoopbackIpv6Hex(addrHex: string): boolean { + return addrHex.toUpperCase() === LOOPBACK_IPV6_PROC_HEX; +} + +/** True for wildcard binds (0.0.0.0 / ::) that are reachable off-loopback. */ +export function isWildcardHex(addrHex: string): boolean { + return /^0+$/.test(addrHex); +} + +export interface ListenerFacts { + present: boolean; + loopbackOnly: boolean; + uids: number[]; + /** + * Socket inodes of the matching listening sockets, sorted. The inode names + * *this* socket, not merely "something on this port", so comparing two + * snapshots detects a listener that was closed and replaced by another + * process between the two reads. UID equality cannot do that: a different + * process under the same runtime UID satisfies `ownerUidMatches`. + */ + inodes: string[]; +} + +/** + * Reduce the two /proc tables to facts about listeners on `port`. A wildcard or + * IPv6-wildcard bind anywhere on the port makes it NOT loopback-only. + */ +export function listenerFactsForPort( + tcp: ProcListenerRow[], + tcp6: ProcListenerRow[], + port: number, +): ListenerFacts { + const wantHex = port.toString(16).toUpperCase().padStart(4, "0"); + const uids: number[] = []; + const inodes: string[] = []; + let present = false; + let loopbackOnly = true; + for (const row of tcp) { + if (row.localPortHex.toUpperCase() !== wantHex || row.state !== TCP_STATE_LISTEN) continue; + present = true; + uids.push(row.uid); + inodes.push(row.inode); + if (isWildcardHex(row.localAddressHex) || !isLoopbackIpv4Hex(row.localAddressHex)) { + loopbackOnly = false; + } + } + for (const row of tcp6) { + if (row.localPortHex.toUpperCase() !== wantHex || row.state !== TCP_STATE_LISTEN) continue; + present = true; + uids.push(row.uid); + inodes.push(row.inode); + if (isWildcardHex(row.localAddressHex) || !isLoopbackIpv6Hex(row.localAddressHex)) { + loopbackOnly = false; + } + } + return { present, loopbackOnly, uids, inodes: [...inodes].sort() }; +} + +/** + * Build a live verifier bound to the expected managed-runtime UID. Reads + * /proc/net/tcp and tcp6 fresh on every call (immediately before mutation). + */ +export function createProcListenerVerifier(expectedUid: number) { + return (port: number): ListenerOwnership => { + let tcp: ProcListenerRow[] = []; + let tcp6: ProcListenerRow[] = []; + try { + tcp = parseProcNetTable(readFileSync("/proc/net/tcp", "utf8")); + } catch { + /* leave empty -> present:false -> fail closed */ + } + try { + tcp6 = parseProcNetTable(readFileSync("/proc/net/tcp6", "utf8")); + } catch { + /* optional */ + } + const facts = listenerFactsForPort(tcp, tcp6, port); + return { + present: facts.present, + loopbackOnly: facts.loopbackOnly, + ownerUidMatches: facts.present && facts.uids.every((uid) => uid === expectedUid), + inodes: facts.inodes, + }; + }; +} diff --git a/packages/tailscale-https-broker/src/protected-ports.test.ts b/packages/tailscale-https-broker/src/protected-ports.test.ts new file mode 100644 index 0000000000..d519cb3f08 --- /dev/null +++ b/packages/tailscale-https-broker/src/protected-ports.test.ts @@ -0,0 +1,386 @@ +/** + * 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([ + [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 = {}; + const Web: Record = {}; + 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/); + }); +}); diff --git a/packages/tailscale-https-broker/src/protocol.test.ts b/packages/tailscale-https-broker/src/protocol.test.ts new file mode 100644 index 0000000000..32b3ba93de --- /dev/null +++ b/packages/tailscale-https-broker/src/protocol.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { ProtocolError, decodeRequest, MAX_REQUEST_BYTES } from "./protocol.js"; + +const RUNTIME = "2af79bb1-ecc5-4410-8438-091be135a921"; + +function frame(obj: unknown): string { + return JSON.stringify(obj); +} + +describe("decodeRequest", () => { + it("decodes a valid reserve request", () => { + const req = decodeRequest( + frame({ + v: 1, + op: "reserve", + requestId: "req-1", + runtimeId: RUNTIME, + listeners: [ + { purpose: "app", port: 42010 }, + { purpose: "vite_hmr", port: 52010 }, + ], + }), + ); + expect(req).toEqual({ + op: "reserve", + requestId: "req-1", + runtimeId: RUNTIME, + listeners: [ + { purpose: "app", port: 42010 }, + { purpose: "vite_hmr", port: 52010 }, + ], + }); + }); + + it("decodes expose, remove, and list", () => { + expect( + decodeRequest(frame({ v: 1, op: "list", requestId: "r" })).op, + ).toBe("list"); + const rm = 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", () => { + expect(() => decodeRequest(frame({ v: 2, op: "list", requestId: "r" }))).toThrow(ProtocolError); + try { + decodeRequest(frame({ v: 2, op: "list", requestId: "r" })); + } catch (e) { + expect((e as ProtocolError).code).toBe("unsupported_version"); + } + }); + + it("rejects unknown operations and unknown fields", () => { + expect(() => decodeRequest(frame({ v: 1, op: "reset", requestId: "r" }))).toThrow(/unknown operation/); + expect(() => + decodeRequest(frame({ v: 1, op: "list", requestId: "r", extra: 1 })), + ).toThrow(/unknown field/); + }); + + it("rejects duplicate keys", () => { + expect(() => decodeRequest('{"v":1,"op":"list","op":"expose","requestId":"r"}')).toThrow(/duplicate/); + }); + + it("rejects a non-UUID runtimeId and out-of-canonical ports", () => { + expect(() => + 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: "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: "reserve", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "app", port: 42010 }] })), + ).toThrow(/duplicate/); + expect(() => + 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(() => + decodeRequest(frame({ v: 1, op: "list", requestId: "r", pad: big })), + ).toThrow(/size limit|unknown field/); + }); + + it("rejects invalid handles on remove", () => { + expect(() => + decodeRequest(frame({ v: 1, op: "remove", requestId: "r", runtimeId: RUNTIME, handle: "short" })), + ).toThrow(); + }); +}); diff --git a/packages/tailscale-https-broker/src/protocol.ts b/packages/tailscale-https-broker/src/protocol.ts new file mode 100644 index 0000000000..cf21a3f43b --- /dev/null +++ b/packages/tailscale-https-broker/src/protocol.ts @@ -0,0 +1,182 @@ +/** + * Versioned, length-bounded broker request protocol with a strict schema + * (PAP-17050 verdict requirement #5). Every request is decoded here before any + * authorization or mutation logic runs. Malformed, oversized, wrong-version, + * 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, + type BrokerRequest, + type OwnedListener, +} from "./types.js"; + +/** Hard cap on a single request frame (bytes). Bounds memory + slowloris. */ +export const MAX_REQUEST_BYTES = 8 * 1024; + +/** Max listeners in one expose request (app + HMR companion). */ +export const MAX_LISTENERS_PER_REQUEST = 2; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +const REQUEST_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +const HANDLE_RE = /^[A-Za-z0-9_-]{16,128}$/; + +const ALLOWED_PURPOSES: ReadonlySet = new Set([ + "app", + "vite_hmr", +]); + +export class ProtocolError extends Error { + constructor( + readonly code: + | "unsupported_version" + | "malformed_request" + | "unknown_operation" + | "invalid_runtime_id" + | "invalid_port", + message: string, + readonly requestId: string | null = null, + ) { + super(message); + this.name = "ProtocolError"; + } +} + +function asObject(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new ProtocolError("malformed_request", "request must be a JSON object"); + } + return value as Record; +} + +function requireStringField( + obj: Record, + field: string, + re: RegExp, + code: ProtocolError["code"], + requestId: string | null, +): string { + const value = obj[field]; + if (typeof value !== "string" || !re.test(value)) { + throw new ProtocolError(code, `invalid or missing field: ${field}`, requestId); + } + return value; +} + +/** + * Decode a raw request frame (utf-8 JSON) into a validated BrokerRequest. + * Throws ProtocolError on any violation; never mutates state. + */ +export function decodeRequest(frame: Buffer | string): BrokerRequest { + const buf = typeof frame === "string" ? Buffer.from(frame, "utf8") : frame; + if (buf.byteLength === 0) { + throw new ProtocolError("malformed_request", "empty request frame"); + } + if (buf.byteLength > MAX_REQUEST_BYTES) { + throw new ProtocolError("malformed_request", "request frame exceeds size limit"); + } + + let parsed: unknown; + try { + parsed = parseJsonNoDuplicateKeys(buf.toString("utf8")); + } catch (error) { + throw new ProtocolError( + "malformed_request", + `invalid JSON: ${(error as Error).message}`, + ); + } + + const obj = asObject(parsed); + + if (obj.v !== BROKER_PROTOCOL_VERSION) { + throw new ProtocolError( + "unsupported_version", + `unsupported protocol version: ${String(obj.v)}`, + ); + } + + const requestId = requireStringField(obj, "requestId", REQUEST_ID_RE, "malformed_request", null); + + const op = obj.op; + if (op !== "reserve" && op !== "expose" && op !== "remove" && op !== "list") { + throw new ProtocolError("unknown_operation", `unknown operation: ${String(op)}`, requestId); + } + + if (op === "list") { + assertExactKeys(obj, ["v", "op", "requestId"], requestId); + return { op: "list", requestId }; + } + + 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, requestId, runtimeId, handle }; + } + + // reserve + assertExactKeys(obj, ["v", "op", "requestId", "runtimeId", "listeners"], requestId); + const runtimeId = requireStringField(obj, "runtimeId", UUID_RE, "invalid_runtime_id", requestId); + const rawListeners = obj.listeners; + if (!Array.isArray(rawListeners) || rawListeners.length === 0) { + throw new ProtocolError("malformed_request", "listeners must be a non-empty array", requestId); + } + if (rawListeners.length > MAX_LISTENERS_PER_REQUEST) { + throw new ProtocolError("malformed_request", "too many listeners in request", requestId); + } + const listeners: OwnedListener[] = rawListeners.map((entry) => { + const listener = asObject(entry); + assertExactKeys(listener, ["purpose", "port"], requestId); + const purpose = listener.purpose; + if (typeof purpose !== "string" || !ALLOWED_PURPOSES.has(purpose as OwnedListener["purpose"])) { + throw new ProtocolError("malformed_request", "invalid listener purpose", requestId); + } + let port: number; + try { + port = assertCanonicalPort(listener.port); + } catch (error) { + throw new ProtocolError("invalid_port", (error as Error).message, requestId); + } + return { purpose: purpose as OwnedListener["purpose"], port }; + }); + + // Reject duplicate ports / duplicate purposes within one request. + const ports = new Set(listeners.map((l) => l.port)); + const purposes = new Set(listeners.map((l) => l.purpose)); + if (ports.size !== listeners.length || purposes.size !== listeners.length) { + throw new ProtocolError("malformed_request", "duplicate listener port or purpose", requestId); + } + + 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( + obj: Record, + allowed: string[], + requestId: string | null, +): void { + const allowedSet = new Set(allowed); + for (const key of Object.keys(obj)) { + if (!allowedSet.has(key)) { + throw new ProtocolError("malformed_request", `unknown field: ${key}`, requestId); + } + } +} diff --git a/packages/tailscale-https-broker/src/registry.ts b/packages/tailscale-https-broker/src/registry.ts new file mode 100644 index 0000000000..3e6061e9a4 --- /dev/null +++ b/packages/tailscale-https-broker/src/registry.ts @@ -0,0 +1,158 @@ +/** + * Root-owned ownership registry persisted atomically (PAP-17050 verdict #3). + * + * Writes go through temp-file + fsync + rename + directory fsync so a crash + * never leaves a torn registry. The file must be mode 0600 in a root-owned, + * non-writable parent directory; unsafe permissions make the broker refuse to + * start (checked by the caller via `assertSafeRegistryPath`). + */ +import { + closeSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import type { BrokerRegistry, LeaseRecord } from "./types.js"; + +export function emptyRegistry(nodeIdentity: string): BrokerRegistry { + return { + version: 1, + nodeIdentity, + generationCounter: 0, + leases: [], + quarantinedPorts: [], + }; +} + +export function loadRegistry(path: string, nodeIdentity: string): BrokerRegistry { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return emptyRegistry(nodeIdentity); + } + throw error; + } + const parsed = JSON.parse(raw) as 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; +} + +/** + * Persist the registry atomically. Temp file in the same directory (same fs), + * fsync data, rename over the target, then fsync the directory so the rename is + * durable. + */ +export function saveRegistry(path: string, registry: BrokerRegistry): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmp = `${path}.tmp-${process.pid}`; + writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 }); + const fd = openSync(tmp, "r"); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + renameSync(tmp, path); + const dirFd = openSync(dir, "r"); + try { + fsyncSync(dirFd); + } finally { + closeSync(dirFd); + } +} + +/** + * Refuse to operate on a registry whose parent directory is group/world + * writable or whose file (when present) is group/world accessible. Returns the + * reason string when unsafe, or null when safe. + */ +export function registryPathUnsafeReason(path: string): string | null { + const dir = dirname(path); + let dirStat; + try { + dirStat = statSync(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + if ((dirStat.mode & 0o022) !== 0) { + return "registry parent directory is group/world writable"; + } + try { + const fileStat = statSync(path); + if ((fileStat.mode & 0o077) !== 0) { + return "registry file is group/world accessible"; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + return null; +} + +export function nextGeneration(registry: BrokerRegistry): number { + registry.generationCounter += 1; + return registry.generationCounter; +} + +export function addLease(registry: BrokerRegistry, lease: LeaseRecord): void { + registry.leases.push(lease); +} + +export function removeLeaseByHandle(registry: BrokerRegistry, handle: string): LeaseRecord | null { + const index = registry.leases.findIndex((lease) => lease.handle === handle); + if (index < 0) return null; + const [removed] = registry.leases.splice(index, 1); + 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); +} + +export function quarantinePort(registry: BrokerRegistry, port: number): void { + if (!registry.quarantinedPorts.includes(port)) { + registry.quarantinedPorts.push(port); + } +} + +/** Ports the registry believes are actively owned by a live lease. */ +export function ownedPorts(registry: BrokerRegistry): Set { + const ports = new Set(); + for (const lease of registry.leases) { + for (const port of lease.ports) ports.add(port); + } + return ports; +} diff --git a/packages/tailscale-https-broker/src/serve-config.test.ts b/packages/tailscale-https-broker/src/serve-config.test.ts new file mode 100644 index 0000000000..a4f9bf4c72 --- /dev/null +++ b/packages/tailscale-https-broker/src/serve-config.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + ServeParseError, + assertPrimaryIntact, + changedPorts, + isSameNumberLoopbackEntry, + parseServeStatus, + primaryDigest, +} from "./serve-config.js"; + +const HOST = "paperclip-dev.tail29c1aa.ts.net"; + +function serve(ports: Record): unknown { + const TCP: Record = {}; + const Web: Record = {}; + for (const [portStr, proxy] of Object.entries(ports)) { + TCP[portStr] = { HTTPS: true }; + Web[`${HOST}:${portStr}`] = { Handlers: { "/": { Proxy: proxy } } }; + } + return { TCP, Web }; +} + +const PRIMARY = { 443: "http://127.0.0.1:3100" }; + +describe("parseServeStatus", () => { + it("normalizes TCP + Web into port entries", () => { + const parsed = parseServeStatus(serve({ ...PRIMARY, 42010: "http://127.0.0.1:42010" })); + expect(parsed.entries.get(42010)?.https).toBe(true); + expect(isSameNumberLoopbackEntry(parsed.entries.get(42010), 42010)).toBe(true); + }); + + it("fails closed on malformed shapes", () => { + expect(() => parseServeStatus(null)).toThrow(ServeParseError); + expect(() => parseServeStatus({ TCP: [] })).toThrow(ServeParseError); + expect(() => parseServeStatus({ Web: { "no-port": {} } })).toThrow(ServeParseError); + }); + + it("tolerates unknown top-level fields", () => { + expect(() => parseServeStatus({ ...(serve(PRIMARY) as object), AllowFunnel: {} })).not.toThrow(); + }); +}); + +describe(":443 invariant", () => { + it("asserts the primary route intact", () => { + const parsed = parseServeStatus(serve(PRIMARY)); + expect(() => assertPrimaryIntact(parsed)).not.toThrow(); + }); + + it("throws when :443 is missing or retargeted", () => { + expect(() => assertPrimaryIntact(parseServeStatus(serve({ 42010: "http://127.0.0.1:42010" })))).toThrow(); + expect(() => assertPrimaryIntact(parseServeStatus(serve({ 443: "http://127.0.0.1:9999" })))).toThrow(); + }); + + it("produces a stable digest that changes only when :443 changes", () => { + const a = primaryDigest(parseServeStatus(serve({ ...PRIMARY, 42010: "http://127.0.0.1:42010" }))); + const b = primaryDigest(parseServeStatus(serve({ ...PRIMARY, 42011: "http://127.0.0.1:42011" }))); + expect(a).toBe(b); + const c = primaryDigest(parseServeStatus(serve({ 443: "http://127.0.0.1:9999" }))); + expect(c).not.toBe(a); + }); +}); + +describe("changedPorts", () => { + it("detects only the ports whose entry differs", () => { + const before = parseServeStatus(serve({ ...PRIMARY })); + const after = parseServeStatus(serve({ ...PRIMARY, 42010: "http://127.0.0.1:42010" })); + expect(changedPorts(before, after)).toEqual([42010]); + }); +}); diff --git a/packages/tailscale-https-broker/src/serve-config.ts b/packages/tailscale-https-broker/src/serve-config.ts new file mode 100644 index 0000000000..9b8e668e4a --- /dev/null +++ b/packages/tailscale-https-broker/src/serve-config.ts @@ -0,0 +1,183 @@ +/** + * Strict parser + invariant checks for `tailscale serve status --json` output. + * + * The primary route `:443 -> http://127.0.0.1:3100` is a protected invariant + * verified before AND after every mutation, and unknown/manual entries are + * never modified (PAP-17050 verdict requirement #3 + invariants). Parsing fails + * closed: any ambiguity, parse error, or unexpected shape is an error, never a + * best-effort guess. + */ +import { assertCanonicalPort } from "./integers.js"; +import { PROTECTED_PRIMARY_PORT, PROTECTED_PRIMARY_TARGET } from "./types.js"; + +export interface ServeHandler { + path: string; + proxy: string | null; +} + +export interface ServeEntry { + port: number; + https: boolean; + handlers: ServeHandler[]; +} + +export interface ParsedServe { + /** Normalized entries keyed by port. */ + entries: Map; +} + +export class ServeParseError extends Error { + constructor(message: string) { + super(message); + this.name = "ServeParseError"; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Extract the trailing `:port` of a `host:port` Web key. */ +function portFromWebKey(key: string): number { + const idx = key.lastIndexOf(":"); + if (idx <= 0 || idx === key.length - 1) { + throw new ServeParseError(`malformed Web key: ${JSON.stringify(key)}`); + } + const portText = key.slice(idx + 1); + if (!/^[0-9]+$/.test(portText)) { + throw new ServeParseError(`non-numeric port in Web key: ${JSON.stringify(key)}`); + } + return assertCanonicalPort(Number(portText)); +} + +/** + * Parse serve status JSON into a normalized, port-keyed model. Tolerates extra + * top-level fields (Tailscale evolves its schema) but strictly validates the + * `TCP` and `Web` shapes it depends on. + */ +export function parseServeStatus(json: unknown): ParsedServe { + if (!isObject(json)) { + throw new ServeParseError("serve status must be a JSON object"); + } + const entries = new Map(); + + const tcp = json.TCP; + const httpsPorts = new Set(); + if (tcp !== undefined && tcp !== null) { + if (!isObject(tcp)) throw new ServeParseError("TCP must be an object"); + for (const [portKey, value] of Object.entries(tcp)) { + if (!/^[0-9]+$/.test(portKey)) { + throw new ServeParseError(`non-numeric TCP port: ${portKey}`); + } + const port = assertCanonicalPort(Number(portKey)); + if (!isObject(value)) throw new ServeParseError(`TCP[${portKey}] must be an object`); + if (value.HTTPS === true) httpsPorts.add(port); + if (!entries.has(port)) entries.set(port, { port, https: value.HTTPS === true, handlers: [] }); + else entries.get(port)!.https = value.HTTPS === true; + } + } + + const web = json.Web; + if (web !== undefined && web !== null) { + if (!isObject(web)) throw new ServeParseError("Web must be an object"); + for (const [hostKey, value] of Object.entries(web)) { + const port = portFromWebKey(hostKey); + if (!isObject(value)) throw new ServeParseError(`Web[${hostKey}] must be an object`); + const handlersRaw = value.Handlers; + const handlers: ServeHandler[] = []; + if (handlersRaw !== undefined && handlersRaw !== null) { + if (!isObject(handlersRaw)) throw new ServeParseError(`Handlers for ${hostKey} must be an object`); + for (const [path, handler] of Object.entries(handlersRaw)) { + if (!isObject(handler)) throw new ServeParseError(`handler ${path} must be an object`); + const proxy = handler.Proxy; + if (proxy !== undefined && typeof proxy !== "string") { + throw new ServeParseError(`handler ${path} Proxy must be a string`); + } + handlers.push({ path, proxy: typeof proxy === "string" ? proxy : null }); + } + } + const existing = entries.get(port); + if (existing) { + existing.handlers.push(...handlers); + if (httpsPorts.has(port)) existing.https = true; + } else { + entries.set(port, { port, https: httpsPorts.has(port), handlers }); + } + } + } + + return { entries }; +} + +/** Stable, order-independent digest of one entry, for before/after comparison. */ +export function entryDigest(entry: ServeEntry | undefined): string { + if (!entry) return "absent"; + const handlers = [...entry.handlers] + .map((h) => `${h.path}=>${h.proxy ?? ""}`) + .sort(); + return JSON.stringify({ port: entry.port, https: entry.https, handlers }); +} + +/** True when the given entry is exactly a same-number HTTPS->loopback listener. */ +export function isSameNumberLoopbackEntry(entry: ServeEntry | undefined, port: number): boolean { + if (!entry || entry.port !== port || !entry.https) return false; + if (entry.handlers.length !== 1) return false; + const [handler] = entry.handlers; + return handler.path === "/" && handler.proxy === `http://127.0.0.1:${port}`; +} + +/** + * Assert the protected primary `:443 -> http://127.0.0.1:3100` route is present + * and exactly as expected. Throws otherwise. Called before and after mutation. + */ +export function assertPrimaryIntact(parsed: ParsedServe): void { + const entry = parsed.entries.get(PROTECTED_PRIMARY_PORT); + if (!entry) { + throw new ServeParseError("protected primary :443 route is missing"); + } + if (!entry.https) { + throw new ServeParseError("protected primary :443 route is not HTTPS"); + } + const root = entry.handlers.find((h) => h.path === "/"); + if (!root || root.proxy !== PROTECTED_PRIMARY_TARGET) { + throw new ServeParseError("protected primary :443 route target changed"); + } +} + +/** Digest of the protected :443 entry, for exact before/after equality. */ +export function primaryDigest(parsed: ParsedServe): string { + return entryDigest(parsed.entries.get(PROTECTED_PRIMARY_PORT)); +} + +/** + * Protected ports whose entry digest differs between two snapshots (PAP-17285). + * Empty is the only healthy result for any broker mutation. + * + * `entryDigest(undefined)` is the sentinel `"absent"`, so a protected entry that + * *disappears* changes this digest exactly as loudly as one that is retargeted. + * That is precisely the failure that went undetected on `42000/52000`: the + * entries were deleted rather than modified, and the only snapshot comparison + * that ran (`changedPorts` vs the intended lease ports) treated their removal as + * the intended effect of the operation. + */ +export function changedProtectedPorts( + before: ParsedServe, + after: ParsedServe, + protectedPorts: readonly number[], +): number[] { + return [...new Set(protectedPorts)] + .filter((port) => entryDigest(before.entries.get(port)) !== entryDigest(after.entries.get(port))) + .sort((a, b) => a - b); +} + +/** Ports whose entry digest differs between two snapshots. */ +export function changedPorts(before: ParsedServe, after: ParsedServe): number[] { + const ports = new Set([...before.entries.keys(), ...after.entries.keys()]); + const changed: number[] = []; + for (const port of ports) { + if (entryDigest(before.entries.get(port)) !== entryDigest(after.entries.get(port))) { + changed.push(port); + } + } + return changed.sort((a, b) => a - b); +} diff --git a/packages/tailscale-https-broker/src/socket-server.test.ts b/packages/tailscale-https-broker/src/socket-server.test.ts new file mode 100644 index 0000000000..e4ff32e6b9 --- /dev/null +++ b/packages/tailscale-https-broker/src/socket-server.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { ClientAdmissionController } from "./socket-server.js"; + +function admission(overrides: Partial[0]> = {}) { + return new ClientAdmissionController({ + maxClients: 32, + maxClientsPerUid: 8, + serviceUid: 999, + reservedServiceClients: 4, + ...overrides, + }); +} + +describe("ClientAdmissionController", () => { + it("bounds one non-service peer by UID even when it opens 40 connections", () => { + const controller = admission(); + const admitted = Array.from({ length: 40 }, () => controller.tryAcquire(1000)); + + expect(admitted.filter(Boolean)).toHaveLength(8); + expect(admitted.slice(8)).toEqual(Array(32).fill(false)); + expect(controller.tryAcquire(999)).toBe(true); + }); + + it("reserves global headroom for the Paperclip service UID", () => { + const controller = admission({ maxClients: 6, maxClientsPerUid: 3, reservedServiceClients: 2 }); + + expect(controller.tryAcquire(1000)).toBe(true); + expect(controller.tryAcquire(1000)).toBe(true); + expect(controller.tryAcquire(1001)).toBe(true); + expect(controller.tryAcquire(1001)).toBe(true); + expect(controller.tryAcquire(1002)).toBe(false); + + expect(controller.tryAcquire(999)).toBe(true); + expect(controller.tryAcquire(999)).toBe(true); + expect(controller.tryAcquire(999)).toBe(false); + }); + + it("returns capacity to the same UID when a socket finishes", () => { + const controller = admission({ maxClients: 3, maxClientsPerUid: 1, reservedServiceClients: 1 }); + + expect(controller.tryAcquire(1000)).toBe(true); + expect(controller.tryAcquire(1000)).toBe(false); + controller.release(1000); + expect(controller.tryAcquire(1000)).toBe(true); + }); + + it("rejects quota configurations that would disable a bound or reservation", () => { + expect(() => admission({ maxClientsPerUid: 0 })).toThrow(/maxClientsPerUid/); + expect(() => admission({ maxClients: 8, maxClientsPerUid: 9 })).toThrow(/must not exceed/); + expect(() => admission({ maxClients: 8, reservedServiceClients: 8 })).toThrow(/less than/); + }); +}); diff --git a/packages/tailscale-https-broker/src/socket-server.ts b/packages/tailscale-https-broker/src/socket-server.ts new file mode 100644 index 0000000000..c287205b3d --- /dev/null +++ b/packages/tailscale-https-broker/src/socket-server.ts @@ -0,0 +1,218 @@ +/** + * Unix-socket transport for the broker. Enforces the resource-consumption and + * protocol controls from PAP-17050 verdict requirement #5: length-prefixed + * frames with a hard byte cap, per-connection read/write/idle deadlines, a + * maximum concurrent-client bound, a per-UID client bound, reserved capacity + * for the Paperclip service UID, and bounded single-frame responses. All + * security decisions are delegated to the tested `BrokerCore`; this layer + * frames bytes, resolves peer identity, and bounds transport resource usage. + */ +import { createServer, type Server, type Socket } from "node:net"; +import { chmodSync, existsSync, unlinkSync } from "node:fs"; +import type { BrokerCore } from "./broker-core.js"; +import { ProtocolError, decodeRequest, MAX_REQUEST_BYTES } from "./protocol.js"; +import type { PeerCredentials } from "./types.js"; + +const LENGTH_PREFIX_BYTES = 4; +const MAX_RESPONSE_BYTES = 16 * 1024; +const DEFAULT_MAX_CLIENTS = 32; +const DEFAULT_MAX_CLIENTS_PER_UID = 8; +const DEFAULT_RESERVED_SERVICE_CLIENTS = 4; + +export interface SocketServerConfig { + socketPath: string; + core: BrokerCore; + resolvePeer: (socket: Socket) => PeerCredentials; + serviceUid: number; + maxClients?: number; + maxClientsPerUid?: number; + reservedServiceClients?: number; + connectionDeadlineMs?: number; +} + +export interface ClientAdmissionConfig { + maxClients: number; + maxClientsPerUid: number; + serviceUid: number; + reservedServiceClients: number; +} + +/** + * Tracks admitted sockets by resolved UID. Non-service peers cannot consume + * the service reservation, and no UID can monopolize the global pool. + */ +export class ClientAdmissionController { + private active = 0; + private readonly activeByUid = new Map(); + + constructor(private readonly config: ClientAdmissionConfig) { + assertPositiveInteger(config.maxClients, "maxClients"); + assertPositiveInteger(config.maxClientsPerUid, "maxClientsPerUid"); + assertNonNegativeInteger(config.serviceUid, "serviceUid"); + assertNonNegativeInteger(config.reservedServiceClients, "reservedServiceClients"); + if (config.maxClientsPerUid > config.maxClients) { + throw new Error("maxClientsPerUid must not exceed maxClients"); + } + if (config.reservedServiceClients >= config.maxClients) { + throw new Error("reservedServiceClients must be less than maxClients"); + } + } + + tryAcquire(uid: number): boolean { + if (!Number.isInteger(uid) || uid < 0) return false; + const activeForUid = this.activeByUid.get(uid) ?? 0; + if (activeForUid >= this.config.maxClientsPerUid || this.active >= this.config.maxClients) { + return false; + } + const nonServiceLimit = this.config.maxClients - this.config.reservedServiceClients; + if (uid !== this.config.serviceUid && this.active >= nonServiceLimit) { + return false; + } + this.active += 1; + this.activeByUid.set(uid, activeForUid + 1); + return true; + } + + release(uid: number): void { + const activeForUid = this.activeByUid.get(uid) ?? 0; + if (activeForUid <= 0) return; + this.active -= 1; + if (activeForUid === 1) { + this.activeByUid.delete(uid); + } else { + this.activeByUid.set(uid, activeForUid - 1); + } + } +} + +export function startSocketServer(config: SocketServerConfig): Server { + const maxClients = config.maxClients ?? DEFAULT_MAX_CLIENTS; + const admission = new ClientAdmissionController({ + maxClients, + maxClientsPerUid: config.maxClientsPerUid ?? Math.min(DEFAULT_MAX_CLIENTS_PER_UID, maxClients), + serviceUid: config.serviceUid, + reservedServiceClients: + config.reservedServiceClients ?? Math.min(DEFAULT_RESERVED_SERVICE_CLIENTS, maxClients - 1), + }); + const deadlineMs = config.connectionDeadlineMs ?? 5_000; + + if (existsSync(config.socketPath)) { + unlinkSync(config.socketPath); + } + + const server = createServer((socket) => { + let peer: PeerCredentials; + try { + peer = config.resolvePeer(socket); + } catch { + socket.destroy(); + return; + } + if (!admission.tryAcquire(peer.uid)) { + socket.destroy(); + return; + } + + let expected = -1; + const chunks: Buffer[] = []; + let received = 0; + let done = false; + let released = false; + + const timer = setTimeout(() => socket.destroy(), deadlineMs); + timer.unref?.(); + + const release = (): void => { + if (released) return; + released = true; + clearTimeout(timer); + admission.release(peer.uid); + }; + + // Keep the admission slot until the kernel reports the socket closed. An + // error can precede `close`, so releasing here would briefly undercount + // live descriptors during an error storm. + socket.on("error", () => socket.destroy()); + socket.on("close", release); + + socket.on("data", (chunk: Buffer) => { + if (done) return; + received += chunk.byteLength; + if (received > LENGTH_PREFIX_BYTES + MAX_REQUEST_BYTES) { + socket.destroy(); + return; + } + chunks.push(chunk); + const buf = Buffer.concat(chunks); + if (expected < 0) { + if (buf.byteLength < LENGTH_PREFIX_BYTES) return; + expected = buf.readUInt32BE(0); + if (expected > MAX_REQUEST_BYTES) { + socket.destroy(); + return; + } + } + if (buf.byteLength < LENGTH_PREFIX_BYTES + expected) return; + const body = buf.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + expected); + void handleFrame(body); + }); + + const handleFrame = async (body: Buffer): Promise => { + // Claim the connection before awaiting BrokerCore so trailing data cannot + // dispatch the same frame more than once. + done = true; + let response: unknown; + try { + const request = decodeRequest(body); + response = await config.core.handle(request, peer); + } catch (error) { + response = + error instanceof ProtocolError + ? { ok: false, requestId: error.requestId, code: error.code, message: error.code } + : { ok: false, requestId: null, code: "malformed_request", message: "malformed_request" }; + } + writeResponse(socket, response); + }; + }); + + server.listen(config.socketPath, () => { + // Socket file itself is restricted to the dedicated group; the parent + // directory ownership/mode is enforced by the installer. + try { + chmodSync(config.socketPath, 0o660); + } catch { + /* best effort; installer verifies */ + } + }); + + server.maxConnections = maxClients; + return server; +} + +function assertPositiveInteger(value: number, name: string): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } +} + +function assertNonNegativeInteger(value: number, name: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer`); + } +} + +function writeResponse(socket: Socket, response: unknown): void { + let json = JSON.stringify(response); + if (Buffer.byteLength(json, "utf8") > MAX_RESPONSE_BYTES) { + json = JSON.stringify({ ok: false, requestId: null, code: "internal_error", message: "internal_error" }); + } + const body = Buffer.from(json, "utf8"); + const frame = Buffer.allocUnsafe(LENGTH_PREFIX_BYTES + body.byteLength); + frame.writeUInt32BE(body.byteLength, 0); + body.copy(frame, LENGTH_PREFIX_BYTES); + try { + socket.end(frame); + } catch { + socket.destroy(); + } +} diff --git a/packages/tailscale-https-broker/src/strict-json.test.ts b/packages/tailscale-https-broker/src/strict-json.test.ts new file mode 100644 index 0000000000..b70e0b21c6 --- /dev/null +++ b/packages/tailscale-https-broker/src/strict-json.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseJsonNoDuplicateKeys } from "./strict-json.js"; + +describe("parseJsonNoDuplicateKeys", () => { + it("parses ordinary JSON", () => { + expect(parseJsonNoDuplicateKeys('{"a":1,"b":[true,null,"x"]}')).toEqual({ + a: 1, + b: [true, null, "x"], + }); + }); + + it("rejects duplicate keys at the top level", () => { + expect(() => parseJsonNoDuplicateKeys('{"op":"list","op":"expose"}')).toThrow(/duplicate/); + }); + + it("rejects duplicate keys nested in objects", () => { + expect(() => + parseJsonNoDuplicateKeys('{"a":{"port":1,"port":2}}'), + ).toThrow(/duplicate/); + }); + + it("does not pollute the prototype via __proto__", () => { + const value = parseJsonNoDuplicateKeys('{"__proto__":{"polluted":true}}') as Record< + string, + unknown + >; + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(value, "__proto__")).toBe(true); + }); + + it("rejects trailing content and control characters in strings", () => { + expect(() => parseJsonNoDuplicateKeys('{"a":1} extra')).toThrow(); + expect(() => parseJsonNoDuplicateKeys('{"a":"\n"}')).toThrow(); + }); + + it("rejects non-canonical numbers", () => { + expect(() => parseJsonNoDuplicateKeys("01")).toThrow(); + }); +}); diff --git a/packages/tailscale-https-broker/src/strict-json.ts b/packages/tailscale-https-broker/src/strict-json.ts new file mode 100644 index 0000000000..2fb78dc837 --- /dev/null +++ b/packages/tailscale-https-broker/src/strict-json.ts @@ -0,0 +1,194 @@ +/** + * Minimal strict JSON parser that rejects duplicate object keys. `JSON.parse` + * silently keeps the last value for a duplicated key, which the threat model + * treats as request smuggling (PAP-17050 verdict requirements #4/#5). This + * recursive-descent parser throws on any duplicate key at any depth. + * + * It is intentionally small and only used for the broker's tiny, length-bounded + * request frames — not a general-purpose JSON library. + */ + +export function parseJsonNoDuplicateKeys(text: string): unknown { + const parser = new StrictParser(text); + const value = parser.parseValue(); + parser.skipWhitespace(); + if (!parser.atEnd()) { + throw new SyntaxError("unexpected trailing content after JSON value"); + } + return value; +} + +class StrictParser { + private pos = 0; + constructor(private readonly src: string) {} + + atEnd(): boolean { + return this.pos >= this.src.length; + } + + skipWhitespace(): void { + while (this.pos < this.src.length) { + const ch = this.src[this.pos]; + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + this.pos += 1; + } else { + break; + } + } + } + + parseValue(): unknown { + this.skipWhitespace(); + if (this.atEnd()) throw new SyntaxError("unexpected end of input"); + const ch = this.src[this.pos]; + switch (ch) { + case "{": + return this.parseObject(); + case "[": + return this.parseArray(); + case '"': + return this.parseString(); + case "t": + case "f": + return this.parseBoolean(); + case "n": + return this.parseNull(); + default: + return this.parseNumber(); + } + } + + private parseObject(): Record { + this.expect("{"); + const obj: Record = {}; + const seen = new Set(); + this.skipWhitespace(); + if (this.peek() === "}") { + this.pos += 1; + return obj; + } + for (;;) { + this.skipWhitespace(); + if (this.peek() !== '"') throw new SyntaxError("expected object key string"); + const key = this.parseString(); + if (seen.has(key)) { + throw new SyntaxError(`duplicate object key: ${JSON.stringify(key)}`); + } + seen.add(key); + this.skipWhitespace(); + this.expect(":"); + const value = this.parseValue(); + // Use defineProperty so a "__proto__" key cannot poison the prototype. + Object.defineProperty(obj, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + this.skipWhitespace(); + const next = this.next(); + if (next === "}") return obj; + if (next !== ",") throw new SyntaxError("expected , or } in object"); + } + } + + private parseArray(): unknown[] { + this.expect("["); + const arr: unknown[] = []; + this.skipWhitespace(); + if (this.peek() === "]") { + this.pos += 1; + return arr; + } + for (;;) { + arr.push(this.parseValue()); + this.skipWhitespace(); + const next = this.next(); + if (next === "]") return arr; + if (next !== ",") throw new SyntaxError("expected , or ] in array"); + } + } + + private parseString(): string { + this.expect('"'); + let out = ""; + for (;;) { + if (this.atEnd()) throw new SyntaxError("unterminated string"); + const ch = this.src[this.pos++]; + if (ch === '"') return out; + if (ch === "\\") { + const esc = this.src[this.pos++]; + switch (esc) { + case '"': out += '"'; break; + case "\\": out += "\\"; break; + case "/": out += "/"; break; + case "b": out += "\b"; break; + case "f": out += "\f"; break; + case "n": out += "\n"; break; + case "r": out += "\r"; break; + case "t": out += "\t"; break; + case "u": { + const hex = this.src.slice(this.pos, this.pos + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new SyntaxError("bad unicode escape"); + out += String.fromCharCode(parseInt(hex, 16)); + this.pos += 4; + break; + } + default: + throw new SyntaxError("bad string escape"); + } + } else { + const code = ch.charCodeAt(0); + if (code < 0x20) throw new SyntaxError("unescaped control character in string"); + out += ch; + } + } + } + + private parseNumber(): number { + const start = this.pos; + const re = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y; + re.lastIndex = this.pos; + const match = re.exec(this.src); + if (!match || match.index !== start) throw new SyntaxError("invalid number"); + this.pos += match[0].length; + const num = Number(match[0]); + if (!Number.isFinite(num)) throw new SyntaxError("non-finite number"); + return num; + } + + private parseBoolean(): boolean { + if (this.src.startsWith("true", this.pos)) { + this.pos += 4; + return true; + } + if (this.src.startsWith("false", this.pos)) { + this.pos += 5; + return false; + } + throw new SyntaxError("invalid literal"); + } + + private parseNull(): null { + if (this.src.startsWith("null", this.pos)) { + this.pos += 4; + return null; + } + throw new SyntaxError("invalid literal"); + } + + private peek(): string { + return this.src[this.pos]; + } + + private next(): string { + return this.src[this.pos++]; + } + + private expect(ch: string): void { + if (this.src[this.pos] !== ch) { + throw new SyntaxError(`expected '${ch}'`); + } + this.pos += 1; + } +} diff --git a/packages/tailscale-https-broker/src/tailscale-cli.test.ts b/packages/tailscale-https-broker/src/tailscale-cli.test.ts new file mode 100644 index 0000000000..8d9a66fdf1 --- /dev/null +++ b/packages/tailscale-https-broker/src/tailscale-cli.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isSupportedTailscaleVersion, parseTailscaleVersion } from "./tailscale-cli.js"; + +describe("parseTailscaleVersion", () => { + it("parses the numeric major.minor prefix of CLI output", () => { + expect(parseTailscaleVersion("1.102.2\n tailscale commit: abc\n")).toEqual({ major: 1, minor: 102 }); + expect(parseTailscaleVersion(" 1.80.3")).toEqual({ major: 1, minor: 80 }); + }); + + it("returns null for malformed output", () => { + expect(parseTailscaleVersion("")).toBeNull(); + expect(parseTailscaleVersion("garbage")).toBeNull(); + expect(parseTailscaleVersion("v1.80.1")).toBeNull(); + expect(parseTailscaleVersion("1")).toBeNull(); + expect(parseTailscaleVersion("tailscale 1.80")).toBeNull(); + }); +}); + +describe("isSupportedTailscaleVersion", () => { + it("accepts the minimum version and anything newer", () => { + expect(isSupportedTailscaleVersion("1.80.0")).toBe(true); + expect(isSupportedTailscaleVersion("1.84.1")).toBe(true); + // Numeric, not lexicographic: "1.102" < "1.80" as strings. + expect(isSupportedTailscaleVersion("1.102.2\n tailscale commit: abc\n")).toBe(true); + expect(isSupportedTailscaleVersion("2.0.0")).toBe(true); + }); + + it("rejects versions below the minimum", () => { + expect(isSupportedTailscaleVersion("1.78")).toBe(false); + expect(isSupportedTailscaleVersion("1.79.9")).toBe(false); + expect(isSupportedTailscaleVersion("0.99.0")).toBe(false); + }); + + it("rejects malformed version output", () => { + expect(isSupportedTailscaleVersion("")).toBe(false); + expect(isSupportedTailscaleVersion("garbage")).toBe(false); + expect(isSupportedTailscaleVersion("v1.102.2")).toBe(false); + }); +}); diff --git a/packages/tailscale-https-broker/src/tailscale-cli.ts b/packages/tailscale-https-broker/src/tailscale-cli.ts new file mode 100644 index 0000000000..228516f00e --- /dev/null +++ b/packages/tailscale-https-broker/src/tailscale-cli.ts @@ -0,0 +1,57 @@ +/** + * Real Tailscale CLI runner: direct spawn with shell:false, absolute binary, + * minimal environment, closed inherited fds, bounded output, and a hard + * timeout+kill (PAP-17050 verdict requirement #4). Used by `main.ts` to build + * the BrokerCore `runTailscale` dependency. + */ +import { spawnSync } from "node:child_process"; +import type { CliResult } from "./broker-core.js"; + +const MAX_OUTPUT_BYTES = 256 * 1024; +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Minimum Tailscale CLI version the broker accepts. Serve subcommand syntax and + * JSON status output are stable from this release onward, so newer releases are + * accepted by numeric comparison rather than a pinned allowlist. + */ +export const MIN_TAILSCALE_VERSION = { major: 1, minor: 80 } as const; + +export function createTailscaleRunner(options: { timeoutMs?: number } = {}) { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return (argv: string[]): CliResult => { + const [bin, ...args] = argv; + const result = spawnSync(bin, args, { + shell: false, + windowsHide: true, + timeout: timeoutMs, + maxBuffer: MAX_OUTPUT_BYTES, + stdio: ["ignore", "pipe", "pipe"], + env: { + PATH: "/usr/sbin:/usr/bin:/sbin:/bin", + HOME: "/nonexistent", + }, + cwd: "/", + }); + const timedOut = result.error !== undefined && (result.error as NodeJS.ErrnoException).code === "ETIMEDOUT"; + return { + code: typeof result.status === "number" ? result.status : timedOut ? 124 : 1, + stdout: (result.stdout ?? "").toString("utf8").slice(0, MAX_OUTPUT_BYTES), + stderr: (result.stderr ?? "").toString("utf8").slice(0, MAX_OUTPUT_BYTES), + timedOut, + }; + }; +} + +/** Parse the numeric `major.minor` prefix of `tailscale version` output. */ +export function parseTailscaleVersion(versionOutput: string): { major: number; minor: number } | null { + const match = /^\s*(\d+)\.(\d+)/.exec(versionOutput); + return match ? { major: Number(match[1]), minor: Number(match[2]) } : null; +} + +export function isSupportedTailscaleVersion(versionOutput: string): boolean { + const version = parseTailscaleVersion(versionOutput); + if (version === null) return false; + if (version.major !== MIN_TAILSCALE_VERSION.major) return version.major > MIN_TAILSCALE_VERSION.major; + return version.minor >= MIN_TAILSCALE_VERSION.minor; +} diff --git a/packages/tailscale-https-broker/src/types.ts b/packages/tailscale-https-broker/src/types.ts new file mode 100644 index 0000000000..9a22e3da4d --- /dev/null +++ b/packages/tailscale-https-broker/src/types.ts @@ -0,0 +1,215 @@ +/** + * Wire and domain types for the least-privilege Tailscale HTTPS host broker. + * + * The broker's only capability is adding and removing Paperclip-owned, + * same-number HTTPS-to-loopback listeners. It never gains general Tailscale + * administration. See PAP-17049 (plan) and PAP-17050 (threat-model verdict). + * + * SECURITY: every value that crosses the socket is untrusted. The Paperclip + * caller, CLI output, Serve state, the registry, and listener metadata are all + * treated as untrusted inputs. + */ + +/** Supported broker protocol version. Requests on other versions are rejected. */ +export const BROKER_PROTOCOL_VERSION = 1; + +/** The protected primary route that must never change: `:443` -> loopback app. */ +export const PROTECTED_PRIMARY_PORT = 443; +export const PROTECTED_PRIMARY_TARGET = "http://127.0.0.1:3100"; + +/** + * Operator-declared ports the broker must never create, remove, or reclaim, + * beyond the primary `:443` route (PAP-17285). + * + * WHY THIS EXISTS. The pre-existing "unknown/manual entries are never modified" + * invariant is provenance-blind: it only protects entries the broker has no + * lease for. It could not protect `42000/52000`, because the broker had itself + * created them for a since-retired canary lane, so its registry still called + * them owned. Operators had meanwhile reclassified those same ports as + * must-preserve by observing `tailscale serve status` and failing to attribute + * them to any live lane. Both views were internally consistent and they + * disagreed, so a fully authorized, shape-valid, primary-preserving removal + * destroyed a mapping that had been declared load-bearing — and no guard could + * fire, because by every automated criterion it was healthy orphan reclamation. + * + * A protected port is therefore an *operator* assertion that outranks the + * broker's own ownership record. It is the only machine-checkable way to make + * "this mapping survives managed lane lifecycle" true rather than hoped-for. + * Enforcement is fail-closed and defence-in-depth: refused during argv + * construction, denied in reserve/expose/remove, excluded from the allocatable + * allowlist, and asserted byte-unchanged across every before/after snapshot. + */ +export const PROTECTED_PRIMARY_PORTS: readonly number[] = [PROTECTED_PRIMARY_PORT]; + +export type BrokerOp = "reserve" | "expose" | "remove" | "list"; + +/** Peer credentials obtained from the OS for an accepted socket connection. */ +export interface PeerCredentials { + uid: number; + gid: number; + pid: number; +} + +/** A single owned HTTPS-to-loopback listener the broker manages. */ +export interface OwnedListener { + purpose: "app" | "vite_hmr"; + /** Public HTTPS port == target loopback port (same-number invariant). */ + port: number; +} + +/** 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; + runtimeId: string; + handle: string; +} + +/** A remove request body. Requires a prior expose lease handle. */ +export interface RemoveRequest { + op: "remove"; + requestId: string; + runtimeId: string; + /** Unguessable handle returned by `expose`; binds ownership. */ + handle: string; +} + +/** A list request body. Scoped to the caller; never returns lease handles. */ +export interface ListRequest { + op: "list"; + requestId: string; +} + +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; + op: "expose"; + requestId: string; + /** Lease handle required for a later `remove`. Never logged. */ + handle: string; + publicPorts: number[]; +} + +export interface RemoveResponseOk { + ok: true; + op: "remove"; + requestId: string; + removedPorts: number[]; +} + +export interface ListResponseOk { + ok: true; + op: "list"; + requestId: string; + /** Caller-owned listeners only; no handles, no manual/unknown Serve state. */ + listeners: Array<{ runtimeId: string; port: number; purpose: OwnedListener["purpose"] }>; +} + +export interface BrokerErrorResponse { + ok: false; + requestId: string | null; + /** Stable machine code; never leaks command lines, paths, or secrets. */ + code: BrokerErrorCode; + message: string; +} + +export type BrokerResponse = + | ReserveResponseOk + | ExposeResponseOk + | RemoveResponseOk + | ListResponseOk + | BrokerErrorResponse; + +export type BrokerErrorCode = + | "unsupported_version" + | "malformed_request" + | "unknown_operation" + | "unauthorized_peer" + | "invalid_runtime_id" + | "invalid_port" + | "port_not_allowlisted" + | "listener_not_owned" + // The three pre-flight listener predicates are reported separately. All of + // them still deny, but collapsing them into one code made the deployed + // failure undiagnosable: the discriminating text only ever reaches the + // root-owned audit file, so the calling account cannot tell an absent + // listener from a genuine off-loopback or wrong-owner violation. Naming the + // failed predicate leaks nothing an attacker could not learn by probing the + // port they already asked us to expose. + | "listener_absent" + | "listener_not_loopback" + | "listener_ownership_mismatch" + // The port carries a listener the broker cannot name (no socket identity), so + // it cannot prove the socket it publishes is the socket it verified. Present + // but unattributable is refused rather than treated as permission. + | "listener_unattributable" + // The verified socket was replaced between verification and publication. The + // three predicates above cannot catch this on their own, because a different + // process under the same managed-runtime UID satisfies all of them. + | "listener_substituted" + | "manual_mapping_present" + // An operator-declared protected port was requested, or a protected entry + // changed across a mutation. Distinct from `manual_mapping_present` (which is + // about an *unleased* entry) because this denies even when the broker's own + // registry says the port is ours to reclaim (PAP-17285). + | "protected_port" + | "protected_entry_violation" + | "reservation_conflict" + | "reservation_expired" + | "primary_route_violation" + | "invalid_handle" + | "serve_parse_error" + | "cli_error" + | "cli_timeout" + | "unexpected_serve_diff" + | "quarantined" + | "rate_limited" + | "too_many_clients" + | "internal_error"; + +/** A registry lease record persisted atomically under root ownership. */ +export interface LeaseRecord { + handle: string; + runtimeId: string; + /** Peer identity bound at expose time. */ + peerUid: number; + 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. */ +export interface BrokerRegistry { + version: 1; + /** Node/boot identity; a change forces quarantine + operator reconciliation. */ + nodeIdentity: string; + generationCounter: number; + leases: LeaseRecord[]; + /** Ports quarantined after ambiguous/failed cleanup; never reused/auto-freed. */ + quarantinedPorts: number[]; +} diff --git a/packages/tailscale-https-broker/tsconfig.json b/packages/tailscale-https-broker/tsconfig.json new file mode 100644 index 0000000000..5a24989cd3 --- /dev/null +++ b/packages/tailscale-https-broker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/tailscale-https-broker/vitest.config.ts b/packages/tailscale-https-broker/vitest.config.ts new file mode 100644 index 0000000000..d4036c2516 --- /dev/null +++ b/packages/tailscale-https-broker/vitest.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { defineConfig } from "vitest/config"; + +const root = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root, + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/server/src/services/workspace-runtime-read-model.test.ts b/server/src/services/workspace-runtime-read-model.test.ts index 1de4250b07..c4184a253e 100644 --- a/server/src/services/workspace-runtime-read-model.test.ts +++ b/server/src/services/workspace-runtime-read-model.test.ts @@ -35,6 +35,9 @@ function runtimeServiceRow( stoppedAt: now, stopPolicy: null, healthStatus: "unknown", + exposure: null, + exposureHandle: null, + backendUrl: null, createdAt: now, updatedAt: now, ...overrides,