feat(runtime-exposure): least-privilege Tailscale HTTPS broker, shared contract, and persisted exposure state (#11524)
<!-- Simplified Technical English (ASD-STE100). --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip starts and supervises managed runtime services for a project's execution workspaces, so an agent's branch can be previewed while it works > - Those services only listen on plain loopback HTTP. A person on another device, or on a phone, cannot open the preview > - A Tailscale HTTPS mapping solves this, but `tailscale serve` needs host privileges that the Paperclip server process must not hold > - This pull request adds the foundation only: a separate least-privilege host broker, the shared exposure contract, and the database columns that hold exposure state > - Nothing calls the broker yet, so there is no behavior change. The benefit is that the privileged surface is small, reviewable, and isolated before any lifecycle code depends on it ## Linked Issues or Issue Description No public GitHub issue exists. The change follows the feature request template. **Subsystem affected** Managed workspace runtime services, the shared type and validator package, and the database schema. **Problem or motivation** A managed runtime service binds to loopback only. There is no supported way to reach that preview from another device. Adding HTTPS directly to the server would mean the server process runs `tailscale serve`, which needs privileges far wider than the task requires. A compromised or buggy server could then map any port to the tailnet. **Proposed solution** Split the privileged work into a separate broker process with a narrow protocol, and define one shared contract that the server, the UI, the runtime, and the broker all read. Land this foundation first, with no caller, so the privileged code can be reviewed on its own. **Alternatives considered** - Call `tailscale serve` from the server process. This was rejected because it gives the server unrestricted mapping authority. - Use `sudo` for single `tailscale` commands. This was rejected because the argument list is the only guard, and it is easy to widen by accident. - Use a generic reverse proxy. This was rejected because it does not remove the need for a privileged Tailscale mapping step. **Roadmap alignment** This supports the existing managed workspace runtime capability. It adds no new product surface on its own. **Additional context** The broker is the security boundary of the feature, so it is deliberately the first slice. Three later pull requests build on it: the server exposure lifecycle, the runtime lease and recovery integration, and the leased-port mediator. ## What Changed - Add the `@paperclipai/tailscale-https-broker` workspace package. The broker listens on a unix socket, authorizes each peer with `SO_PEERCRED`, and answers a small request protocol. - Restrict what the broker will map. It accepts only same-number HTTPS-to-loopback pairs inside the Paperclip port range, refuses protected ports, and confirms that the loopback port belongs to a Paperclip-owned listener. - Parse every request with a strict JSON reader that rejects duplicate keys, prototype keys, and unknown fields. - Write an append-only audit record for each broker decision. - Add the shared exposure contract in `@paperclipai/shared`: the `RuntimeExposureConfig`, `RuntimeExposureState`, and `RuntimeExposureStatus` types, their zod validators, the app and HMR port rules, and the loopback-bind helpers. - Persist exposure state on `workspace_runtime_services` with the new `exposure` column, plus the server-private `exposure_handle` and `backend_url` columns that are never serialized to API clients. - Add the `execution_workspace_runtime_leases` table that the later lease slice uses. - Extend the runtime read-model test fixture for the three new columns. ## Verification Focused checks, all run on this branch: - `pnpm --filter @paperclipai/tailscale-https-broker test` — 12 files, 82 tests pass. This covers peer credentials, port policy, protected ports, the serve config writer, the strict JSON reader, argv parsing, and the socket server. - `pnpm --filter @paperclipai/tailscale-https-broker typecheck` — clean. - `npx vitest run --root packages/shared src/runtime-exposure src/validators/runtime-exposure.test.ts` — 3 files, 40 tests pass. - `pnpm --filter @paperclipai/db typecheck` — runs `check:migrations` first. Migration numbering and migration safety both pass. - `pnpm --filter @paperclipai/shared typecheck` — clean. - `pnpm --filter @paperclipai/ui typecheck` — clean. - `npx vitest run --root server src/services/workspace-runtime-read-model.test.ts` — 3 tests pass. - `npx tsc --noEmit -p server/tsconfig.json` — 139 errors, which is exactly the count on `master` before this branch. All 139 come from the unbuilt `@paperclipai/plugin-sdk` package. To confirm the exposure state is inert, start a managed runtime service as usual. The new columns stay null and the service behaves as it does today. ## Risks - Migration risk is low. Both migrations only add a table and three nullable columns. No column is backfilled and no existing column changes. The migration safety check passes. - Behavior risk is low. No code path calls the broker in this pull request, and the shared exposure fields are optional. - The broker is privileged, so it is the real risk surface. It is mitigated by peer-credential authorization, a fixed port range, a protected-port deny list, same-number pair enforcement, listener-ownership checks, strict JSON parsing, and an audit trail. Reviewers should read `packages/tailscale-https-broker/src/authorization.ts` and `src/port-policy.ts` closely. - The broker requires a `tailscale` version floor, which its README records. An older host CLI makes the broker refuse to start rather than map incorrectly. - `pnpm-lock.yaml` changes because a new workspace package is added. The diff is the new importer block, plus one duplicate `tinyexec` entry that pnpm removed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. ## Model Used Claude Opus 5 (`claude-opus-5`), 1M context window, extended thinking, with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
55464204a6
commit
f4802b1bbc
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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:<uuid>` or `run:<uuid>`). 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<Record<string, unknown>>(),
|
||||
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),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>(),
|
||||
// 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<RuntimeExposureStatus | null>(),
|
||||
// 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(),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 <mode>` / `--bind-host <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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, unknown> | 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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof runtimeExposureConfigSchema>;
|
||||
export type RuntimeExposureStatusInput = z.infer<typeof runtimeExposureStatusSchema>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
// 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<string, unknown>;
|
||||
const known = new Set<string>([...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<string, unknown> = { ...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);
|
||||
}
|
||||
|
|
@ -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 <paperclip-app-account>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number>;
|
||||
/** Exact set of allowed peer GIDs (the dedicated broker socket group). */
|
||||
allowedGids: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<number, string>([[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<string, unknown> = {};
|
||||
const Web: Record<string, unknown> = {};
|
||||
for (const [port, proxy] of this.ports) {
|
||||
TCP[String(port)] = { HTTPS: true };
|
||||
Web[`${HOST}:${port}`] = { Handlers: { "/": { Proxy: proxy } } };
|
||||
}
|
||||
return JSON.stringify({ TCP, Web });
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<unknown> = 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<BrokerResponse> {
|
||||
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<BrokerResponse> {
|
||||
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<BrokerRequest, { op: "reserve" }>,
|
||||
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<BrokerRequest, { op: "expose" }>,
|
||||
peer: PeerCredentials,
|
||||
): BrokerResponse {
|
||||
const registry = this.loadRegistry(false);
|
||||
let lease;
|
||||
try {
|
||||
lease = authorizeRemoval(registry.leases, request, peer);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) denied(error.code, error.message);
|
||||
throw error;
|
||||
}
|
||||
if (lease.state === "reserved" && lease.expiresAtIso && Date.parse(lease.expiresAtIso) <= Date.parse(this.config.deps.nowIso())) {
|
||||
removeLeaseByHandle(registry, lease.handle);
|
||||
saveRegistry(this.config.registryPath, registry);
|
||||
denied("reservation_expired", "reservation expired before exposure");
|
||||
}
|
||||
|
||||
// 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<number, string>();
|
||||
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=<port> 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<BrokerRequest, { op: "expose" }>,
|
||||
): 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<BrokerRequest, { op: "remove" }>,
|
||||
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<BrokerRequest, { op: "list" }>,
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <node_api.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
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)
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
};
|
||||
}
|
||||
|
|
@ -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<number>();
|
||||
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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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<number, string>([
|
||||
[443, "http://127.0.0.1:3100"],
|
||||
[MANUAL_APP, MANUAL_APP_TARGET],
|
||||
[MANUAL_HMR, MANUAL_HMR_TARGET],
|
||||
[PROTECTED_APP, `http://127.0.0.1:${PROTECTED_APP}`],
|
||||
[PROTECTED_HMR, `http://127.0.0.1:${PROTECTED_HMR}`],
|
||||
]);
|
||||
/** Funnel stays null for the whole suite; the broker has no Funnel verb. */
|
||||
funnel: unknown = null;
|
||||
failExposePort: number | null = null;
|
||||
/** Simulate a CLI that clobbers an unrelated port as a side effect. */
|
||||
strayOnRemove: number | null = null;
|
||||
exposeCalls = 0;
|
||||
removeCalls = 0;
|
||||
removedPorts: number[] = [];
|
||||
|
||||
run = (argv: string[]): CliResult => {
|
||||
const [, sub, a2, a3] = argv;
|
||||
if (sub === "serve" && a2 === "status") {
|
||||
return { code: 0, stdout: this.statusJson(), stderr: "", timedOut: false };
|
||||
}
|
||||
if (sub === "serve" && a2 === "--bg") {
|
||||
this.exposeCalls += 1;
|
||||
const port = Number(a3.replace("--https=", ""));
|
||||
if (this.failExposePort === port) {
|
||||
return { code: 1, stdout: "", stderr: "boom", timedOut: false };
|
||||
}
|
||||
this.ports.set(port, `http://127.0.0.1:${port}`);
|
||||
return { code: 0, stdout: "", stderr: "", timedOut: false };
|
||||
}
|
||||
if (sub === "serve" && a2.startsWith("--https=") && a3 === "off") {
|
||||
this.removeCalls += 1;
|
||||
const port = Number(a2.replace("--https=", ""));
|
||||
this.removedPorts.push(port);
|
||||
this.ports.delete(port);
|
||||
if (this.strayOnRemove !== null) this.ports.delete(this.strayOnRemove);
|
||||
return { code: 0, stdout: "", stderr: "", timedOut: false };
|
||||
}
|
||||
return { code: 2, stdout: "", stderr: "unknown", timedOut: false };
|
||||
};
|
||||
|
||||
private statusJson(): string {
|
||||
const TCP: Record<string, unknown> = {};
|
||||
const Web: Record<string, unknown> = {};
|
||||
for (const [port, proxy] of this.ports) {
|
||||
TCP[String(port)] = { HTTPS: true };
|
||||
Web[`${HOST}:${port}`] = { Handlers: { "/": { Proxy: proxy } } };
|
||||
}
|
||||
return JSON.stringify({ TCP, Web, AllowFunnel: this.funnel });
|
||||
}
|
||||
|
||||
/** Exactly the entries that must never move, as a comparable snapshot. */
|
||||
preservedSnapshot() {
|
||||
return {
|
||||
manualApp: this.ports.get(MANUAL_APP),
|
||||
manualHmr: this.ports.get(MANUAL_HMR),
|
||||
protectedApp: this.ports.get(PROTECTED_APP),
|
||||
protectedHmr: this.ports.get(PROTECTED_HMR),
|
||||
primary: this.ports.get(443),
|
||||
funnel: this.funnel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const PRESERVED_INTACT = {
|
||||
manualApp: MANUAL_APP_TARGET,
|
||||
manualHmr: MANUAL_HMR_TARGET,
|
||||
protectedApp: `http://127.0.0.1:${PROTECTED_APP}`,
|
||||
protectedHmr: `http://127.0.0.1:${PROTECTED_HMR}`,
|
||||
primary: "http://127.0.0.1:3100",
|
||||
funnel: null,
|
||||
};
|
||||
|
||||
function makeCore(
|
||||
fake: FakeTailscale,
|
||||
registryPath: string,
|
||||
protectedPorts: readonly number[] = [PROTECTED_APP, PROTECTED_HMR],
|
||||
ownership: (port: number) => ListenerOwnership = () => ({
|
||||
present: true,
|
||||
loopbackOnly: true,
|
||||
ownerUidMatches: true,
|
||||
inodes: ["5001"],
|
||||
}),
|
||||
) {
|
||||
const audit = new MemoryAuditSink();
|
||||
const core = new BrokerCore({
|
||||
tailscaleBinPath: BIN,
|
||||
registryPath,
|
||||
auditSink: audit,
|
||||
peerPolicy: { allowedUids: new Set([999]), allowedGids: new Set([987]) },
|
||||
nodeIdentity: "node-1",
|
||||
isAllowedPort: (port) => defaultIsAllowedPort(port) && !protectedPorts.includes(port),
|
||||
protectedPorts,
|
||||
deps: {
|
||||
runTailscale: fake.run,
|
||||
verifyListenerOwnership: ownership,
|
||||
nowIso: () => "2026-08-14T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
return { core, audit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant the exact registry state that caused the incident: a live `exposed`
|
||||
* lease the broker itself issued for the now-retired lane, still naming the
|
||||
* ports operators later declared must-preserve.
|
||||
*/
|
||||
function plantRetiredLaneLease(registryPath: string, ports: number[]) {
|
||||
const handle = "retired-lane-handle-000000000000";
|
||||
saveRegistry(registryPath, {
|
||||
version: 1,
|
||||
nodeIdentity: "node-1",
|
||||
generationCounter: 7,
|
||||
leases: [{
|
||||
handle,
|
||||
runtimeId: RUNTIME_A,
|
||||
peerUid: PEER.uid,
|
||||
peerGid: PEER.gid,
|
||||
ports,
|
||||
purposes: ports.map((_, index) => (index === 0 ? "app" : "vite_hmr")),
|
||||
state: "exposed",
|
||||
generation: 7,
|
||||
createdAtIso: "2026-08-11T12:07:18.000Z",
|
||||
expiresAtIso: null,
|
||||
}],
|
||||
quarantinedPorts: [],
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
const reserveReq = (ports: number[], runtimeId = RUNTIME_A): BrokerRequest => ({
|
||||
op: "reserve",
|
||||
requestId: "req-r",
|
||||
runtimeId,
|
||||
listeners: ports.map((port, index) => ({
|
||||
purpose: index === 0 ? "app" : "vite_hmr",
|
||||
port,
|
||||
})),
|
||||
});
|
||||
|
||||
let registryPath: string;
|
||||
beforeEach(() => {
|
||||
registryPath = join(mkdtempSync(join(tmpdir(), "broker-protected-")), "registry.json");
|
||||
});
|
||||
|
||||
describe("operator-protected ports (PAP-17285)", () => {
|
||||
it("refuses to remove a protected pair even when a valid broker lease names it", async () => {
|
||||
// The incident, reproduced: the lease is real, the handle matches, the peer
|
||||
// is authorized, and the entries are shape-valid same-number listeners. The
|
||||
// ONLY thing that can save them is the operator declaration.
|
||||
const fake = new FakeTailscale();
|
||||
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP, PROTECTED_HMR]);
|
||||
const { core, audit } = makeCore(fake, registryPath);
|
||||
|
||||
const res = await core.handle(
|
||||
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle },
|
||||
PEER,
|
||||
);
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.code).toBe("protected_port");
|
||||
// Fail-closed: denial precedes every Serve read and mutation.
|
||||
expect(fake.removeCalls).toBe(0);
|
||||
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
||||
const denial = audit.events.find((event) => event.decision === "deny");
|
||||
expect(denial?.reasonCode).toBe("protected_port");
|
||||
expect(denial?.op).toBe("remove");
|
||||
});
|
||||
|
||||
it("NEGATIVE CONTROL: the identical removal succeeds when the ports are not protected", async () => {
|
||||
// Proves the assertion above is carried by the new guard and not by some
|
||||
// unrelated precondition — without this, that test could pass vacuously.
|
||||
const fake = new FakeTailscale();
|
||||
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP, PROTECTED_HMR]);
|
||||
const { core } = makeCore(fake, registryPath, []); // no protected ports
|
||||
|
||||
const res = await core.handle(
|
||||
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle },
|
||||
PEER,
|
||||
);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok && res.op === "remove") {
|
||||
expect(res.removedPorts).toEqual([PROTECTED_APP, PROTECTED_HMR]);
|
||||
}
|
||||
// This is precisely the production loss, reproduced on demand.
|
||||
expect(fake.ports.has(PROTECTED_APP)).toBe(false);
|
||||
expect(fake.ports.has(PROTECTED_HMR)).toBe(false);
|
||||
// Even here the unrelated unknown/manual pair and the primary are untouched.
|
||||
expect(fake.ports.get(MANUAL_APP)).toBe(MANUAL_APP_TARGET);
|
||||
expect(fake.ports.get(MANUAL_HMR)).toBe(MANUAL_HMR_TARGET);
|
||||
expect(fake.ports.get(443)).toBe("http://127.0.0.1:3100");
|
||||
expect(fake.funnel).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to reserve or expose a protected port, so no lane can acquire one", async () => {
|
||||
const fake = new FakeTailscale();
|
||||
const { core } = makeCore(fake, registryPath);
|
||||
|
||||
const reserved = await core.handle(reserveReq([PROTECTED_APP, PROTECTED_HMR]), PEER);
|
||||
expect(reserved.ok).toBe(false);
|
||||
if (!reserved.ok) expect(reserved.code).toBe("protected_port");
|
||||
|
||||
// Also unreachable via a lease forged straight into the registry.
|
||||
const handle = plantRetiredLaneLease(registryPath, [PROTECTED_APP]);
|
||||
const exposed = await core.handle(
|
||||
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle },
|
||||
PEER,
|
||||
);
|
||||
expect(exposed.ok).toBe(false);
|
||||
if (!exposed.ok) expect(exposed.code).toBe("protected_port");
|
||||
|
||||
expect(fake.exposeCalls).toBe(0);
|
||||
expect(fake.removeCalls).toBe(0);
|
||||
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
||||
});
|
||||
|
||||
it("preserves both pairs across a full healthy reserve/expose/remove lifecycle", async () => {
|
||||
const fake = new FakeTailscale();
|
||||
const { core } = makeCore(fake, registryPath);
|
||||
const lanePorts = [42010, 52010];
|
||||
|
||||
const reserved = await core.handle(reserveReq(lanePorts), PEER);
|
||||
expect(reserved.ok).toBe(true);
|
||||
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
|
||||
|
||||
const exposed = await core.handle(
|
||||
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
|
||||
PEER,
|
||||
);
|
||||
expect(exposed.ok).toBe(true);
|
||||
expect(fake.ports.get(42010)).toBe("http://127.0.0.1:42010");
|
||||
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
||||
|
||||
const removed = await core.handle(
|
||||
{ op: "remove", requestId: "req-d", runtimeId: RUNTIME_A, handle: reserved.handle },
|
||||
PEER,
|
||||
);
|
||||
expect(removed.ok).toBe(true);
|
||||
expect(fake.ports.has(42010)).toBe(false);
|
||||
expect(fake.ports.has(52010)).toBe(false);
|
||||
// The whole point: only the lane's own ports moved.
|
||||
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
||||
});
|
||||
|
||||
it("preserves both pairs when expose fails midway and is compensated", async () => {
|
||||
const fake = new FakeTailscale();
|
||||
fake.failExposePort = 52010; // second port fails, first is already applied
|
||||
const { core, audit } = makeCore(fake, registryPath);
|
||||
|
||||
const reserved = await core.handle(reserveReq([42010, 52010]), PEER);
|
||||
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
|
||||
const exposed = await core.handle(
|
||||
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
|
||||
PEER,
|
||||
);
|
||||
|
||||
expect(exposed.ok).toBe(false);
|
||||
if (!exposed.ok) expect(exposed.code).toBe("cli_error");
|
||||
// Compensation rolled back the partial application...
|
||||
expect(fake.ports.has(42010)).toBe(false);
|
||||
// ...and touched nothing it did not apply.
|
||||
expect(fake.removedPorts).toEqual([42010]);
|
||||
expect(fake.preservedSnapshot()).toEqual(PRESERVED_INTACT);
|
||||
|
||||
// Compensation is a Serve mutation, so it must leave a durable record
|
||||
// (req #6). Before this change it emitted none at all.
|
||||
const compensation = audit.events.find((event) => event.recovery === "cleanup" && event.op === "expose");
|
||||
expect(compensation?.reason).toBe("expose compensated");
|
||||
});
|
||||
|
||||
it("detects and quarantines when compensation collaterally changes an unrelated entry", async () => {
|
||||
// Requirement #3: any unrelated Serve change must be DETECTED. This path
|
||||
// previously trusted the rollback exit code and re-read nothing.
|
||||
const fake = new FakeTailscale();
|
||||
fake.failExposePort = 52010;
|
||||
fake.strayOnRemove = MANUAL_APP; // rollback clobbers an unknown/manual entry
|
||||
const { core, audit } = makeCore(fake, registryPath);
|
||||
|
||||
const reserved = await core.handle(reserveReq([42010, 52010]), PEER);
|
||||
if (!reserved.ok || reserved.op !== "reserve") throw new Error("reserve failed");
|
||||
const exposed = await core.handle(
|
||||
{ op: "expose", requestId: "req-x", runtimeId: RUNTIME_A, handle: reserved.handle },
|
||||
PEER,
|
||||
);
|
||||
|
||||
// The original failure is still the reported error — never masked.
|
||||
expect(exposed.ok).toBe(false);
|
||||
if (!exposed.ok) expect(exposed.code).toBe("cli_error");
|
||||
|
||||
// The collateral damage is detected, recorded, and the port quarantined so
|
||||
// it is never silently reused. Recoverability preserved, loss surfaced.
|
||||
const denial = audit.events.find((event) => event.reason.startsWith("expose compensation unverified"));
|
||||
expect(denial).toBeDefined();
|
||||
expect(denial?.reason).toContain(`unexpected_serve_diff:${MANUAL_APP}`);
|
||||
expect(denial?.recovery).toBe("quarantine");
|
||||
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
||||
expect(registry.quarantinedPorts).toContain(42010);
|
||||
});
|
||||
|
||||
it("detects a protected entry that DISAPPEARS, not just one that is retargeted", () => {
|
||||
// The incident was a deletion. `entryDigest(undefined)` is the `"absent"`
|
||||
// sentinel precisely so removal is as loud as retargeting.
|
||||
const withBoth = parseServeStatus(JSON.parse(new FakeTailscale().run([BIN, "serve", "status", "--json"]).stdout));
|
||||
const missing = new FakeTailscale();
|
||||
missing.ports.delete(PROTECTED_APP);
|
||||
const withoutOne = parseServeStatus(JSON.parse(missing.run([BIN, "serve", "status", "--json"]).stdout));
|
||||
|
||||
expect(changedProtectedPorts(withBoth, withoutOne, [PROTECTED_APP, PROTECTED_HMR])).toEqual([PROTECTED_APP]);
|
||||
expect(changedProtectedPorts(withBoth, withBoth, [PROTECTED_APP, PROTECTED_HMR])).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to build a mutating argv for a protected port", () => {
|
||||
const guarded = [PROTECTED_APP, PROTECTED_HMR];
|
||||
expect(() => buildExposeArgv(BIN, PROTECTED_APP, guarded)).toThrow(/operator-protected/);
|
||||
expect(() => buildRemoveArgv(BIN, PROTECTED_HMR, guarded)).toThrow(/operator-protected/);
|
||||
// Unprotected ports in the dedicated range still build exactly as before.
|
||||
expect(buildRemoveArgv(BIN, 42010, guarded)).toEqual([BIN, "serve", "--https=42010", "off"]);
|
||||
expect(buildExposeArgv(BIN, 42010, guarded)).toEqual([
|
||||
BIN, "serve", "--bg", "--https=42010", "http://127.0.0.1:42010",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses BROKER_PROTECTED_PORTS fail-closed", () => {
|
||||
expect(parseProtectedPorts(undefined)).toEqual([]);
|
||||
expect(parseProtectedPorts("")).toEqual([]);
|
||||
expect(parseProtectedPorts("52000,42000")).toEqual([42000, 52000]);
|
||||
expect(parseProtectedPorts("42000 52000")).toEqual([42000, 52000]);
|
||||
expect(parseProtectedPorts("42000,42000")).toEqual([42000]);
|
||||
// A malformed list must stop the broker, not silently protect nothing.
|
||||
expect(() => parseProtectedPorts("42000,abc")).toThrow(/non-numeric/);
|
||||
expect(() => parseProtectedPorts("70000")).toThrow(/out-of-range/);
|
||||
expect(() => parseProtectedPorts("443")).toThrow(/must not list 443/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<OwnedListener["purpose"]> = 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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new ProtocolError("malformed_request", "request must be a JSON object");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireStringField(
|
||||
obj: Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<number> {
|
||||
const ports = new Set<number>();
|
||||
for (const lease of registry.leases) {
|
||||
for (const port of lease.ports) ports.add(port);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
|
@ -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<number, string | null>): unknown {
|
||||
const TCP: Record<string, unknown> = {};
|
||||
const Web: Record<string, unknown> = {};
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number, ServeEntry>;
|
||||
}
|
||||
|
||||
export class ServeParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ServeParseError";
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
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<number, ServeEntry>();
|
||||
|
||||
const tcp = json.TCP;
|
||||
const httpsPorts = new Set<number>();
|
||||
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<number>([...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);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ClientAdmissionController } from "./socket-server.js";
|
||||
|
||||
function admission(overrides: Partial<ConstructorParameters<typeof ClientAdmissionController>[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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number, number>();
|
||||
|
||||
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<void> => {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>).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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown> {
|
||||
this.expect("{");
|
||||
const obj: Record<string, unknown> = {};
|
||||
const seen = new Set<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -35,6 +35,9 @@ function runtimeServiceRow(
|
|||
stoppedAt: now,
|
||||
stopPolicy: null,
|
||||
healthStatus: "unknown",
|
||||
exposure: null,
|
||||
exposureHandle: null,
|
||||
backendUrl: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
|
|
|
|||
Loading…
Reference in New Issue