a73x

4a319280

docs: correct claims the code has outgrown

a73x   2026-08-12 19:27

Commit message
docs: correct claims the code has outgrown

Ten statements in the docs describe a system that no longer exists, or
never did. Each is corrected against the code that decides it.

- architecture.md described the pre-v0.0.4 managed-CA model: "the server
  holds an SSH CA, mints short-lived user certs". It holds a HOST CA and
  SignHostCert is its only signing verb; users self-sign locally with
  their tenant's own CA. README.md and ssh-access.md already said so.
- deploy/server/config.required claimed the KEK seals "each tenant's
  managed SSH CA in the database". seal.Seal/Open have three call sites,
  all in sshca.go, all on-disk files. Tenant user CAs are BYO public
  keys, so a stolen database backup holds no signing key at all.
- ssh-access.md said a VM created before eitri recorded its trusted CAs
  reads "unrecorded" with no record of which CAs those were. The
  trusted_cas backfill runs on every store.Open, so no such VM survives.
- ssh-access.md said the gate rejects "a bare or foreign-prefixed name".
  It resolves a bare name within the connection's tenant; only a foreign
  prefix is rejected. The one-liner section already had it right.
- ssh-access.md's delegation recipe signed with ~/.ssh/eitri-user-ca.
  The CLI default and every other mention is eitri_user_ca.
- docs/README.md still said a quickstart was planned for v0.0.1. It has
  been the site's lead page for some time; byo-idp, faq and releases
  were missing from the index too.
- architecture.md cited TestWireContractIsLeaf for R3. The test is
  TestWirePlaneIsLeaf, which generalizes R3 to the whole wire plane.
- Both make ci descriptions listed roughly half the gate, omitting
  build-darwin, fmt-check, api-check, deadcode, site-check, web-test and
  web-check — enough for a reader to conclude the console is ungated.
  Both also called the gate "CI"; there is none, and .githooks/pre-push
  is what actually blocks a red push to main.
- ssh-access.md's env table omitted EITRI_CONFIG, and predated the
  config file the other variables now override.
- audit_retention was documented nowhere, so operators told their
  revocations are audited were not told the record is pruned at 90 days.

