mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
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:
1 parent
6e14ed3031
commit
ec7adcaf3d
36 files changed
+3293
No files matched your search
@@ -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",
|
||||
},
|
||||
});
|
||||
Generated
+12
@@ -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':
|
||||
|
||||
Reference in new issue
Block a user