d058f942
docs,web: one voice, one dash system
a73x 2026-07-30 06:33
Commit message
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> |