feat(runtime-exposure): least-privilege Tailscale HTTPS broker + shared contract (PAP-17051)

Foundation for opt-in `tailscale_https` exposure of managed branch runtimes,
implementing the PAP-17049 plan under the PAP-17050 threat-model verdict.

Broker (packages/tailscale-https-broker): root/dedicated-account host broker
exposing only idempotent list/expose/remove over a Unix socket. Implements the
six required verdict controls: SO_PEERCRED + UID/GID allowlist + unguessable
broker-issued lease handles (req #1); dedicated port allowlist + /proc listener
ownership/loopback proof before mutation (req #2); per-mutation transaction
(strict-parse serve JSON -> verify absence/exact-lease -> verify :443 -> one
fixed per-port op -> reread -> require only-intended-changed + :443 identical
-> atomic root-owned registry commit, quarantine-on-ambiguity) (req #3);
execve/shell=false argv built only from canonical integers + fixed tokens,
pinned CLI schema (req #4); versioned length-bounded strict-JSON protocol
(req #5); bounded append-only audit events (req #6). Deny-by-default for
Funnel/cert/Services/reset/set-config/path-handlers/:443/reserved ports.

Shared contract (packages/shared): single source of truth for exposure config,
lifecycle state (pending/ready/failed/cleanup_pending/removed), listener
ownership, and status, with strict zod validators and deterministic
app/HMR port-pair + URL derivation so DB/server/UI/runtime cannot drift.

Tests: broker 57/57, shared runtime-exposure 13/13, both typecheck clean.
Server/DB/UI lifecycle integration + docs land in follow-on commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-08-16 19:23:17 +00:00
1 parent 6e14ed3031
commit ec7adcaf3d
36 files changed
+3293

No files matched your search

+39
View File
@@ -2425,3 +2425,42 @@ 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,
DEFAULT_TAILSCALE_HTTPS_EXPOSURE,
type RuntimeExposureConfigInput,
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,
buildRuntimeExposureUrl,
buildRuntimeExposureHealthUrl,
} from "./runtime-exposure/ports.js";
@@ -0,0 +1,65 @@
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,
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("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,95 @@
/**
* 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 appPort + 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;
}
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_TAILSCALE_HTTPS_EXPOSURE,
parseRuntimeExposureConfig,
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("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,88 @@
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);
}
@@ -0,0 +1,24 @@
{
"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": "./src/main.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^22.20.1",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
}
}
@@ -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,61 @@
/**
* 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): 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)");
}
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): string[] {
assertAbsolute(binPath);
const p = assertMutablePort(port);
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): string[] {
assertAbsolute(binPath);
const p = assertMutablePort(port);
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,73 @@
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"],
generation: 1,
createdAtIso: "2026-08-11T00:00:00.000Z",
};
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,278 @@
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;
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}`);
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,
}),
) {
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: () => "2026-08-11T00:00:00.000Z",
},
});
return { core, audit };
}
const exposeReq = (runtimeId = RUNTIME_A, port = 42010): BrokerRequest => ({
op: "expose",
requestId: "req-e",
runtimeId,
listeners: [{ purpose: "app", port }],
});
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("exposes a same-number loopback listener and persists a lease", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const res = await core.handle(exposeReq(), PEER);
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]);
// Handle is persisted server-side but never appears in list output.
});
it("is idempotent: re-exposing the same port does not double-apply", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
await core.handle(exposeReq(), PEER);
const before = fake.exposeCalls;
const res = await core.handle(exposeReq(), PEER);
expect(res.ok).toBe(true);
expect(fake.exposeCalls).toBe(before); // no additional CLI mutation
});
it("rejects an unauthorized peer without mutating serve", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
const res = await core.handle(exposeReq(), { uid: 1000, gid: 987, pid: 1 });
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(exposeReq(RUNTIME_A, 8080), PEER);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("port_not_allowlisted");
});
it("rejects when the loopback listener is not owned / not loopback-only / absent (SSRF guard)", async () => {
for (const bad of [
{ present: true, loopbackOnly: false, ownerUidMatches: true },
{ present: true, loopbackOnly: true, ownerUidMatches: false },
{ present: false, loopbackOnly: true, ownerUidMatches: true },
]) {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath, () => bad);
const res = await core.handle(exposeReq(), PEER);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("listener_ownership_mismatch");
expect(fake.ports.has(42010)).toBe(false);
}
});
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(exposeReq(), 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 core.handle(exposeReq(), PEER);
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 core.handle(exposeReq(), PEER);
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(exposeReq(), 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 core.handle(exposeReq(runtime, port), PEER);
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
});
});
describe("list", () => {
it("returns caller-owned ports and never lease handles", async () => {
const fake = new FakeTailscale();
const { core } = makeCore(fake, registryPath);
await core.handle(exposeReq(), PEER);
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 core.handle(exposeReq(), PEER);
// 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 }),
nowIso: () => "2026-08-11T00:00:00.000Z",
},
});
const res = await core2.handle(exposeReq(RUNTIME_B, 42011), PEER);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.code).toBe("quarantined");
});
});
@@ -0,0 +1,432 @@
/**
* 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,
isSameNumberLoopbackEntry,
parseServeStatus,
primaryDigest,
} from "./serve-config.js";
import {
addLease,
isPortQuarantined,
loadRegistry,
nextGeneration,
quarantinePort,
removeLeaseByHandle,
saveRegistry,
} from "./registry.js";
import type {
BrokerRegistry,
BrokerRequest,
BrokerResponse,
OwnedListener,
PeerCredentials,
} from "./types.js";
/** 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;
}
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;
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) {}
/** 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 "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(): 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");
}
return registry;
}
private doExpose(
request: Extract<BrokerRequest, { op: "expose" }>,
peer: PeerCredentials,
): BrokerResponse {
const registry = this.loadRegistry();
// Pre-flight every requested port: allowlist, quarantine, and the
// immediately-before /proc ownership check (req #2).
for (const listener of request.listeners) {
if (!this.config.isAllowedPort(listener.port)) {
denied("port_not_allowlisted", `port ${listener.port} is outside the dedicated range`);
}
if (isPortQuarantined(registry, listener.port)) {
denied("quarantined", `port ${listener.port} is quarantined`);
}
const ownership = this.config.deps.verifyListenerOwnership(listener.port);
if (!ownership.present) {
denied("listener_ownership_mismatch", `no loopback listener on port ${listener.port}`);
}
if (!ownership.loopbackOnly) {
denied("listener_ownership_mismatch", `listener on ${listener.port} is not loopback-only`);
}
if (!ownership.ownerUidMatches) {
denied("listener_ownership_mismatch", `listener on ${listener.port} not owned by managed runtime`);
}
}
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 listener of request.listeners) {
const entry = before.entries.get(listener.port);
if (entry && !isSameNumberLoopbackEntry(entry, listener.port)) {
denied("manual_mapping_present", `port ${listener.port} already has a non-Paperclip mapping`);
}
}
const appliedPorts: number[] = [];
try {
for (const listener of request.listeners) {
const already = before.entries.get(listener.port);
if (already && isSameNumberLoopbackEntry(already, listener.port)) {
continue; // idempotent
}
const result = this.config.deps.runTailscale(
buildExposeArgv(this.config.tailscaleBinPath, listener.port),
);
if (result.timedOut) denied("cli_timeout", `expose ${listener.port} timed out`);
if (result.code !== 0) denied("cli_error", `expose ${listener.port} exited non-zero`);
appliedPorts.push(listener.port);
}
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);
// Only the intended ports may have changed, and each must now be an exact
// same-number loopback listener.
const diff = new Set(changedPorts(before, after));
const intended = new Set(request.listeners.map((l) => l.port));
for (const port of diff) {
if (!intended.has(port)) denied("unexpected_serve_diff", `unexpected change on port ${port}`);
}
for (const listener of request.listeners) {
if (!isSameNumberLoopbackEntry(after.entries.get(listener.port), listener.port)) {
denied("unexpected_serve_diff", `port ${listener.port} not exactly exposed`);
}
}
const handle = generateLeaseHandle();
const generation = nextGeneration(registry);
addLease(registry, {
handle,
runtimeId: request.runtimeId,
peerUid: peer.uid,
peerGid: peer.gid,
ports: request.listeners.map((l) => l.port),
purposes: request.listeners.map((l) => l.purpose) as OwnedListener["purpose"][],
generation,
createdAtIso: this.config.deps.nowIso(),
});
saveRegistry(this.config.registryPath, registry);
this.config.auditSink.write({
timestampIso: this.config.deps.nowIso(),
peer,
op: "expose",
runtimeId: request.runtimeId,
ports: request.listeners.map((l) => l.port),
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,
publicPorts: request.listeners.map((l) => l.port),
};
} 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);
throw error;
}
}
private compensateExpose(appliedPorts: number[], registry: BrokerRegistry): void {
for (const port of appliedPorts) {
let cleaned = false;
try {
const result = this.config.deps.runTailscale(
buildRemoveArgv(this.config.tailscaleBinPath, port),
);
cleaned = !result.timedOut && result.code === 0;
} catch {
cleaned = false;
}
if (!cleaned) quarantinePort(registry, port);
}
try {
saveRegistry(this.config.registryPath, registry);
} catch {
/* best effort; registry may already reflect quarantine on next load */
}
}
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;
}
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),
);
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);
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.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 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,54 @@
/**
* 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 { registryPathUnsafeReason } from "./registry.js";
export interface BrokerHostConfig {
socketPath: string;
registryPath: string;
auditPath: string;
tailscaleBinPath: string;
nodeIdentity: string;
serviceUid: number;
serviceGid: number;
runtimeUid: number;
trustSocketPermissions: boolean;
}
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"),
trustSocketPermissions: env.BROKER_TRUST_SOCKET_PERMISSIONS === "true",
};
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,88 @@
#!/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 { 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,
};
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,
isAllowedPort: defaultIsAllowedPort,
deps: {
runTailscale: createTailscaleRunner(),
verifyListenerOwnership: createProcListenerVerifier(config.runtimeUid),
nowIso: () => new Date().toISOString(),
},
});
const server = startSocketServer({
socketPath: config.socketPath,
core,
resolvePeer: createPeerResolver({
serviceUid: config.serviceUid,
serviceGid: config.serviceGid,
trustSocketPermissions: config.trustSocketPermissions,
}),
});
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,50 @@
/**
* Peer-credential resolution for accepted socket connections.
*
* Node does not expose SO_PEERCRED through a public API. The broker's PRIMARY
* access boundary is therefore the OS filesystem: the socket lives in a
* root-owned directory and is mode 0660 owned by `root:<dedicated-group>`, so
* only the allowlisted Paperclip service identity can `connect()` at all (this
* is exactly the dedicated-socket-group control in PAP-17050 verdict req #1).
*
* `createPeerResolver` returns the identity to bind leases to. If a native
* SO_PEERCRED mechanism is wired via `soPeercred`, it is used and its result is
* cross-checked against the allowlist. Otherwise the resolver falls back to the
* configured, OS-enforced identity — but ONLY when the operator has asserted
* the socket is protected by the dedicated group (`trustSocketPermissions`).
* With neither, it fails closed (connection refused).
*/
import type { Socket } from "node:net";
import type { PeerCredentials } from "./types.js";
export interface PeerResolverConfig {
/** The single OS identity the socket permissions restrict connections to. */
serviceUid: number;
serviceGid: number;
/** Operator assertion that the socket is 0660 root:<dedicated-group>. */
trustSocketPermissions: boolean;
/** Optional native SO_PEERCRED reader; returns null if 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) ?? null;
if (native) {
// Defense in depth: even with a native reader, the OS-enforced identity
// must match what we expect.
if (native.uid !== config.serviceUid || native.gid !== config.serviceGid) {
throw new PeerResolutionError("peer credentials do not match the service identity");
}
return native;
}
if (config.trustSocketPermissions) {
return { uid: config.serviceUid, gid: config.serviceGid, pid: 0 };
}
throw new PeerResolutionError(
"no peer-credential mechanism available and socket-permission trust not asserted",
);
};
}
@@ -0,0 +1,18 @@
/**
* 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;
}
@@ -0,0 +1,53 @@
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]);
});
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,130 @@
/**
* /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[];
}
/**
* 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[] = [];
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);
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);
if (isWildcardHex(row.localAddressHex) || !isLoopbackIpv6Hex(row.localAddressHex)) {
loopbackOnly = false;
}
}
return { present, loopbackOnly, uids };
}
/**
* 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),
};
};
}
@@ -0,0 +1,95 @@
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 expose request", () => {
const req = decodeRequest(
frame({
v: 1,
op: "expose",
requestId: "req-1",
runtimeId: RUNTIME,
listeners: [
{ purpose: "app", port: 42010 },
{ purpose: "vite_hmr", port: 52010 },
],
}),
);
expect(req).toEqual({
op: "expose",
requestId: "req-1",
runtimeId: RUNTIME,
listeners: [
{ purpose: "app", port: 42010 },
{ purpose: "vite_hmr", port: 52010 },
],
});
});
it("decodes 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");
});
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: "expose", requestId: "r", runtimeId: "not-a-uuid", listeners: [{ purpose: "app", port: 42010 }] })),
).toThrow(/runtime/);
expect(() =>
decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: "42010" }] })),
).toThrow();
});
it("rejects duplicate ports/purposes and too many listeners", () => {
expect(() =>
decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "app", port: 42010 }] })),
).toThrow(/duplicate/);
expect(() =>
decodeRequest(frame({ v: 1, op: "expose", requestId: "r", runtimeId: RUNTIME, listeners: [{ purpose: "app", port: 42010 }, { purpose: "vite_hmr", port: 52010 }, { purpose: "app", port: 42011 }] })),
).toThrow(/too many/);
});
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,168 @@
/**
* 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 { 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 !== "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") {
assertExactKeys(obj, ["v", "op", "requestId", "runtimeId", "handle"], requestId);
const runtimeId = requireStringField(obj, "runtimeId", UUID_RE, "invalid_runtime_id", requestId);
const handle = requireStringField(obj, "handle", HANDLE_RE, "malformed_request", requestId);
return { op: "remove", requestId, runtimeId, handle };
}
// expose
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);
}
return { op: "expose", 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,136 @@
/**
* 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");
}
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;
}
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,162 @@
/**
* 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));
}
/** 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,135 @@
/**
* 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, and bounded single-frame responses. All
* security decisions are delegated to the tested `BrokerCore`; this layer only
* frames bytes and resolves peer identity.
*/
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;
export interface SocketServerConfig {
socketPath: string;
core: BrokerCore;
resolvePeer: (socket: Socket) => PeerCredentials;
maxClients?: number;
connectionDeadlineMs?: number;
}
export function startSocketServer(config: SocketServerConfig): Server {
const maxClients = config.maxClients ?? 32;
const deadlineMs = config.connectionDeadlineMs ?? 5_000;
let active = 0;
if (existsSync(config.socketPath)) {
unlinkSync(config.socketPath);
}
const server = createServer((socket) => {
if (active >= maxClients) {
socket.destroy();
return;
}
active += 1;
let peer: PeerCredentials;
try {
peer = config.resolvePeer(socket);
} catch {
active -= 1;
socket.destroy();
return;
}
let expected = -1;
const chunks: Buffer[] = [];
let received = 0;
let done = false;
const timer = setTimeout(() => finish(), deadlineMs);
timer.unref?.();
const finish = (): void => {
if (done) return;
done = true;
clearTimeout(timer);
active -= 1;
};
socket.on("error", () => finish());
socket.on("close", () => finish());
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> => {
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);
finish();
};
});
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 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,51 @@
/**
* 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;
/** Tailscale CLI versions this broker has been validated against (pinned). */
export const SUPPORTED_TAILSCALE_VERSIONS = new Set<string>(["1.80", "1.82", "1.84"]);
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 `major.minor` prefix of `tailscale version` output. */
export function parseTailscaleMinor(versionOutput: string): string | null {
const match = /^\s*(\d+)\.(\d+)/.exec(versionOutput);
return match ? `${match[1]}.${match[2]}` : null;
}
export function isSupportedTailscaleVersion(versionOutput: string): boolean {
const minor = parseTailscaleMinor(versionOutput);
return minor !== null && SUPPORTED_TAILSCALE_VERSIONS.has(minor);
}
@@ -0,0 +1,146 @@
/**
* 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";
export type BrokerOp = "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;
}
/** An expose request body (already schema-validated). */
export interface ExposeRequest {
op: "expose";
requestId: string;
/** Runtime-service UUID the caller claims to own. */
runtimeId: string;
/** Loopback target ports to expose; each becomes a same-number HTTPS listener. */
listeners: OwnedListener[];
}
/** 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 = ExposeRequest | RemoveRequest | ListRequest;
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 =
| 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"
| "listener_ownership_mismatch"
| "manual_mapping_present"
| "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"][];
/** Monotonic generation to defeat ABA remove/re-expose confusion. */
generation: number;
createdAtIso: string;
}
/** 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",
},
});
+12
View File
@@ -714,6 +714,18 @@ importers:
specifier: ^22.20.1
version: 22.20.1
packages/tailscale-https-broker:
devDependencies:
'@types/node':
specifier: ^22.20.1
version: 22.20.1
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.1.10
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))
packages/teams-catalog:
devDependencies:
'@types/node':