4a319280
docs: correct claims the code has outgrown
a73x 2026-08-12 19:27
Commit message
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 | ||