README.md
Old New
@@ -117,10 +117,12 @@ docs/shape.* the generated, explorable architecture graph
117 make ci 117 make ci
118 ``` 118 ```
119 119
120 It runs `go vet`, the build, the architecture fitness functions, `golangci-lint`, 120 It runs `go vet`, the build for linux and darwin, the architecture fitness
121 the test suite under `-race`, per-package coverage floors, `go mod tidy` and proto 121 functions, `golangci-lint`, `gofmt`, the test suite under `-race`, per-package
122 checks, and the shape-graph check. A pre-push hook runs it and blocks a red push 122 coverage floors, the drift checks (`go mod tidy`, protobuf, API contract,
123 to `main`. 123 shape graph), the deadcode sweep, a site render, and the console's tests and
124 typecheck. The `.githooks/pre-push` hook—installed by `make hooks`—runs it and
125 blocks a red push to `main`.
124 126
125 Common loops: 127 Common loops:
126 128
deploy/server/config.required
Old New
@@ -25,9 +25,11 @@ db_path required
25 http_listen required 25 http_listen required
26 quic_listen required 26 quic_listen required
27 host_secret required 27 host_secret required
28 # Seals every piece of key material the plane holds — the host CA and gate host 28 # Seals every piece of key material the plane holds — the host CA and the gate's
29 # key on the PVC, and each tenant's managed SSH CA in the database — so the 29 # host key, both files on the PVC — so the volume and its backups hold
30 # volume, the database and its backups hold ciphertext. 64 hex chars, minted with 30 # ciphertext. Tenant user CAs are not among them: those are public keys the
31 # tenant brings, and no user signing key is ever in the database to seal.
32 # 64 hex chars, minted with
31 # `openssl rand -hex 32`, and never rotated in place: a plane that loses it loses 33 # `openssl rand -hex 32`, and never rotated in place: a plane that loses it loses
32 # its host CA, and with it the identity every client pins and every VM's host 34 # its host CA, and with it the identity every client pins and every VM's host
33 # certificate names. This Secret is that key's only home; keep the operator's 35 # certificate names. This Secret is that key's only home; keep the operator's
@@ -68,6 +70,8 @@ oidc.allowed_identities optional
68 # disables release discovery and every upgrade surface with it. 70 # disables release discovery and every upgrade surface with it.
69 release_manifest_url optional 71 release_manifest_url optional
70 credential_max_age optional 72 credential_max_age optional
73 # How long the append-only audit log keeps a row. Absent means 90 days; "0"
74 # keeps every row forever.
71 audit_retention optional 75 audit_retention optional
72 76
73 admin_token retired 77 admin_token retired
docs/README.md
Old New
@@ -13,19 +13,25 @@ By what you're trying to do:
13 13
14 **Run a fleet** 14 **Run a fleet**
15 15
16 - [quickstart.md](quickstart.md)—from nothing to a VM you can SSH into,
17 hosted or self-hosted
18 - [byo-idp.md](byo-idp.md)—signing the console in against your own OIDC
19 provider instead of the bundled issuer
16 - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity, 20 - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity,
17 and why expiry is never an emergency 21 and why expiry is never an emergency
18 - [credential-revocation.md](credential-revocation.md)—leaked host 22 - [credential-revocation.md](credential-revocation.md)—leaked host
19 credentials, leaked SSH certs, and the disaster levers 23 credentials, leaked SSH certs, and the disaster levers
20 - [upgrade.md](upgrade.md)—upgrading agents from the console, the server, 24 - [upgrade.md](upgrade.md)—upgrading agents from the console, the server,
21 and cloud-hypervisor 25 and cloud-hypervisor
22 - Quickstart is planned for v0.0.1—see [ROADMAP.md](../ROADMAP.md) 26 - [releases.md](releases.md)—what shipped in each version, and where the
27 tarballs live
23 28
24 **Use a fleet** 29 **Use a fleet**
25 30
26 - [ssh-access.md](ssh-access.md)—reaching a guest through the jump gate with 31 - [ssh-access.md](ssh-access.md)—reaching a guest through the jump gate with
27 your own tenant CA 32 your own tenant CA
28 - [mcp.md](mcp.md)—`/mcp`, the endpoint that lets Claude drive VMs 33 - [mcp.md](mcp.md)—`/mcp`, the endpoint that lets Claude drive VMs
34 - [faq.md](faq.md)—guest networking, and the other sharp edges
29 35
30 **Why it's this way** 36 **Why it's this way**
31 37
docs/architecture.md
Old New
@@ -24,11 +24,13 @@ services (the SSH jump gate and cert minting). All side effects on VMs and hosts
24 live in the agent. 24 live in the agent.
25 25
26 Real SSH into a guest goes through the **SSH jump gate** 26 Real SSH into a guest goes through the **SSH jump gate**
27 (`internal/server/sshgate` + `internal/server/sshca`): the server holds an SSH 27 (`internal/server/sshgate` + `internal/server/sshca`): the server holds a
28 CA, mints short-lived user certs, and tunnels TCP:22 to the VM over the existing 28 **host** CA, signs the host certificates the gate and every VM present, and
29 server↔agent sync channel. There is no user network—a VM is reachable at the 29 tunnels TCP:22 to the VM over the existing server↔agent sync channel. It holds
30 guest address its host reports (`assigned_ip`), dialled by the agent on that 30 no user signing key—you sign your own short-lived user cert with your tenant's
31 host. How the guest came by that address is the host's business: a Linux host 31 own CA, whose private half never leaves your machine. There is no user
32 network—a VM is reachable at the guest address its host reports
33 (`assigned_ip`), dialled by the agent on that host. How the guest came by that address is the host's business: a Linux host
32 allocates it from a bridge it builds, a Mac reads what vmnet's own DHCP handed 34 allocates it from a bridge it builds, a Mac reads what vmnet's own DHCP handed
33 out. 35 out.
34 36
@@ -38,7 +40,7 @@ out.
38 |---|-----------|-------------| 40 |---|-----------|-------------|
39 | **R1** | The control plane and the data plane never import each other (even transitively). | `internal/arch` `TestControlAndDataPlaneAreDisjoint` (production, transitive) + `depguard` `server-no-agent` / `agent-no-server` (non-test files). | 41 | **R1** | The control plane and the data plane never import each other (even transitively). | `internal/arch` `TestControlAndDataPlaneAreDisjoint` (production, transitive) + `depguard` `server-no-agent` / `agent-no-server` (non-test files). |
40 | **R2** | No `internal/server` package shells out—the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). | 42 | **R2** | No `internal/server` package shells out—the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). |
41 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. | 43 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWirePlaneIsLeaf`. Behavior pinned by `transport` round-trip contract tests. |
42 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | 44 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. |
43 | **R5** | The reconcile boundary interface (`Provisioner`) stays consumer-owned and small. A VM's network attachment lives inside it, because on every backend we ship the NIC is a launch argument rather than a separately-lifecycled resource; what crosses the seam is data (the VM's address) and not mechanism (taps, reservations). The host-wide counterpart crosses the *sync* seam the same way: a host reports the subnet its guests are on (`guest_cidr`) rather than being told which one to build, so the fleet records an observation and never infers a mechanism from it. | Convention (below) + `ireturn` allow-list keeps the seam's interface returns honest. | 45 | **R5** | The reconcile boundary interface (`Provisioner`) stays consumer-owned and small. A VM's network attachment lives inside it, because on every backend we ship the NIC is a launch argument rather than a separately-lifecycled resource; what crosses the seam is data (the VM's address) and not mechanism (taps, reservations). The host-wide counterpart crosses the *sync* seam the same way: a host reports the subnet its guests are on (`guest_cidr`) rather than being told which one to build, so the fleet records an observation and never infers a mechanism from it. | Convention (below) + `ireturn` allow-list keeps the seam's interface returns honest. |
44 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The exceptions are the provisioners — `agent/cloudhv` and `agent/vfkit` — which launch the long-lived VMM process directly, and the composition root `agent/run`, which builds the Runner it injects. Checked transitively (reaching `os/exec` via a sanctioned package is fine). | `internal/arch` `TestOnlyProvisionersAndRootImportOsExecInDataPlane` (transitive). | 46 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The exceptions are the provisioners — `agent/cloudhv` and `agent/vfkit` — which launch the long-lived VMM process directly, and the composition root `agent/run`, which builds the Runner it injects. Checked transitively (reaching `os/exec` via a sanctioned package is fine). | `internal/arch` `TestOnlyProvisionersAndRootImportOsExecInDataPlane` (transitive). |
@@ -46,7 +48,7 @@ out.
46 48
47 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see 49 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see
48 > edges changing elsewhere in the module. Always run them with `-count=1` 50 > edges changing elsewhere in the module. Always run them with `-count=1`
49 > (`make arch` and CI do). 51 > (`make arch` does).
50 52
51 ## Design conventions 53 ## Design conventions
52 54
@@ -79,21 +81,34 @@ why the invariants hold:
79 81
80 ## The quality gate 82 ## The quality gate
81 83
82 `make ci` is the merge gate and runs locally identically to CI: 84 `make ci` is the merge gate. It runs on your machine—the `.githooks/pre-push`
85 hook (installed by `make hooks`) runs it on any push that updates `main` and
86 blocks a red one:
83 87
84 | Step | Target | Blocks merge? | 88 | Step | Target | Blocks merge? |
85 |------|--------|---------------| 89 |------|--------|---------------|
86 | Compile all packages | `make build-go` | yes |
87 | `go vet` | `make vet` | yes | 90 | `go vet` | `make vet` | yes |
88 | Architecture fitness tests (R1–R13) | `make arch` | yes | 91 | Compile all packages | `make build-go` | yes |
92 | Compile the agent for darwin/arm64 | `make build-darwin` | yes |
93 | Architecture fitness tests (R1–R14) | `make arch` | yes |
89 | Block-tier lint (boundaries + correctness) | `make lint` | yes | 94 | Block-tier lint (boundaries + correctness) | `make lint` | yes |
95 | `gofmt` drift | `make fmt-check` | yes |
90 | Race-detector tests | `make test` | yes | 96 | Race-detector tests | `make test` | yes |
91 | Per-package coverage ratchet | `make cover` | yes | 97 | Per-package coverage ratchet | `make cover` | yes |
92 | `go mod tidy` drift | `make tidy-check` | yes | 98 | `go mod tidy` drift | `make tidy-check` | yes |
93 | Generated protobuf drift | `make proto-check` | yes | 99 | Generated protobuf drift | `make proto-check` | yes |
100 | Generated API contract drift (`docs/openapi.json`, `web/src/lib/api-types.ts`) | `make api-check` | yes |
94 | Architecture-shape diagram drift | `make shape-check` | yes | 101 | Architecture-shape diagram drift | `make shape-check` | yes |
102 | Code unreachable from any `cmd/` entrypoint, on linux/amd64 and darwin/arm64 | `make deadcode` | yes |
103 | The eitri.sh site renders, docs included | `make site-check` | yes |
104 | Console unit tests (vitest) | `make web-test` | yes |
105 | Console typecheck (svelte-check) | `make web-check` | yes |
95 | Complexity/style lint | `make lint-extra` | **no** (informational) | 106 | Complexity/style lint | `make lint-extra` | **no** (informational) |
96 107
108 `web-test` and `web-check` need `web/node_modules`; without it they print a
109 skip rather than failing, so run `make web` once before trusting a green `ci`
110 on console changes.
111
97 Real-VM verification is not part of the per-PR gate: `make deploy` runs a boot-gate 112 Real-VM verification is not part of the per-PR gate: `make deploy` runs a boot-gate
98 (`cmd/eitri-smoke`) against the live fleet—create a throwaway VM, prove it boots 113 (`cmd/eitri-smoke`) against the live fleet—create a throwaway VM, prove it boots
99 under UEFI, reap it—which is the fleet's one automated real-VM check. Because the 114 under UEFI, reap it—which is the fleet's one automated real-VM check. Because the
@@ -113,8 +128,8 @@ once its baseline is clean.
113 ### Coverage ratchet 128 ### Coverage ratchet
114 129
115 `scripts/coverage.sh` floors each package a few points below its current 130 `scripts/coverage.sh` floors each package a few points below its current
116 coverage. CI fails if any package drops below its floor; raise the floor when 131 coverage. `make cover` fails if any package drops below its floor; raise the
117 you raise coverage. Aspirational targets (not yet enforced): logic/domain 132 floor when you raise coverage. Aspirational targets (not yet enforced): logic/domain
118 packages → 80%, host-touching effectful packages → 50%. Generated code 133 packages → 80%, host-touching effectful packages → 50%. Generated code
119 (`internal/pb`) and thin `cmd/*` mains are not gated here. 134 (`internal/pb`) and thin `cmd/*` mains are not gated here.
120 135
docs/credential-revocation.md
Old New
@@ -66,6 +66,15 @@ A compromised **tenant user CA** is the bigger event: the tenant's CA must be
66 replaced and its VMs re-seeded to drop trust in the old one—fast CA-level 66 replaced and its VMs re-seeded to drop trust in the old one—fast CA-level
67 revocation is an open follow-up. 67 revocation is an open follow-up.
68 68
69 ## The audit log is pruned
70
71 Both records above—`host.credential.revoke` and `ssh-cert.revoke`—live in the
72 server's append-only audit log, and that log does not grow forever.
73 `audit_retention` in `server.json` (Go duration) is the window, default
74 `"2160h"` (90 days); the server prunes older rows at startup and once a day
75 after that. `"0"` disables pruning and keeps the log indefinitely. A negative
76 value is refused at startup rather than silently ignored.
77
69 ## Related 78 ## Related
70 79
71 - [ssh-access.md](ssh-access.md)—how user and host certs work 80 - [ssh-access.md](ssh-access.md)—how user and host certs work
docs/ssh-access.md
Old New
@@ -47,9 +47,7 @@ the guest is the only way to change what it trusts.
47 47
48 A VM's page lists them under **Trusted CAs**, by the label you gave the CA and 48 A VM's page lists them under **Trusted CAs**, by the label you gave the CA and
49 its fingerprint. Match those against Settings → SSH Access to tell whether a 49 its fingerprint. Match those against Settings → SSH Access to tell whether a
50 certificate you are about to sign will open that guest. A VM created before 50 certificate you are about to sign will open that guest.
51 eitri recorded this reads *unrecorded*—it trusts whatever was registered on the
52 day it was made, and there is no record of which CAs those were.
53 51
54 On the fleet page, a VM missing any CA your tenant now has is marked *stale 52 On the fleet page, a VM missing any CA your tenant now has is marked *stale
55 trust*. It still works with the CAs it was created against; it just cannot be 53 trust*. It still works with the CAs it was created against; it just cannot be
@@ -73,7 +71,7 @@ Sign it with your own CA, on your own terms:
73 71
74 ```sh 72 ```sh
75 printf '%s\n' "<public_key from the response>" > eitri-delegation.pub 73 printf '%s\n' "<public_key from the response>" > eitri-delegation.pub
76 ssh-keygen -s ~/.ssh/eitri-user-ca -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub 74 ssh-keygen -s ~/.ssh/eitri_user_ca -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub
77 ``` 75 ```
78 76
79 `-n ubuntu` is not optional. A guest matches the certificate's principals 77 `-n ubuntu` is not optional. A guest matches the certificate's principals
@@ -121,10 +119,16 @@ tenant: `eitri ssh` derives it from your credential via `/me`, or takes it from
121 `EITRI_TENANT` when set (offline, and the escape hatch for a CA registered in 119 `EITRI_TENANT` when set (offline, and the escape hatch for a CA registered in
122 more than one tenant). 120 more than one tenant).
123 121
124 Environment variables: 122 Environment variables. `EITRI_URL`, `EITRI_GATE`, `EITRI_CA`, `EITRI_TENANT`
123 and `EITRI_KEY` are per-invocation overrides above the config file `eitri init`
124 writes, so the chain is variable, then `~/.eitri/config.json`, then the default
125 below. `EITRI_TOKEN` and `EITRI_KNOWN_HOSTS` have no config-file rung—no
126 credential is ever written to disk, and the pin file is a cache rather than a
127 setting.
125 128
126 | Var | Meaning | 129 | Var | Meaning |
127 | ------------- | --------------------------------------------------------- | 130 | ------------- | --------------------------------------------------------- |
131 | `EITRI_CONFIG`| Path to that config file (default `~/.eitri/config.json`) |
128 | `EITRI_URL` | Base URL of the eitri server (default the hosted `https://console.eitri.sh`) | 132 | `EITRI_URL` | Base URL of the eitri server (default the hosted `https://console.eitri.sh`) |
129 | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`); otherwise the plane's own `/me` answer, and the hosted `gate.eitri.sh:2222` only when `EITRI_URL` is the hosted plane | 133 | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`); otherwise the plane's own `/me` answer, and the hosted `gate.eitri.sh:2222` only when `EITRI_URL` is the hosted plane |
130 | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) | 134 | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) |
@@ -170,8 +174,8 @@ The client is a thin wrapper over three steps you can run by hand:
170 174
171 The inner user must be `ubuntu` (the cert principal). The outer gate hop 175 The inner user must be `ubuntu` (the cert principal). The outer gate hop
172 accepts any username. The gate derives your tenant from the CA that signed 176 accepts any username. The gate derives your tenant from the CA that signed
173 your cert, resolves names within that tenant, and rejects a bare or 177 your cert, resolves names within that tenant, and rejects a foreign-prefixed
174 foreign-prefixed name. 178 name.
175 179
176 ## Certs are short-lived 180 ## Certs are short-lived
177 181