a73x

d058f942

docs,web: one voice, one dash system

a73x   2026-07-30 06:33

Commit message
docs,web: one voice, one dash system

The landing states the premise in the author's own voice — compute costs
at all-time highs, the hardware we already own sitting idle, ease of use
the only missing piece — and every published page reads like one author
wrote it: duplicated facts stated once, reflexive em-dash connectives
broken into sentences, reassurance intensifiers dropped, release notes
stating limits as limits.

One dash system across every published surface — site, docs, console,
and the browser-tab titles: closed em-dashes for prose
interruptions—the way they're typeset—spaced en-dashes for title and
label separators (eitri – docs), en-dashes for ranges.

README.md
Old New
@@ -18,8 +18,8 @@ eitri is built around a single desired-state loop, the same shape as a kubelet:
18 └──────────ActualStateReport─────────────┘ (also the heartbeat) 18 └──────────ActualStateReport─────────────┘ (also the heartbeat)
19 ``` 19 ```
20 20
21 - **The control plane (`eitri-server`)** holds the desired fleet — which VMs 21 - **The control plane (`eitri-server`)** holds the desired fleet—which VMs
22 should exist, on which host, with what resources — and streams it to each host 22 should exist, on which host, with what resources—and streams it to each host
23 over a persistent QUIC connection. 23 over a persistent QUIC connection.
24 - **The agent (`eitri-agent`)** reconciles: it gives every VM its own worker 24 - **The agent (`eitri-agent`)** reconciles: it gives every VM its own worker
25 goroutine that creates, converges, or tears down that one guest, so a slow 25 goroutine that creates, converges, or tears down that one guest, so a slow
@@ -52,7 +52,7 @@ create), and a content-addressed image cache (each base image is downloaded and
52 ## Access model 52 ## Access model
53 53
54 eitri is multi-tenant. A **fleet** of hosts is partitioned into **tenants**, each 54 eitri is multi-tenant. A **fleet** of hosts is partitioned into **tenants**, each
55 its own isolated namespace with its own SSH user CA — eitri holds no tenant user 55 its own isolated namespace with its own SSH user CA—eitri holds no tenant user
56 signing key. You reach a guest by name: 56 signing key. You reach a guest by name:
57 57
58 ``` 58 ```
@@ -76,7 +76,7 @@ make build # binaries into ./bin
76 `eitri-agent join <blob>` enrolls it (posting to `/api/v1/enroll`), pins the 76 `eitri-agent join <blob>` enrolls it (posting to `/api/v1/enroll`), pins the
77 server certificate from the blob, and persists its identity. From then on the 77 server certificate from the blob, and persists its identity. From then on the
78 agent runs the reconcile + sync loop against the fleet. Run it under systemd 78 agent runs the reconcile + sync loop against the fleet. Run it under systemd
79 with `scripts/eitri-agent.service` — `Restart=on-failure` revives a crashed 79 with `scripts/eitri-agent.service`—`Restart=on-failure` revives a crashed
80 agent, and its `KillMode=process` keeps running VMs alive across agent stops. 80 agent, and its `KillMode=process` keeps running VMs alive across agent stops.
81 81
82 Guests boot from cloud images (the default is Ubuntu resolute) via UEFI firmware 82 Guests boot from cloud images (the default is Ubuntu resolute) via UEFI firmware
@@ -85,7 +85,7 @@ disk-only image boots unmodified.
85 85
86 ## Documentation 86 ## Documentation
87 87
88 [docs/](docs/README.md) is indexed by what you're trying to do — understand it 88 [docs/](docs/README.md) is indexed by what you're trying to do—understand it
89 ([architecture](docs/architecture.md), [ethos](docs/ethos.md)), run a fleet 89 ([architecture](docs/architecture.md), [ethos](docs/ethos.md)), run a fleet
90 ([cert rotation](docs/cert-rotation.md), [revocation](docs/credential-revocation.md)), 90 ([cert rotation](docs/cert-rotation.md), [revocation](docs/credential-revocation.md)),
91 use a fleet ([ssh access](docs/ssh-access.md), [mcp](docs/mcp.md)), or see why 91 use a fleet ([ssh access](docs/ssh-access.md), [mcp](docs/mcp.md)), or see why
@@ -108,7 +108,7 @@ docs/shape.* the generated, explorable architecture graph
108 108
109 ## Development 109 ## Development
110 110
111 `make ci` is the gate — everything a change must pass before it lands: 111 `make ci` is the gate—everything a change must pass before it lands:
112 112
113 ```sh 113 ```sh
114 make ci 114 make ci
docs/README.md
Old New
@@ -4,30 +4,30 @@ By what you're trying to do:
4 4
5 **Understand it** 5 **Understand it**
6 6
7 - [architecture.md](architecture.md) — the architectural invariants and the 7 - [architecture.md](architecture.md)—the architectural invariants and the
8 executable governance that enforces them 8 executable governance that enforces them
9 - [ethos.md](ethos.md) — the design principles; what to fall back on when a 9 - [ethos.md](ethos.md)—the design principles; what to fall back on when a
10 debate stalls 10 debate stalls
11 - [shape.html](shape.html) — the generated, explorable package graph 11 - [shape.html](shape.html)—the generated, explorable package graph
12 (`make shape` regenerates) 12 (`make shape` regenerates)
13 13
14 **Run a fleet** 14 **Run a fleet**
15 15
16 - [cert-rotation.md](cert-rotation.md) — rotating the server's QUIC identity, 16 - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity,
17 and why expiry is never an emergency 17 and why expiry is never an emergency
18 - [credential-revocation.md](credential-revocation.md) — leaked host 18 - [credential-revocation.md](credential-revocation.md)—leaked host
19 credentials, leaked SSH certs, and the disaster levers 19 credentials, leaked SSH certs, and the disaster levers
20 - [upgrade.md](upgrade.md) — upgrading agents from the console, the server, 20 - [upgrade.md](upgrade.md)—upgrading agents from the console, the server,
21 and cloud-hypervisor 21 and cloud-hypervisor
22 - Quickstart is planned for v0.0.1 — see [ROADMAP.md](../ROADMAP.md) 22 - Quickstart is planned for v0.0.1—see [ROADMAP.md](../ROADMAP.md)
23 23
24 **Use a fleet** 24 **Use a fleet**
25 25
26 - [ssh-access.md](ssh-access.md) — reaching a guest through the jump gate with 26 - [ssh-access.md](ssh-access.md)—reaching a guest through the jump gate with
27 your own tenant CA 27 your own tenant CA
28 - [mcp.md](mcp.md) — `eitri-mcp`, the MCP server that lets Claude drive VMs 28 - [mcp.md](mcp.md)—`eitri-mcp`, the MCP server that lets Claude drive VMs
29 29
30 **Why it's this way** 30 **Why it's this way**
31 31
32 - [decisions.md](decisions.md) — the settled decisions, the alternatives they 32 - [decisions.md](decisions.md)—the settled decisions, the alternatives they
33 beat, and the reasons 33 beat, and the reasons
docs/architecture.md
Old New
@@ -2,7 +2,7 @@
2 2
3 This document records the architectural invariants that keep Eitri's control 3 This document records the architectural invariants that keep Eitri's control
4 plane and data plane decoupled, and the **executable** governance that enforces 4 plane and data plane decoupled, and the **executable** governance that enforces
5 them. Every invariant below is backed by a test or lint rule — if you violate 5 them. Every invariant below is backed by a test or lint rule—if you violate
6 one, `make ci` fails. The intent is that the architecture cannot silently drift. 6 one, `make ci` fails. The intent is that the architecture cannot silently drift.
7 7
8 ## The two planes 8 ## The two planes
@@ -26,7 +26,7 @@ live in the agent.
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 an SSH
28 CA, mints short-lived user certs, and tunnels TCP:22 to the VM over the existing 28 CA, mints short-lived user certs, and tunnels TCP:22 to the VM over the existing
29 server↔agent sync channel. There is no user network — VMs are reachable at their 29 server↔agent sync channel. There is no user network—VMs are reachable at their
30 bridge IP (`assigned_ip`) via the agent. 30 bridge IP (`assigned_ip`) via the agent.
31 31
32 ## Invariants 32 ## Invariants
@@ -34,7 +34,7 @@ bridge IP (`assigned_ip`) via the agent.
34 | # | Invariant | Enforced by | 34 | # | Invariant | Enforced by |
35 |---|-----------|-------------| 35 |---|-----------|-------------|
36 | **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). | 36 | **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). |
37 | **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). | 37 | **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). |
38 | **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. | 38 | **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. |
39 | **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`. | 39 | **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`. |
40 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the addressing seam (`NetEnv.ReserveIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | 40 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the addressing seam (`NetEnv.ReserveIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. |
@@ -59,7 +59,7 @@ why the invariants hold:
59 `reconcile.Engine` is the template: collaborators as interface fields 59 `reconcile.Engine` is the template: collaborators as interface fields
60 (`Prov`, `Net`), pure side effects as func fields (`Images`, `Seed`, 60 (`Prov`, `Net`), pure side effects as func fields (`Images`, `Seed`,
61 `BootID`, `Now`). Injected time (`Now func() time.Time`) is the sanctioned 61 `BootID`, `Now`). Injected time (`Now func() time.Time`) is the sanctioned
62 way to make decision logic testable — don't call `time.Now()` directly in 62 way to make decision logic testable—don't call `time.Now()` directly in
63 reconcile/store decision paths. 63 reconcile/store decision paths.
64 3. **No mutable global state.** Constructors (`store.Open`, `hub.New`, 64 3. **No mutable global state.** Constructors (`store.Open`, `hub.New`,
65 `syncsvc.New`) own all state. Immutable package vars (compiled regexps) are 65 `syncsvc.New`) own all state. Immutable package vars (compiled regexps) are
@@ -67,7 +67,7 @@ why the invariants hold:
67 4. **Mock only true external dependencies.** Tests use hand-written fakes for 67 4. **Mock only true external dependencies.** Tests use hand-written fakes for
68 the boundary interfaces (`Provisioner`, `NetEnv`) and the 68 the boundary interfaces (`Provisioner`, `NetEnv`) and the
69 `exec.Runner`. The SQLite store is used for real in tests, not mocked. Don't 69 `exec.Runner`. The SQLite store is used for real in tests, not mocked. Don't
70 introduce a mocking framework — hand-written fakes keep tests honest about 70 introduce a mocking framework—hand-written fakes keep tests honest about
71 real behavior. 71 real behavior.
72 5. **The server expresses intent, never actuates.** A new server feature that 72 5. **The server expresses intent, never actuates.** A new server feature that
73 wants something to happen to a VM adds it to desired state; the agent makes 73 wants something to happen to a VM adds it to desired state; the agent makes
@@ -91,14 +91,14 @@ why the invariants hold:
91 | Complexity/style lint | `make lint-extra` | **no** (informational) | 91 | Complexity/style lint | `make lint-extra` | **no** (informational) |
92 92
93 Real-VM verification is not part of the per-PR gate: `make deploy` runs a boot-gate 93 Real-VM verification is not part of the per-PR gate: `make deploy` runs a boot-gate
94 (`cmd/eitri-smoke`) against the live fleet — create a throwaway VM, prove it boots 94 (`cmd/eitri-smoke`) against the live fleet—create a throwaway VM, prove it boots
95 under UEFI, reap it — which is the fleet's one automated real-VM check. Because the 95 under UEFI, reap it—which is the fleet's one automated real-VM check. Because the
96 fleet binaries are deployed with `-cover`, the same run also collects merged 96 fleet binaries are deployed with `-cover`, the same run also collects merged
97 server+agent integration coverage (flushed on SIGUSR1 via `internal/covsnap`). 97 server+agent integration coverage (flushed on SIGUSR1 via `internal/covsnap`).
98 98
99 ### Linting tiers 99 ### Linting tiers
100 100
101 `.golangci.yml` enables only the **block tier** — linters that are clean today 101 `.golangci.yml` enables only the **block tier**—linters that are clean today
102 and must stay clean (`govet`, `staticcheck`, `ineffassign`, `unused`, 102 and must stay clean (`govet`, `staticcheck`, `ineffassign`, `unused`,
103 `bodyclose`, `rowserrcheck`, `sqlclosecheck`, `contextcheck`, `containedctx`, 103 `bodyclose`, `rowserrcheck`, `sqlclosecheck`, `contextcheck`, `containedctx`,
104 `depguard`, `ireturn`). The **warn tier** (`errcheck`, `revive`, `gocyclo`, 104 `depguard`, `ireturn`). The **warn tier** (`errcheck`, `revive`, `gocyclo`,
@@ -116,7 +116,7 @@ packages → 80%, host-touching effectful packages → 50%. Generated code
116 116
117 ### The shape diagram 117 ### The shape diagram
118 118
119 `make shape` regenerates `docs/shape.html` — a self-contained, explorable view 119 `make shape` regenerates `docs/shape.html`—a self-contained, explorable view
120 of the package graph (open it directly in a browser; no server needed). It is 120 of the package graph (open it directly in a browser; no server needed). It is
121 generated from `go list`, so it cannot drift from the code; `make shape-check` 121 generated from `go list`, so it cannot drift from the code; `make shape-check`
122 gates it. `docs/shape.json` is the authoritative model that produces it. New 122 gates it. `docs/shape.json` is the authoritative model that produces it. New
docs/byo-idp.md
Old New
@@ -1,7 +1,7 @@
1 # Bring your own IdP 1 # Bring your own IdP
2 2
3 The console signs in through OIDC. The [quickstart](quickstart.md) uses the 3 The console signs in through OIDC. The [quickstart](quickstart.md) uses the
4 bundled `eitri-oidc` issuer, but any OIDC provider works — Google, Authentik, 4 bundled `eitri-oidc` issuer, but any OIDC provider works—Google, Authentik,
5 Okta, Keycloak. Point the server at yours and you never install `eitri-oidc`; 5 Okta, Keycloak. Point the server at yours and you never install `eitri-oidc`;
6 the tarball isn't even on the box. 6 the tarball isn't even on the box.
7 7
@@ -9,7 +9,7 @@ the tarball isn't even on the box.
9 9
10 At your IdP, register a **confidential** web client with: 10 At your IdP, register a **confidential** web client with:
11 11
12 - **Redirect URL:** `<public_url>/auth/callback` — `public_url` is where 12 - **Redirect URL:** `<public_url>/auth/callback`—`public_url` is where
13 browsers reach your console (e.g. `https://eitri.example.com/auth/callback`). 13 browsers reach your console (e.g. `https://eitri.example.com/auth/callback`).
14 - **Scopes:** `openid email`. The server requires an `email` claim **and 14 - **Scopes:** `openid email`. The server requires an `email` claim **and
15 `email_verified: true`**; sign-in fails against an issuer that omits either — 15 `email_verified: true`**; sign-in fails against an issuer that omits either —
@@ -35,13 +35,13 @@ In `server.json`:
35 - `issuer` is the IdP's base URL; the server discovers its endpoints from 35 - `issuer` is the IdP's base URL; the server discovers its endpoints from
36 `<issuer>/.well-known/openid-configuration`. 36 `<issuer>/.well-known/openid-configuration`.
37 - `client_secret` is required for an external confidential client (the bundled 37 - `client_secret` is required for an external confidential client (the bundled
38 issuer omits it — it's a public PKCE client). 38 issuer omits it—it's a public PKCE client).
39 - `public_url` builds the redirect and must match what you registered. 39 - `public_url` builds the redirect and must match what you registered.
40 40
41 ## The signup gate 41 ## The signup gate
42 42
43 `allowed_domains` and `allowed_identities` are the signup gate. If either is 43 `allowed_domains` and `allowed_identities` are the signup gate. If either is
44 set, an identity matching neither is rejected at callback — no tenant created. 44 set, an identity matching neither is rejected at callback—no tenant created.
45 Leave both unset for open signup (anyone your IdP authenticates gets a tenant). 45 Leave both unset for open signup (anyone your IdP authenticates gets a tenant).
46 46
47 Each new identity's first sign-in creates its own tenant. 47 Each new identity's first sign-in creates its own tenant.
docs/cert-rotation.md
Old New
@@ -3,13 +3,13 @@
3 The server's QUIC identity is a self-signed ECDSA cert generated on first 3 The server's QUIC identity is a self-signed ECDSA cert generated on first
4 start (`transport.GenerateServerCert`, 2-year validity) and persisted next to 4 start (`transport.GenerateServerCert`, 2-year validity) and persisted next to
5 the database (`server.crt` / `server.key`). Agents trust it by **fingerprint 5 the database (`server.crt` / `server.key`). Agents trust it by **fingerprint
6 pin** carried in the join blob — not by CA path and **not by expiry**: an 6 pin** carried in the join blob—not by CA path and **not by expiry**: an
7 expired cert keeps working for already-enrolled agents. Rotation is therefore 7 expired cert keeps working for already-enrolled agents. Rotation is therefore
8 never an emergency at `NotAfter`; it is hygiene that bounds how long a stolen 8 never an emergency at `NotAfter`; it is hygiene that bounds how long a stolen
9 `server.key` stays useful. 9 `server.key` stays useful.
10 10
11 The server logs its cert fingerprint at every startup, and warns — at startup 11 The server logs its cert fingerprint at every startup. It warns, at startup
12 and daily thereafter — when the cert is within 90 days of expiry 12 and daily thereafter, when the cert is within 90 days of expiry
13 (`transport.CertRenewalDue`). 13 (`transport.CertRenewalDue`).
14 14
15 ## Why rotate 15 ## Why rotate
@@ -27,32 +27,32 @@ pin. Each agent must re-enroll to pick up the new fingerprint.
27 1. Stop the server. 27 1. Stop the server.
28 2. Move the old cert+key aside: `mv server.crt server.crt.old && mv 28 2. Move the old cert+key aside: `mv server.crt server.crt.old && mv
29 server.key server.key.old` (in the DB directory). 29 server.key server.key.old` (in the DB directory).
30 3. Start the server — it generates and persists a fresh 2-year cert and logs 30 3. Start the server—it generates and persists a fresh 2-year cert and logs
31 the new fingerprint. 31 the new fingerprint.
32 4. For each host: mint a join token (`POST /api/v1/enroll-tokens`), copy the 32 4. For each host: mint a join token (`POST /api/v1/enroll-tokens`), copy the
33 `join` blob, run `eitri-agent join <blob>` on the host, **then restart the 33 `join` blob, run `eitri-agent join <blob>` on the host, **then restart the
34 agent daemon** (e.g. `systemctl restart eitri-agent`). `join` only rewrites 34 agent daemon** (e.g. `systemctl restart eitri-agent`). `join` only rewrites
35 the on-disk identity; the running daemon holds its identity in memory and 35 the on-disk identity; the running daemon holds its identity in memory and
36 keeps pinning the old fingerprint until restarted. Running VMs are 36 keeps pinning the old fingerprint until restarted. Running VMs are
37 untouched — they survive agent restarts by design, and the agent's 37 untouched—they survive agent restarts by design, and the agent's
38 reconcile state is independent of its server identity. 38 reconcile state is independent of its server identity.
39 5. Delete `server.crt.old` / `server.key.old` once every host has reconnected 39 5. Delete `server.crt.old` / `server.key.old` once every host has reconnected
40 (watch `GET /api/v1/hosts` for `online: true`). 40 (watch `GET /api/v1/hosts` for `online: true`).
41 41
42 Until a host re-enrolls, its agent logs `server cert pin mismatch` and backs 42 Until a host re-enrolls, its agent logs `server cert pin mismatch` and backs
43 off — VMs keep running, but the host is dark to the control plane. Rotate 43 off—VMs keep running, but the host is dark to the control plane. Rotate
44 during a window where that is acceptable, host by host. 44 during a window where that is acceptable, host by host.
45 45
46 ## Out of scope (future) 46 ## Out of scope (future)
47 47
48 - Overlap rotation (serving old+new certs simultaneously) — needs dual-cert 48 - Overlap rotation (serving old+new certs simultaneously)—needs dual-cert
49 listener support. 49 listener support.
50 - Pushing new pins over the existing authenticated channel (would remove the 50 - Pushing new pins over the existing authenticated channel (would remove the
51 re-enroll requirement). 51 re-enroll requirement).
52 52
53 ## Related 53 ## Related
54 54
55 - [credential-revocation.md](credential-revocation.md) — revoking a leaked host 55 - [credential-revocation.md](credential-revocation.md)—revoking a leaked host
56 credential (generation bump), the complementary emergency lever 56 credential (generation bump), the complementary emergency lever
57 - [ssh-access.md](ssh-access.md) — the SSH CAs, which are separate from the 57 - [ssh-access.md](ssh-access.md)—the SSH CAs, which are separate from the
58 QUIC identity rotated here 58 QUIC identity rotated here
docs/credential-revocation.md
Old New
@@ -31,14 +31,14 @@ revokes a credential on one of your own hosts.
31 31
32 ## Server `host_secret` leaked (the disaster case) 32 ## Server `host_secret` leaked (the disaster case)
33 33
34 Rotate `host_secret` in `server.json` and restart — this revokes **every** 34 Rotate `host_secret` in `server.json` and restart—this revokes **every**
35 host credential at once; re-enroll each host as above. 35 host credential at once; re-enroll each host as above.
36 36
37 ## Optional max-age 37 ## Optional max-age
38 38
39 `credential_max_age` in `server.json` (Go duration, e.g. `"2160h"`) rejects 39 `credential_max_age` in `server.json` (Go duration, e.g. `"2160h"`) rejects
40 credentials older than the window — at Hello and, like revocation, on every 40 credentials older than the window. It is checked at Hello and, like revocation,
41 report tick, so a live session cannot outlive the window. Off by default: 41 on every report tick, so a live session cannot outlive the window. Off by default:
42 there is no automatic renewal channel yet, so expiry trades credential 42 there is no automatic renewal channel yet, so expiry trades credential
43 lifetime against operator toil. Generation revocation is the primary 43 lifetime against operator toil. Generation revocation is the primary
44 mechanism. 44 mechanism.
@@ -46,7 +46,7 @@ mechanism.
46 ## SSH user certificates 46 ## SSH user certificates
47 47
48 Guest SSH access uses short-lived certificates self-signed with a tenant's own 48 Guest SSH access uses short-lived certificates self-signed with a tenant's own
49 user CA (see [ssh-access.md](ssh-access.md)) — eitri holds no user signing key. 49 user CA (see [ssh-access.md](ssh-access.md))—eitri holds no user signing key.
50 The short validity you sign with (`eitri ssh` uses 30 minutes) is the 50 The short validity you sign with (`eitri ssh` uses 30 minutes) is the
51 first line of defense: a leaked cert expires on its own. 51 first line of defense: a leaked cert expires on its own.
52 52
@@ -63,10 +63,10 @@ revoked serials at auth, and the revocation is recorded in the audit log
63 (`ssh-cert.revoke`). 63 (`ssh-cert.revoke`).
64 64
65 A compromised **tenant user CA** is the bigger event: the tenant's CA must be 65 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 ## Related 69 ## Related
70 70
71 - [ssh-access.md](ssh-access.md) — how user and host certs work 71 - [ssh-access.md](ssh-access.md)—how user and host certs work
72 - [cert-rotation.md](cert-rotation.md) — rotating the server's QUIC identity 72 - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity
docs/decisions.md
Old New
@@ -1,14 +1,14 @@
1 # Decisions 1 # Decisions
2 2
3 Settled ground: the irreversible or contested choices, what they beat, and why. 3 Settled ground: the irreversible or contested choices, what they beat, and why.
4 Three lines each — when a rationale needs more, it lives in the relevant doc. 4 Three lines each—when a rationale needs more, it lives in the relevant doc.
5 For *how* we decide, see [ethos.md](ethos.md). 5 For *how* we decide, see [ethos.md](ethos.md).
6 6
7 ### Desired-state loop, not RPC 7 ### Desired-state loop, not RPC
8 8
9 The server streams desired state; agents converge toward it and report actual 9 The server streams desired state; agents converge toward it and report actual
10 state back. Instead of imperative RPC per operation. Level-triggered convergence 10 state back. Instead of imperative RPC per operation. Level-triggered convergence
11 survives disconnects, crashes, and missed messages — a host re-derives 11 survives disconnects, crashes, and missed messages—a host re-derives
12 everything from persisted records plus what it observes. Details in 12 everything from persisted records plus what it observes. Details in
13 [architecture.md](architecture.md). 13 [architecture.md](architecture.md).
14 14
@@ -23,7 +23,7 @@ kernel upgrades are the guest's business, not the fleet's.
23 23
24 Each tenant signs its own SSH user certs; eitri stores only CA public keys. 24 Each tenant signs its own SSH user certs; eitri stores only CA public keys.
25 Instead of the server minting user certs from an eitri-held CA. A compromised 25 Instead of the server minting user certs from an eitri-held CA. A compromised
26 eitri server cannot mint access to any tenant's guests — the user-auth trust 26 eitri server cannot mint access to any tenant's guests—the user-auth trust
27 root lives with the tenant. Details in [ssh-access.md](ssh-access.md). 27 root lives with the tenant. Details in [ssh-access.md](ssh-access.md).
28 28
29 ### SSH-CA jump gate, not a mesh 29 ### SSH-CA jump gate, not a mesh
@@ -37,7 +37,7 @@ dependency to operate or trust.
37 37
38 Each host admits vCPU / memory / disk / address through one serialized gate on 38 Each host admits vCPU / memory / disk / address through one serialized gate on
39 the agent. Instead of a server-side scheduling ledger. The host is the source 39 the agent. Instead of a server-side scheduling ledger. The host is the source
40 of truth for its own capacity — admission that consults reality cannot drift 40 of truth for its own capacity—admission that consults reality cannot drift
41 from it, and a partitioned host stays correct on its own. 41 from it, and a partitioned host stays correct on its own.
42 42
43 ### Embedded DHCP owns guest addressing 43 ### Embedded DHCP owns guest addressing
@@ -72,6 +72,6 @@ owner.
72 ### Agents pin the server cert by fingerprint 72 ### Agents pin the server cert by fingerprint
73 73
74 Agents trust the server's self-signed QUIC cert by fingerprint from the join 74 Agents trust the server's self-signed QUIC cert by fingerprint from the join
75 blob — not by CA path, not by expiry. Instead of a CA hierarchy or expiring 75 blob—not by CA path, not by expiry. Instead of a CA hierarchy or expiring
76 trust. An expired cert can never brick a fleet; rotation is deliberate hygiene, 76 trust. An expired cert can never brick a fleet; rotation is deliberate hygiene,
77 not an emergency. Details in [cert-rotation.md](cert-rotation.md). 77 not an emergency. Details in [cert-rotation.md](cert-rotation.md).
docs/ethos.md
Old New
@@ -4,7 +4,7 @@
4 4
5 ## North star 5 ## North star
6 6
7 eitri is a **general-purpose cloud for quasi-cattle** — user-administered VMs with an on-demand lifecycle (throwaway → long-lived webserver). 7 eitri is a **general-purpose cloud for quasi-cattle**—user-administered VMs with an on-demand lifecycle (throwaway → long-lived webserver).
8 8
9 > **The guest owns the guest; eitri owns the fleet.** 9 > **The guest owns the guest; eitri owns the fleet.**
10 10
@@ -14,26 +14,26 @@ That line is the tie-breaker for almost everything.
14 14
15 Each is tagged with a real fork it settled, so it stays concrete rather than aspirational. 15 Each is tagged with a real fork it settled, so it stays concrete rather than aspirational.
16 16
17 1. **The guest/fleet boundary is the VM's edge.** Inside — kernel, packages, filesystem, in-VM config — is the user's; don't manage it. Outside — placement, lifecycle, access seam, quotas, identity — is eitri's; own it fully. 17 1. **The guest/fleet boundary is the VM's edge.** Inside—kernel, packages, filesystem, in-VM config—is the user's; don't manage it. Outside—placement, lifecycle, access seam, quotas, identity—is eitri's; own it fully.
18 *→ guests own their kernel (UEFI + guest GRUB, not pinned by us); cloud-init injection stays minimal.* 18 *→ guests own their kernel (UEFI + guest GRUB, not pinned by us); cloud-init injection stays minimal.*
19 19
20 2. **When unsure, do what a real cloud does — and distrust anything the clouds abandoned.** EC2/GCE/Linode/DO already paid for these lessons. 20 2. **When unsure, do what a real cloud does—and distrust anything the clouds abandoned.** EC2/GCE/Linode/DO already paid for these lessons.
21 *→ UEFI + guest GRUB, not provider-pinned external kernels (which AWS→PV-GRUB, DigitalOcean, and Linode all migrated away from).* 21 *→ UEFI + guest GRUB, not provider-pinned external kernels (which AWS→PV-GRUB, DigitalOcean, and Linode all migrated away from).*
22 22
23 3. **Composable, agnostic seams — BYO everything (CNI-style).** We care that an interface is satisfied, not what satisfies it. 23 3. **Composable, agnostic seams—BYO everything (CNI-style).** We care that an interface is satisfied, not what satisfies it.
24 *→ BYO image = just a disk; network-provider seam; no hardcoded fabric, image, or kernel.* 24 *→ BYO image = just a disk; network-provider seam; no hardcoded fabric, image, or kernel.*
25 25
26 4. **Design for the most demanding workload in the union, not the average.** The long-lived pet's needs dominate; serve it and the throwaway case is free. 26 4. **Design for the most demanding workload in the union, not the average.** The long-lived pet's needs dominate; serve it and the throwaway case is free.
27 *→ "kernel security updates must take effect" (the webserver) drove UEFI over direct kernel boot.* 27 *→ "kernel security updates must take effect" (the webserver) drove UEFI over direct kernel boot.*
28 28
29 5. **Prefer the design that removes machinery.** Ballooning scope — extra wire fields, validation ladders, lifecycle plumbing — is a smell that you're on the wrong side of a boundary or fighting the ecosystem. 29 5. **Prefer the design that removes machinery.** Ballooning scope—extra wire fields, validation ladders, lifecycle plumbing—is a smell that you're on the wrong side of a boundary or fighting the ecosystem.
30 *→ a firmware swap beat direct kernel boot + a wire contract + a validation ladder + kernel-as-control-plane-input.* 30 *→ a firmware swap beat direct kernel boot + a wire contract + a validation ladder + kernel-as-control-plane-input.*
31 31
32 6. **Defer freely, foreclose never.** Ship the minimum; keep seams open so deferred features slot in without a rewrite. 32 6. **Defer freely, foreclose never.** Ship the minimum; keep seams open so deferred features slot in without a rewrite.
33 *→ multi-tenancy deferred but design-forward; direct kernel boot kept as a back-pocket fast-ephemeral profile.* 33 *→ multi-tenancy deferred but design-forward; direct kernel boot kept as a back-pocket fast-ephemeral profile.*
34 34
35 7. **Prove it on the stack; evidence over assertion.** Boot the VM, capture the serial. 35 7. **Prove it on the stack; evidence over assertion.** Boot the VM, capture the serial.
36 *→ the "modern images panic under CH firmware (EFI/Secure-Boot/TPM, CH #7356)" comment was flat wrong — only caught by reproducing it.* 36 *→ the "modern images panic under CH firmware (EFI/Secure-Boot/TPM, CH #7356)" comment was flat wrong—only caught by reproducing it.*
37 37
38 ## The "um and ahh" checklist 38 ## The "um and ahh" checklist
39 39
docs/faq.md
Old New
@@ -8,11 +8,11 @@ live on private per-host bridges, and the only built-in way in is the SSH
8 [jump gate](ssh-access.md). 8 [jump gate](ssh-access.md).
9 9
10 To serve traffic from a VM, or to reach one directly from another network, 10 To serve traffic from a VM, or to reach one directly from another network,
11 install [Tailscale](https://tailscale.com) (or WireGuard) inside it — it is 11 install [Tailscale](https://tailscale.com) (or WireGuard) inside it—it is
12 a normal Linux machine. A public gateway is on the 12 a normal Linux machine. A public gateway is on the
13 [roadmap](../ROADMAP.md). 13 [roadmap](../ROADMAP.md).
14 14
15 ## VMs boot and SSH works, but have no outbound network — why? 15 ## VMs boot and SSH works, but have no outbound network—why?
16 16
17 Docker. Installing (or starting) Docker on a host sets the kernel's iptables 17 Docker. Installing (or starting) Docker on a host sets the kernel's iptables
18 `FORWARD` policy to drop, which silently discards the guests' NAT'd traffic — 18 `FORWARD` policy to drop, which silently discards the guests' NAT'd traffic —
docs/mcp.md
Old New
@@ -2,7 +2,7 @@
2 2
3 `eitri-mcp` (`cmd/eitri-mcp`) is a stdio MCP server that gives Claude seven 3 `eitri-mcp` (`cmd/eitri-mcp`) is a stdio MCP server that gives Claude seven
4 explicit tools for creating and controlling VMs on an eitri fleet. It is an 4 explicit tools for creating and controlling VMs on an eitri fleet. It is an
5 API client of the eitri control plane plus SSH — it embeds no control-plane 5 API client of the eitri control plane plus SSH—it embeds no control-plane
6 or agent code. 6 or agent code.
7 7
8 ## Tools 8 ## Tools
@@ -15,11 +15,11 @@ or agent code.
15 | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. | 15 | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. |
16 | `vm_write_file` | Write content to a path in a VM over SFTP (parent dirs created). | 16 | `vm_write_file` | Write content to a path in a VM over SFTP (parent dirs created). |
17 | `vm_read_file` | Read a file from a VM over SFTP (capped at 1 MiB, truncation flagged). | 17 | `vm_read_file` | Read a file from a VM over SFTP (capped at 1 MiB, truncation flagged). |
18 | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only — never called automatically. | 18 | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. |
19 19
20 Deliberately absent: any host or fleet-level operation (enroll, decommission, 20 Deliberately absent: any host or fleet-level operation (enroll, decommission,
21 power management, image/firmware knobs). The worst case from a confused model 21 power management, image/firmware knobs). The worst case from a confused model
22 is VM churn, never fleet damage — and Claude Code's per-tool permission 22 is VM churn, never fleet damage—and Claude Code's per-tool permission
23 prompts gate every call regardless. 23 prompts gate every call regardless.
24 24
25 ## Setup 25 ## Setup
@@ -30,7 +30,7 @@ prompts gate every call regardless.
30 make build 30 make build
31 ``` 31 ```
32 32
33 (or `go build ./cmd/eitri-mcp`) — this produces `bin/eitri-mcp`. 33 (or `go build ./cmd/eitri-mcp`)—this produces `bin/eitri-mcp`.
34 34
35 2. Create `~/.config/eitri-mcp/config.json`: 35 2. Create `~/.config/eitri-mcp/config.json`:
36 36
@@ -44,20 +44,20 @@ prompts gate every call regardless.
44 ``` 44 ```
45 45
46 Fields (see `internal/mcpserver/config.go`): 46 Fields (see `internal/mcpserver/config.go`):
47 - `server_url` — required, the eitri API base URL. 47 - `server_url`—required, the eitri API base URL.
48 - `token_file` — required, path to a file holding a personal access token 48 - `token_file`—required, path to a file holding a personal access token
49 (mint one in the console Settings page; read at startup, held in memory, 49 (mint one in the console Settings page; read at startup, held in memory,
50 never surfaced in a tool result or error). 50 never surfaced in a tool result or error).
51 - `gate` — the SSH-CA jump gate address, `<gate-domain>:<port>`; the MCP 51 - `gate`—the SSH-CA jump gate address, `<gate-domain>:<port>`; the MCP
52 reaches all VMs by name through it. The host part must match the 52 reaches all VMs by name through it. The host part must match the
53 gate's host certificate principal (the server's `ssh_gate_domain`, 53 gate's host certificate principal (the server's `ssh_gate_domain`,
54 which defaults to the `ssh_listen` host). 54 which defaults to the `ssh_listen` host).
55 - `vm_user` — guest SSH user; defaults to `ubuntu` if omitted. 55 - `vm_user`—guest SSH user; defaults to `ubuntu` if omitted.
56 - `ca_key_path` — optional path to this client's persistent user CA 56 - `ca_key_path`—optional path to this client's persistent user CA
57 (load-or-create); defaults to a `user_ca` file next to the config. Its 57 (load-or-create); defaults to a `user_ca` file next to the config. Its
58 public key self-registers with the tenant on first gate use, so a fresh 58 public key self-registers with the tenant on first gate use, so a fresh
59 install needs only a PAT — no manual `eitri ca upload`. 59 install needs only a PAT—no manual `eitri ca upload`.
60 - `tenant` — optional. The credential names the tenant (VM connect names are 60 - `tenant`—optional. The credential names the tenant (VM connect names are
61 derived from it), so leave this unset. Set it only when the PAT's user 61 derived from it), so leave this unset. Set it only when the PAT's user
62 CA/tenant mapping is ambiguous, e.g. a human or CA belonging to more than 62 CA/tenant mapping is ambiguous, e.g. a human or CA belonging to more than
63 one tenant. 63 one tenant.
@@ -73,49 +73,49 @@ prompts gate every call regardless.
73 73
74 ## Access model 74 ## Access model
75 75
76 eitri-mcp reaches VMs by name through eitri's SSH-CA jump gate — there is no 76 eitri-mcp reaches VMs by name through eitri's SSH-CA jump gate. There is no
77 injected key and no TOFU. It holds its own user CA (`ca_key_path`, 77 injected key and no TOFU. It holds its own user CA (`ca_key_path`,
78 load-or-create) and self-registers that CA's public key with its tenant on 78 load-or-create) and self-registers that CA's public key with its tenant on
79 first use — eitri never sees the private half. Per connection it signs a 79 first use; eitri never sees the private half. Per connection it signs a
80 short-lived user certificate locally (principal `ubuntu`) with that CA. It 80 short-lived user certificate locally (principal `ubuntu`) with that CA. It
81 also fetches the eitri host CA's public key once via `GET /api/v1/ssh-ca` 81 fetches the eitri host CA's public key once via `GET /api/v1/ssh-ca`
82 and caches it. To reach a VM, it derives its tenant from the credential 82 and caches it. To reach a VM, it derives its tenant from the credential
83 (`/me`, unless `tenant` pins one), dials the gate (the configured `gate` 83 (`/me`, unless `tenant` pins one), dials the gate (the configured `gate`
84 address), authenticates with the user certificate, and opens a tunnel to 84 address), authenticates with the user certificate, and opens a tunnel to
85 `<tenant>.<vm>:22` — VMs are addressed by their namespaced connect name, 85 `<tenant>.<vm>:22`. VMs are addressed by their namespaced connect name,
86 not IP, so recycled IPs and host-key churn are not a concern. Host identity 86 not IP, so recycled IPs and host-key churn are not a concern. Host identity
87 is verified on both hops using `ssh.CertChecker` against the eitri host CA: 87 is verified on both hops using `ssh.CertChecker` against the eitri host CA:
88 the gate's host certificate must carry its configured domain as principal, 88 the gate's host certificate must carry its configured domain as principal,
89 and each VM's host certificate must carry the VM's connect name. The guest 89 and each VM's host certificate must carry the VM's connect name. The guest
90 trusts the tenant's registered user CAs via `TrustedUserCAKeys` 90 trusts the tenant's registered user CAs via `TrustedUserCAKeys`
91 (provisioned through vendor-data), so no per-VM `authorized_key` injection 91 (provisioned through vendor-data), so no per-VM `authorized_key` injection
92 is needed. The PAT authenticates API calls only — actual SSH traffic uses 92 is needed. The PAT authenticates API calls only; SSH traffic uses
93 the certificate, and the token itself is never surfaced in a tool result or 93 the certificate, and the token itself is never surfaced in a tool result or
94 error. 94 error.
95 95
96 ## Semantics 96 ## Semantics
97 97
98 - Every VM is created with `persistent: true`. There is **no TTL and no 98 - Every VM is created with `persistent: true`. There is **no TTL and no
99 reaper** — VMs live until something explicitly destroys them. `vm_destroy` 99 reaper**—VMs live until something explicitly destroys them. `vm_destroy`
100 (exact id or name, no wildcards, no bulk) is the only kill path, and Claude 100 (exact id or name, no wildcards, no bulk) is the only kill path, and Claude
101 is instructed to treat it as explicit-only, never automatic cleanup. 101 is instructed to treat it as explicit-only, never automatic cleanup.
102 - The tool surface has no host or fleet operations by design — see the tools 102 - The tool surface has no host or fleet operations by design—see the tools
103 table above. 103 table above.
104 - Service exposure (ports, DNS, TLS certs, routing) is out of scope: the 104 - Service exposure (ports, DNS, TLS certs, routing) is out of scope: the
105 tools hand back a host and an `ssh` command; getting a service reachable 105 tools hand back a host and an `ssh` command; getting a service reachable
106 from outside the VM is the caller's business. 106 from outside the VM is the caller's business.
107 - The claude.ai connector (streamable HTTP transport + auth + ingress) is 107 - The claude.ai connector (streamable HTTP transport + auth + ingress) is
108 phase 2 and not built — today's transport is stdio, for Claude Code only. 108 phase 2 and not built—today's transport is stdio, for Claude Code only.
109 Phase 2's VM access is expected to reuse the same short-lived-certificate 109 Phase 2's VM access is expected to reuse the same short-lived-certificate
110 flow through the gate, just with gate ingress reachable from claude.ai 110 flow through the gate, just with gate ingress reachable from claude.ai
111 instead of only from the MCP's host. 111 instead of only from the MCP's host.
112 112
113 > **IMPORTANT — "ready" is not "booted."** `vm_create`'s `lifecycle=ready` 113 > **IMPORTANT—"ready" is not "booted."** `vm_create`'s `lifecycle=ready`
114 > means cloud-hypervisor is up and the VM has an allocated IP; it does **not** 114 > means cloud-hypervisor is up and the VM has an allocated IP; it does **not**
115 > mean the guest has finished booting Linux, brought up its NIC, or started 115 > mean the guest has finished booting Linux, brought up its NIC, or started
116 > `sshd`. `vm_create` (with the default `wait: true`) accounts for this: it 116 > `sshd`. `vm_create` (with the default `wait: true`) accounts for this: it
117 > polls for `ready` + IP, then retries SSH until it connects, then runs 117 > polls for `ready` + IP, then retries SSH until it connects, then runs
118 > `cloud-init status --wait` before returning — so a normal `vm_create` call 118 > `cloud-init status --wait` before returning—so a normal `vm_create` call
119 > only returns once the guest is genuinely usable. But if you reach a 119 > only returns once the guest is genuinely usable. But if you reach a
120 > just-created VM some other way (e.g. its IP from `vm_list`/`vm_info` 120 > just-created VM some other way (e.g. its IP from `vm_list`/`vm_info`
121 > immediately after creation, or `wait: false`), it may still be mid-boot and 121 > immediately after creation, or `wait: false`), it may still be mid-boot and
docs/quickstart.md
Old New
@@ -15,7 +15,7 @@ sign-in creates your tenant.
15 15
16 ### Join a host 16 ### Join a host
17 17
18 On the machine that will serve VMs (Linux, KVM — see "What you need" 18 On the machine that will serve VMs (Linux, KVM—see "What you need"
19 under Self-hosting), download and verify the host bundle: 19 under Self-hosting), download and verify the host bundle:
20 20
21 ```sh 21 ```sh
@@ -27,7 +27,7 @@ tar xzf "eitri_${V}_linux_amd64.tar.gz" && cd "eitri_${V}_linux_amd64"
27 ``` 27 ```
28 28
29 Then click **+ Add host** in the console and run the command it prints 29 Then click **+ Add host** in the console and run the command it prints
30 from the unpacked bundle — it installs the agent and joins this machine 30 from the unpacked bundle—it installs the agent and joins this machine
31 to your tenant: 31 to your tenant:
32 32
33 ```sh 33 ```sh
@@ -38,7 +38,7 @@ sudo systemctl daemon-reload
38 sudo systemctl enable --now eitri-agent 38 sudo systemctl enable --now eitri-agent
39 ``` 39 ```
40 40
41 The agent dials out — a machine behind NAT needs no open ports. It goes 41 The agent dials out—a machine behind NAT needs no open ports. It goes
42 online in the console within seconds. 42 online in the console within seconds.
43 43
44 ### Boot a VM 44 ### Boot a VM
@@ -48,7 +48,7 @@ browser serial console.
48 48
49 ### SSH in 49 ### SSH in
50 50
51 SSH access uses certificates signed by **your** CA — eitri never holds a 51 SSH access uses certificates signed by **your** CA—eitri never holds a
52 user key that can enter your VMs. On your laptop, download the client CLI. It 52 user key that can enter your VMs. On your laptop, download the client CLI. It
53 targets the hosted service by default, so the only thing to set is a personal 53 targets the hosted service by default, so the only thing to set is a personal
54 access token: 54 access token:
@@ -62,7 +62,7 @@ sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri
62 export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens 62 export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens
63 ``` 63 ```
64 64
65 Register a user CA once — eitri gets the public key, never the private one: 65 Register a user CA once—eitri gets the public key, never the private one:
66 66
67 ```sh 67 ```sh
68 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" 68 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
@@ -73,7 +73,7 @@ Your token names the tenant, so neither command needs one. In more than one
73 tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly. 73 tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly.
74 74
75 Then SSH in. The console and gate are hosted defaults, and `eitri ssh` uses 75 Then SSH in. The console and gate are hosted defaults, and `eitri ssh` uses
76 your token to look up your tenant for the connect name — nothing else to set: 76 your token to look up your tenant for the connect name—nothing else to set:
77 77
78 ```sh 78 ```sh
79 eitri ssh <vm-name> 79 eitri ssh <vm-name>
@@ -82,7 +82,7 @@ eitri ssh <vm-name> uptime
82 82
83 ## Self-hosting 83 ## Self-hosting
84 84
85 Run the whole control plane yourself — everything below is your own box. 85 Run the whole control plane yourself—everything below is your own box.
86 eitri is three pieces: a server, an agent on every box that runs VMs, and 86 eitri is three pieces: a server, an agent on every box that runs VMs, and
87 your laptop. `192.0.2.10` is the server below. Substitute yours. 87 your laptop. `192.0.2.10` is the server below. Substitute yours.
88 88
@@ -97,12 +97,12 @@ manage them by hand instead, disable it in `/etc/default/eitri-agent`:
97 Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle 97 Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle
98 (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and 98 (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and
99 their systemd units. The issuer bundle 99 their systemd units. The issuer bundle
100 (`eitri-oidc_<version>_linux_amd64.tar.gz`) has `eitri-oidc` — the bundled 100 (`eitri-oidc_<version>_linux_amd64.tar.gz`) has `eitri-oidc`—the bundled
101 sign-in provider — and its unit. The client bundle 101 sign-in provider—and its unit. The client bundle
102 (`eitri-cli_<version>_<os>_<arch>.tar.gz`) is the single `eitri` binary for 102 (`eitri-cli_<version>_<os>_<arch>.tar.gz`) is the single `eitri` binary for
103 your laptop, built for linux and macOS. arm64 boxes take the arm64 bundle. 103 your laptop, built for linux and macOS. arm64 boxes take the arm64 bundle.
104 104
105 Download and verify — set `V` to the current release (shown at 105 Download and verify—set `V` to the current release (shown at
106 [/dl](https://eitri.sh/dl/)): 106 [/dl](https://eitri.sh/dl/)):
107 107
108 ```sh 108 ```sh
@@ -114,7 +114,7 @@ curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS"
114 sha256sum -c SHA256SUMS --ignore-missing 114 sha256sum -c SHA256SUMS --ignore-missing
115 ``` 115 ```
116 116
117 Plain files at stable URLs — no install script. `sha256sum -c` must say OK 117 Plain files at stable URLs—no install script. `sha256sum -c` must say OK
118 for every tarball before you unpack anything. 118 for every tarball before you unpack anything.
119 119
120 ### The server 120 ### The server
@@ -127,7 +127,7 @@ sudo useradd --system --home-dir /var/lib/eitri --shell /usr/sbin/nologin eitri
127 sudo mkdir -p /etc/eitri 127 sudo mkdir -p /etc/eitri
128 ``` 128 ```
129 129
130 The server runs as the dedicated `eitri` user — it needs no privilege (every 130 The server runs as the dedicated `eitri` user—it needs no privilege (every
131 listener is on an unprivileged port); its state directory `/var/lib/eitri` is 131 listener is on an unprivileged port); its state directory `/var/lib/eitri` is
132 created and owned for it by the unit. 132 created and owned for it by the unit.
133 133
@@ -166,7 +166,7 @@ sudo chgrp eitri /etc/eitri/server.json && sudo chmod 0640 /etc/eitri/server.jso
166 ``` 166 ```
167 167
168 The chmod matters: `server.json` carries `host_secret`, so it is root-owned 168 The chmod matters: `server.json` carries `host_secret`, so it is root-owned
169 and readable only via the `eitri` group — not world-readable. 169 and readable only via the `eitri` group—not world-readable.
170 170
171 `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any 171 `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any
172 cloud-init disk image works as the default image; the Ubuntu one boots out of 172 cloud-init disk image works as the default image; the Ubuntu one boots out of
@@ -206,11 +206,11 @@ sudo -u eitri-oidc eitri-oidc user add you@example.com # prompts for a passwo
206 ``` 206 ```
207 207
208 The issuer handles passwords and holds the sign-in signing key, so it runs as 208 The issuer handles passwords and holds the sign-in signing key, so it runs as
209 its own `eitri-oidc` user — separate even from the server's `eitri` user, 209 its own `eitri-oidc` user—separate even from the server's `eitri` user,
210 keeping the key material unreadable by any other service. Its mutable state 210 keeping the key material unreadable by any other service. Its mutable state
211 (user file, autogenerated signing key) lives in `/var/lib/eitri-oidc`, created 211 (user file, autogenerated signing key) lives in `/var/lib/eitri-oidc`, created
212 and owned for it by the unit; that's also why `user add` runs via 212 and owned for it by the unit; that's also why `user add` runs via
213 `sudo -u eitri-oidc` — files it writes stay readable by the service. 213 `sudo -u eitri-oidc`—files it writes stay readable by the service.
214 214
215 `redirect_url` must equal the server's `oidc.public_url` + `/auth/callback`. On 215 `redirect_url` must equal the server's `oidc.public_url` + `/auth/callback`. On
216 this single box the loopback `issuer`/`public_url` work because your browser is 216 this single box the loopback `issuer`/`public_url` work because your browser is
docs/releases.md
Old New
@@ -8,7 +8,7 @@ running VM.
8 ## v0.0.1 8 ## v0.0.1
9 9
10 The first release. eitri is a small cloud backed by your own hardware: one 10 The first release. eitri is a small cloud backed by your own hardware: one
11 control-plane server — hosted at eitri.sh or run yourself — an agent on every 11 control-plane server—hosted at eitri.sh or run yourself—an agent on every
12 Linux/KVM box you enroll, cloud-hypervisor microVMs, a web console, and SSH 12 Linux/KVM box you enroll, cloud-hypervisor microVMs, a web console, and SSH
13 into guests through an identity-checking jump gate. 13 into guests through an identity-checking jump gate.
14 14
@@ -16,35 +16,35 @@ into guests through an identity-checking jump gate.
16 console. The agent reconciles each VM in its own worker, admits VMs against 16 console. The agent reconciles each VM in its own worker, admits VMs against
17 the host's real resources, fetches cloud-hypervisor and the guest firmware 17 the host's real resources, fetches cloud-hypervisor and the guest firmware
18 itself (sha-verified against the release), and upgrades itself from the 18 itself (sha-verified against the release), and upgrades itself from the
19 console — running VMs survive agent restarts and upgrades. 19 console—running VMs survive agent restarts and upgrades.
20 20
21 **VMs.** UEFI guests boot any cloud-init disk image — the guest owns its 21 **VMs.** UEFI guests boot any cloud-init disk image—the guest owns its
22 kernel. Addressing is an embedded DHCP server with per-VM reservations. The 22 kernel. Addressing is an embedded DHCP server with per-VM reservations. The
23 console shows live host metrics, per-VM status and a browser serial console; 23 console shows live host metrics, per-VM status and a browser serial console;
24 tenant quotas bound what a tenant can create. 24 tenant quotas bound what a tenant can create.
25 25
26 **Sign-in and tenancy.** The console signs in through OIDC — the bundled 26 **Sign-in and tenancy.** The console signs in through OIDC—the bundled
27 `eitri-oidc` issuer for a single box, or [bring your own IdP](byo-idp.md) 27 `eitri-oidc` issuer for a single box, or [bring your own IdP](byo-idp.md)
28 (Google, Okta, Keycloak…). Every identity's first sign-in creates its own 28 (Google, Okta, Keycloak…). Every identity's first sign-in creates its own
29 tenant; every API request, event stream, console session and audit row is 29 tenant; every API request, event stream, console session and audit row is
30 tenant-scoped. CLI and automation authenticate with personal access tokens 30 tenant-scoped. CLI and automation authenticate with personal access tokens
31 minted in the console. 31 minted in the console.
32 32
33 **SSH.** Tenants register their own user CA — eitri never holds a user 33 **SSH.** Tenants register their own user CA—eitri never holds a user
34 private key. `eitri ssh` self-signs a short-lived certificate, pins eitri's 34 private key. `eitri ssh` self-signs a short-lived certificate, pins eitri's
35 host CA, and jumps the gate to `<tenant>.<vm>`; certificates are revocable 35 host CA, and jumps the gate to `<tenant>.<vm>`; certificates are revocable
36 per serial. See [ssh-access.md](ssh-access.md). 36 per serial. See [ssh-access.md](ssh-access.md).
37 37
38 **Hosted.** Don't want to run the control plane? Sign in at 38 **Hosted.** Don't want to run the control plane? Sign in at
39 [console.eitri.sh](https://console.eitri.sh) with Google and join your own 39 [console.eitri.sh](https://console.eitri.sh) with Google and join your own
40 machines — the same server the tarball ships, run for you. Your hardware 40 machines—the same server the tarball ships, run for you. Your hardware
41 still serves every VM; the hosted plane only coordinates it. Self-hosting 41 still serves every VM; the hosted plane only coordinates it. Self-hosting
42 stays a first-class path. 42 stays a first-class path.
43 43
44 **Running it.** `eitri-server` and `eitri-oidc` ship with hardened systemd 44 **Running it.** `eitri-server` and `eitri-oidc` ship with hardened systemd
45 units and run as dedicated non-root users; the agent's unit documents exactly 45 units and run as dedicated non-root users; the agent's unit documents exactly
46 why it runs as root. Server and issuer state live under `/var/lib`, config 46 why it runs as root. Server and issuer state live under `/var/lib`, config
47 under `/etc/eitri`. The server speaks plain HTTP — front it with TLS if it 47 under `/etc/eitri`. The server speaks plain HTTP—front it with TLS if it
48 leaves your LAN. 48 leaves your LAN.
49 49
50 **Platforms.** Hosts: Linux amd64 and arm64 with KVM. Client CLI: Linux and 50 **Platforms.** Hosts: Linux amd64 and arm64 with KVM. Client CLI: Linux and
@@ -52,9 +52,9 @@ macOS, amd64 and arm64.
52 52
53 **Known limits.** 53 **Known limits.**
54 54
55 - One user per tenant; shared/multi-user tenants are the headline for v0.0.2. 55 - One user per tenant.
56 - No TLS termination in the server — put a proxy in front for anything 56 - No TLS termination in the server—put a proxy in front for anything
57 routable. 57 routable.
58 - Switching `oidc.issuer` after tenants exist strands their identity 58 - Switching `oidc.issuer` after tenants exist strands their identity
59 bindings; pick your issuer before inviting sign-ins. 59 bindings; pick your issuer before inviting sign-ins.
60 - macOS hosts are not supported yet (planned). 60 - macOS hosts are not supported.
docs/ssh-access.md
Old New
@@ -2,15 +2,15 @@
2 2
3 eitri runs an SSH **jump gate**: a bastion that accepts an `ssh -J` hop and 3 eitri runs an SSH **jump gate**: a bastion that accepts an `ssh -J` hop and
4 forwards you to a VM's SSHd. You authenticate to the gate with a **short-lived 4 forwards you to a VM's SSHd. You authenticate to the gate with a **short-lived
5 SSH user certificate that you sign yourself**, with your tenant's own user CA — 5 SSH user certificate that you sign yourself**, using your tenant's own user CA.
6 eitri never holds a user signing key. Every VM in your tenant trusts your 6 Every VM in your tenant trusts your tenant's user CAs—seeded at VM create, so
7 tenant's user CAs (seeded at VM create), so no per-VM key management is needed. 7 there's no per-VM key to manage—and the cert carries the principal `ubuntu`,
8 The cert carries the principal `ubuntu`, which is the login user on the VM. 8 the login user on the VM.
9 9
10 Verification runs **both ways**. Just as the VM trusts your user cert, you 10 Verification runs **both ways**. Just as the VM trusts your user cert, you
11 verify what you connect to: the gate and every VM present a **host certificate** 11 verify what you connect to: the gate and every VM present a **host certificate**
12 signed by eitri's host CA. You pin that CA once (`@cert-authority`) and both 12 signed by eitri's host CA. You pin that CA once (`@cert-authority`) and both
13 hops are then verified by certificate — no blind trust-on-first-use, and no 13 hops are then verified by certificate—no blind trust-on-first-use, and no
14 host-key-changed warnings when VM names or IPs are recycled. 14 host-key-changed warnings when VM names or IPs are recycled.
15 15
16 Two CAs, two directions: **your tenant's user CA** (private key on your machine) 16 Two CAs, two directions: **your tenant's user CA** (private key on your machine)
@@ -34,7 +34,7 @@ eitri ca upload ~/.ssh/eitri_user_ca.pub
34 ``` 34 ```
35 35
36 The CA's private key never leaves your machine; the server stores only the 36 The CA's private key never leaves your machine; the server stores only the
37 public key. Upload the CA **before creating VMs** — a VM trusts the tenant user 37 public key. Upload the CA **before creating VMs**—a VM trusts the tenant user
38 CAs present at its creation. The gate authorizes each connection against the 38 CAs present at its creation. The gate authorizes each connection against the
39 tenant the signing CA was uploaded to. 39 tenant the signing CA was uploaded to.
40 40
@@ -49,7 +49,7 @@ eitri ssh <vm-name> uptime # runs a command and exits
49 ``` 49 ```
50 50
51 You pass the bare `<vm-name>`, but the name that reaches the wire is always the 51 You pass the bare `<vm-name>`, but the name that reaches the wire is always the
52 **gate connect name** `<tenant>.<vm-name>` — a VM's host cert carries exactly 52 **gate connect name** `<tenant>.<vm-name>`—a VM's host cert carries exactly
53 that one principal, and `eitri ssh` verifies the dialed name against it under 53 that one principal, and `eitri ssh` verifies the dialed name against it under
54 strict checking, so a bare name would fail host verification. (The gate itself 54 strict checking, so a bare name would fail host verification. (The gate itself
55 also *resolves* a bare name within the connection's tenant, but the VM's cert 55 also *resolves* a bare name within the connection's tenant, but the VM's cert
@@ -70,14 +70,11 @@ Environment variables:
70 | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) | 70 | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) |
71 | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) | 71 | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) |
72 72
73 The SSH session authenticates with no API credential — your signing CA *is* the 73 The SSH session authenticates with no API credential—your signing CA *is* the
74 credential. `eitri ssh` generates `~/.ssh/id_ed25519` if missing, self-signs a 74 credential. `eitri ssh` generates `~/.ssh/id_ed25519` if missing, self-signs a
75 30-minute cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the 75 30-minute cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the
76 eitri host CA and pins it as `@cert-authority *` in a dedicated known_hosts 76 eitri host CA and pins it as `@cert-authority *` in a dedicated known_hosts
77 file, and execs `ssh` with both hops verified. The one thing it needs your 77 file, and execs `ssh` with both hops verified.
78 `EITRI_TOKEN` for is looking up your tenant to build the namespaced connect
79 name — set `EITRI_TENANT` instead to skip that call (offline, or the
80 multi-tenant pin).
81 78
82 > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, 79 > The host `EITRI_GATE` points at **must match** the gate's host-cert principal,
83 > i.e. the server's `ssh_gate_domain` (which defaults to the host part of 80 > i.e. the server's `ssh_gate_domain` (which defaults to the host part of
@@ -87,7 +84,7 @@ multi-tenant pin).
87 84
88 The client is a thin wrapper over three steps you can run by hand: 85 The client is a thin wrapper over three steps you can run by hand:
89 86
90 1. **Self-sign a cert** for your public key with your tenant CA — no server 87 1. **Self-sign a cert** for your public key with your tenant CA—no server
91 involved: 88 involved:
92 89
93 ```sh 90 ```sh
@@ -97,7 +94,7 @@ The client is a thin wrapper over three steps you can run by hand:
97 94
98 2. **Place the cert beside the key.** `ssh-keygen -s` writes 95 2. **Place the cert beside the key.** `ssh-keygen -s` writes
99 `id_ed25519-cert.pub` next to the key, and OpenSSH auto-offers a cert named 96 `id_ed25519-cert.pub` next to the key, and OpenSSH auto-offers a cert named
100 `<key>-cert.pub` — nothing further needed, no `ssh-add`. 97 `<key>-cert.pub`—nothing further needed, no `ssh-add`.
101 98
102 3. **Hop through the gate** to `ubuntu@<tenant>.<vm-name>`: 99 3. **Hop through the gate** to `ubuntu@<tenant>.<vm-name>`:
103 100
@@ -113,19 +110,16 @@ The client is a thin wrapper over three steps you can run by hand:
113 ## Certs are short-lived 110 ## Certs are short-lived
114 111
115 Self-signed certs should carry a short validity (`-V +30m` above). When one 112 Self-signed certs should carry a short validity (`-V +30m` above). When one
116 expires, ssh is simply rejected — re-run `eitri ssh` (or the signing step) 113 expires, ssh is simply rejected—re-run `eitri ssh` (or the signing step)
117 to refresh. A specific cert can also be revoked at the gate by serial before it 114 to refresh. A specific cert can also be revoked at the gate by serial before it
118 expires; see [credential-revocation.md](credential-revocation.md). 115 expires; see [credential-revocation.md](credential-revocation.md).
119 116
120 ## Host verification (via the CA) 117 ## Host verification (via the CA)
121 118
122 The gate and every VM present a **host certificate** signed by eitri's host CA. 119 You pin eitri's host CA once and let certificate verification stand in for
123 You verify them by pinning that CA once as a `@cert-authority` entry, rather 120 trust-on-first-use. Fetch the CA (public material, no token needed) and pin it
124 than trust-on-first-use. 121 in a **dedicated** known_hosts file—never your main `~/.ssh/known_hosts`,
125 122 where a `*` wildcard CA would be trusted for *every* host you ssh to:
126 Fetch the host CA (public material, no token needed) and pin it in a
127 **dedicated** known_hosts file — never your main `~/.ssh/known_hosts`, where a
128 `*` wildcard CA would be trusted for *every* host you ssh to:
129 123
130 ```sh 124 ```sh
131 curl -sS "$EITRI_URL/api/v1/ssh-ca" | jq -r .ca \ 125 curl -sS "$EITRI_URL/api/v1/ssh-ca" | jq -r .ca \
@@ -150,12 +144,12 @@ ssh \
150 The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), 144 The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it),
151 and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the 145 and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the
152 inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by 146 inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by
153 CA, recycling a VM name or IP never produces a host-key-changed warning — the 147 CA, recycling a VM name or IP never produces a host-key-changed warning—the
154 new VM simply presents a fresh CA-signed cert for that name. `eitri ssh` 148 new VM simply presents a fresh CA-signed cert for that name. `eitri ssh`
155 does all of this for you. 149 does all of this for you.
156 150
157 ## Related 151 ## Related
158 152
159 - [credential-revocation.md](credential-revocation.md) — revoking a leaked user 153 - [credential-revocation.md](credential-revocation.md)—revoking a leaked user
160 cert or host credential 154 cert or host credential
161 - [cert-rotation.md](cert-rotation.md) — rotating the server's QUIC identity 155 - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity
docs/upgrade.md
Old New
@@ -12,8 +12,8 @@ agent to upgrade itself:
12 12
13 1. The agent downloads the new binary from eitri.sh and verifies its sha256 13 1. The agent downloads the new binary from eitri.sh and verifies its sha256
14 against the release manifest. 14 against the release manifest.
15 2. It swaps the binary in place — the old one is kept next to it as 15 2. It swaps the binary in place—the old one is kept next to it as
16 `eitri-agent.prev` — and re-execs. The process keeps its PID; running VMs 16 `eitri-agent.prev`—and re-execs. The process keeps its PID; running VMs
17 are untouched and stay under the agent's care throughout. 17 are untouched and stay under the agent's care throughout.
18 3. The host reports its new version on the next sync, and the button 18 3. The host reports its new version on the next sync, and the button
19 disappears. 19 disappears.
@@ -23,7 +23,7 @@ hand, stop the agent, move `eitri-agent.prev` back over the binary, and start
23 it again. 23 it again.
24 24
25 **Requirements.** The button lights up only when the running agent reports a 25 **Requirements.** The button lights up only when the running agent reports a
26 release version (`vX.Y.Z`) — agents built from an untagged or dirty tree 26 release version (`vX.Y.Z`)—agents built from an untagged or dirty tree
27 report a git hash instead and are never offered upgrades. The server needs 27 report a git hash instead and are never offered upgrades. The server needs
28 `release_manifest_url` reachable; set it to `""` in the server config to 28 `release_manifest_url` reachable; set it to `""` in the server config to
29 disable upgrade checks entirely. 29 disable upgrade checks entirely.
@@ -31,7 +31,7 @@ disable upgrade checks entirely.
31 ## The server 31 ## The server
32 32
33 The console banner links here when the published release differs from the 33 The console banner links here when the published release differs from the
34 running server's version. Server upgrades are manual and downtime is fine — agents keep 34 running server's version. Server upgrades are manual and downtime is fine—agents keep
35 reconciling and VMs keep running while it's away: 35 reconciling and VMs keep running while it's away:
36 36
37 1. Stop `eitri-server`. 37 1. Stop `eitri-server`.
@@ -40,7 +40,7 @@ reconciling and VMs keep running while it's away:
40 left as-is), and agents reconnect on their own. 40 left as-is), and agents reconnect on their own.
41 41
42 A newer server with older agents is safe: new fields in the sync protocol are 42 A newer server with older agents is safe: new fields in the sync protocol are
43 simply ignored by agents that predate them. 43 ignored by agents that predate them.
44 44
45 ## cloud-hypervisor 45 ## cloud-hypervisor
46 46
@@ -51,12 +51,12 @@ no live handover for running guests.
51 ## Operations 51 ## Operations
52 52
53 The agent runs under systemd (`eitri-agent.service`). The unit sets 53 The agent runs under systemd (`eitri-agent.service`). The unit sets
54 `KillMode=process` — that line is load-bearing: the default would kill every 54 `KillMode=process`—that line is load-bearing: the default would kill every
55 cloud-hypervisor guest in the unit's cgroup whenever the agent stops. Logs: 55 cloud-hypervisor guest in the unit's cgroup whenever the agent stops. Logs:
56 `journalctl -u eitri-agent`. 56 `journalctl -u eitri-agent`.
57 57
58 ## Related 58 ## Related
59 59
60 - [cert-rotation.md](cert-rotation.md) — rotating the server's QUIC identity 60 - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity
61 - [credential-revocation.md](credential-revocation.md) — the disaster levers 61 - [credential-revocation.md](credential-revocation.md)—the disaster levers
62 when credentials leak 62 when credentials leak
internal/site/site.go
Old New
@@ -93,7 +93,7 @@ func Build(cfg Config) error {
93 return err 93 return err
94 } 94 }
95 if err := page(filepath.Join(cfg.OutDir, "docs", "index.html"), 95 if err := page(filepath.Join(cfg.OutDir, "docs", "index.html"),
96 pageData{Title: "eitri — docs", Section: "docs", Content: content}); err != nil { 96 pageData{Title: "eitri – docs", Section: "docs", Content: content}); err != nil {
97 return err 97 return err
98 } 98 }
99 for _, slug := range pages { 99 for _, slug := range pages {
@@ -101,7 +101,7 @@ func Build(cfg Config) error {
101 return err 101 return err
102 } 102 }
103 if err := page(filepath.Join(cfg.OutDir, "docs", slug, "index.html"), 103 if err := page(filepath.Join(cfg.OutDir, "docs", slug, "index.html"),
104 pageData{Title: "eitri — " + slug, Section: "docs", Content: content}); err != nil { 104 pageData{Title: "eitri – " + slug, Section: "docs", Content: content}); err != nil {
105 return err 105 return err
106 } 106 }
107 } 107 }
@@ -109,7 +109,7 @@ func Build(cfg Config) error {
109 return err 109 return err
110 } 110 }
111 if err := page(filepath.Join(cfg.OutDir, "docs", "roadmap", "index.html"), 111 if err := page(filepath.Join(cfg.OutDir, "docs", "roadmap", "index.html"),
112 pageData{Title: "eitri — roadmap", Section: "docs", Content: content}); err != nil { 112 pageData{Title: "eitri – roadmap", Section: "docs", Content: content}); err != nil {
113 return err 113 return err
114 } 114 }
115 115
@@ -123,7 +123,7 @@ func Build(cfg Config) error {
123 return err 123 return err
124 } 124 }
125 if err := page(filepath.Join(cfg.OutDir, "dl", "index.html"), 125 if err := page(filepath.Join(cfg.OutDir, "dl", "index.html"),
126 pageData{Title: "eitri — downloads", Section: "dl", Content: template.HTML(h)}); err != nil { 126 pageData{Title: "eitri – downloads", Section: "dl", Content: template.HTML(h)}); err != nil {
127 return err 127 return err
128 } 128 }
129 129
internal/site/site_test.go
Old New
@@ -106,7 +106,7 @@ func TestBuildSectionsAndTitles(t *testing.T) {
106 t.Errorf("landing section wrong:\n%s", got) 106 t.Errorf("landing section wrong:\n%s", got)
107 } 107 }
108 qs := read(t, filepath.Join(out, "docs", "quickstart", "index.html")) 108 qs := read(t, filepath.Join(out, "docs", "quickstart", "index.html"))
109 if !strings.Contains(qs, `data-s="docs"`) || !strings.Contains(qs, "<title>eitri — quickstart</title>") { 109 if !strings.Contains(qs, `data-s="docs"`) || !strings.Contains(qs, "<title>eitri – quickstart</title>") {
110 t.Errorf("doc page section/title wrong:\n%s", qs) 110 t.Errorf("doc page section/title wrong:\n%s", qs)
111 } 111 }
112 } 112 }
site/docs.md
Old New
@@ -2,31 +2,31 @@
2 2
3 **Get started** 3 **Get started**
4 4
5 - [quickstart](quickstart.md) — zero to a first VM on one host, then a 5 - [quickstart](quickstart.md)—zero to a first VM on one host, then a
6 second host 6 second host
7 7
8 **Use a fleet** 8 **Use a fleet**
9 9
10 - [ssh access](ssh-access.md) — reaching a VM through the jump gate with 10 - [ssh access](ssh-access.md)—reaching a VM through the jump gate with
11 your own keys 11 your own keys
12 - [mcp](mcp.md) — let an AI agent create and drive VMs 12 - [mcp](mcp.md)—let an AI agent create and drive VMs
13 13
14 **Run a fleet** 14 **Run a fleet**
15 15
16 - [upgrading](upgrade.md) — agents from the console; the server; 16 - [upgrading](upgrade.md)—agents from the console; the server;
17 cloud-hypervisor 17 cloud-hypervisor
18 - [cert rotation](cert-rotation.md) — rotating the server certificate 18 - [cert rotation](cert-rotation.md)—rotating the server certificate
19 without an outage 19 without an outage
20 - [credential revocation](credential-revocation.md) — the levers when 20 - [credential revocation](credential-revocation.md)—the levers when
21 something leaks 21 something leaks
22 22
23 **FAQ** 23 **FAQ**
24 24
25 - [faq](faq.md) — networking, and other sharp edges 25 - [faq](faq.md)—networking, and other sharp edges
26 26
27 **Releases** 27 **Releases**
28 28
29 - [release notes](releases.md) — what shipped in each version 29 - [release notes](releases.md)—what shipped in each version
30 30
31 **Where it's going** 31 **Where it's going**
32 32
site/index.md
Old New
@@ -1,12 +1,25 @@
1 # eitri — the cloud you already own 1 # eitri – the cloud you already own
2 2
3 *The ease of use of the cloud, backed by your own hardware* 3 *The ease of a cloud provider, backed by what you own*
4 4
5 ## DESCRIPTION 5 ## DESCRIPTION
6 6
7 eitri connects machines you already own into a private cloud. The 7 The cost of compute is rising—RAM, storage, GPU—at all-time highs,
8 workstation you replaced, the mini PC in a drawer — point eitri at them 8 with RAM prices not expected to come down until 2030.
9 and they serve VMs the way a cloud does, without the bill. 9
10 Companies are fighting over supply chains, cloud providers are raising
11 prices, and we're all lamenting that we should have upgraded RAM in 2024.
12
13 With the rise of LLMs, it's now easier than ever to create software, but
14 what hasn't improved is the cost of the hardware. eitri's premise is
15 simple: let's use what we have instead of consuming more. We don't need
16 to all go buy Mac Minis to run an AI agent (seriously, what was this
17 about?!) or spin up a VM in some remote cloud that charges us by the
18 hour. We have the hardware; we just need the ease of use. Repurposing
19 that old laptop and being able to manage it from a single pane of glass.
20 Being able to connect it to your favourite LLM and say, deploy here,
21 please, and run that software you've just developed on a VM, and show it
22 off.
10 23
11 Boot a throwaway sandbox for a risky experiment. Keep a dev machine that 24 Boot a throwaway sandbox for a risky experiment. Keep a dev machine that
12 survives host reboots. Give an AI agent a VM where it can run wild. Delete 25 survives host reboots. Give an AI agent a VM where it can run wild. Delete
@@ -15,12 +28,12 @@ any of it when you're done.
15 ## HOW IT WORKS 28 ## HOW IT WORKS
16 29
17 Run the server on one box; join the rest with one command each. Click 30 Run the server on one box; join the rest with one command each. Click
18 **+ Create VM** and a fresh VM boots in seconds — open its console in the 31 **+ Create VM** and a fresh VM boots in seconds—open its console in the
19 browser, or SSH in with certificates signed by your own CA (eitri never 32 browser, or SSH in with certificates signed by your own CA (eitri never
20 holds your keys). 33 holds your keys).
21 34
22 ## GETTING IT 35 ## GETTING IT
23 36
24 Prebuilt tarballs and checksums: [downloads](/dl/). No installer — unpack, 37 Prebuilt tarballs and checksums: [downloads](/dl/). No installer—unpack,
25 run, done. The [quickstart](quickstart.md) takes you from zero to a first 38 run, done. The [quickstart](quickstart.md) takes you from zero to a first
26 VM; the [FAQ](faq.md) covers the sharp edges. 39 VM; the [FAQ](faq.md) covers the sharp edges.
web/src/lib/Console.svelte
Old New
@@ -34,12 +34,12 @@
34 status = 'connecting'; 34 status = 'connecting';
35 error = ''; 35 error = '';
36 // Hoisted out of the try so the catch can close a socket constructed 36 // Hoisted out of the try so the catch can close a socket constructed
37 // before the `ws = sock` handoff — a throw in that window would 37 // before the `ws = sock` handoff—a throw in that window would
38 // otherwise leak a live connection the server holds open. 38 // otherwise leak a live connection the server holds open.
39 let sock: WebSocket | undefined; 39 let sock: WebSocket | undefined;
40 try { 40 try {
41 // Dynamic import: xterm touches `document`, and this SPA prerenders 41 // Dynamic import: xterm touches `document`, and this SPA prerenders
42 // (adapter-static SSR) — never load its JS at module scope. 42 // (adapter-static SSR)—never load its JS at module scope.
43 const { Terminal } = await import('@xterm/xterm'); 43 const { Terminal } = await import('@xterm/xterm');
44 const ticket = await mintTicket(); 44 const ticket = await mintTicket();
45 if (myGen !== gen || !holder) return; // superseded while importing/minting 45 if (myGen !== gen || !holder) return; // superseded while importing/minting
@@ -62,7 +62,7 @@
62 sock.onclose = (e) => { 62 sock.onclose = (e) => {
63 if (myGen !== gen) return; // superseded socket: don't touch live state 63 if (myGen !== gen) return; // superseded socket: don't touch live state
64 // The server delivers agent-leg failures as a close reason 64 // The server delivers agent-leg failures as a close reason
65 // (host offline, VM refused) — surface it verbatim. 65 // (host offline, VM refused)—surface it verbatim.
66 if (e.reason) { 66 if (e.reason) {
67 status = 'error'; 67 status = 'error';
68 error = e.reason; 68 error = e.reason;
@@ -87,7 +87,7 @@
87 } 87 }
88 } 88 }
89 89
90 // release frees the imperative resources without touching status — used 90 // release frees the imperative resources without touching status—used
91 // by close() and by open() to clear a remotely-closed session's leftovers 91 // by close() and by open() to clear a remotely-closed session's leftovers
92 // before starting fresh. 92 // before starting fresh.
93 function release() { 93 function release() {
@@ -106,7 +106,7 @@
106 // Reset the session when this component is REUSED for a different VM: the 106 // Reset the session when this component is REUSED for a different VM: the
107 // detail page reuses one Console instance across /vms/A → /vms/B, so an 107 // detail page reuses one Console instance across /vms/A → /vms/B, so an
108 // open console must tear down when vmId actually changes. Guarded on a real 108 // open console must tear down when vmId actually changes. Guarded on a real
109 // change — NOT returned as an effect cleanup — because the detail page 109 // change—NOT returned as an effect cleanup—because the detail page
110 // re-renders ~1/s from the SSE fleet stream (fleet.vms is reassigned on 110 // re-renders ~1/s from the SSE fleet stream (fleet.vms is reassigned on
111 // every push), and an effect that returned close() as its cleanup re-ran 111 // every push), and an effect that returned close() as its cleanup re-ran
112 // that cleanup on every push, killing a live console ~1s after it opened. 112 // that cleanup on every push, killing a live console ~1s after it opened.
@@ -117,7 +117,7 @@
117 }); 117 });
118 118
119 // Teardown on unmount only. The body reads nothing reactive, so its cleanup 119 // Teardown on unmount only. The body reads nothing reactive, so its cleanup
120 // runs solely on destroy — never on a re-render. 120 // runs solely on destroy—never on a re-render.
121 $effect(() => close); 121 $effect(() => close);
122 </script> 122 </script>
123 123
@@ -138,7 +138,7 @@
138 <div class="term" bind:this={holder} class:hidden={status === 'closed' || status === 'error'}></div> 138 <div class="term" bind:this={holder} class:hidden={status === 'closed' || status === 'error'}></div>
139 {#if status === 'closed'} 139 {#if status === 'closed'}
140 <p class="hint"> 140 <p class="hint">
141 Serial console — you'll see the boot log and anything printed to ttyS0. 141 Serial console—you'll see the boot log and anything printed to ttyS0.
142 Logging in requires credentials your cloud-init set up; eitri injects none. 142 Logging in requires credentials your cloud-init set up; eitri injects none.
143 </p> 143 </p>
144 {/if} 144 {/if}
web/src/routes/+page.svelte
Old New
@@ -39,7 +39,7 @@
39 ); 39 );
40 // hostNameById / vmCountByHost: computed once per hosts/vms snapshot instead 40 // hostNameById / vmCountByHost: computed once per hosts/vms snapshot instead
41 // of re-scanning on every row (host lookup was O(hosts) per VM row, VM count 41 // of re-scanning on every row (host lookup was O(hosts) per VM row, VM count
42 // was O(vms) per host row — both now O(1) lookups into a map built once). 42 // was O(vms) per host row—both now O(1) lookups into a map built once).
43 const hostNameById = $derived.by(() => new Map(fleet.hosts.map((h) => [h.id, h.name]))); 43 const hostNameById = $derived.by(() => new Map(fleet.hosts.map((h) => [h.id, h.name])));
44 const vmCountByHost = $derived.by(() => { 44 const vmCountByHost = $derived.by(() => {
45 const m = new Map<string, number>(); 45 const m = new Map<string, number>();
@@ -67,7 +67,7 @@
67 } 67 }
68 // loadRatio: normalized pressure = 1-min load ÷ vCPUs (a 0–1+ gauge, unlike 68 // loadRatio: normalized pressure = 1-min load ÷ vCPUs (a 0–1+ gauge, unlike
69 // the raw whole-machine load). null when offline, metric-less, or vCPUs are 69 // the raw whole-machine load). null when offline, metric-less, or vCPUs are
70 // unknown — the cell then shows a dash rather than a bogus 0. 70 // unknown—the cell then shows a dash rather than a bogus 0.
71 function loadRatio(h: (typeof fleet.hosts)[number]): number | null { 71 function loadRatio(h: (typeof fleet.hosts)[number]): number | null {
72 if (!h.online || !h.metrics) return null; 72 if (!h.online || !h.metrics) return null;
73 const vcpus = h.capacity.vcpus; 73 const vcpus = h.capacity.vcpus;
@@ -100,7 +100,7 @@
100 } 100 }
101 101
102 // stripEmpty drops keys whose value is empty/undefined/null before sending 102 // stripEmpty drops keys whose value is empty/undefined/null before sending
103 // the create-VM form — the caller guarantees the required keys (host_id) 103 // the create-VM form—the caller guarantees the required keys (host_id)
104 // are non-empty, so this only ever strips optional-but-blank fields. 104 // are non-empty, so this only ever strips optional-but-blank fields.
105 function stripEmpty<T extends object>(f: T): T { 105 function stripEmpty<T extends object>(f: T): T {
106 return Object.fromEntries( 106 return Object.fromEntries(
@@ -140,7 +140,7 @@
140 140
141 {#if fleet.latest_version && fleet.server_version && fleet.latest_version !== fleet.server_version} 141 {#if fleet.latest_version && fleet.server_version && fleet.latest_version !== fleet.server_version}
142 <div class="update-banner"> 142 <div class="update-banner">
143 eitri {fleet.latest_version} is available (server running {fleet.server_version}) — 143 eitri {fleet.latest_version} is available (server running {fleet.server_version}) –
144 <a href="https://eitri.sh/docs/upgrade" target="_blank" rel="noreferrer">upgrade guide</a> 144 <a href="https://eitri.sh/docs/upgrade" target="_blank" rel="noreferrer">upgrade guide</a>
145 </div> 145 </div>
146 {/if} 146 {/if}
@@ -168,7 +168,7 @@
168 <div class="onboard"> 168 <div class="onboard">
169 <h3>Add your first host</h3> 169 <h3>Add your first host</h3>
170 <p class="hint"> 170 <p class="hint">
171 A host is any Linux box that runs your VMs — the server's own box counts. Mint a one-time 171 A host is any Linux box that runs your VMs—the server's own box counts. Mint a one-time
172 join token, then run the printed command on the box; it comes online here the moment it 172 join token, then run the printed command on the box; it comes online here the moment it
173 enrolls. 173 enrolls.
174 </p> 174 </p>
@@ -288,7 +288,7 @@
288 <td>{hostName(v.host_id)}</td> 288 <td>{hostName(v.host_id)}</td>
289 <td>{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td> 289 <td>{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td>
290 <td> 290 <td>
291 {#if v.deleted}<span class="teardown">deleting — undo available</span>{:else}{vmStatus(v)}{/if}{v.last_error 291 {#if v.deleted}<span class="teardown">deleting—undo available</span>{:else}{vmStatus(v)}{/if}{v.last_error
292 ? ` · ${v.last_error}` 292 ? ` · ${v.last_error}`
293 : ''} 293 : ''}
294 </td> 294 </td>
web/src/routes/hosts/[id]/+page.svelte
Old New
@@ -91,14 +91,14 @@
91 </tbody> 91 </tbody>
92 </table> 92 </table>
93 <p class="note"> 93 <p class="note">
94 Measured off the host — distinct from the allocated bars above, which sum committed VM specs (quotas). 94 Measured off the host—distinct from the allocated bars above, which sum committed VM specs (quotas).
95 </p> 95 </p>
96 {/if} 96 {/if}
97 97
98 {#if host.status !== 'decommissioning'} 98 {#if host.status !== 'decommissioning'}
99 <button class="danger" onclick={decommission}>Decommission host</button> 99 <button class="danger" onclick={decommission}>Decommission host</button>
100 {:else} 100 {:else}
101 <p class="hint">Decommissioning — draining {vms.length} VM(s)…</p> 101 <p class="hint">Decommissioning—draining {vms.length} VM(s)…</p>
102 {/if} 102 {/if}
103 103
104 <h2>VMs on this host ({vms.length})</h2> 104 <h2>VMs on this host ({vms.length})</h2>
web/src/routes/settings/+page.svelte
Old New
@@ -24,12 +24,12 @@
24 ]; 24 ];
25 let patForm = $state<{ name: string; ttl: number }>({ name: '', ttl: 0 }); 25 let patForm = $state<{ name: string; ttl: number }>({ name: '', ttl: 0 });
26 let patBusy = $state(false); 26 let patBusy = $state(false);
27 // minted holds the freshly created token's secret — shown exactly once, then 27 // minted holds the freshly created token's secret—shown exactly once, then
28 // dismissed (it is never fetchable again). 28 // dismissed (it is never fetchable again).
29 let minted = $state<CreateAPITokenResponse | null>(null); 29 let minted = $state<CreateAPITokenResponse | null>(null);
30 let copied = $state(false); 30 let copied = $state(false);
31 // confirmRevoke is the id of the token whose Revoke button is in its second 31 // confirmRevoke is the id of the token whose Revoke button is in its second
32 // (confirm) step — an inline two-step replaces a blocking confirm() dialog. 32 // (confirm) step—an inline two-step replaces a blocking confirm() dialog.
33 let confirmRevoke = $state(''); 33 let confirmRevoke = $state('');
34 34
35 async function refreshTokens() { 35 async function refreshTokens() {
@@ -75,7 +75,7 @@
75 const el = document.getElementById('minted-token'); 75 const el = document.getElementById('minted-token');
76 if (el) { 76 if (el) {
77 window.getSelection()?.selectAllChildren(el); 77 window.getSelection()?.selectAllChildren(el);
78 fleet.error = 'Clipboard unavailable over http — token selected, press Ctrl-C'; 78 fleet.error = 'Clipboard unavailable over http—token selected, press Ctrl-C';
79 } 79 }
80 } catch (err) { 80 } catch (err) {
81 fleet.error = String(err); 81 fleet.error = String(err);
@@ -95,7 +95,7 @@
95 return isNaN(d.getTime()) ? s : d.toLocaleString(); 95 return isNaN(d.getTime()) ? s : d.toLocaleString();
96 } 96 }
97 97
98 // SSH user-CA state (moved from the fleet page — this is tenant settings) --- 98 // SSH user-CA state (moved from the fleet page—this is tenant settings) ---
99 let caForm = $state<{ public_key: string; label: string }>({ public_key: '', label: '' }); 99 let caForm = $state<{ public_key: string; label: string }>({ public_key: '', label: '' });
100 let caBusy = $state(false); 100 let caBusy = $state(false);
101 101
@@ -153,7 +153,7 @@
153 <strong>Token “{minted.name}” created</strong> 153 <strong>Token “{minted.name}” created</strong>
154 <button type="button" class="ghost" onclick={() => (minted = null)}>Dismiss</button> 154 <button type="button" class="ghost" onclick={() => (minted = null)}>Dismiss</button>
155 </div> 155 </div>
156 <p class="warn">Copy it now — you won't be able to see this token again.</p> 156 <p class="warn">Copy it now—you won't be able to see this token again.</p>
157 <div class="secret-value"> 157 <div class="secret-value">
158 <code id="minted-token">{minted.token}</code> 158 <code id="minted-token">{minted.token}</code>
159 <button type="button" onclick={copySecret}>{copied ? 'Copied' : 'Copy'}</button> 159 <button type="button" onclick={copySecret}>{copied ? 'Copied' : 'Copy'}</button>
@@ -209,7 +209,7 @@
209 </div> 209 </div>
210 210
211 {#if fleet.userCAs.length === 0} 211 {#if fleet.userCAs.length === 0}
212 <p class="hint">No SSH CA registered — a VM cannot be created until the tenant has one.</p> 212 <p class="hint">No SSH CA registered—a VM cannot be created until the tenant has one.</p>
213 <div class="enroll"> 213 <div class="enroll">
214 Generate a CA, paste its public key below, then connect: 214 Generate a CA, paste its public key below, then connect:
215 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code> 215 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code>
web/src/routes/vms/[id]/+page.svelte
Old New
@@ -108,7 +108,7 @@
108 {/snippet} 108 {/snippet}
109 109
110 {#if !vm} 110 {#if !vm}
111 <p class="hint">VM not found — it may have been destroyed. Its recorded lifecycle history:</p> 111 <p class="hint">VM not found—it may have been destroyed. Its recorded lifecycle history:</p>
112 {@render timelineSection()} 112 {@render timelineSection()}
113 {:else} 113 {:else}
114 <h2>{vm.name}</h2> 114 <h2>{vm.name}</h2>
@@ -118,7 +118,7 @@
118 <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr> 118 <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr>
119 <tr><th>Status</th><td>{vmStatus(vm)}</td></tr> 119 <tr><th>Status</th><td>{vmStatus(vm)}</td></tr>
120 <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr> 120 <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr>
121 <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT — not reachable off-host)</span></td></tr> 121 <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT—not reachable off-host)</span></td></tr>
122 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> 122 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr>
123 <tr><th>Persistent</th><td>{vm.persistent}</td></tr> 123 <tr><th>Persistent</th><td>{vm.persistent}</td></tr>
124 <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> 124 <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr>
@@ -131,8 +131,8 @@
131 <div class="teardown-callout"> 131 <div class="teardown-callout">
132 <h3>This VM is being deleted</h3> 132 <h3>This VM is being deleted</h3>
133 <p> 133 <p>
134 The guest is already stopped. You can still undo this{#if approx} — otherwise it's 134 The guest is already stopped. You can still undo this{#if approx}—otherwise it's
135 destroyed automatically in {approx}{:else} — it's destroyed automatically once the 135 destroyed automatically in {approx}{:else}—it's destroyed automatically once the
136 guest finishes shutting down{/if}. 136 guest finishes shutting down{/if}.
137 </p> 137 </p>
138 <button class="restore" onclick={cancelDeletion}>Undo delete</button> 138 <button class="restore" onclick={cancelDeletion}>Undo delete</button>