Files
PaperClipAI/packages/tailscale-https-broker/src/broker-core.ts
T
Dotta f4802b1bbc feat(runtime-exposure): least-privilege Tailscale HTTPS broker, shared contract, and persisted exposure state (#11524)
<!-- Simplified Technical English (ASD-STE100). -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip starts and supervises managed runtime services for a
project's execution workspaces, so an agent's branch can be previewed
while it works
> - Those services only listen on plain loopback HTTP. A person on
another device, or on a phone, cannot open the preview
> - A Tailscale HTTPS mapping solves this, but `tailscale serve` needs
host privileges that the Paperclip server process must not hold
> - This pull request adds the foundation only: a separate
least-privilege host broker, the shared exposure contract, and the
database columns that hold exposure state
> - Nothing calls the broker yet, so there is no behavior change. The
benefit is that the privileged surface is small, reviewable, and
isolated before any lifecycle code depends on it

## Linked Issues or Issue Description

No public GitHub issue exists. The change follows the feature request
template.

**Subsystem affected**

Managed workspace runtime services, the shared type and validator
package, and the database schema.

**Problem or motivation**

A managed runtime service binds to loopback only. There is no supported
way to reach that preview from another device. Adding HTTPS directly to
the server would mean the server process runs `tailscale serve`, which
needs privileges far wider than the task requires. A compromised or
buggy server could then map any port to the tailnet.

**Proposed solution**

Split the privileged work into a separate broker process with a narrow
protocol, and define one shared contract that the server, the UI, the
runtime, and the broker all read. Land this foundation first, with no
caller, so the privileged code can be reviewed on its own.

**Alternatives considered**

- Call `tailscale serve` from the server process. This was rejected
because it gives the server unrestricted mapping authority.
- Use `sudo` for single `tailscale` commands. This was rejected because
the argument list is the only guard, and it is easy to widen by
accident.
- Use a generic reverse proxy. This was rejected because it does not
remove the need for a privileged Tailscale mapping step.

**Roadmap alignment**

This supports the existing managed workspace runtime capability. It adds
no new product surface on its own.

**Additional context**

The broker is the security boundary of the feature, so it is
deliberately the first slice. Three later pull requests build on it: the
server exposure lifecycle, the runtime lease and recovery integration,
and the leased-port mediator.

## What Changed

- Add the `@paperclipai/tailscale-https-broker` workspace package. The
broker listens on a unix socket, authorizes each peer with
`SO_PEERCRED`, and answers a small request protocol.
- Restrict what the broker will map. It accepts only same-number
HTTPS-to-loopback pairs inside the Paperclip port range, refuses
protected ports, and confirms that the loopback port belongs to a
Paperclip-owned listener.
- Parse every request with a strict JSON reader that rejects duplicate
keys, prototype keys, and unknown fields.
- Write an append-only audit record for each broker decision.
- Add the shared exposure contract in `@paperclipai/shared`: the
`RuntimeExposureConfig`, `RuntimeExposureState`, and
`RuntimeExposureStatus` types, their zod validators, the app and HMR
port rules, and the loopback-bind helpers.
- Persist exposure state on `workspace_runtime_services` with the new
`exposure` column, plus the server-private `exposure_handle` and
`backend_url` columns that are never serialized to API clients.
- Add the `execution_workspace_runtime_leases` table that the later
lease slice uses.
- Extend the runtime read-model test fixture for the three new columns.

## Verification

Focused checks, all run on this branch:

- `pnpm --filter @paperclipai/tailscale-https-broker test` — 12 files,
82 tests pass. This covers peer credentials, port policy, protected
ports, the serve config writer, the strict JSON reader, argv parsing,
and the socket server.
- `pnpm --filter @paperclipai/tailscale-https-broker typecheck` — clean.
- `npx vitest run --root packages/shared src/runtime-exposure
src/validators/runtime-exposure.test.ts` — 3 files, 40 tests pass.
- `pnpm --filter @paperclipai/db typecheck` — runs `check:migrations`
first. Migration numbering and migration safety both pass.
- `pnpm --filter @paperclipai/shared typecheck` — clean.
- `pnpm --filter @paperclipai/ui typecheck` — clean.
- `npx vitest run --root server
src/services/workspace-runtime-read-model.test.ts` — 3 tests pass.
- `npx tsc --noEmit -p server/tsconfig.json` — 139 errors, which is
exactly the count on `master` before this branch. All 139 come from the
unbuilt `@paperclipai/plugin-sdk` package.

To confirm the exposure state is inert, start a managed runtime service
as usual. The new columns stay null and the service behaves as it does
today.

## Risks

- Migration risk is low. Both migrations only add a table and three
nullable columns. No column is backfilled and no existing column
changes. The migration safety check passes.
- Behavior risk is low. No code path calls the broker in this pull
request, and the shared exposure fields are optional.
- The broker is privileged, so it is the real risk surface. It is
mitigated by peer-credential authorization, a fixed port range, a
protected-port deny list, same-number pair enforcement,
listener-ownership checks, strict JSON parsing, and an audit trail.
Reviewers should read
`packages/tailscale-https-broker/src/authorization.ts` and
`src/port-policy.ts` closely.
- The broker requires a `tailscale` version floor, which its README
records. An older host CLI makes the broker refuse to start rather than
map incorrectly.
- `pnpm-lock.yaml` changes because a new workspace package is added. The
diff is the new importer block, plus one duplicate `tinyexec` entry that
pnpm removed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR.

## Model Used

Claude Opus 5 (`claude-opus-5`), 1M context window, extended thinking,
with tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-17 05:54:12 -04:00

828 lines
32 KiB
TypeScript

/**
* Broker transaction core. One serialized, fail-closed transaction per
* mutation implements every PAP-17050 verdict requirement and invariant:
*
* - Complete peer mediation + lease-handle ownership (req #1).
* - Dedicated-range + immediately-before /proc listener-ownership check
* defeats SSRF-equivalent publication of unrelated loopback services (#2).
* - Read → strict-parse → verify absence-or-exact-lease-match → verify
* protected :443 → one fixed per-port op → reread → require only the
* intended entry changed and :443 identical → atomic registry commit; any
* ambiguity quarantines and fails closed (#3).
* - Argv-only, shell:false CLI via an injected runner (#4).
* - Bounded, serialized mutation queue (#5).
* - One audit event per allow/deny/outcome (#6).
*/
import {
AuthorizationError,
authorizePeer,
authorizeRemoval,
generateLeaseHandle,
type PeerPolicy,
} from "./authorization.js";
import { buildExposeArgv, buildRemoveArgv, buildStatusArgv } from "./argv.js";
import type { AuditSink } from "./audit.js";
import {
ParsedServe,
ServeParseError,
assertPrimaryIntact,
changedPorts,
changedProtectedPorts,
isSameNumberLoopbackEntry,
parseServeStatus,
primaryDigest,
} from "./serve-config.js";
import {
addLease,
isPortQuarantined,
loadRegistry,
nextGeneration,
pruneExpiredReservations,
quarantinePort,
removeLeaseByHandle,
saveRegistry,
} from "./registry.js";
import type {
BrokerRegistry,
BrokerRequest,
BrokerResponse,
PeerCredentials,
} from "./types.js";
const RESERVATION_TTL_MS = 5 * 60 * 1_000;
/** Result of running a tailscale CLI command (argv, shell:false). */
export interface CliResult {
code: number;
stdout: string;
stderr: string;
timedOut: boolean;
}
/**
* Immediately-before-mutation ownership facts about a loopback listener,
* derived from /proc (req #2). `loopbackOnly` must be true (reject wildcard,
* IPv6-wildcard, dual-stack off-loopback); `ownerUidMatches` binds the listener
* to the expected managed-runtime identity; `present` guards the swap race.
*/
export interface ListenerOwnership {
present: boolean;
loopbackOnly: boolean;
ownerUidMatches: boolean;
/**
* Socket inodes of the listening sockets on the port, sorted. This is the
* listener's *identity*, and it is required rather than optional: a caller
* that cannot name the socket must fail closed, not fall through to
* "permitted". `present: true` with an empty array is contradictory and is
* refused as `listener_unattributable`.
*
* The three booleans above only describe "something acceptable is on this
* port". They are re-read but cannot detect substitution, because a different
* process under the same managed-runtime UID satisfies all three. Comparing
* inode sets across the reserve/expose window is what proves the socket the
* broker verified is the socket it publishes.
*/
inodes: string[];
}
export interface BrokerDeps {
runTailscale(argv: string[]): CliResult;
/** Inspect a loopback listener immediately before mutation (req #2). */
verifyListenerOwnership(port: number): ListenerOwnership;
nowIso(): string;
}
export interface BrokerCoreConfig {
tailscaleBinPath: string;
registryPath: string;
auditSink: AuditSink;
peerPolicy: PeerPolicy;
/** hostname + boot id; a change forces quarantine + operator reconciliation. */
nodeIdentity: string;
/** Deny-by-default port allowlist. Defaults to the dedicated runtime range. */
isAllowedPort(port: number): boolean;
/**
* Operator-declared ports that must never be created, removed, or reclaimed
* (PAP-17285). Outranks the broker's own ownership record: a protected port is
* denied even when a valid lease names it, which is exactly the case that
* destroyed `42000/52000`. Defaults to none.
*/
protectedPorts?: readonly number[];
deps: BrokerDeps;
}
function denied(code: string, message: string): never {
const err = new Error(message) as Error & { brokerCode: string };
err.brokerCode = code;
throw err;
}
export class BrokerCore {
private queue: Promise<unknown> = Promise.resolve();
constructor(private readonly config: BrokerCoreConfig) {}
private get protectedPorts(): readonly number[] {
return this.config.protectedPorts ?? [];
}
/**
* Deny any request naming an operator-protected port, before anything reads or
* mutates Serve (PAP-17285). Deliberately checked ahead of the lease/ownership
* logic: the protection must hold *because* the operator declared it, not
* because the broker happens to lack a lease for the port.
*/
private assertNoProtectedPort(ports: readonly number[]): void {
for (const port of ports) {
if (this.protectedPorts.includes(port)) {
denied("protected_port", `port ${port} is operator-protected and may not be mutated`);
}
}
}
/**
* Assert every protected entry is byte-identical across a mutation. Any change
* — including disappearance — fails closed. Callers must run this on the same
* `before`/`after` snapshots used for the primary-route check.
*/
private assertProtectedIntact(before: ParsedServe, after: ParsedServe): void {
const changed = changedProtectedPorts(before, after, this.protectedPorts);
if (changed.length > 0) {
denied(
"protected_entry_violation",
`operator-protected entries changed during mutation: ${changed.join(",")}`,
);
}
}
/** Public entry point. Serializes all requests through one mutation queue. */
async handle(request: BrokerRequest, peer: PeerCredentials): Promise<BrokerResponse> {
const run = this.queue.then(() => this.dispatch(request, peer));
// Keep the chain alive even if this request rejects.
this.queue = run.catch(() => undefined);
return run;
}
private async dispatch(request: BrokerRequest, peer: PeerCredentials): Promise<BrokerResponse> {
try {
authorizePeer(peer, this.config.peerPolicy);
} catch (error) {
return this.fail(request.requestId, peer, request.op, "unauthorized_peer", error);
}
try {
switch (request.op) {
case "list":
return this.doList(request, peer);
case "reserve":
return this.doReserve(request, peer);
case "expose":
return this.doExpose(request, peer);
case "remove":
return this.doRemove(request, peer);
}
} catch (error) {
const code = (error as { brokerCode?: string }).brokerCode ?? codeForError(error);
return this.fail(request.requestId, peer, request.op, code, error);
}
}
private readServe(): ParsedServe {
const result = this.config.deps.runTailscale(buildStatusArgv(this.config.tailscaleBinPath));
if (result.timedOut) denied("cli_timeout", "serve status timed out");
if (result.code !== 0) denied("cli_error", "serve status exited non-zero");
let json: unknown;
try {
json = JSON.parse(result.stdout);
} catch {
denied("serve_parse_error", "serve status returned invalid JSON");
}
try {
return parseServeStatus(json);
} catch (error) {
if (error instanceof ServeParseError) denied("serve_parse_error", error.message);
throw error;
}
}
private loadRegistry(pruneReservations = true): BrokerRegistry {
const registry = loadRegistry(this.config.registryPath, this.config.nodeIdentity);
// Boot/node identity change forces quarantine + operator reconciliation.
if (registry.nodeIdentity !== this.config.nodeIdentity) {
denied("quarantined", "node identity changed; operator reconciliation required");
}
if (pruneReservations && pruneExpiredReservations(registry, this.config.deps.nowIso()).length > 0) {
saveRegistry(this.config.registryPath, registry);
}
return registry;
}
private doReserve(
request: Extract<BrokerRequest, { op: "reserve" }>,
peer: PeerCredentials,
): BrokerResponse {
const registry = this.loadRegistry();
// Protected ports are never allocatable, so a lane can never acquire a lease
// on one and no later lifecycle op can reach it (PAP-17285).
this.assertNoProtectedPort(request.listeners.map((listener) => listener.port));
for (const listener of request.listeners) {
if (!this.config.isAllowedPort(listener.port)) {
denied("port_not_allowlisted", `port ${listener.port} is outside the dedicated range`);
}
if (isPortQuarantined(registry, listener.port)) {
denied("quarantined", `port ${listener.port} is quarantined`);
}
}
const requestedPorts = request.listeners.map((listener) => listener.port);
const requestedPurposes = request.listeners.map((listener) => listener.purpose);
const existingForRuntime = registry.leases.find((lease) =>
lease.runtimeId === request.runtimeId
&& lease.peerUid === peer.uid
&& lease.peerGid === peer.gid
&& sameNumbers(lease.ports, requestedPorts)
&& sameStrings(lease.purposes, requestedPurposes));
if (existingForRuntime) {
return {
ok: true,
op: "reserve",
requestId: request.requestId,
handle: existingForRuntime.handle,
reservedPorts: [...existingForRuntime.ports],
};
}
for (const lease of registry.leases) {
if (lease.ports.some((port) => requestedPorts.includes(port))) {
denied("reservation_conflict", "a requested port is reserved by another runtime");
}
}
const serve = this.readServe();
assertPrimaryIntact(serve);
for (const port of requestedPorts) {
if (serve.entries.has(port)) {
denied("manual_mapping_present", `port ${port} already has a Serve mapping`);
}
}
const createdAtIso = this.config.deps.nowIso();
const handle = generateLeaseHandle();
addLease(registry, {
handle,
runtimeId: request.runtimeId,
peerUid: peer.uid,
peerGid: peer.gid,
ports: requestedPorts,
purposes: requestedPurposes,
state: "reserved",
generation: nextGeneration(registry),
createdAtIso,
expiresAtIso: new Date(Date.parse(createdAtIso) + RESERVATION_TTL_MS).toISOString(),
});
saveRegistry(this.config.registryPath, registry);
this.config.auditSink.write({
timestampIso: this.config.deps.nowIso(),
peer,
op: "reserve",
runtimeId: request.runtimeId,
ports: requestedPorts,
requestId: request.requestId,
decision: "allow",
reasonCode: "ok",
reason: "reserved",
beforeDigest: primaryDigest(serve),
afterDigest: primaryDigest(serve),
cliExitCategory: "ok",
recovery: "none",
});
return { ok: true, op: "reserve", requestId: request.requestId, handle, reservedPorts: requestedPorts };
}
/**
* Prove the port carries an acceptable, attributable managed listener, or
* deny. Each predicate keeps its own reason code so a deployed failure can be
* attributed to a missing listener, an off-loopback bind, a foreign owner, or
* a socket the broker could not name.
*/
private verifyListener(port: number): ListenerOwnership {
const ownership = this.config.deps.verifyListenerOwnership(port);
if (!ownership.present) {
denied("listener_absent", `no loopback listener on port ${port}`);
}
if (!ownership.loopbackOnly) {
denied("listener_not_loopback", `listener on ${port} is not loopback-only`);
}
if (!ownership.ownerUidMatches) {
denied("listener_ownership_mismatch", `listener on ${port} not owned by managed runtime`);
}
// Present but unnameable is not permission. Without a socket identity the
// broker cannot prove that the socket it publishes is the socket it
// verified, so it refuses rather than publishing on trust.
if (ownership.inodes.length === 0) {
denied("listener_unattributable", `listener on ${port} has no identifiable socket`);
}
return ownership;
}
/**
* Re-prove that `port` still carries the exact socket verified earlier. A
* changed identity means the listener was substituted inside the window, so
* publishing would expose a service the broker never authorized.
*/
private assertListenerUnchanged(port: number, expected: string | undefined): void {
if (expected === undefined) {
denied("listener_substituted", `port ${port} was not verified before mutation`);
}
const current = listenerIdentity(this.verifyListener(port));
if (current !== expected) {
denied(
"listener_substituted",
`listener on ${port} changed after verification; refusing to expose a substituted service`,
);
}
}
/**
* Current socket identity without denying on a failed predicate. Used by the
* post-publication sweep, which must classify *every* port before it throws:
* an absent or unnameable listener is as much a substitution as a swapped one,
* and each case still needs the mapping withdrawn.
*
* SCOPE. Every check here is point-in-time, and it bounds the transaction only.
* A same-UID process can still replace a listener *after* a successful expose
* returns, while the Serve mapping persists. No check inside this transaction
* can close that, because the mapping outlives the transaction; the broker has
* no way to pin a Serve entry to a socket. That case is a lifecycle concern and
* is handled above the broker: the server re-verifies listener ownership on
* every readiness and health check, and reconciliation fails closed when a
* reserved pair is held by a different execution workspace. What this
* transaction guarantees is narrower and worth stating plainly — a *successful*
* expose published the socket it verified, and a failed one leaves nothing of
* ours published.
*/
private currentListenerIdentity(port: number): string {
try {
return listenerIdentity(this.config.deps.verifyListenerOwnership(port));
} catch {
return "";
}
}
private doExpose(
request: Extract<BrokerRequest, { op: "expose" }>,
peer: PeerCredentials,
): BrokerResponse {
const registry = this.loadRegistry(false);
let lease;
try {
lease = authorizeRemoval(registry.leases, request, peer);
} catch (error) {
if (error instanceof AuthorizationError) denied(error.code, error.message);
throw error;
}
if (lease.state === "reserved" && lease.expiresAtIso && Date.parse(lease.expiresAtIso) <= Date.parse(this.config.deps.nowIso())) {
removeLeaseByHandle(registry, lease.handle);
saveRegistry(this.config.registryPath, registry);
denied("reservation_expired", "reservation expired before exposure");
}
// A protected port must be refused even when a previously-issued lease names
// it, so an operator declaration made *after* a lease existed still holds.
this.assertNoProtectedPort(lease.ports);
// Captured before the transaction can promote the lease: true means a prior
// expose already published these ports, so the broker owns whatever mapping
// is on them. This is the provenance the withdrawal path uses.
const leaseWasExposed = lease.state === "exposed";
// Pre-flight every requested port: allowlist, quarantine, and the
// immediately-before /proc ownership check (req #2).
//
// The verified socket identity per port is retained, because the checks
// below are not the last thing to happen before Serve is mutated: reading
// Serve status runs a `tailscale` subprocess, which is unbounded wall-clock
// time during which the verified listener can close and another process can
// take the port. Every mutation therefore re-proves this identity.
const verifiedIdentities = new Map<number, string>();
for (const port of lease.ports) {
if (!this.config.isAllowedPort(port)) {
denied("port_not_allowlisted", `port ${port} is outside the dedicated range`);
}
if (isPortQuarantined(registry, port)) {
denied("quarantined", `port ${port} is quarantined`);
}
// Each predicate gets its own code. Enforcement is unchanged — every
// branch below still denies, before anything reads or mutates Serve — but
// the caller can now tell which predicate failed. Previously all three
// returned `listener_ownership_mismatch` and only the root-owned audit
// file carried the reason, so a deployed failure could not be attributed
// to a missing listener, an off-loopback bind, or a foreign owner.
const ownership = this.verifyListener(port);
verifiedIdentities.set(port, listenerIdentity(ownership));
}
const before = this.readServe();
assertPrimaryIntact(before);
const beforePrimary = primaryDigest(before);
// Each target port must be absent or already exactly our same-number entry
// (idempotent re-expose). A manual/unknown entry is never touched.
for (const port of lease.ports) {
const entry = before.entries.get(port);
if (entry && !isSameNumberLoopbackEntry(entry, port)) {
denied("manual_mapping_present", `port ${port} already has a non-Paperclip mapping`);
}
}
const appliedPorts: number[] = [];
try {
for (const port of lease.ports) {
const already = before.entries.get(port);
if (already && isSameNumberLoopbackEntry(already, port)) {
continue; // idempotent
}
// Immediately before this port's mutation, and again after the whole
// batch below. Checking only once before `readServe()` left the verified
// socket free to be replaced during that subprocess.
this.assertListenerUnchanged(port, verifiedIdentities.get(port));
const result = this.config.deps.runTailscale(
buildExposeArgv(this.config.tailscaleBinPath, port, this.protectedPorts),
);
if (result.timedOut) denied("cli_timeout", `expose ${port} timed out`);
if (result.code !== 0) denied("cli_error", `expose ${port} exited non-zero`);
appliedPorts.push(port);
}
const after = this.readServe();
// Digest equality (vs the known-good `before`) is the strongest primary
// check: any retarget, removal, or structural change fails closed here as
// a primary_route_violation before any weaker classification runs.
if (primaryDigest(after) !== beforePrimary) {
denied("primary_route_violation", "protected :443 route changed during expose");
}
assertPrimaryIntact(after);
this.assertProtectedIntact(before, after);
// Only the intended ports may have changed, and each must now be an exact
// same-number loopback listener.
const diff = new Set(changedPorts(before, after));
const intended = new Set(lease.ports);
for (const port of diff) {
if (!intended.has(port)) denied("unexpected_serve_diff", `unexpected change on port ${port}`);
}
for (const port of lease.ports) {
if (!isSameNumberLoopbackEntry(after.entries.get(port), port)) {
denied("unexpected_serve_diff", `port ${port} not exactly exposed`);
}
}
// Final proof, after the last mutation and the status read that follows
// it: every published port must still carry the socket that was verified.
//
// Classify all ports before throwing. A port skipped above as idempotent
// was never added to `appliedPorts`, so denying alone would leave its
// pre-existing mapping active and now pointing at the replacement service.
// Any port whose identity no longer matches is therefore made eligible for
// withdrawal, whether this request published it or found it already
// correct. Which of those ports are ours to withdraw is decided below from
// the registry, not from the shape of the Serve entry.
const substituted = lease.ports.filter(
(port) => this.currentListenerIdentity(port) !== verifiedIdentities.get(port),
);
if (substituted.length > 0) {
// Provenance for a withdrawal comes from the broker's own registry, not
// from the shape of a Serve entry. An already-`exposed` lease means the
// broker published these ports itself, so they are ours to withdraw — and
// that holds even if Serve cannot be read at this moment, which is why
// this does not depend on a live status read that might fail and silently
// preserve the substituted mapping. A still-`reserved` lease published
// nothing, so there is nothing of ours to withdraw.
//
// Shape alone would be the wrong test in both directions: it cannot prove
// an identically-shaped entry is ours, and it cannot be evaluated at all
// when the status read fails. An operator who needs a mapping to survive
// managed lifecycle declares the port protected, which is refused far
// above this point.
if (leaseWasExposed) {
for (const port of substituted) {
if (!appliedPorts.includes(port)) appliedPorts.push(port);
}
}
denied(
"listener_substituted",
`listener on ${substituted.join(",")} changed after verification; withdrawing the mapping`,
);
}
lease.state = "exposed";
lease.expiresAtIso = null;
saveRegistry(this.config.registryPath, registry);
this.config.auditSink.write({
timestampIso: this.config.deps.nowIso(),
peer,
op: "expose",
runtimeId: request.runtimeId,
ports: lease.ports,
requestId: request.requestId,
decision: "allow",
reasonCode: "ok",
reason: "exposed",
beforeDigest: beforePrimary,
afterDigest: primaryDigest(after),
cliExitCategory: "ok",
recovery: "none",
});
return {
ok: true,
op: "expose",
requestId: request.requestId,
handle: lease.handle,
publicPorts: [...lease.ports],
};
} catch (error) {
// Partial success is not healthy: compensate by removing only the exact
// entries we applied; if compensation cannot be proven, quarantine.
this.compensateExpose(appliedPorts, registry, before, peer, request);
throw error;
}
}
/**
* Roll back a partially-applied expose.
*
* This path used to mutate Serve with no verification and no audit event at
* all: it fired one `--https=<port> off` per applied port, trusted the exit
* code, and returned. That made it the one broker mutation where an unrelated
* Serve change was structurally undetectable, and where a mutation left no
* durable record (PAP-17285 requirement #3, and req #6 which mandates one
* audit event per mutation outcome).
*
* It now re-reads Serve and proves three things against the pre-mutation
* snapshot: the primary route is intact, every protected entry is unchanged,
* and nothing outside the compensated set changed. Any port it cannot prove
* clean is quarantined, so recoverability is preserved rather than traded away.
* Compensation never throws — the original failure is the caller's error and
* must not be masked — but it can no longer fail silently either.
*/
private compensateExpose(
appliedPorts: number[],
registry: BrokerRegistry,
before: ParsedServe,
peer: PeerCredentials,
request: Extract<BrokerRequest, { op: "expose" }>,
): void {
const unproven: number[] = [];
for (const port of appliedPorts) {
let cleaned = false;
try {
const result = this.config.deps.runTailscale(
buildRemoveArgv(this.config.tailscaleBinPath, port, this.protectedPorts),
);
cleaned = !result.timedOut && result.code === 0;
} catch {
cleaned = false;
}
if (!cleaned) {
quarantinePort(registry, port);
unproven.push(port);
}
}
// Verify the rollback actually restored the pre-mutation state. A failure
// here is a containment failure, not a cleanup detail, so every port we
// touched is quarantined even if its own `off` reported success.
let verifyFailure: string | null = null;
try {
const after = this.readServe();
if (primaryDigest(after) !== primaryDigest(before)) {
verifyFailure = "primary_route_violation";
} else {
const protectedChanged = changedProtectedPorts(before, after, this.protectedPorts);
if (protectedChanged.length > 0) {
verifyFailure = `protected_entry_violation:${protectedChanged.join(",")}`;
} else {
const compensated = new Set(appliedPorts);
const stray = changedPorts(before, after).filter((port) => !compensated.has(port));
if (stray.length > 0) verifyFailure = `unexpected_serve_diff:${stray.join(",")}`;
}
}
} catch (error) {
// Could not even read Serve back — treat as unproven, never as clean.
verifyFailure = `unverifiable:${(error as { brokerCode?: string }).brokerCode ?? "read_failed"}`;
}
if (verifyFailure) {
for (const port of appliedPorts) {
quarantinePort(registry, port);
if (!unproven.includes(port)) unproven.push(port);
}
}
try {
saveRegistry(this.config.registryPath, registry);
} catch {
/* best effort; registry may already reflect quarantine on next load */
}
try {
this.config.auditSink.write({
timestampIso: this.config.deps.nowIso(),
peer,
op: "expose",
runtimeId: request.runtimeId,
ports: appliedPorts,
requestId: request.requestId,
decision: verifyFailure || unproven.length > 0 ? "deny" : "allow",
reasonCode: verifyFailure ? "unexpected_serve_diff" : "ok",
reason: verifyFailure
? `expose compensation unverified: ${verifyFailure}`
: unproven.length > 0
? `expose compensation could not clean ports: ${unproven.join(",")}`
: "expose compensated",
cliExitCategory: verifyFailure || unproven.length > 0 ? "error" : "ok",
recovery: unproven.length > 0 ? "quarantine" : "cleanup",
});
} catch {
/* never let an audit failure mask the original expose error */
}
}
private doRemove(
request: Extract<BrokerRequest, { op: "remove" }>,
peer: PeerCredentials,
): BrokerResponse {
const registry = this.loadRegistry();
let lease;
try {
lease = authorizeRemoval(registry.leases, request, peer);
} catch (error) {
if (error instanceof AuthorizationError) denied(error.code, error.message);
throw error;
}
// Refuse before any Serve read or mutation. This is the exact clause whose
// absence let an authorized, shape-valid removal destroy `42000/52000`: the
// lease was genuinely the broker's own, so nothing else in this path could
// object (PAP-17285).
this.assertNoProtectedPort(lease.ports);
if (lease.state === "reserved") {
removeLeaseByHandle(registry, lease.handle);
saveRegistry(this.config.registryPath, registry);
this.config.auditSink.write({
timestampIso: this.config.deps.nowIso(),
peer,
op: "remove",
runtimeId: request.runtimeId,
ports: lease.ports,
requestId: request.requestId,
decision: "allow",
reasonCode: "ok",
reason: "reservation_released",
cliExitCategory: "ok",
recovery: "cleanup",
});
return { ok: true, op: "remove", requestId: request.requestId, removedPorts: [] };
}
const before = this.readServe();
assertPrimaryIntact(before);
const beforePrimary = primaryDigest(before);
const removedPorts: number[] = [];
for (const port of lease.ports) {
const entry = before.entries.get(port);
if (!entry) continue; // already gone; idempotent
if (!isSameNumberLoopbackEntry(entry, port)) {
// Unknown/manual/mismatched entry — never modify; cannot prove cleanup.
quarantinePort(registry, port);
saveRegistry(this.config.registryPath, registry);
denied("listener_ownership_mismatch", `port ${port} does not match the owned lease entry`);
}
const result = this.config.deps.runTailscale(
buildRemoveArgv(this.config.tailscaleBinPath, port, this.protectedPorts),
);
if (result.timedOut) denied("cli_timeout", `remove ${port} timed out`);
if (result.code !== 0) denied("cli_error", `remove ${port} exited non-zero`);
removedPorts.push(port);
}
const after = this.readServe();
if (primaryDigest(after) !== beforePrimary) {
denied("primary_route_violation", "protected :443 route changed during remove");
}
assertPrimaryIntact(after);
this.assertProtectedIntact(before, after);
const diff = new Set(changedPorts(before, after));
for (const port of diff) {
if (!lease.ports.includes(port)) denied("unexpected_serve_diff", `unexpected change on port ${port}`);
}
for (const port of removedPorts) {
if (after.entries.get(port)) denied("unexpected_serve_diff", `port ${port} still present after remove`);
}
removeLeaseByHandle(registry, lease.handle);
saveRegistry(this.config.registryPath, registry);
this.config.auditSink.write({
timestampIso: this.config.deps.nowIso(),
peer,
op: "remove",
runtimeId: request.runtimeId,
ports: lease.ports,
requestId: request.requestId,
decision: "allow",
reasonCode: "ok",
reason: "removed",
beforeDigest: beforePrimary,
afterDigest: primaryDigest(after),
cliExitCategory: "ok",
recovery: "cleanup",
});
return { ok: true, op: "remove", requestId: request.requestId, removedPorts };
}
private doList(
request: Extract<BrokerRequest, { op: "list" }>,
peer: PeerCredentials,
): BrokerResponse {
const registry = this.loadRegistry();
const listeners = registry.leases
.filter((lease) => lease.state === "exposed" && lease.peerUid === peer.uid && lease.peerGid === peer.gid)
.flatMap((lease) =>
lease.ports.map((port, index) => ({
runtimeId: lease.runtimeId,
port,
purpose: lease.purposes[index] ?? "app",
})),
);
this.config.auditSink.write({
timestampIso: this.config.deps.nowIso(),
peer,
op: "list",
runtimeId: null,
ports: listeners.map((l) => l.port),
requestId: request.requestId,
decision: "allow",
reasonCode: "ok",
reason: "listed",
});
return { ok: true, op: "list", requestId: request.requestId, listeners };
}
private fail(
requestId: string | null,
peer: PeerCredentials | null,
op: string,
code: string,
error: unknown,
): BrokerResponse {
const message = error instanceof Error ? error.message : String(error);
try {
this.config.auditSink.write({
timestampIso: this.config.deps.nowIso(),
peer,
op,
runtimeId: null,
ports: [],
requestId,
decision: "deny",
reasonCode: code as never,
reason: message,
});
} catch {
/* never let an audit failure mask the denial response */
}
return { ok: false, requestId, code: code as never, message: safeMessage(code) };
}
}
function sameNumbers(left: readonly number[], right: readonly number[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/**
* Stable, comparable identity for the listening socket(s) on a port. Inodes are
* already sorted by the verifier; joining them makes two snapshots comparable
* with a single equality check.
*/
function listenerIdentity(ownership: ListenerOwnership): string {
return ownership.inodes.join(",");
}
function codeForError(error: unknown): string {
if (error instanceof ServeParseError) return "serve_parse_error";
if (error instanceof AuthorizationError) return error.code;
return "internal_error";
}
/** Client-facing message is a stable label; never echoes host/command detail. */
function safeMessage(code: string): string {
return code;
}