carlos / platform Public

spec: fleet-internal engines (#335), rev 1 draft, and its review brief

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 55c9b06f9f0e2871d47af4bc5007863566e7c843 parent 3d444a1
2 files changed, +445 −0
  • docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md +385 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-ENGINES.md +60 −0

CI cancelled — superseded by a newer push — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m

amadan: job cancelled — a newer push superseded it
diff --git a/docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md b/docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md
new file mode 100644
index 000000000..abe272058
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md
@@ -0,0 +1,385 @@
+# Fleet-internal engines: a doc engine nobody on the internet can call
+
+**Date:** 2026-10-21
+**Revision:** 1 (draft, not reviewed).
+**Discussion:** amadan carlos/platform #335. Related: #328 (per-app egress allow).
+
+## 1. Intent
+
+The `doc_engine` capability (`internal/caps/render.go`, README "App
+capabilities") fences a conversion engine's **egress** away from private
+networks. It says nothing about who can **call** the engine. A doc-engine
+route is a route like any other: the engine box's public edge serves its
+hostname on :443, any other box backhauls a public request for it, and the
+only gate is whatever auth the engine applies itself (Gotenberg: basic auth,
+often none). An operator who skips that auth, or leaks its password,
+publishes a Chromium and LibreOffice renderer to the internet: free compute
+for anyone, and an SSRF engine whose remaining reach is the public internet
+and its own box's loopback.
+
+This spec makes an engine **fleet-internal**. Its hostname answers only
+calls the platform carried from an app the operator granted, and an
+isolated tenant reaches it through a socket inside its own instance, with no
+private address opened in its fence.
+
+## 2. Goals, non-goals, anti-goals
+
+**Goals**
+
+- G1. A registered engine's hostname is refused to every public request, on
+ every box, on every listener and every path except the ACME challenge.
+- G2. An operator grants an app the use of a named engine, per app. Every
+ instance of the app, on any box and in any unit kind the app's code runs
+ as, can then call it. A revoke takes effect within the freshness bounds
+ of §6.4.
+- G3. A nested (isolated) instance calls an engine through a unix socket in
+ its own run directory. Its `IPAddressDeny=` fence is unchanged.
+- G4. The engine box knows which app made each call, and refuses a call
+ from an app without a grant even if the calling box was wrong.
+- G5. One app cannot starve the others: the engine box caps each app's
+ calls in flight.
+- G6. Provider-neutral. Nothing here uses a cloud API; see §10.
+
+**Non-goals**
+
+- Metering or billing engine use.
+- Members requesting a grant. Grants are operator-only, as `carlos store
+ grant` is.
+- A general "internal route" product for arbitrary services. The mechanism
+ in §5 is not specific to conversion, but the vocabulary, the CLI and the
+ docs speak only of engines. Widening it is a later spec.
+- Reaching an engine over TCP from inside a nested instance. Its fence
+ denies loopback and every private range, and `IPAddressAllow=` cannot
+ name a port (#328's limit 1). The socket is the only way in.
+- Replacing #328. An operator's engine that lives outside the platform (as
+ Tito's Gotenberg does) still needs #328's egress allow.
+
+**Anti-goals** (would make this a failure even if it works)
+
+- A window in which an engine is public again: a stale mirror, a restarted
+ edge or a box that cannot reach the bucket must never reopen a hostname
+ the platform has said is internal (§6.3).
+- A caller identity a tenant can forge. Who is calling comes from which
+ socket the request arrived on, or from a door key, never from a header
+ the app wrote.
+- Opening any private address in an isolated instance's fence.
+
+## 3. What exists today, and what this builds on
+
+Cited from main at `3d444a1b`.
+
+- **The backhaul hop** (`internal/edge/edge.go` `backhaul`/`backhaulTo`,
+ `homemap.go`): one hop, ever, from the box a request reached to the box
+ that homes the host. It dials the home box's `private_ip` when region HA
+ is on and both boxes share a region and placement, and its public address
+ otherwise (`homemap.go` `dialAddress`), on :443 with public-CA TLS and SNI
+ set to the real hostname. It is authenticated by `X-Carlos-Hop` and
+ `X-Carlos-Hop-Auth`, an HMAC-SHA256 over host and timestamp (±30 s) under
+ the deployment's mesh key `control/edge-mesh-key` (`mesh.go`). **The hop
+ carries no caller**: the receiving edge knows "some edge in this
+ deployment" and nothing else (`acceptHop`, `homemap.go`).
+- **The edge-local door** (#175 P7, `internal/edge/door.go`): a unix socket
+ (`CARLOS_EDGE_DOOR`) through which an app's own processes reach the app's
+ hosts on any box, with an app-scoped door key (`internal/doorkey`). The
+ key is a console-minted, root-signed, revisioned, expiring record under
+ `control/door-keys/`, mirrored by every edge every 30 s with a ten-minute
+ stale bound (`doorkeys.go`). The door marks the request with a context
+ value, never a header, and `ServeHTTP` scopes it to the caller's own
+ routes. The mark does not cross a hop.
+- **The per-instance control socket** (`internal/edge/purge.go`
+ `ListenControl`): one listener per host, opened by the edge, with the host
+ fixed when the listener is built. Nested path
+ `/run/carlos/instances/<iid>/app.sock.control`; the instance sees it
+ because every instance unit binds its whole run directory
+ (`internal/host/units/carlos-instance@.service`, `BindPaths` of
+ `/run/carlos/instances/%I`). Generations get their own under `g<n>/`.
+ The app learns the path from `CARLOS_CONTROL_SOCKET`, which tenant config
+ cannot set (`backend_exec.go`).
+- **The doc-engine grant** (`carlos route --host X --grant doc-engine`):
+ box-local, on a `kind=instance, backing=unit` route, materialized as the
+ `10-carlos-capabilities.conf` drop-in on `carlos-app@<host>`. The README
+ asks for a dedicated box.
+- **Operator-only per-app grants**: `carlos store grant` is the precedent
+ (gated by store operators, recorded in the console, projected to the
+ bucket).
+
+## 4. The shape, in one paragraph
+
+An operator **registers** an engine: a name (`gotenberg`) bound to the
+hostname of a route that already serves it. The console records it and
+publishes a root-signed `control/engines.json` that every edge mirrors.
+From then on every edge refuses that hostname to public requests. The
+operator **grants** apps the engine by name. A granted instance's code sends
+plain HTTP to `$CARLOS_ENGINES_SOCKET` with `Host: gotenberg`. Its box's
+edge knows the caller from the socket, checks the grant, and forwards the
+request to the engine's home box over the existing backhaul hop, now signed
+with the caller's account, app and engine. The engine box verifies the
+signature, checks the grant again, applies the per-app concurrency cap and
+serves the route. App processes outside an instance (the door's callers)
+use the door with `Host: <engine name>` in the same way.
+
+## 5. The parts
+
+### 5.1 Registration and grants (console)
+
+New operator-only CLI, gated like `domains attach --platform`: an owner of
+the ops account or an address on `CARLOS_SYSTEM_OPERATORS`
+(`Server.systemOperator`).
+
+ carlos engines add <name> --host <hostname> [--per-app <n>]
+ carlos engines remove <name>
+ carlos engines grant <name> --app <account>/<app>
+ carlos engines revoke <name> --app <account>/<app>
+ carlos engines list
+
+- `<name>` matches `^[a-z][a-z0-9-]{0,31}$` and is unique in the
+ deployment. It is what apps put in `Host:`.
+- `--host` is the engine route's hostname as the edge serves it: exact, no
+ wildcard, lower-cased. The console does not check that a box serves it
+ (the console holds no route registry); §5.4 reports the mismatch from the
+ box.
+- `--per-app` is the engine box's in-flight cap per calling app (§5.5).
+ Default 4, range 1 to 64.
+- `remove` refuses while grants remain. Removing an engine makes its
+ hostname public again (§6.3), so it is a deliberate two-step act.
+- Every change is audited with `store.RecordEvent`, like the store grant.
+- `list` shows each engine, its host, its cap and its grantees to an
+ operator. A member sees only the engines granted to apps in accounts they
+ belong to, with name and status, never the host or the other grantees.
+
+The console keeps engines and grants in its own database (the authority)
+and publishes the whole set as one record, `control/engines.json`,
+signed with the deployment's root release key under its own signing domain
+(`carlos engines v1\n`), with a revision and `expires_at` 72 hours out,
+exactly as door keys are (`internal/doorkey`). The console re-signs well
+inside the lifetime. One file, because a box needs the whole set to decide
+G1, and a deployment has a handful of engines.
+
+ {
+ "v": 1, "revision": 17, "signed_at": "...", "expires_at": "...",
+ "engines": [
+ { "name": "gotenberg", "host": "gotenberg.example.com", "per_app": 4,
+ "grants": [ { "account": "acct1", "app": "shop" } ] }
+ ]
+ }
+
+### 5.2 The engine mirror (every edge)
+
+A new mirror beside the door-key mirror, same loop shape and same rules for
+a signature, shape, expiry or revision failure (`doorkeys.go`). It feeds two
+views with **different failure rules**:
+
+- **The internal set** (hostnames that must be refused publicly) fails
+ closed. See §6.3.
+- **The grant table** (which app may call which engine) fails closed the
+ other way: stale or expired means no grants, so calls are refused, never
+ admitted on old authority.
+
+### 5.3 Refusing the public (every edge)
+
+For a host in the internal set, a request that is not an engine call
+(§5.4) or the engine app's own door caller gets `unknownHost`, the same
+answer as a host nobody serves. Concretely:
+
+- `ServeHTTP`: after the route lookup and before backhaul, the gate, the
+ meter, the cache and the dispatch. A box that does not home the host
+ refuses without dialling. The home box refuses a hop that carries no
+ verified engine caller (a backhauled public request).
+- The :80 handler (`httpFallbackHandler`) serves the ACME challenge for the
+ host and nothing else: no redirect to https.
+- `Allowed` (certificate issuance) is unchanged. The home box needs a
+ publicly trusted certificate for the hop's TLS (§3). The hostname is not
+ a secret: certificate transparency publishes it anyway. The refusal is
+ about serving, not hiding.
+- The engine app's own door callers keep today's door rule (their own
+ routes). The engine's operator can still reach it from their own app's
+ processes.
+
+### 5.4 The call path
+
+**From a nested instance.** For every instance the edge prepares in the
+nested layout, it opens `engines.sock` in the instance's run directory:
+`/run/carlos/instances/<iid>/engines.sock`, mode 0600, owner carlos. It
+opens it beside the control socket, with the same per-host construction
+(the host fixed at build time, never read from the request). One socket per
+instance, not per generation: the run directory is bound into every unit
+kind (run, generation, seed, `carlos run` command, legacy sidecar), so all
+of them reach it. Each gets `CARLOS_ENGINES_SOCKET` in its launch
+environment, set the way `CARLOS_CONTROL_SOCKET` is, so tenant config cannot
+set or override it. The socket exists whether or not the app holds a grant,
+so a later grant needs no restart.
+
+**From the door.** A door caller (`X-Carlos-Door-Key`) whose `Host:` is an
+engine *name* in the grant table, granted to the key's app, is an engine
+call. Today a door caller's `Host:` is a real hostname; engine names cannot
+collide with one because a hostname registered in a deployment always has a
+dot and a name never does.
+
+**On the calling box.** The listener:
+
+1. strips every client-address header and every `X-Carlos-*` header, as the
+ door does (`doorRequest`), and presents the box's own address;
+2. resolves the caller: the instance's route gives account and app
+ (`release.RouteAccount`, `Route.App`);
+3. reads `Host:` as an engine name, looks it up in the grant table, and
+ answers 404 `engine not granted` for an unknown name, an ungranted app,
+ or a stale or expired table (one body for all three);
+4. puts an engine-call mark (account, app, engine) on the request context
+ and rewrites `Host:` to the engine's hostname;
+5. serves the engine route locally if this box homes it (allowed, but the
+ README's dedicated-box advice stands), or backhauls it to the home box.
+
+**The hop.** An engine call's hop carries a new header,
+`X-Carlos-Hop-Caller: <account>/<app>/<engine>`, and its MAC covers it:
+`hopMAC` gains a versioned form over `host\nts\ncaller`. The receiving edge
+verifies it with the same dual-key rule (`verifyHopEitherKey`), deletes it
+with the other hop headers, and turns it into the same engine-call context
+mark. A hop without a caller is an ordinary hop and stays exactly as today.
+The caller header is the mesh's vocabulary: the door and the engine socket
+delete it on the way in, and `acceptHop` deletes it on every request.
+
+**On the engine box.** An engine call (local or hopped) is admitted to the
+engine route only when the mark's engine names this host in the internal
+set and the grant table grants it to the mark's app. Otherwise
+`unknownHost`. Then §5.5's cap, then today's dispatch. The cache is
+skipped for engine calls.
+
+### 5.5 Limits
+
+- **Per-app in flight, on the engine box:** at most `per_app` concurrent
+ calls per calling app, summed across its instances and boxes. Over the
+ cap: 429 with `Retry-After: 1` and a log line naming the app, rate
+ limited. The engine box is the one place that sees every caller.
+- **Request body:** 64 MiB, refused with 413 at the calling box before the
+ hop.
+- **Time:** the hop and the socket allow a call up to 5 minutes end to end.
+ Longer work belongs in the engine's async mode, whose webhook reaches the
+ caller's public URL over the internet (the engine's egress allows the
+ public internet).
+
+### 5.6 What the engine sees
+
+The request as the app sent it, with `Host:` set to the engine's hostname,
+the client-address headers replaced by the calling box's address, and the
+app's own `Authorization` passed through. An engine that keeps basic auth
+keeps it as a second lock. The edge's access log on the engine box records
+the caller app for each call. No caller header reaches the engine.
+
+### 5.7 Box-side advisories
+
+The hourly capability pass on the engine's home box logs one line per pass
+when a registered engine host is served by a route without the
+`doc-engine` grant, naming the grant command. It does not refuse: the
+internal set protects any route registered, and the grant is about the
+engine's own egress.
+
+## 6. Correctness rules
+
+### 6.1 Identity
+
+Who calls is decided by the socket the request arrived on, or by a verified
+door key, on the calling box; and by a verified hop caller on the engine
+box. No other input names a caller. Every box holds the mesh key, so any
+box can sign any caller: a compromised box can call any engine as any app.
+That is the trust the hop already places in boxes, and stated here rather
+than solved.
+
+### 6.2 Flat layout
+
+A flat instance gets **no engine socket**. In the flat layout every
+instance runs as uid carlos with the same view of `/run/carlos`, so a
+sibling could dial another host's socket and the socket would prove
+nothing. A flat app reaches an engine through the door with a door key,
+like any app process; the door key says which app it is. (My call; see §11.)
+
+### 6.3 The internal set never reopens by accident
+
+- The edge persists the last verified internal set to the box's data
+ directory and loads it at start, before any listener opens.
+- A stale, expired, missing or unverifiable `engines.json` leaves the
+ internal set as it was. Only a verified record with a higher revision
+ can remove a host from it.
+- A host leaves the set only through `carlos engines remove`, whose record
+ no longer lists it.
+- A box that has never read a verified record has an empty set: nothing it
+ serves was ever internal on its watch.
+
+### 6.4 Freshness
+
+The grant table follows the door-key contract: a grant or revoke lands on
+every edge within one 30 s interval of the record; a box that cannot list
+the bucket drops every grant ten minutes after its last good read; an
+expired record (a console down for 72 hours) drops every grant. The two
+edges in a call (calling and engine box) check independently, so a revoke
+is in force once either has read it.
+
+## 7. Deploy order and rollout
+
+- Every box and the console must run a build carrying this before the first
+ `carlos engines add`. A box on an older build ignores `engines.json`. If
+ the old box homes the engine, it keeps serving the host publicly; if it
+ only receives a public request for it, it backhauls as today and the new
+ home box refuses the hop (no caller). So **the engine's home box is the
+ one that must be current**; the runbook checks its version first.
+- Registering an engine cuts public callers within 30 s. Move callers onto
+ the socket or door before `add`, not after.
+- Dormant until the first `add`: with no `engines.json`, the internal set
+ is empty, the grant table is empty, and every engine socket answers 404.
+- Roll order as usual: dev boxes, dev console, check, flagship boxes,
+ flagship console.
+
+## 8. Tests that prove it
+
+Production-wired, through real listeners and real hops between two edges in
+one test process, not handler fixtures:
+
+- A public request for a registered host, on the home box and on a
+ non-home box, gets `unknownHost`, on :443 and :80 (no redirect), and the
+ ACME challenge path still answers.
+- An instance socket call with a grant reaches the engine on another edge;
+ without a grant it gets 404 and the engine sees nothing.
+- A forged `X-Carlos-Hop-Caller` from a client, a door caller or an app
+ through the socket never survives to the engine box's decision.
+- A hop signed with a caller for an ungranted app is refused at the engine
+ box even though the calling box admitted it (G4: inject the calling
+ box's table out of date).
+- A stale mirror and a restarted edge with no bucket both keep refusing a
+ registered host (§6.3).
+- The per-app cap: the n+1th concurrent call from one app gets 429 while a
+ second app's call proceeds.
+- Every unit kind of a nested instance (run, generation, seed, command) can
+ reach the socket and sees `CARLOS_ENGINES_SOCKET`; tenant config cannot
+ set it.
+
+## 9. Docs
+
+`docs/site/cli.md`: a `carlos engines` section; the README's "App
+capabilities" doc-engine passage gains the public-refusal story and drops
+"behind basic auth" as the only gate; a short "Calling an engine from your
+app" passage with a Go example using a unix-socket transport and
+`http://gotenberg/forms/chromium/convert/html`. All user-facing strings go
+through copy review before they ship.
+
+## 10. Provider boundary
+
+Core only. The mirror reads a `release.Store` (S3 or a directory); signing
+uses the root release key; the hop is the existing mesh. On AWS with region
+HA, a hop inside one region uses private addresses; everywhere else it
+crosses the public internet under TLS and the hop MAC, as backhaul does
+today. A non-AWS deployment loses nothing. "Fleet-internal" means "only the
+platform can call it", not "only over a private network".
+
+## 11. Calls I made (for review)
+
+- Explicit registration, not "every doc-engine grant is internal". A
+ doc-engine grant is box-local, and other boxes cannot see it; a
+ console-side registration gives every box the same answer, and no
+ existing public engine changes behaviour until an operator registers it.
+- Flat instances get no socket and use the door (§6.2).
+- Engine selection by `Host:` name on one socket, so a client's base URL is
+ just `http://<name>`.
+- Any route may be registered; a missing doc-engine grant is advised, not
+ refused (§5.7).
+- Limits: 64 MiB body, 5 minutes, per-app cap default 4.
+- The engine box re-checks the grant (G4) rather than trusting the calling
+ box.
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-ENGINES.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-ENGINES.md
new file mode 100644
index 000000000..5314c02b3
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-ENGINES.md
@@ -0,0 +1,60 @@
+# Review brief: fleet-internal engines (#335)
+
+You are an independent second reader of a design spec for the CARLOS
+platform (this repository). Read it cold and adversarially.
+
+**Spec:** `docs/superpowers/specs/2026-10-21-fleet-internal-engines-design.md`,
+at the commit named in the prompt.
+
+## What it claims
+
+A registered doc engine's hostname is refused to every public request on
+every box, and granted apps reach it through a per-instance unix socket (or
+the edge-local door), relayed over the existing backhaul hop, which now
+signs the caller. Read §2's anti-goals first: the spec fails if an engine
+can become public again by accident, if a tenant can forge a caller, or if
+an isolated instance's fence opens.
+
+## What to check
+
+Check against the code on `main`, not the spec's description of it. Cite
+`file:line` for every claim about existing code.
+
+- **The edge:** `internal/edge/edge.go` (`ServeHTTP`, `backhaul`,
+ `backhaulTo`, `httpFallbackHandler`, `Allowed`, `unknownHost`, the
+ cache path), `homemap.go` (`acceptHop`, `dialAddress`, `Owner`,
+ `ExactHome`), `mesh.go` (hop MAC), `door.go`, `doorkeys.go`,
+ `purge.go` / `purge_listen_unix.go` (`ListenControl`).
+- **Instances:** `internal/activator/backend_exec.go` (launch env,
+ `CARLOS_CONTROL_SOCKET`), `provision.go`, `prepare.go`,
+ `generations.go`, `cmdunit.go`, `internal/host/units/carlos-instance*@.service`,
+ `internal/instancectl/gen.go`, `internal/layout/preflight.go`.
+- **Grants and records:** `internal/doorkey`, `internal/caps`,
+ `internal/console/apistore.go` (store grant), `apisystemupdate.go`
+ (`systemOperator`).
+
+## Questions to answer
+
+1. Can a public request reach a registered engine by any path: a spelling
+ of the host, a wildcard route, the :80 handler, a door caller, a
+ backhaul from an old box, the login re-resolution, an upgrade, the
+ cache, a hibernating wake?
+2. Can a tenant forge or borrow a caller identity: through headers, through
+ another instance's socket, across generations, in the flat layout, via
+ the door?
+3. Does §6.3's "never reopens" hold across restarts, stale mirrors, revision
+ rollback by a bucket writer, and `engines remove`?
+4. Can the per-instance socket be built as described for every unit kind,
+ within the 107-byte socket path limit, and is the "one socket per
+ instance" claim true of the binds?
+5. Does the hop extension stay compatible with boxes on the old build in
+ both directions?
+6. Anything §5.5's limits break (streaming, upgrades, long conversions).
+
+## Output
+
+Findings ranked P1 (spec fails a goal or anti-goal, or cannot be built), P2
+(a real gap a builder would hit), P3 (worth fixing). For each: the section,
+the failure scenario as concrete steps, the code citation, and a suggested
+fix. End with an overall verdict: accept, accept with fixes, or reject.
+Do not report wording or style.