a9a7f4b6
docs: pages named for what the reader is doing
a73x 2026-08-18 18:53
Commit message
.githooks/commit-msg
| Old | New | ||
|---|---|---|---|
| @@ -1,13 +0,0 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # git-collab-managed-hook: stamps `Patch: <id>` trailers onto commit messages. | ||
| 3 | # | ||
| 4 | # Written by `git-collab init`, which rewrites this file in place — | ||
| 5 | # edits here are not preserved. Safe to delete: it is a convenience, | ||
| 6 | # and merges are recorded by scanning at sync time regardless. | ||
| 7 | # | ||
| 8 | # This must never fail a commit, so the exit status below is discarded | ||
| 9 | # deliberately — do not "fix" it. | ||
| 10 | GIT_COLLAB='/home/xanderle/.local/bin/git-collab' | ||
| 11 | [ -x "$GIT_COLLAB" ] || GIT_COLLAB=git-collab | ||
| 12 | "$GIT_COLLAB" hooks run-commit-msg "$1" >/dev/null 2>&1 || true | ||
| 13 | exit 0 | ||
README.md
| Old | New | ||
|---|---|---|---|
| @@ -91,7 +91,7 @@ framework's own EFI bootloader, and each guest keeps its NVRAM beside its disk. | |||
| 91 | [docs/](docs/README.md) is indexed by what you're trying to do—understand it | 91 | [docs/](docs/README.md) is indexed by what you're trying to do—understand it |
| 92 | ([architecture](docs/architecture.md), [ethos](docs/ethos.md)), run a fleet | 92 | ([architecture](docs/architecture.md), [ethos](docs/ethos.md)), run a fleet |
| 93 | ([cert rotation](docs/cert-rotation.md), [revocation](docs/credential-revocation.md)), | 93 | ([cert rotation](docs/cert-rotation.md), [revocation](docs/credential-revocation.md)), |
| 94 | use a fleet ([ssh access](docs/ssh-access.md), [mcp](docs/mcp.md)), or see why | 94 | use a fleet ([connecting](docs/connecting.md), [mcp](docs/mcp.md)), or see why |
| 95 | it's built this way ([decisions](docs/decisions.md)). What ships next is in | 95 | it's built this way ([decisions](docs/decisions.md)). What ships next is in |
| 96 | [ROADMAP.md](ROADMAP.md). | 96 | [ROADMAP.md](ROADMAP.md). |
| 97 | 97 | ||
cmd/eitri/main.go
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,7 @@ | |||
| 1 | // Command eitri is the end-user client: SSH into fleet VMs through the | 1 | // Command eitri is the end-user client: SSH into fleet VMs through the |
| 2 | // jump gate with self-signed short-lived certs (eitri ssh) and register | 2 | // jump gate with self-signed short-lived certs (eitri ssh) and register |
| 3 | // tenant user CAs (eitri ca upload). All behavior lives in internal/cli | 3 | // tenant user CAs (eitri ca upload). All behavior lives in internal/cli |
| 4 | // (Main); this package is wiring only (arch R14). See docs/ssh-access.md. | 4 | // (Main); this package is wiring only (arch R14). See docs/connecting.md. |
| 5 | package main | 5 | package main |
| 6 | 6 | ||
| 7 | import ( | 7 | import ( |
docs/README.md
| Old | New | ||
|---|---|---|---|
| @@ -13,8 +13,9 @@ By what you're trying to do: | |||
| 13 | 13 | ||
| 14 | **Run a fleet** | 14 | **Run a fleet** |
| 15 | 15 | ||
| 16 | - [quickstart.md](quickstart.md)—from nothing to a VM you can SSH into, | 16 | - [quickstart.md](quickstart.md)—from nothing to a VM you can SSH into, on |
| 17 | hosted or self-hosted | 17 | the hosted plane |
| 18 | - [self-hosting.md](self-hosting.md)—running the whole control plane yourself | ||
| 18 | - [byo-idp.md](byo-idp.md)—signing the console in against your own OIDC | 19 | - [byo-idp.md](byo-idp.md)—signing the console in against your own OIDC |
| 19 | provider instead of the bundled issuer | 20 | provider instead of the bundled issuer |
| 20 | - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity, | 21 | - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity, |
| @@ -28,8 +29,10 @@ By what you're trying to do: | |||
| 28 | 29 | ||
| 29 | **Use a fleet** | 30 | **Use a fleet** |
| 30 | 31 | ||
| 31 | - [ssh-access.md](ssh-access.md)—reaching a guest through the jump gate with | 32 | - [joining.md](joining.md)—putting a machine into the fleet, Linux or Mac |
| 33 | - [connecting.md](connecting.md)—reaching a guest through the jump gate with | ||
| 32 | your own tenant CA | 34 | your own tenant CA |
| 35 | - [networking.md](networking.md)—published ports, and a guest on your own LAN | ||
| 33 | - [mcp.md](mcp.md)—`/mcp`, the endpoint that lets Claude drive VMs | 36 | - [mcp.md](mcp.md)—`/mcp`, the endpoint that lets Claude drive VMs |
| 34 | - [faq.md](faq.md)—guest networking, and the other sharp edges | 37 | - [faq.md](faq.md)—guest networking, and the other sharp edges |
| 35 | 38 | ||
docs/byo-idp.md
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,6 @@ | |||
| 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 [self-hosting](self-hosting.md) page 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. |
docs/cert-rotation.md
| Old | New | ||
|---|---|---|---|
| @@ -54,5 +54,5 @@ during a window where that is acceptable, host by host. | |||
| 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 | - [connecting](connecting.md)—the SSH CAs, which are separate from the |
| 58 | QUIC identity rotated here | 58 | QUIC identity rotated here |
docs/connecting.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,291 @@ | |||
| 1 | # Connecting to a VM | ||
| 2 | |||
| 3 | *Your CA, the client, and the jump gate in front of every guest* | ||
| 4 | |||
| 5 | eitri runs an SSH **jump gate**: a bastion that accepts an `ssh -J` hop and | ||
| 6 | forwards you to a VM's SSHd. You authenticate to the gate with a **short-lived | ||
| 7 | SSH user certificate that you sign yourself**, using your tenant's own user CA. | ||
| 8 | Every VM in your tenant trusts your tenant's user CAs—seeded at VM create, so | ||
| 9 | there's no per-VM key to manage—and the cert carries the principal `ubuntu`, | ||
| 10 | the login user on the VM. | ||
| 11 | |||
| 12 | Verification runs **both ways**. Just as the VM trusts your user cert, you | ||
| 13 | verify what you connect to: the gate and every VM present a **host certificate** | ||
| 14 | signed by eitri's host CA. You pin that CA once (`@cert-authority`) and both | ||
| 15 | hops are then verified by certificate—no blind trust-on-first-use, and no | ||
| 16 | host-key-changed warnings when VM names or IPs are recycled. | ||
| 17 | |||
| 18 | Two CAs, two directions: **your tenant's user CA** (private key on your machine) | ||
| 19 | signs what you present; **eitri's host CA** (private key on the server) signs | ||
| 20 | what the gate and VMs present. eitri deliberately holds no user signing key — | ||
| 21 | a server compromise cannot mint user credentials. | ||
| 22 | |||
| 23 | ## Get the client | ||
| 24 | |||
| 25 | SSH access uses certificates signed by **your** CA—eitri never holds a | ||
| 26 | user key that can enter your VMs. A guest trusts the CA set it is created with, | ||
| 27 | so this comes before your first VM. | ||
| 28 | |||
| 29 | Download the client CLI. It targets the hosted service by default: | ||
| 30 | |||
| 31 | ```sh | ||
| 32 | OS=$(uname -s | tr A-Z a-z) ARCH=amd64 # arm64 laptop → ARCH=arm64 | ||
| 33 | curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS | ||
| 34 | V=$(sed -n "s/.*eitri-cli_\(v[^_]*\)_${OS}_${ARCH}\.tar\.gz\$/\1/p" SHA256SUMS) | ||
| 35 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-cli_${V}_${OS}_${ARCH}.tar.gz" | ||
| 36 | sha256sum -c SHA256SUMS --ignore-missing | ||
| 37 | tar xzf "eitri-cli_${V}_${OS}_${ARCH}.tar.gz" | ||
| 38 | sudo install -m 0755 "eitri-cli_${V}_${OS}_${ARCH}/eitri" /usr/local/bin/eitri | ||
| 39 | ``` | ||
| 40 | |||
| 41 | Then run the guided setup: | ||
| 42 | |||
| 43 | ```sh | ||
| 44 | eitri init | ||
| 45 | ``` | ||
| 46 | |||
| 47 | It asks for a personal access token—mint one in the console under **Settings → | ||
| 48 | Personal access tokens**—and walks three steps, printing what each will do and | ||
| 49 | doing it only on a `y`: | ||
| 50 | |||
| 51 | 1. **Identity.** Calls `/api/v1/me` with the token to learn your tenant and | ||
| 52 | which SSH gate this plane runs. The token is used once and never stored; | ||
| 53 | entered at the prompt it is not echoed. | ||
| 54 | 2. **CA.** Lists the CAs your tenant has already registered. If the signing key | ||
| 55 | on this laptop is one of them, there is nothing to do. If you have a key it | ||
| 56 | does not know—here or at a path you give it—init offers to register the | ||
| 57 | public half. Only when there is neither does it offer to generate an ed25519 | ||
| 58 | pair, naming both files and their modes before writing anything. | ||
| 59 | 3. **Config.** Writes `~/.eitri/config.json`—plane, gate, tenant, CA and key | ||
| 60 | paths—after showing the current values beside what would change. | ||
| 61 | |||
| 62 | Run it again whenever: each step reports what is already settled and touches | ||
| 63 | nothing. It is also how you find out that the key on this laptop signs certs | ||
| 64 | your tenant never registered—the failure that otherwise arrives as a bare | ||
| 65 | "permission denied" from a guest that was never going to trust you. | ||
| 66 | |||
| 67 | After init, `eitri ssh` needs no environment at all: the config names the plane | ||
| 68 | and the tenant, the certificate is signed locally, and the host-CA pin comes | ||
| 69 | from a public endpoint. Every `EITRI_*` variable still overrides the file, and | ||
| 70 | the file still overrides the hosted defaults. | ||
| 71 | |||
| 72 | ## Bring your own CA (once per tenant) | ||
| 73 | |||
| 74 | Generate a user CA and register its **public** key with your tenant. The token | ||
| 75 | names the tenant (`POST /api/v1/user-cas`), so no handle is needed; a token that | ||
| 76 | can act for more than one tenant pins one with `eitri ca upload <tenant> <key>` | ||
| 77 | (`POST /api/v1/tenants/<tenant>/user-cas`): | ||
| 78 | |||
| 79 | ```sh | ||
| 80 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA" | ||
| 81 | |||
| 82 | export EITRI_URL=https://eitri.example.com | ||
| 83 | export EITRI_TOKEN=<personal-access-token> # mint one in the console → Settings | ||
| 84 | eitri ca upload ~/.ssh/eitri_user_ca.pub | ||
| 85 | ``` | ||
| 86 | |||
| 87 | The CA's private key never leaves your machine; the server stores only the | ||
| 88 | public key. Upload the CA **before creating VMs**—a VM trusts the tenant user | ||
| 89 | CAs present at its creation. The gate authorizes each connection against the | ||
| 90 | tenant the signing CA was uploaded to. | ||
| 91 | |||
| 92 | Registering a CA later does not reach a VM that already exists. The set is | ||
| 93 | copied onto the VM as it is created and nothing rewrites it, so a certificate | ||
| 94 | signed by a CA registered afterwards is refused by that guest's sshd. Recreating | ||
| 95 | the guest is the only way to change what it trusts. | ||
| 96 | |||
| 97 | The console does the same under **Settings**, and so does the MCP `ca_upload` | ||
| 98 | tool. All three register into the same set. | ||
| 99 | |||
| 100 | ## Which CAs does a guest trust? | ||
| 101 | |||
| 102 | A VM's page lists them under **Trusted CAs**, by the label you gave the CA and | ||
| 103 | its fingerprint. Match those against Settings → SSH Access to tell whether a | ||
| 104 | certificate you are about to sign will open that guest. | ||
| 105 | |||
| 106 | On the fleet page, a VM missing any CA your tenant now has is marked *stale | ||
| 107 | trust*. It still works with the CAs it was created against; it just cannot be | ||
| 108 | opened by every CA you hold. | ||
| 109 | |||
| 110 | ## Delegating access to eitri | ||
| 111 | |||
| 112 | A caller holding only a token has no CA and no private key, so it cannot sign | ||
| 113 | anything — and eitri holds no signing key for anyone, so it cannot sign on their | ||
| 114 | behalf either. Instead, you lend eitri a credential. | ||
| 115 | |||
| 116 | eitri generates an ephemeral keypair for your tenant, in memory only, and hands | ||
| 117 | you the public half: | ||
| 118 | |||
| 119 | ```sh | ||
| 120 | curl -X POST -H "Authorization: Bearer $EITRI_TOKEN" \ | ||
| 121 | https://eitri.example.com/api/v1/delegations | ||
| 122 | ``` | ||
| 123 | |||
| 124 | Sign it with your own CA, on your own terms: | ||
| 125 | |||
| 126 | ```sh | ||
| 127 | printf '%s\n' "<public_key from the response>" > eitri-delegation.pub | ||
| 128 | ssh-keygen -s ~/.ssh/eitri_user_ca -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub | ||
| 129 | ``` | ||
| 130 | |||
| 131 | `-n ubuntu` is not optional. A guest matches the certificate's principals | ||
| 132 | against the login user, so a certificate naming anything else is refused by | ||
| 133 | every guest. Post the result back: | ||
| 134 | |||
| 135 | ```sh | ||
| 136 | curl -X PUT -H "Authorization: Bearer $EITRI_TOKEN" \ | ||
| 137 | -H 'Content-Type: application/json' \ | ||
| 138 | -d "{\"certificate\": \"$(cat eitri-delegation-cert.pub)\"}" \ | ||
| 139 | https://eitri.example.com/api/v1/delegations | ||
| 140 | ``` | ||
| 141 | |||
| 142 | eitri now authenticates to your guests as that key plus that certificate, until | ||
| 143 | the certificate expires. It holds nothing else. `GET /api/v1/delegations` | ||
| 144 | reports the expiry; `DELETE` ends it immediately. So does restarting the | ||
| 145 | control plane — a delegation is in memory and nowhere else, which is the point. | ||
| 146 | |||
| 147 | Because the certificate chains to a CA you have already registered, guests | ||
| 148 | created **before** you delegated accept it. That is the difference from | ||
| 149 | registering a new CA: there is no ordering constraint, because nothing about the | ||
| 150 | guest's trust changes. | ||
| 151 | |||
| 152 | This is what makes [the remote MCP endpoint](mcp.md) work with nothing but a | ||
| 153 | PAT, where the same two steps are the `delegate_begin` and `delegate_complete` | ||
| 154 | tools. | ||
| 155 | |||
| 156 | ## One-liner | ||
| 157 | |||
| 158 | ```sh | ||
| 159 | export EITRI_URL=https://eitri.example.com | ||
| 160 | export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address | ||
| 161 | |||
| 162 | eitri ssh <vm-name> # opens a shell on the VM | ||
| 163 | eitri ssh <vm-name> uptime # runs a command and exits | ||
| 164 | ``` | ||
| 165 | |||
| 166 | You pass the bare `<vm-name>`, but the name that reaches the wire is always the | ||
| 167 | **gate connect name** `<tenant>.<vm-name>`—a VM's host cert carries exactly | ||
| 168 | that one principal, and `eitri ssh` verifies the dialed name against it under | ||
| 169 | strict checking, so a bare name would fail host verification. (The gate itself | ||
| 170 | also *resolves* a bare name within the connection's tenant, but the VM's cert | ||
| 171 | does not, so the client sends the namespaced form.) You never need to know your | ||
| 172 | tenant: `eitri ssh` derives it from your credential via `/me`, or takes it from | ||
| 173 | `EITRI_TENANT` when set (offline, and the escape hatch for a CA registered in | ||
| 174 | more than one tenant). | ||
| 175 | |||
| 176 | Environment variables. `EITRI_URL`, `EITRI_GATE`, `EITRI_CA`, `EITRI_TENANT` | ||
| 177 | and `EITRI_KEY` are per-invocation overrides above the config file `eitri init` | ||
| 178 | writes, so the chain is variable, then `~/.eitri/config.json`, then the default | ||
| 179 | below. `EITRI_TOKEN` and `EITRI_KNOWN_HOSTS` have no config-file rung—no | ||
| 180 | credential is ever written to disk, and the pin file is a cache rather than a | ||
| 181 | setting. | ||
| 182 | |||
| 183 | | Var | Meaning | | ||
| 184 | | ------------- | --------------------------------------------------------- | | ||
| 185 | | `EITRI_CONFIG`| Path to that config file (default `~/.eitri/config.json`) | | ||
| 186 | | `EITRI_URL` | Base URL of the eitri server (default the hosted `https://console.eitri.sh`) | | ||
| 187 | | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`); otherwise the plane's own `/me` answer, and the hosted `gate.eitri.sh:2222` only when `EITRI_URL` is the hosted plane | | ||
| 188 | | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) | | ||
| 189 | | `EITRI_TOKEN` | Personal access token, used only to look up your tenant for the connect name (skipped when `EITRI_TENANT` is set) | | ||
| 190 | | `EITRI_TENANT`| Optional: pins the tenant (offline, and the escape hatch when your CA is registered in more than one); otherwise derived from the token | | ||
| 191 | | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) | | ||
| 192 | | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) | | ||
| 193 | |||
| 194 | The SSH session authenticates with no API credential—your signing CA *is* the | ||
| 195 | credential. `eitri ssh` generates `~/.ssh/id_ed25519` if missing, self-signs a | ||
| 196 | 30-minute cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the | ||
| 197 | eitri host CA and pins it as `@cert-authority *` in a dedicated known_hosts | ||
| 198 | file, and execs `ssh` with both hops verified. | ||
| 199 | |||
| 200 | > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, | ||
| 201 | > i.e. the server's `ssh_gate_domain` (which defaults to the host part of | ||
| 202 | > `ssh_listen`). A mismatch is a hard host-verification failure, by design. | ||
| 203 | > A server whose `ssh_listen` binds every interface (`:2222`, `0.0.0.0:2222`) | ||
| 204 | > has no host part to default to, so `ssh_gate_domain` is required there and | ||
| 205 | > the server refuses to start without it. | ||
| 206 | |||
| 207 | Against your own server, `EITRI_URL` must name it—unset, the client defaults to | ||
| 208 | the hosted service—and the gate comes from that server's `/api/v1/me`, which | ||
| 209 | serves whatever `ssh_gate_domain` and `ssh_listen` say. A `/me` that names no | ||
| 210 | gate is an error naming those two settings, never a silent hop through | ||
| 211 | eitri.sh. | ||
| 212 | |||
| 213 | ## Manual flow | ||
| 214 | |||
| 215 | The client is a thin wrapper over three steps you can run by hand: | ||
| 216 | |||
| 217 | 1. **Self-sign a cert** for your public key with your tenant CA—no server | ||
| 218 | involved: | ||
| 219 | |||
| 220 | ```sh | ||
| 221 | ssh-keygen -s ~/.ssh/eitri_user_ca -I "$(whoami)@$(hostname)" \ | ||
| 222 | -n ubuntu -V +30m ~/.ssh/id_ed25519.pub | ||
| 223 | ``` | ||
| 224 | |||
| 225 | 2. **Place the cert beside the key.** `ssh-keygen -s` writes | ||
| 226 | `id_ed25519-cert.pub` next to the key, and OpenSSH auto-offers a cert named | ||
| 227 | `<key>-cert.pub`—nothing further needed, no `ssh-add`. | ||
| 228 | |||
| 229 | 3. **Hop through the gate** to `ubuntu@<tenant>.<vm-name>`: | ||
| 230 | |||
| 231 | ```sh | ||
| 232 | ssh -J "$EITRI_GATE" ubuntu@<tenant>.<vm-name> | ||
| 233 | ``` | ||
| 234 | |||
| 235 | The inner user must be `ubuntu` (the cert principal). The outer gate hop | ||
| 236 | accepts any username. The gate derives your tenant from the CA that signed | ||
| 237 | your cert, resolves names within that tenant, and rejects a foreign-prefixed | ||
| 238 | name. | ||
| 239 | |||
| 240 | ## Certs are short-lived | ||
| 241 | |||
| 242 | Self-signed certs should carry a short validity (`-V +30m` above). When one | ||
| 243 | expires, ssh is simply rejected—re-run `eitri ssh` (or the signing step) | ||
| 244 | to refresh. A specific cert can also be revoked at the gate by serial before it | ||
| 245 | expires; see [credential-revocation.md](credential-revocation.md). | ||
| 246 | |||
| 247 | ## Host verification (via the CA) | ||
| 248 | |||
| 249 | You pin eitri's host CA once and let certificate verification stand in for | ||
| 250 | trust-on-first-use. Fetch the CA (public material, no token needed) and pin it | ||
| 251 | in a **dedicated** known_hosts file—never your main `~/.ssh/known_hosts`, | ||
| 252 | where a `*` wildcard CA would be trusted for *every* host you ssh to: | ||
| 253 | |||
| 254 | ```sh | ||
| 255 | curl -sS "$EITRI_URL/api/v1/ssh-ca" | jq -r .ca \ | ||
| 256 | | sed 's/^/@cert-authority * /' > ~/.ssh/eitri_known_hosts | ||
| 257 | ``` | ||
| 258 | |||
| 259 | Then both hops are verified against the CA with `StrictHostKeyChecking=yes`. A | ||
| 260 | command-line `-o` reaches only the *final* hop, so thread the same options to the | ||
| 261 | jump hop with an explicit `ProxyCommand` instead of `-J`: | ||
| 262 | |||
| 263 | ```sh | ||
| 264 | GATE_HOST=${EITRI_GATE%%:*}; GATE_PORT=${EITRI_GATE##*:} | ||
| 265 | [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 | ||
| 266 | KH=~/.ssh/eitri_known_hosts | ||
| 267 | ssh \ | ||
| 268 | -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile='$KH' -p $GATE_PORT ubuntu@$GATE_HOST" \ | ||
| 269 | -o StrictHostKeyChecking=yes \ | ||
| 270 | -o "UserKnownHostsFile=$KH" \ | ||
| 271 | ubuntu@<tenant>.<vm-name> | ||
| 272 | ``` | ||
| 273 | |||
| 274 | The inner `UserKnownHostsFile` is quoted twice over. The outer shell expands | ||
| 275 | `$KH` into the `ProxyCommand` string, and ssh then runs that string through a | ||
| 276 | shell of its own—so the single quotes are what survive to the inner shell and | ||
| 277 | keep a `$HOME` with a space in it one word. | ||
| 278 | |||
| 279 | The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), | ||
| 280 | and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the | ||
| 281 | inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by | ||
| 282 | CA, recycling a VM name or IP never produces a host-key-changed warning—the | ||
| 283 | new VM simply presents a fresh CA-signed cert for that name. `eitri ssh` | ||
| 284 | does all of this for you. | ||
| 285 | |||
| 286 | ## Related | ||
| 287 | |||
| 288 | - [networking](networking.md)—reaching a service a guest serves, past the gate | ||
| 289 | - [credential-revocation.md](credential-revocation.md)—revoking a leaked user | ||
| 290 | cert or host credential | ||
| 291 | - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity | ||
docs/credential-revocation.md
| Old | New | ||
|---|---|---|---|
| @@ -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 [connecting.md](connecting.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 | ||
| @@ -77,5 +77,5 @@ value is refused at startup rather than silently ignored. | |||
| 77 | 77 | ||
| 78 | ## Related | 78 | ## Related |
| 79 | 79 | ||
| 80 | - [ssh-access.md](ssh-access.md)—how user and host certs work | 80 | - [connecting.md](connecting.md)—how user and host certs work |
| 81 | - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity | 81 | - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -25,7 +25,7 @@ kernel upgrades are the guest's business, not the fleet's. | |||
| 25 | Each tenant signs its own SSH user certs; eitri stores only CA public keys. | 25 | Each tenant signs its own SSH user certs; eitri stores only CA public keys. |
| 26 | Instead of the server minting user certs from an eitri-held CA. A compromised | 26 | Instead of the server minting user certs from an eitri-held CA. A compromised |
| 27 | eitri server cannot mint access to any tenant's guests—the user-auth trust | 27 | eitri server cannot mint access to any tenant's guests—the user-auth trust |
| 28 | root lives with the tenant. Details in [ssh-access.md](ssh-access.md). | 28 | root lives with the tenant. Details in [connecting.md](connecting.md). |
| 29 | 29 | ||
| 30 | ### Delegated credentials, not a CA eitri holds | 30 | ### Delegated credentials, not a CA eitri holds |
| 31 | 31 | ||
| @@ -37,7 +37,7 @@ power that never lapses. A certificate does lapse, and a restart drops it. | |||
| 37 | Instead of persisting delegations, too — a credential that survives the process | 37 | Instead of persisting delegations, too — a credential that survives the process |
| 38 | that was granted it is one nobody remembers granting. The cost is re-delegating | 38 | that was granted it is one nobody remembers granting. The cost is re-delegating |
| 39 | after a redeploy, which is one `ssh-keygen` against a stable public key. | 39 | after a redeploy, which is one `ssh-keygen` against a stable public key. |
| 40 | Details in [ssh-access.md](ssh-access.md). | 40 | Details in [connecting.md](connecting.md). |
| 41 | 41 | ||
| 42 | ### A guest's host key is generated by its host, and certified by the fleet | 42 | ### A guest's host key is generated by its host, and certified by the fleet |
| 43 | 43 | ||
docs/faq.md
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,7 @@ | |||
| 5 | Outbound works out of the box: guests are NAT'd through their host and reach | 5 | Outbound works out of the box: guests are NAT'd through their host and reach |
| 6 | the internet like any process on it. That NIC is unconditional—the guest's | 6 | the internet like any process on it. That NIC is unconditional—the guest's |
| 7 | egress, its way to the other guests on that host, and where the SSH | 7 | egress, its way to the other guests on that host, and where the SSH |
| 8 | [jump gate](ssh-access.md) meets it. | 8 | [jump gate](connecting.md) meets it. |
| 9 | 9 | ||
| 10 | Inbound has two answers. The gate works from anywhere: it authenticates every | 10 | Inbound has two answers. The gate works from anywhere: it authenticates every |
| 11 | connection against your tenant's CA and tunnels you in. The other is to put the | 11 | connection against your tenant's CA and tunnels you in. The other is to put the |
| @@ -13,7 +13,7 @@ VM on your own network—declare a bridge on a Linux host, name it to the agent | |||
| 13 | (`--host-network lan=br0`), pick that name at create. Your own DHCP server | 13 | (`--host-network lan=br0`), pick that name at create. Your own DHCP server |
| 14 | addresses the guest's second NIC, and it is then a machine on the network: | 14 | addresses the guest's second NIC, and it is then a machine on the network: |
| 15 | every port, every device, nothing of eitri's in front of that NIC. The | 15 | every port, every device, nothing of eitri's in front of that NIC. The |
| 16 | quickstart's [Put a VM on your LAN](quickstart.md) is the recipe. | 16 | [networking](networking.md) has the recipe. |
| 17 | 17 | ||
| 18 | For reach beyond that LAN it is still a normal Linux machine, so install | 18 | For reach beyond that LAN it is still a normal Linux machine, so install |
| 19 | [Tailscale](https://tailscale.com) (or WireGuard) inside it. A public gateway | 19 | [Tailscale](https://tailscale.com) (or WireGuard) inside it. A public gateway |
| @@ -21,7 +21,7 @@ is on the [roadmap](../ROADMAP.md). | |||
| 21 | 21 | ||
| 22 | ## Is an exposed port authenticated? | 22 | ## Is an exposed port authenticated? |
| 23 | 23 | ||
| 24 | No. The SSH [jump gate](ssh-access.md) authenticates every connection against | 24 | No. The SSH [jump gate](connecting.md) authenticates every connection against |
| 25 | your tenant's CA. A published port has nothing in front of it: whoever can | 25 | your tenant's CA. A published port has nothing in front of it: whoever can |
| 26 | reach the host on that port reaches the service, exactly as if the service were | 26 | reach the host on that port reaches the service, exactly as if the service were |
| 27 | running on the host itself. | 27 | running on the host itself. |
docs/joining.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,125 @@ | |||
| 1 | # Joining a host | ||
| 2 | |||
| 3 | *Putting a machine into your fleet, Linux or Mac* | ||
| 4 | |||
| 5 | A host is any machine that runs VMs for you. Joining is one command per | ||
| 6 | machine, whichever plane it reports to: the console prints a one-shot join | ||
| 7 | blob, the agent dials out with it, and the host comes **online**. Nothing | ||
| 8 | listens for an inbound connection, so a machine behind NAT needs no open | ||
| 9 | ports. | ||
| 10 | |||
| 11 | Where the console lives differs. On the hosted service it is | ||
| 12 | <https://console.eitri.sh> ([quickstart](quickstart.md)); on your own plane it | ||
| 13 | is your server ([self-hosting](self-hosting.md)), and the join command comes | ||
| 14 | from your own **+ Add host**. | ||
| 15 | |||
| 16 | ## Join a Linux host | ||
| 17 | |||
| 18 | On the machine that will serve VMs—Linux with KVM (`ls -l /dev/kvm`)—download | ||
| 19 | and verify the host bundle: | ||
| 20 | |||
| 21 | ```sh | ||
| 22 | curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS | ||
| 23 | V=$(sed -n 's/.*eitri-server_\(v[^_]*\)_linux_amd64\.tar\.gz$/\1/p' SHA256SUMS) | ||
| 24 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-server_${V}_linux_amd64.tar.gz" | ||
| 25 | sha256sum -c SHA256SUMS --ignore-missing | ||
| 26 | tar xzf "eitri-server_${V}_linux_amd64.tar.gz" && cd "eitri-server_${V}_linux_amd64" | ||
| 27 | ``` | ||
| 28 | |||
| 29 | `/dl/latest/` is the current release. Its filenames carry the version, so | ||
| 30 | `SHA256SUMS`—which you need to verify anyway—is where you read it from. | ||
| 31 | |||
| 32 | The Linux host bundle is named for the `eitri-server` it also carries: a box | ||
| 33 | that only runs VMs installs the `eitri-agent` inside it and nothing else. On an | ||
| 34 | arm64 host, `_arm64` replaces `_amd64` throughout. | ||
| 35 | |||
| 36 | Then click **+ Add host** in the console and run the command it prints | ||
| 37 | from the unpacked bundle—it installs the agent and joins this machine | ||
| 38 | to your tenant: | ||
| 39 | |||
| 40 | ```sh | ||
| 41 | sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent | ||
| 42 | sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service | ||
| 43 | sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console> | ||
| 44 | sudo systemctl daemon-reload | ||
| 45 | sudo systemctl enable --now eitri-agent | ||
| 46 | ``` | ||
| 47 | |||
| 48 | The agent dials out—a machine behind NAT needs no open ports. It goes | ||
| 49 | online in the console within seconds. | ||
| 50 | |||
| 51 | ## Join a Mac | ||
| 52 | |||
| 53 | A Mac joins the same way, with two differences. It runs guests through | ||
| 54 | [vfkit](https://github.com/crc-org/vfkit) on Apple's Virtualization.framework, | ||
| 55 | and you install that yourself: | ||
| 56 | |||
| 57 | ```sh | ||
| 58 | brew install vfkit | ||
| 59 | ``` | ||
| 60 | |||
| 61 | The agent installs cloud-hypervisor on a Linux host but cannot do the same | ||
| 62 | here: vfkit only works carrying Apple's virtualization entitlement, and an | ||
| 63 | entitlement lives in a code signature. Homebrew's copy is signed. Without it, | ||
| 64 | VMs placed on this host fail at once, saying so. | ||
| 65 | |||
| 66 | Take the darwin bundle instead of the linux one: | ||
| 67 | `eitri-agent_<version>_darwin_arm64.tar.gz`, the agent and its LaunchAgent | ||
| 68 | installer, no server. macOS ships no `sha256sum`, so `shasum` checks the one | ||
| 69 | line of `SHA256SUMS` that names it: | ||
| 70 | |||
| 71 | ```sh | ||
| 72 | curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS | ||
| 73 | V=$(sed -n 's/.*eitri-agent_\(v[^_]*\)_darwin_arm64\.tar\.gz$/\1/p' SHA256SUMS) | ||
| 74 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-agent_${V}_darwin_arm64.tar.gz" | ||
| 75 | grep " eitri-agent_${V}_darwin_arm64.tar.gz$" SHA256SUMS | shasum -a 256 -c - | ||
| 76 | tar xzf "eitri-agent_${V}_darwin_arm64.tar.gz" && cd "eitri-agent_${V}_darwin_arm64" | ||
| 77 | ``` | ||
| 78 | |||
| 79 | Nothing here needs `sudo`, and nothing goes in a system directory: | ||
| 80 | |||
| 81 | ```sh | ||
| 82 | mkdir -p ~/.local/bin && cp eitri-agent ~/.local/bin/ | ||
| 83 | ~/.local/bin/eitri-agent join eitri_join_<blob-from-console> | ||
| 84 | ./eitri-agent-launchagent.sh install ~/.local/bin/eitri-agent | ||
| 85 | ``` | ||
| 86 | |||
| 87 | State lands in `~/.eitri/agent`, logs in `~/Library/Logs/eitri-agent.log`, and | ||
| 88 | the agent starts with your session. | ||
| 89 | |||
| 90 | The agent dials its guests over vmnet, and macOS gates local-network dialing | ||
| 91 | per app: the agent needs the **Local Network** permission. Run from a | ||
| 92 | terminal, the agent is prompted for it; installed as a LaunchAgent, it may | ||
| 93 | never be. Grant it under System Settings → Privacy & Security → Local | ||
| 94 | Network. Without | ||
| 95 | it, guests boot and take a vmnet lease but stay unreachable through the | ||
| 96 | gate—`eitri ssh` fails with "cannot reach VM". | ||
| 97 | |||
| 98 | Keep the binary somewhere this account owns. The agent replaces its own binary | ||
| 99 | when it upgrades, so a root-owned prefix like `/usr/local/bin` would look tidy | ||
| 100 | and quietly disable every future upgrade—the installer refuses rather than let | ||
| 101 | that happen. | ||
| 102 | |||
| 103 | ## Sharp edges on a Mac | ||
| 104 | |||
| 105 | Unprivileged throughout, because on macOS eitri owns none of the host's | ||
| 106 | networking: Virtualization.framework owns the guest network and vfkit carries | ||
| 107 | the entitlement for it. Contrast the Linux agent, which is root because it owns | ||
| 108 | the bridge, the taps, `/dev/kvm` and the nftables rules. | ||
| 109 | |||
| 110 | A **LaunchAgent**, not a LaunchDaemon, because a Mac host is nearly always | ||
| 111 | somebody's Mac and an agent runs where that person already is. For a dedicated | ||
| 112 | machine that must run guests with nobody logged in, move the generated plist to | ||
| 113 | `/Library/LaunchDaemons` and add a `UserName` key naming this account; leave the | ||
| 114 | binary and state where they are. | ||
| 115 | |||
| 116 | Apple Silicon runs **arm64 guests only**: the framework cannot emulate another | ||
| 117 | architecture. You do not have to remember that—`default_images` is keyed by | ||
| 118 | host architecture, so a VM created without an explicit image gets the arm64 one | ||
| 119 | on a Mac. If you *do* name an image, name an arm64 image; nothing downstream | ||
| 120 | checks, and one built for the wrong architecture boots into nothing. | ||
| 121 | |||
| 122 | Guests get their addresses from macOS's own NAT rather than from the agent, so | ||
| 123 | a Mac's guests sit on vmnet's subnet. The host reports that subnet to the fleet, | ||
| 124 | so it is what the console shows—no allocation is made for a Mac and none is | ||
| 125 | used. Everything above that—`eitri ssh`, the console, reconcile—is the same. | ||
docs/networking.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,96 @@ | |||
| 1 | # Networking | ||
| 2 | |||
| 3 | *Reaching what a guest serves, and putting a guest on your own network* | ||
| 4 | |||
| 5 | Every guest lives on a private bridge behind its host, NAT'd outbound. The SSH | ||
| 6 | gate reaches it; nothing else does, until you say so. There are two ways to | ||
| 7 | say so, and they answer different questions: publish a port when a service | ||
| 8 | needs to be reachable from where the host is, and give the VM a second NIC on | ||
| 9 | your own network when the guest itself belongs there. | ||
| 10 | |||
| 11 | ## Expose a service | ||
| 12 | |||
| 13 | The gate reaches a guest over SSH. Anything else a guest serves needs a | ||
| 14 | published port: the fleet binds one on the VM's host and pipes it to the guest. | ||
| 15 | |||
| 16 | Open the VM's page in the console, find **Exposures**, and enter the port your | ||
| 17 | service listens on inside the guest—8080, say. Pick `tcp` or `udp`. Leave the | ||
| 18 | host port blank and eitri allocates one from 30000–32767, the range it reserves | ||
| 19 | on every host. The row then reads: | ||
| 20 | |||
| 21 | guest :8080/tcp → 192.168.1.40:30080 ● active | ||
| 22 | |||
| 23 | That address is the host's. Anything that can reach the host on that port | ||
| 24 | reaches the service—there is no authentication in front of a published port, so | ||
| 25 | publish what you are content to serve to everything on that network, and leave | ||
| 26 | the rest to the gate. | ||
| 27 | |||
| 28 | A host port is claimed per protocol, so `30080/tcp` and `30080/udp` can be two | ||
| 29 | different services. | ||
| 30 | |||
| 31 | `active` means the host's socket is bound. Whether anything answers on the | ||
| 32 | guest's 8080 is the guest's business. | ||
| 33 | |||
| 34 | A UDP exposure asks for one thing more of you than a TCP one. UDP has no | ||
| 35 | handshake, so nothing proves a datagram came from the address it claims: the | ||
| 36 | host sends the guest's reply wherever that source header says, which makes a | ||
| 37 | published UDP port something an outsider can aim at a third party. That is the | ||
| 38 | same bargain the rest of a published port already is—reaching the host is | ||
| 39 | reaching the service—so it is accepted rather than papered over, and it is one | ||
| 40 | more reason to publish only onto a network you are content to serve. | ||
| 41 | |||
| 42 | Remove the row to take the port down. Deleting the VM takes its exposures with | ||
| 43 | it. | ||
| 44 | |||
| 45 | ## Put a VM on your LAN | ||
| 46 | |||
| 47 | Every guest lives on a private bridge behind its host, NAT'd out. A VM can | ||
| 48 | *also* sit on your own network: declare a bridge on a Linux host, name it to | ||
| 49 | the agent, pick that name at create—a second NIC there, and it keeps the first. | ||
| 50 | |||
| 51 | The bridge is yours, declared in the host's own network config; eitri attaches | ||
| 52 | taps and never creates or addresses one. netplan shown, networkd and | ||
| 53 | NetworkManager have equivalents: | ||
| 54 | |||
| 55 | ```yaml | ||
| 56 | network: | ||
| 57 | ethernets: | ||
| 58 | enp4s0: {} | ||
| 59 | bridges: | ||
| 60 | br0: | ||
| 61 | interfaces: [enp4s0] | ||
| 62 | dhcp4: true # or the host's static address, verbatim | ||
| 63 | ``` | ||
| 64 | |||
| 65 | The host's address moves onto `br0` at boot, and so must anything bound to the | ||
| 66 | uplink by name—a PXE dnsmasq's `interface=enp4s0` becomes `interface=br0`. | ||
| 67 | |||
| 68 | Name the network to the agent in `/etc/default/eitri-agent`, then restart it: | ||
| 69 | |||
| 70 | ```sh | ||
| 71 | EITRI_AGENT_FLAGS="--host-network lan=br0" | ||
| 72 | ``` | ||
| 73 | |||
| 74 | The flag repeats. Names are 1–32 of `[a-z0-9-]`, no leading | ||
| 75 | or trailing hyphen; `nat` is reserved. Linux hosts only. An agent whose named | ||
| 76 | bridge is missing refuses to start, naming it. | ||
| 77 | |||
| 78 | Create the VM on that network—the console's dialog shows a **Network** select | ||
| 79 | for a host advertising any, and naming one its host does not is refused. | ||
| 80 | |||
| 81 | The VM page then shows both addresses. **IP** is the private one the gate, the | ||
| 82 | console and published ports aim at; **Network** is your own name for the | ||
| 83 | network, and gains the address your DHCP server granted the second NIC once | ||
| 84 | that lease lands. A guest configured static there never DHCPs—reachable all | ||
| 85 | the same, but **Network** shows the name alone. | ||
| 86 | |||
| 87 | The private NIC keeps the default route (route metric 100, against 200 on the | ||
| 88 | second NIC), so outbound leaves through the host's masquerade whatever your | ||
| 89 | network is doing—on the default Ubuntu image, where netplan honours those | ||
| 90 | metrics. Which address a service advertises is that service's own business. | ||
| 91 | |||
| 92 | A guest on your network is flat on it: every port, every device, no gate in | ||
| 93 | front of that NIC—attach what you are content to have live there, the rest | ||
| 94 | NAT-only. It is also still on the host's private bridge beside that host's | ||
| 95 | other guests, which is what lets them talk and what a compromise of it reaches. | ||
| 96 | |||
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -2,194 +2,35 @@ | |||
| 2 | 2 | ||
| 3 | *From nothing to a VM you can SSH into* | 3 | *From nothing to a VM you can SSH into* |
| 4 | 4 | ||
| 5 | ## The hosted way (console.eitri.sh) | ||
| 6 | |||
| 7 | eitri.sh runs a control plane so you don't have to. You bring the | 5 | eitri.sh runs a control plane so you don't have to. You bring the |
| 8 | hardware; the console, sync, and SSH gate are already up. No server | 6 | hardware; the console, sync, and SSH gate are already up. No server |
| 9 | install at all. | 7 | install at all. Prefer to run the plane yourself? [self-hosting](self-hosting.md) |
| 8 | does the same job on your own box, and the three steps below are unchanged | ||
| 9 | after it. | ||
| 10 | 10 | ||
| 11 | ### Create an account | 11 | ## Create an account |
| 12 | 12 | ||
| 13 | Open <https://console.eitri.sh> and sign in with Google. Your first | 13 | Open <https://console.eitri.sh> and sign in with Google. Your first |
| 14 | sign-in creates your tenant. | 14 | sign-in creates your tenant. |
| 15 | 15 | ||
| 16 | ### Join a host | 16 | ## Join a host |
| 17 | |||
| 18 | On the machine that will serve VMs (Linux with KVM—see "What you need" | ||
| 19 | under Self-hosting, or a Mac, see "Join a Mac"), download and verify the | ||
| 20 | host bundle: | ||
| 21 | |||
| 22 | ```sh | ||
| 23 | curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS | ||
| 24 | V=$(sed -n 's/.*eitri-server_\(v[^_]*\)_linux_amd64\.tar\.gz$/\1/p' SHA256SUMS) | ||
| 25 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-server_${V}_linux_amd64.tar.gz" | ||
| 26 | sha256sum -c SHA256SUMS --ignore-missing | ||
| 27 | tar xzf "eitri-server_${V}_linux_amd64.tar.gz" && cd "eitri-server_${V}_linux_amd64" | ||
| 28 | ``` | ||
| 29 | |||
| 30 | `/dl/latest/` is the current release. Its filenames carry the version, so | ||
| 31 | `SHA256SUMS`—which you need to verify anyway—is where you read it from. | ||
| 32 | |||
| 33 | The Linux host bundle is named for the `eitri-server` it also carries: a box | ||
| 34 | that only runs VMs installs the `eitri-agent` inside it and nothing else. On an | ||
| 35 | arm64 host, `_arm64` replaces `_amd64` throughout. | ||
| 36 | |||
| 37 | Then click **+ Add host** in the console and run the command it prints | ||
| 38 | from the unpacked bundle—it installs the agent and joins this machine | ||
| 39 | to your tenant: | ||
| 40 | |||
| 41 | ```sh | ||
| 42 | sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent | ||
| 43 | sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service | ||
| 44 | sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console> | ||
| 45 | sudo systemctl daemon-reload | ||
| 46 | sudo systemctl enable --now eitri-agent | ||
| 47 | ``` | ||
| 48 | |||
| 49 | The agent dials out—a machine behind NAT needs no open ports. It goes | ||
| 50 | online in the console within seconds. | ||
| 51 | |||
| 52 | ### Join a Mac | ||
| 53 | |||
| 54 | A Mac joins the same way, with two differences. It runs guests through | ||
| 55 | [vfkit](https://github.com/crc-org/vfkit) on Apple's Virtualization.framework, | ||
| 56 | and you install that yourself: | ||
| 57 | |||
| 58 | ```sh | ||
| 59 | brew install vfkit | ||
| 60 | ``` | ||
| 61 | |||
| 62 | The agent installs cloud-hypervisor on a Linux host but cannot do the same | ||
| 63 | here: vfkit only works carrying Apple's virtualization entitlement, and an | ||
| 64 | entitlement lives in a code signature. Homebrew's copy is signed. Without it, | ||
| 65 | VMs placed on this host fail at once, saying so. | ||
| 66 | |||
| 67 | Take the darwin bundle instead of the linux one: | ||
| 68 | `eitri-agent_<version>_darwin_arm64.tar.gz`, the agent and its LaunchAgent | ||
| 69 | installer, no server. macOS ships no `sha256sum`, so `shasum` checks the one | ||
| 70 | line of `SHA256SUMS` that names it: | ||
| 71 | |||
| 72 | ```sh | ||
| 73 | curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS | ||
| 74 | V=$(sed -n 's/.*eitri-agent_\(v[^_]*\)_darwin_arm64\.tar\.gz$/\1/p' SHA256SUMS) | ||
| 75 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-agent_${V}_darwin_arm64.tar.gz" | ||
| 76 | grep " eitri-agent_${V}_darwin_arm64.tar.gz$" SHA256SUMS | shasum -a 256 -c - | ||
| 77 | tar xzf "eitri-agent_${V}_darwin_arm64.tar.gz" && cd "eitri-agent_${V}_darwin_arm64" | ||
| 78 | ``` | ||
| 79 | |||
| 80 | Nothing here needs `sudo`, and nothing goes in a system directory: | ||
| 81 | |||
| 82 | ```sh | ||
| 83 | mkdir -p ~/.local/bin && cp eitri-agent ~/.local/bin/ | ||
| 84 | ~/.local/bin/eitri-agent join eitri_join_<blob-from-console> | ||
| 85 | ./eitri-agent-launchagent.sh install ~/.local/bin/eitri-agent | ||
| 86 | ``` | ||
| 87 | |||
| 88 | State lands in `~/.eitri/agent`, logs in `~/Library/Logs/eitri-agent.log`, and | ||
| 89 | the agent starts with your session. | ||
| 90 | |||
| 91 | The agent dials its guests over vmnet, and macOS gates local-network dialing | ||
| 92 | per app: the agent needs the **Local Network** permission. Run from a | ||
| 93 | terminal, the agent is prompted for it; installed as a LaunchAgent, it may | ||
| 94 | never be. Grant it under System Settings → Privacy & Security → Local | ||
| 95 | Network. Without | ||
| 96 | it, guests boot and take a vmnet lease but stay unreachable through the | ||
| 97 | gate—`eitri ssh` fails with "cannot reach VM". | ||
| 98 | |||
| 99 | Keep the binary somewhere this account owns. The agent replaces its own binary | ||
| 100 | when it upgrades, so a root-owned prefix like `/usr/local/bin` would look tidy | ||
| 101 | and quietly disable every future upgrade—the installer refuses rather than let | ||
| 102 | that happen. | ||
| 103 | |||
| 104 | Unprivileged throughout, because on macOS eitri owns none of the host's | ||
| 105 | networking: Virtualization.framework owns the guest network and vfkit carries | ||
| 106 | the entitlement for it. Contrast the Linux agent, which is root because it owns | ||
| 107 | the bridge, the taps, `/dev/kvm` and the nftables rules. | ||
| 108 | |||
| 109 | A **LaunchAgent**, not a LaunchDaemon, because a Mac host is nearly always | ||
| 110 | somebody's Mac and an agent runs where that person already is. For a dedicated | ||
| 111 | machine that must run guests with nobody logged in, move the generated plist to | ||
| 112 | `/Library/LaunchDaemons` and add a `UserName` key naming this account; leave the | ||
| 113 | binary and state where they are. | ||
| 114 | |||
| 115 | Apple Silicon runs **arm64 guests only**: the framework cannot emulate another | ||
| 116 | architecture. You do not have to remember that—`default_images` is keyed by | ||
| 117 | host architecture, so a VM created without an explicit image gets the arm64 one | ||
| 118 | on a Mac. If you *do* name an image, name an arm64 image; nothing downstream | ||
| 119 | checks, and one built for the wrong architecture boots into nothing. | ||
| 120 | |||
| 121 | Guests get their addresses from macOS's own NAT rather than from the agent, so | ||
| 122 | a Mac's guests sit on vmnet's subnet. The host reports that subnet to the fleet, | ||
| 123 | so it is what the console shows—no allocation is made for a Mac and none is | ||
| 124 | used. Everything above that—`eitri ssh`, the console, reconcile—is the same. | ||
| 125 | |||
| 126 | ### Set up your laptop | ||
| 127 | |||
| 128 | SSH access uses certificates signed by **your** CA—eitri never holds a | ||
| 129 | user key that can enter your VMs. A guest trusts the CA set it is created with, | ||
| 130 | so this comes before your first VM. | ||
| 131 | |||
| 132 | Download the client CLI. It targets the hosted service by default: | ||
| 133 | |||
| 134 | ```sh | ||
| 135 | OS=$(uname -s | tr A-Z a-z) ARCH=amd64 # arm64 laptop → ARCH=arm64 | ||
| 136 | curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS | ||
| 137 | V=$(sed -n "s/.*eitri-cli_\(v[^_]*\)_${OS}_${ARCH}\.tar\.gz\$/\1/p" SHA256SUMS) | ||
| 138 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-cli_${V}_${OS}_${ARCH}.tar.gz" | ||
| 139 | sha256sum -c SHA256SUMS --ignore-missing | ||
| 140 | tar xzf "eitri-cli_${V}_${OS}_${ARCH}.tar.gz" | ||
| 141 | sudo install -m 0755 "eitri-cli_${V}_${OS}_${ARCH}/eitri" /usr/local/bin/eitri | ||
| 142 | ``` | ||
| 143 | |||
| 144 | Then run the guided setup: | ||
| 145 | |||
| 146 | ```sh | ||
| 147 | eitri init | ||
| 148 | ``` | ||
| 149 | |||
| 150 | It asks for a personal access token—mint one in the console under **Settings → | ||
| 151 | Personal access tokens**—and walks three steps, printing what each will do and | ||
| 152 | doing it only on a `y`: | ||
| 153 | |||
| 154 | 1. **Identity.** Calls `/api/v1/me` with the token to learn your tenant and | ||
| 155 | which SSH gate this plane runs. The token is used once and never stored; | ||
| 156 | entered at the prompt it is not echoed. | ||
| 157 | 2. **CA.** Lists the CAs your tenant has already registered. If the signing key | ||
| 158 | on this laptop is one of them, there is nothing to do. If you have a key it | ||
| 159 | does not know—here or at a path you give it—init offers to register the | ||
| 160 | public half. Only when there is neither does it offer to generate an ed25519 | ||
| 161 | pair, naming both files and their modes before writing anything. | ||
| 162 | 3. **Config.** Writes `~/.eitri/config.json`—plane, gate, tenant, CA and key | ||
| 163 | paths—after showing the current values beside what would change. | ||
| 164 | 17 | ||
| 165 | Run it again whenever: each step reports what is already settled and touches | 18 | Any machine with KVM, or a Mac. **+ Add host** in the console prints a one-shot |
| 166 | nothing. It is also how you find out that the key on this laptop signs certs | 19 | command; the machine dials out, and no inbound port is needed. |
| 167 | your tenant never registered—the failure that otherwise arrives as a bare | 20 | [joining](joining.md) has the commands for both, and the sharp edges of a Mac. |
| 168 | "permission denied" from a guest that was never going to trust you. | ||
| 169 | 21 | ||
| 170 | After init, `eitri ssh` needs no environment at all: the config names the plane | 22 | ## Set up your laptop |
| 171 | and the tenant, the certificate is signed locally, and the host-CA pin comes | ||
| 172 | from a public endpoint. Every `EITRI_*` variable still overrides the file, and | ||
| 173 | the file still overrides the hosted defaults. | ||
| 174 | 23 | ||
| 175 | By hand instead—`eitri init` does exactly this, and asks first: | 24 | SSH access uses certificates signed by **your** CA—eitri never holds a user key |
| 25 | that can enter your VMs. A guest trusts the CA set it is created with, so this | ||
| 26 | comes before your first VM: install the client and run `eitri init`, which | ||
| 27 | registers a CA if your tenant has none. [connecting](connecting.md) walks it. | ||
| 176 | 28 | ||
| 177 | ```sh | 29 | ## Boot a VM |
| 178 | export EITRI_TOKEN=<pat> | ||
| 179 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" | ||
| 180 | eitri ca upload ~/.ssh/eitri_user_ca.pub | ||
| 181 | ``` | ||
| 182 | 30 | ||
| 183 | Your token names the tenant, so neither command needs one. In more than one | 31 | Console → **+ Create VM**, pick your host, create. Watch it boot in the browser |
| 184 | tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly. | 32 | serial console. Defaults: 2 vCPUs, 2048 MB, 10 GB, the default image; status |
| 185 | 33 | reads `creating` while the image downloads and the guest boots, then `ready`. | |
| 186 | The console does the same under **Settings**, and so does the MCP `ca_upload` | ||
| 187 | tool. All three register into the same set. | ||
| 188 | |||
| 189 | ### Boot a VM | ||
| 190 | |||
| 191 | Console → **+ Create VM**, pick your host, create. Watch it boot in the | ||
| 192 | browser serial console. | ||
| 193 | 34 | ||
| 194 | A tenant with no registered CA is refused here, rather than handed a guest that | 35 | A tenant with no registered CA is refused here, rather than handed a guest that |
| 195 | nothing can reach: the CA set is baked into the guest at create and registering | 36 | nothing can reach: the CA set is baked into the guest at create and registering |
| @@ -197,7 +38,7 @@ one afterwards does not reach a VM that already exists. Register a CA first—th | |||
| 197 | console's Settings page, `eitri ca upload <ca.pub>`, or the MCP `ca_upload` | 38 | console's Settings page, `eitri ca upload <ca.pub>`, or the MCP `ca_upload` |
| 198 | tool—then create the VM. | 39 | tool—then create the VM. |
| 199 | 40 | ||
| 200 | ### SSH in | 41 | ## SSH in |
| 201 | 42 | ||
| 202 | `eitri init` wrote the plane, the gate and your tenant, so there is nothing | 43 | `eitri init` wrote the plane, the gate and your tenant, so there is nothing |
| 203 | left to set: | 44 | left to set: |
| @@ -211,363 +52,9 @@ Skipped init? The console and gate fall back to the hosted defaults, and | |||
| 211 | `EITRI_TOKEN` alone is enough—`eitri ssh` asks the plane for your tenant and | 52 | `EITRI_TOKEN` alone is enough—`eitri ssh` asks the plane for your tenant and |
| 212 | its gate. | 53 | its gate. |
| 213 | 54 | ||
| 214 | ### Expose a service | 55 | ## Then what |
| 215 | |||
| 216 | The gate reaches a guest over SSH. Anything else a guest serves needs a | ||
| 217 | published port: the fleet binds one on the VM's host and pipes it to the guest. | ||
| 218 | |||
| 219 | Open the VM's page in the console, find **Exposures**, and enter the port your | ||
| 220 | service listens on inside the guest—8080, say. Pick `tcp` or `udp`. Leave the | ||
| 221 | host port blank and eitri allocates one from 30000–32767, the range it reserves | ||
| 222 | on every host. The row then reads: | ||
| 223 | |||
| 224 | guest :8080/tcp → 192.168.1.40:30080 ● active | ||
| 225 | |||
| 226 | That address is the host's. Anything that can reach the host on that port | ||
| 227 | reaches the service—there is no authentication in front of a published port, so | ||
| 228 | publish what you are content to serve to everything on that network, and leave | ||
| 229 | the rest to the gate. | ||
| 230 | |||
| 231 | A host port is claimed per protocol, so `30080/tcp` and `30080/udp` can be two | ||
| 232 | different services. | ||
| 233 | |||
| 234 | `active` means the host's socket is bound. Whether anything answers on the | ||
| 235 | guest's 8080 is the guest's business. | ||
| 236 | |||
| 237 | A UDP exposure asks for one thing more of you than a TCP one. UDP has no | ||
| 238 | handshake, so nothing proves a datagram came from the address it claims: the | ||
| 239 | host sends the guest's reply wherever that source header says, which makes a | ||
| 240 | published UDP port something an outsider can aim at a third party. That is the | ||
| 241 | same bargain the rest of a published port already is—reaching the host is | ||
| 242 | reaching the service—so it is accepted rather than papered over, and it is one | ||
| 243 | more reason to publish only onto a network you are content to serve. | ||
| 244 | |||
| 245 | Remove the row to take the port down. Deleting the VM takes its exposures with | ||
| 246 | it. | ||
| 247 | |||
| 248 | ### Put a VM on your LAN | ||
| 249 | |||
| 250 | Every guest lives on a private bridge behind its host, NAT'd out. A VM can | ||
| 251 | *also* sit on your own network: declare a bridge on a Linux host, name it to | ||
| 252 | the agent, pick that name at create—a second NIC there, and it keeps the first. | ||
| 253 | |||
| 254 | The bridge is yours, declared in the host's own network config; eitri attaches | ||
| 255 | taps and never creates or addresses one. netplan shown, networkd and | ||
| 256 | NetworkManager have equivalents: | ||
| 257 | |||
| 258 | ```yaml | ||
| 259 | network: | ||
| 260 | ethernets: | ||
| 261 | enp4s0: {} | ||
| 262 | bridges: | ||
| 263 | br0: | ||
| 264 | interfaces: [enp4s0] | ||
| 265 | dhcp4: true # or the host's static address, verbatim | ||
| 266 | ``` | ||
| 267 | |||
| 268 | The host's address moves onto `br0` at boot, and so must anything bound to the | ||
| 269 | uplink by name—a PXE dnsmasq's `interface=enp4s0` becomes `interface=br0`. | ||
| 270 | |||
| 271 | Name the network to the agent in `/etc/default/eitri-agent`, then restart it: | ||
| 272 | |||
| 273 | ```sh | ||
| 274 | EITRI_AGENT_FLAGS="--host-network lan=br0" | ||
| 275 | ``` | ||
| 276 | |||
| 277 | The flag repeats. Names are 1–32 of `[a-z0-9-]`, no leading | ||
| 278 | or trailing hyphen; `nat` is reserved. Linux hosts only. An agent whose named | ||
| 279 | bridge is missing refuses to start, naming it. | ||
| 280 | |||
| 281 | Create the VM on that network—the console's dialog shows a **Network** select | ||
| 282 | for a host advertising any, and naming one its host does not is refused. | ||
| 283 | |||
| 284 | The VM page then shows both addresses. **IP** is the private one the gate, the | ||
| 285 | console and published ports aim at; **Network** is your own name for the | ||
| 286 | network, and gains the address your DHCP server granted the second NIC once | ||
| 287 | that lease lands. A guest configured static there never DHCPs—reachable all | ||
| 288 | the same, but **Network** shows the name alone. | ||
| 289 | |||
| 290 | The private NIC keeps the default route (route metric 100, against 200 on the | ||
| 291 | second NIC), so outbound leaves through the host's masquerade whatever your | ||
| 292 | network is doing—on the default Ubuntu image, where netplan honours those | ||
| 293 | metrics. Which address a service advertises is that service's own business. | ||
| 294 | |||
| 295 | A guest on your network is flat on it: every port, every device, no gate in | ||
| 296 | front of that NIC—attach what you are content to have live there, the rest | ||
| 297 | NAT-only. It is also still on the host's private bridge beside that host's | ||
| 298 | other guests, which is what lets them talk and what a compromise of it reaches. | ||
| 299 | |||
| 300 | ## Self-hosting | ||
| 301 | |||
| 302 | Run the whole control plane yourself—everything below is your own box. | ||
| 303 | eitri is three pieces: a server, an agent on every box that runs VMs, and | ||
| 304 | your laptop. `192.0.2.10` is the server below. Substitute yours. | ||
| 305 | |||
| 306 | ### What you need | ||
| 307 | |||
| 308 | Every Linux VM host needs KVM (`ls -l /dev/kvm`); a Mac needs `brew install | ||
| 309 | vfkit` (see "Join a Mac"). Guest images are decoded in the | ||
| 310 | agent, so there is no image toolchain to install. On Linux the agent fetches | ||
| 311 | cloud-hypervisor and the | ||
| 312 | guest firmware itself on first start, sha-verified against the release. To | ||
| 313 | manage them by hand instead, disable it in `/etc/default/eitri-agent`: | ||
| 314 | `EITRI_AGENT_FLAGS="--bootstrap-url="`. | ||
| 315 | |||
| 316 | Tarballs live at <https://eitri.sh/dl/latest/>. The Linux host bundle | ||
| 317 | (`eitri-server_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and | ||
| 318 | their systemd units—a box that only runs VMs takes the same tarball and | ||
| 319 | installs just the agent from it. A Mac host has a bundle of its own | ||
| 320 | (`eitri-agent_<version>_darwin_arm64.tar.gz`): the agent and its LaunchAgent | ||
| 321 | installer, no server. The issuer bundle | ||
| 322 | (`eitri-oidc_<version>_linux_amd64.tar.gz`) has `eitri-oidc`—the bundled | ||
| 323 | sign-in provider—and its unit. The client bundle | ||
| 324 | (`eitri-cli_<version>_<os>_<arch>.tar.gz`) is the single `eitri` binary for | ||
| 325 | your laptop, built for linux and macOS. arm64 boxes take the arm64 bundle. | ||
| 326 | |||
| 327 | Download and verify. `SHA256SUMS` comes first: it verifies the rest, and its | ||
| 328 | filenames name the release, so `V` is read from it rather than typed: | ||
| 329 | |||
| 330 | ```sh | ||
| 331 | curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS | ||
| 332 | V=$(sed -n 's/.*eitri-server_\(v[^_]*\)_linux_amd64\.tar\.gz$/\1/p' SHA256SUMS) | ||
| 333 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-server_${V}_linux_amd64.tar.gz" | ||
| 334 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-oidc_${V}_linux_amd64.tar.gz" | ||
| 335 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd64.tar.gz" | ||
| 336 | sha256sum -c SHA256SUMS --ignore-missing | ||
| 337 | ``` | ||
| 338 | |||
| 339 | Every artifact also has an immutable home at `/dl/<version>/`, which is what | ||
| 340 | [/dl](https://eitri.sh/dl/) links; `/dl/latest/` is the moving alias, and only | ||
| 341 | the current release is served. | ||
| 342 | |||
| 343 | Plain files at stable URLs—no install script. `sha256sum -c` must say OK | ||
| 344 | for every tarball before you unpack anything. | ||
| 345 | |||
| 346 | ### The server | ||
| 347 | |||
| 348 | ```sh | ||
| 349 | tar xzf eitri-server_*_linux_amd64.tar.gz && cd eitri-server_*_linux_amd64 | ||
| 350 | sudo install -m 0755 eitri-server /usr/local/bin/eitri-server | ||
| 351 | sudo install -m 0644 eitri-server.service /etc/systemd/system/eitri-server.service | ||
| 352 | sudo useradd --system --home-dir /var/lib/eitri --shell /usr/sbin/nologin eitri | ||
| 353 | sudo mkdir -p /etc/eitri | ||
| 354 | ``` | ||
| 355 | |||
| 356 | The server runs as the dedicated `eitri` user—it needs no privilege (every | ||
| 357 | listener is on an unprivileged port); its state directory `/var/lib/eitri` is | ||
| 358 | created and owned for it by the unit. | ||
| 359 | |||
| 360 | Set `SERVER_ADDR`, paste the rest: | ||
| 361 | |||
| 362 | ```sh | ||
| 363 | SERVER_ADDR=192.0.2.10 | ||
| 364 | IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current | ||
| 365 | HOST_SECRET=$(openssl rand -hex 32) | ||
| 366 | KEK=$(openssl rand -hex 32) | ||
| 367 | sha() { curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="resolute-server-cloudimg-$1.img" '$2 == "*" f {print $1}'; } | ||
| 368 | AMD64_SHA=$(sha amd64) | ||
| 369 | ARM64_SHA=$(sha arm64) | ||
| 370 | |||
| 371 | sudo tee /etc/eitri/server.json >/dev/null <<EOF | ||
| 372 | { | ||
| 373 | "http_listen": ":8080", | ||
| 374 | "quic_listen": ":8443", | ||
| 375 | "advertise_http": "http://$SERVER_ADDR:8080", | ||
| 376 | "advertise_quic": "$SERVER_ADDR:8443", | ||
| 377 | "db_path": "/var/lib/eitri/eitri.db", | ||
| 378 | "cidr_pool": "10.100.0.0/16", | ||
| 379 | "oidc": { | ||
| 380 | "issuer": "http://127.0.0.1:9111", | ||
| 381 | "client_id": "eitri-console", | ||
| 382 | "public_url": "http://$SERVER_ADDR:8080" | ||
| 383 | }, | ||
| 384 | "host_secret": "$HOST_SECRET", | ||
| 385 | "key_encryption_key": "$KEK", | ||
| 386 | "default_images": { | ||
| 387 | "amd64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-amd64.img", "sha256": "$AMD64_SHA"}, | ||
| 388 | "arm64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-arm64.img", "sha256": "$ARM64_SHA"} | ||
| 389 | }, | ||
| 390 | "ssh_listen": ":2222", | ||
| 391 | "ssh_gate_domain": "$SERVER_ADDR", | ||
| 392 | "ssh_ca_key": "/var/lib/eitri/ssh_ca", | ||
| 393 | "ssh_host_key": "/var/lib/eitri/ssh_host_key" | ||
| 394 | } | ||
| 395 | EOF | ||
| 396 | sudo chgrp eitri /etc/eitri/server.json && sudo chmod 0640 /etc/eitri/server.json | ||
| 397 | ``` | ||
| 398 | |||
| 399 | The chmod matters: `server.json` carries `host_secret` and | ||
| 400 | `key_encryption_key`—the key that encrypts everything eitri signs with, the host | ||
| 401 | CA and gate host key in `/var/lib/eitri` included—so it is root-owned and | ||
| 402 | readable only via the `eitri` group, not world-readable. | ||
| 403 | |||
| 404 | Keep a copy of `server.json` somewhere other than the machine it runs on, and | ||
| 405 | somewhere other than your backups of `/var/lib/eitri`. Separating the two is | ||
| 406 | what makes a stolen disk useless; losing the config while keeping the disk is | ||
| 407 | what makes your own backups useless. Restoring this server elsewhere needs | ||
| 408 | both. | ||
| 409 | |||
| 410 | `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any | ||
| 411 | cloud-init disk image works as a default image; the Ubuntu one boots out of | ||
| 412 | the box. | ||
| 413 | |||
| 414 | `ssh_listen` here binds every interface, which is why `ssh_gate_domain` is set | ||
| 415 | beside it: a wildcard bind (`:2222`, `0.0.0.0:2222`) gives the gate no name, so | ||
| 416 | the server requires one and refuses to start without it. That name is what | ||
| 417 | clients dial and the one principal on the gate's host certificate. A gate bound | ||
| 418 | to one concrete address (`127.0.0.1:2222` for a single-machine plane) names | ||
| 419 | itself and may leave `ssh_gate_domain` out. | ||
| 420 | |||
| 421 | `default_images` is keyed by the architecture of the host a VM lands on, and a | ||
| 422 | VM created without an explicit image takes the entry for its host. Configure | ||
| 423 | every architecture in your fleet: a host can only run a guest built for its own | ||
| 424 | CPU, and a create for an architecture you have not listed is refused rather than | ||
| 425 | served an image that cannot boot. One entry is plenty for a single-architecture | ||
| 426 | fleet—both are shown because an Apple silicon host takes `arm64`. | ||
| 427 | |||
| 428 | The `oidc` block points the console's sign-in at the bundled issuer you start | ||
| 429 | next. `public_url` is where browsers reach the console (the callback lands at | ||
| 430 | `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing | ||
| 431 | your own IdP instead of the bundled issuer: see [byo-idp.md](byo-idp.md). | ||
| 432 | |||
| 433 | ### Sign-in | ||
| 434 | |||
| 435 | The console always signs in through OIDC. The bundled `eitri-oidc` issuer runs | ||
| 436 | next to the server on loopback. Install it, write its config, and add yourself: | ||
| 437 | |||
| 438 | ```sh | ||
| 439 | tar xzf eitri-oidc_*_linux_amd64.tar.gz && cd eitri-oidc_*_linux_amd64 | ||
| 440 | sudo install -m 0755 eitri-oidc /usr/local/bin/eitri-oidc | ||
| 441 | sudo install -m 0644 eitri-oidc.service /etc/systemd/system/eitri-oidc.service | ||
| 442 | sudo useradd --system --home-dir /var/lib/eitri-oidc --shell /usr/sbin/nologin eitri-oidc | ||
| 443 | |||
| 444 | sudo tee /etc/eitri/eitri-oidc.json >/dev/null <<EOF | ||
| 445 | { | ||
| 446 | "listen": "127.0.0.1:9111", | ||
| 447 | "issuer": "http://127.0.0.1:9111", | ||
| 448 | "users_file": "/var/lib/eitri-oidc/users.json", | ||
| 449 | "signing_key": "/var/lib/eitri-oidc/signing.key", | ||
| 450 | "clients": [ | ||
| 451 | {"id": "eitri-console", "redirect_url": "http://$SERVER_ADDR:8080/auth/callback"} | ||
| 452 | ] | ||
| 453 | } | ||
| 454 | EOF | ||
| 455 | |||
| 456 | sudo systemctl daemon-reload | ||
| 457 | sudo systemctl enable --now eitri-oidc | ||
| 458 | sudo -u eitri-oidc eitri-oidc user add you@example.com # prompts for a password | ||
| 459 | ``` | ||
| 460 | |||
| 461 | The issuer handles passwords and holds the sign-in signing key, so it runs as | ||
| 462 | its own `eitri-oidc` user—separate even from the server's `eitri` user, | ||
| 463 | keeping the key material unreadable by any other service. Its mutable state | ||
| 464 | (user file, autogenerated signing key) lives in `/var/lib/eitri-oidc`, created | ||
| 465 | and owned for it by the unit; that's also why `user add` runs via | ||
| 466 | `sudo -u eitri-oidc`—files it writes stay readable by the service. | ||
| 467 | |||
| 468 | `redirect_url` must equal the server's `oidc.public_url` + `/auth/callback`. On | ||
| 469 | this single box the loopback `issuer`/`public_url` work because your browser is | ||
| 470 | on the same machine; anything multi-machine needs a routable issuer. | ||
| 471 | |||
| 472 | Now start the server: | ||
| 473 | |||
| 474 | ```sh | ||
| 475 | sudo systemctl enable --now eitri-server | ||
| 476 | journalctl -u eitri-server -f # watch it come up | ||
| 477 | ``` | ||
| 478 | |||
| 479 | It speaks plain HTTP, so keep it on your LAN or put TLS in front. Open | ||
| 480 | `8080/tcp` (console, enroll), `8443/udp` (sync), `2222/tcp` (SSH gate). The | ||
| 481 | issuer stays on loopback. | ||
| 482 | |||
| 483 | Sign in at `http://192.0.2.10:8080` with the user you added. Your first | ||
| 484 | sign-in creates your tenant. | ||
| 485 | |||
| 486 | ### Join a host | ||
| 487 | |||
| 488 | Once per box that runs VMs. The server's box counts. | ||
| 489 | |||
| 490 | **+ Add host** in the console prints a one-shot join command. On the box, | ||
| 491 | from the unpacked host bundle: | ||
| 492 | |||
| 493 | ```sh | ||
| 494 | sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent | ||
| 495 | sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service | ||
| 496 | sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console> | ||
| 497 | sudo systemctl daemon-reload | ||
| 498 | sudo systemctl enable --now eitri-agent | ||
| 499 | ``` | ||
| 500 | |||
| 501 | The host goes **online** in the console. Logs: | ||
| 502 | `journalctl -u eitri-agent -f`. | ||
| 503 | |||
| 504 | A Mac joins the same fleet from its own bundle, | ||
| 505 | `eitri-agent_<version>_darwin_arm64.tar.gz`—`brew install vfkit` first, then | ||
| 506 | `eitri-agent join`, then `eitri-agent-launchagent.sh install` in | ||
| 507 | place of the systemd unit. The differences are the same ones the hosted section covers above, and | ||
| 508 | the one extra thing self-hosting asks of you is an `arm64` entry in the | ||
| 509 | server's `default_images`. | ||
| 510 | |||
| 511 | ### Boot a VM | ||
| 512 | |||
| 513 | VMs trust your SSH CA from birth, so register one first—create refuses a tenant | ||
| 514 | that has none, rather than hand it a guest nothing can reach. eitri gets the | ||
| 515 | public key, never the private one. On your laptop: | ||
| 516 | |||
| 517 | ```sh | ||
| 518 | tar xzf eitri-cli_*_$(uname -s | tr A-Z a-z)_*.tar.gz | ||
| 519 | sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri | ||
| 520 | |||
| 521 | EITRI_URL=http://192.0.2.10:8080 eitri init | ||
| 522 | ``` | ||
| 523 | |||
| 524 | `EITRI_URL` points the first run at your box; init writes it to | ||
| 525 | `~/.eitri/config.json` along with your tenant and the gate address the server | ||
| 526 | names on `/api/v1/me`, so later runs need neither variable. The three steps and | ||
| 527 | what each confirms are described under "Set up your laptop" above. | ||
| 528 | |||
| 529 | By hand instead: | ||
| 530 | |||
| 531 | ```sh | ||
| 532 | export EITRI_URL=http://192.0.2.10:8080 | ||
| 533 | export EITRI_TOKEN=<pat> # mint one in the console → Settings | ||
| 534 | |||
| 535 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" | ||
| 536 | eitri ca upload ~/.ssh/eitri_user_ca.pub | ||
| 537 | ``` | ||
| 538 | |||
| 539 | Your token names the tenant, so `ca upload` needs no handle. In more than one | ||
| 540 | tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly. | ||
| 541 | |||
| 542 | **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the | ||
| 543 | default image. Status reads `creating` while the image downloads and the | ||
| 544 | guest boots, then `ready`. Power reads `running`, an IP appears, you're on. | ||
| 545 | |||
| 546 | ### SSH in | ||
| 547 | |||
| 548 | ```sh | ||
| 549 | eitri ssh <vm-name> | ||
| 550 | eitri ssh <vm-name> uptime | ||
| 551 | ``` | ||
| 552 | |||
| 553 | `eitri init` wrote the plane and the gate, so nothing needs exporting. Without | ||
| 554 | a config, `EITRI_URL` must name your box—unset it defaults to the hosted | ||
| 555 | service at eitri.sh—and the gate comes from that server's `/api/v1/me`, which | ||
| 556 | serves whatever `ssh_gate_domain` and `ssh_listen` say. `EITRI_GATE` overrides | ||
| 557 | it either way, for a server behind a different address than it advertises. The | ||
| 558 | hosted gate address is assumed only for the hosted plane: against your own | ||
| 559 | server, a `/me` that names no gate is an error naming these two settings, never | ||
| 560 | a silent hop through eitri.sh. | ||
| 561 | |||
| 562 | `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with | ||
| 563 | your CA, pins eitri's host CA, and jumps the gate to `ubuntu@<tenant>.<vm-name>` | ||
| 564 | — the VM's host cert carries that one namespaced principal, so that is the name | ||
| 565 | verified. You still type only `<vm-name>`; `eitri ssh` looks the tenant up from | ||
| 566 | your token (or `EITRI_TENANT`). [ssh-access.md](ssh-access.md) shows it done by | ||
| 567 | hand. | ||
| 568 | |||
| 569 | ### More | ||
| 570 | 56 | ||
| 571 | - [ssh-access.md](ssh-access.md): the jump gate and the BYO-CA model | 57 | - [connecting](connecting.md)—the jump gate, your CA, and `eitri ssh` by hand |
| 572 | - [upgrade.md](upgrade.md): upgrading agents, the server, cloud-hypervisor | 58 | - [networking](networking.md)—publishing a port, or putting a VM on your LAN |
| 573 | - [credential-revocation.md](credential-revocation.md): when something leaks | 59 | - [mcp](mcp.md)—letting an AI agent create and drive VMs |
| 60 | - [upgrade](upgrade.md)—upgrading agents, the server, cloud-hypervisor | ||
docs/releases.md
| Old | New | ||
|---|---|---|---|
| @@ -292,7 +292,7 @@ minted in the console. | |||
| 292 | **SSH.** Tenants register their own user CA—eitri never holds a user | 292 | **SSH.** Tenants register their own user CA—eitri never holds a user |
| 293 | private key. `eitri ssh` self-signs a short-lived certificate, pins eitri's | 293 | private key. `eitri ssh` self-signs a short-lived certificate, pins eitri's |
| 294 | host CA, and jumps the gate to `<tenant>.<vm>`; certificates are revocable | 294 | host CA, and jumps the gate to `<tenant>.<vm>`; certificates are revocable |
| 295 | per serial. See [ssh-access.md](ssh-access.md). | 295 | per serial. See [connecting.md](connecting.md). |
| 296 | 296 | ||
| 297 | **Hosted.** Don't want to run the control plane? Sign in at | 297 | **Hosted.** Don't want to run the control plane? Sign in at |
| 298 | [console.eitri.sh](https://console.eitri.sh) with Google and join your own | 298 | [console.eitri.sh](https://console.eitri.sh) with Google and join your own |
docs/self-hosting.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,211 @@ | |||
| 1 | # Self-hosting | ||
| 2 | |||
| 3 | *Running the control plane yourself* | ||
| 4 | |||
| 5 | Run the whole control plane yourself—everything below is your own box. | ||
| 6 | eitri is three pieces: a server, an agent on every box that runs VMs, and | ||
| 7 | your laptop. `192.0.2.10` is the server below. Substitute yours. | ||
| 8 | |||
| 9 | ## What you need | ||
| 10 | |||
| 11 | Every Linux VM host needs KVM (`ls -l /dev/kvm`); a Mac needs `brew install | ||
| 12 | vfkit` (see "Join a Mac"). Guest images are decoded in the | ||
| 13 | agent, so there is no image toolchain to install. On Linux the agent fetches | ||
| 14 | cloud-hypervisor and the | ||
| 15 | guest firmware itself on first start, sha-verified against the release. To | ||
| 16 | manage them by hand instead, disable it in `/etc/default/eitri-agent`: | ||
| 17 | `EITRI_AGENT_FLAGS="--bootstrap-url="`. | ||
| 18 | |||
| 19 | Tarballs live at <https://eitri.sh/dl/latest/>. The Linux host bundle | ||
| 20 | (`eitri-server_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and | ||
| 21 | their systemd units—a box that only runs VMs takes the same tarball and | ||
| 22 | installs just the agent from it. A Mac host has a bundle of its own | ||
| 23 | (`eitri-agent_<version>_darwin_arm64.tar.gz`): the agent and its LaunchAgent | ||
| 24 | installer, no server. The issuer bundle | ||
| 25 | (`eitri-oidc_<version>_linux_amd64.tar.gz`) has `eitri-oidc`—the bundled | ||
| 26 | sign-in provider—and its unit. The client bundle | ||
| 27 | (`eitri-cli_<version>_<os>_<arch>.tar.gz`) is the single `eitri` binary for | ||
| 28 | your laptop, built for linux and macOS. arm64 boxes take the arm64 bundle. | ||
| 29 | |||
| 30 | Download and verify. `SHA256SUMS` comes first: it verifies the rest, and its | ||
| 31 | filenames name the release, so `V` is read from it rather than typed: | ||
| 32 | |||
| 33 | ```sh | ||
| 34 | curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS | ||
| 35 | V=$(sed -n 's/.*eitri-server_\(v[^_]*\)_linux_amd64\.tar\.gz$/\1/p' SHA256SUMS) | ||
| 36 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-server_${V}_linux_amd64.tar.gz" | ||
| 37 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-oidc_${V}_linux_amd64.tar.gz" | ||
| 38 | curl -fsSLO "https://eitri.sh/dl/latest/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd64.tar.gz" | ||
| 39 | sha256sum -c SHA256SUMS --ignore-missing | ||
| 40 | ``` | ||
| 41 | |||
| 42 | Every artifact also has an immutable home at `/dl/<version>/`, which is what | ||
| 43 | [/dl](https://eitri.sh/dl/) links; `/dl/latest/` is the moving alias, and only | ||
| 44 | the current release is served. | ||
| 45 | |||
| 46 | Plain files at stable URLs—no install script. `sha256sum -c` must say OK | ||
| 47 | for every tarball before you unpack anything. | ||
| 48 | |||
| 49 | ## The server | ||
| 50 | |||
| 51 | ```sh | ||
| 52 | tar xzf eitri-server_*_linux_amd64.tar.gz && cd eitri-server_*_linux_amd64 | ||
| 53 | sudo install -m 0755 eitri-server /usr/local/bin/eitri-server | ||
| 54 | sudo install -m 0644 eitri-server.service /etc/systemd/system/eitri-server.service | ||
| 55 | sudo useradd --system --home-dir /var/lib/eitri --shell /usr/sbin/nologin eitri | ||
| 56 | sudo mkdir -p /etc/eitri | ||
| 57 | ``` | ||
| 58 | |||
| 59 | The server runs as the dedicated `eitri` user—it needs no privilege (every | ||
| 60 | listener is on an unprivileged port); its state directory `/var/lib/eitri` is | ||
| 61 | created and owned for it by the unit. | ||
| 62 | |||
| 63 | Set `SERVER_ADDR`, paste the rest: | ||
| 64 | |||
| 65 | ```sh | ||
| 66 | SERVER_ADDR=192.0.2.10 | ||
| 67 | IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current | ||
| 68 | HOST_SECRET=$(openssl rand -hex 32) | ||
| 69 | KEK=$(openssl rand -hex 32) | ||
| 70 | sha() { curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="resolute-server-cloudimg-$1.img" '$2 == "*" f {print $1}'; } | ||
| 71 | AMD64_SHA=$(sha amd64) | ||
| 72 | ARM64_SHA=$(sha arm64) | ||
| 73 | |||
| 74 | sudo tee /etc/eitri/server.json >/dev/null <<EOF | ||
| 75 | { | ||
| 76 | "http_listen": ":8080", | ||
| 77 | "quic_listen": ":8443", | ||
| 78 | "advertise_http": "http://$SERVER_ADDR:8080", | ||
| 79 | "advertise_quic": "$SERVER_ADDR:8443", | ||
| 80 | "db_path": "/var/lib/eitri/eitri.db", | ||
| 81 | "cidr_pool": "10.100.0.0/16", | ||
| 82 | "oidc": { | ||
| 83 | "issuer": "http://127.0.0.1:9111", | ||
| 84 | "client_id": "eitri-console", | ||
| 85 | "public_url": "http://$SERVER_ADDR:8080" | ||
| 86 | }, | ||
| 87 | "host_secret": "$HOST_SECRET", | ||
| 88 | "key_encryption_key": "$KEK", | ||
| 89 | "default_images": { | ||
| 90 | "amd64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-amd64.img", "sha256": "$AMD64_SHA"}, | ||
| 91 | "arm64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-arm64.img", "sha256": "$ARM64_SHA"} | ||
| 92 | }, | ||
| 93 | "ssh_listen": ":2222", | ||
| 94 | "ssh_gate_domain": "$SERVER_ADDR", | ||
| 95 | "ssh_ca_key": "/var/lib/eitri/ssh_ca", | ||
| 96 | "ssh_host_key": "/var/lib/eitri/ssh_host_key" | ||
| 97 | } | ||
| 98 | EOF | ||
| 99 | sudo chgrp eitri /etc/eitri/server.json && sudo chmod 0640 /etc/eitri/server.json | ||
| 100 | ``` | ||
| 101 | |||
| 102 | The chmod matters: `server.json` carries `host_secret` and | ||
| 103 | `key_encryption_key`—the key that encrypts everything eitri signs with, the host | ||
| 104 | CA and gate host key in `/var/lib/eitri` included—so it is root-owned and | ||
| 105 | readable only via the `eitri` group, not world-readable. | ||
| 106 | |||
| 107 | Keep a copy of `server.json` somewhere other than the machine it runs on, and | ||
| 108 | somewhere other than your backups of `/var/lib/eitri`. Separating the two is | ||
| 109 | what makes a stolen disk useless; losing the config while keeping the disk is | ||
| 110 | what makes your own backups useless. Restoring this server elsewhere needs | ||
| 111 | both. | ||
| 112 | |||
| 113 | `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any | ||
| 114 | cloud-init disk image works as a default image; the Ubuntu one boots out of | ||
| 115 | the box. | ||
| 116 | |||
| 117 | `ssh_listen` here binds every interface, which is why `ssh_gate_domain` is set | ||
| 118 | beside it: a wildcard bind (`:2222`, `0.0.0.0:2222`) gives the gate no name, so | ||
| 119 | the server requires one and refuses to start without it. That name is what | ||
| 120 | clients dial and the one principal on the gate's host certificate. A gate bound | ||
| 121 | to one concrete address (`127.0.0.1:2222` for a single-machine plane) names | ||
| 122 | itself and may leave `ssh_gate_domain` out. | ||
| 123 | |||
| 124 | `default_images` is keyed by the architecture of the host a VM lands on, and a | ||
| 125 | VM created without an explicit image takes the entry for its host. Configure | ||
| 126 | every architecture in your fleet: a host can only run a guest built for its own | ||
| 127 | CPU, and a create for an architecture you have not listed is refused rather than | ||
| 128 | served an image that cannot boot. One entry is plenty for a single-architecture | ||
| 129 | fleet—both are shown because an Apple silicon host takes `arm64`. | ||
| 130 | |||
| 131 | The `oidc` block points the console's sign-in at the bundled issuer you start | ||
| 132 | next. `public_url` is where browsers reach the console (the callback lands at | ||
| 133 | `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing | ||
| 134 | your own IdP instead of the bundled issuer: see [byo-idp.md](byo-idp.md). | ||
| 135 | |||
| 136 | ## Sign-in | ||
| 137 | |||
| 138 | The console always signs in through OIDC. The bundled `eitri-oidc` issuer runs | ||
| 139 | next to the server on loopback. Install it, write its config, and add yourself: | ||
| 140 | |||
| 141 | ```sh | ||
| 142 | tar xzf eitri-oidc_*_linux_amd64.tar.gz && cd eitri-oidc_*_linux_amd64 | ||
| 143 | sudo install -m 0755 eitri-oidc /usr/local/bin/eitri-oidc | ||
| 144 | sudo install -m 0644 eitri-oidc.service /etc/systemd/system/eitri-oidc.service | ||
| 145 | sudo useradd --system --home-dir /var/lib/eitri-oidc --shell /usr/sbin/nologin eitri-oidc | ||
| 146 | |||
| 147 | sudo tee /etc/eitri/eitri-oidc.json >/dev/null <<EOF | ||
| 148 | { | ||
| 149 | "listen": "127.0.0.1:9111", | ||
| 150 | "issuer": "http://127.0.0.1:9111", | ||
| 151 | "users_file": "/var/lib/eitri-oidc/users.json", | ||
| 152 | "signing_key": "/var/lib/eitri-oidc/signing.key", | ||
| 153 | "clients": [ | ||
| 154 | {"id": "eitri-console", "redirect_url": "http://$SERVER_ADDR:8080/auth/callback"} | ||
| 155 | ] | ||
| 156 | } | ||
| 157 | EOF | ||
| 158 | |||
| 159 | sudo systemctl daemon-reload | ||
| 160 | sudo systemctl enable --now eitri-oidc | ||
| 161 | sudo -u eitri-oidc eitri-oidc user add you@example.com # prompts for a password | ||
| 162 | ``` | ||
| 163 | |||
| 164 | The issuer handles passwords and holds the sign-in signing key, so it runs as | ||
| 165 | its own `eitri-oidc` user—separate even from the server's `eitri` user, | ||
| 166 | keeping the key material unreadable by any other service. Its mutable state | ||
| 167 | (user file, autogenerated signing key) lives in `/var/lib/eitri-oidc`, created | ||
| 168 | and owned for it by the unit; that's also why `user add` runs via | ||
| 169 | `sudo -u eitri-oidc`—files it writes stay readable by the service. | ||
| 170 | |||
| 171 | `redirect_url` must equal the server's `oidc.public_url` + `/auth/callback`. On | ||
| 172 | this single box the loopback `issuer`/`public_url` work because your browser is | ||
| 173 | on the same machine; anything multi-machine needs a routable issuer. | ||
| 174 | |||
| 175 | Now start the server: | ||
| 176 | |||
| 177 | ```sh | ||
| 178 | sudo systemctl enable --now eitri-server | ||
| 179 | journalctl -u eitri-server -f # watch it come up | ||
| 180 | ``` | ||
| 181 | |||
| 182 | It speaks plain HTTP, so keep it on your LAN or put TLS in front. Open | ||
| 183 | `8080/tcp` (console, enroll), `8443/udp` (sync), `2222/tcp` (SSH gate). The | ||
| 184 | issuer stays on loopback. | ||
| 185 | |||
| 186 | Sign in at `http://192.0.2.10:8080` with the user you added. Your first | ||
| 187 | sign-in creates your tenant. | ||
| 188 | |||
| 189 | ## Join your hosts | ||
| 190 | |||
| 191 | Once per box that runs VMs, and the server's box counts. **+ Add host** in | ||
| 192 | your console prints the join command; the steps are the same ones | ||
| 193 | [joining](joining.md) covers, run against your plane rather than the hosted | ||
| 194 | one. Self-hosting asks one extra thing of a Mac host: an `arm64` entry in the | ||
| 195 | server's `default_images`. | ||
| 196 | |||
| 197 | ## Your laptop, and a first VM | ||
| 198 | |||
| 199 | `eitri init` against your own plane needs to be told where that plane is— | ||
| 200 | `EITRI_URL=http://192.0.2.10:8080 eitri init`—and writes it to | ||
| 201 | `~/.eitri/config.json` with your tenant and the gate address the server names | ||
| 202 | on `/api/v1/me`, so later runs need no variable. Everything else about | ||
| 203 | registering a CA, creating the VM and reaching it is on | ||
| 204 | [connecting](connecting.md). | ||
| 205 | |||
| 206 | ## Related | ||
| 207 | |||
| 208 | - [byo-idp](byo-idp.md)—your own IdP instead of the bundled issuer | ||
| 209 | - [upgrade](upgrade.md)—upgrading agents, the server, cloud-hypervisor | ||
| 210 | - [cert rotation](cert-rotation.md)—rotating the server certificate | ||
| 211 | - [credential revocation](credential-revocation.md)—when something leaks | ||
docs/ssh-access.md
| Old | New | ||
|---|---|---|---|
| @@ -1,230 +0,0 @@ | |||
| 1 | # SSH access via the eitri jump gate | ||
| 2 | |||
| 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 | ||
| 5 | SSH user certificate that you sign yourself**, using your tenant's own user CA. | ||
| 6 | Every VM in your tenant trusts your tenant's user CAs—seeded at VM create, so | ||
| 7 | there's no per-VM key to manage—and the cert carries the principal `ubuntu`, | ||
| 8 | the login user on the VM. | ||
| 9 | |||
| 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** | ||
| 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 | ||
| 14 | host-key-changed warnings when VM names or IPs are recycled. | ||
| 15 | |||
| 16 | Two CAs, two directions: **your tenant's user CA** (private key on your machine) | ||
| 17 | signs what you present; **eitri's host CA** (private key on the server) signs | ||
| 18 | what the gate and VMs present. eitri deliberately holds no user signing key — | ||
| 19 | a server compromise cannot mint user credentials. | ||
| 20 | |||
| 21 | ## Bring your own CA (once per tenant) | ||
| 22 | |||
| 23 | Generate a user CA and register its **public** key with your tenant. The token | ||
| 24 | names the tenant (`POST /api/v1/user-cas`), so no handle is needed; a token that | ||
| 25 | can act for more than one tenant pins one with `eitri ca upload <tenant> <key>` | ||
| 26 | (`POST /api/v1/tenants/<tenant>/user-cas`): | ||
| 27 | |||
| 28 | ```sh | ||
| 29 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA" | ||
| 30 | |||
| 31 | export EITRI_URL=https://eitri.example.com | ||
| 32 | export EITRI_TOKEN=<personal-access-token> # mint one in the console → Settings | ||
| 33 | eitri ca upload ~/.ssh/eitri_user_ca.pub | ||
| 34 | ``` | ||
| 35 | |||
| 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 | ||
| 38 | CAs present at its creation. The gate authorizes each connection against the | ||
| 39 | tenant the signing CA was uploaded to. | ||
| 40 | |||
| 41 | Registering a CA later does not reach a VM that already exists. The set is | ||
| 42 | copied onto the VM as it is created and nothing rewrites it, so a certificate | ||
| 43 | signed by a CA registered afterwards is refused by that guest's sshd. Recreating | ||
| 44 | the guest is the only way to change what it trusts. | ||
| 45 | |||
| 46 | ## Which CAs does a guest trust? | ||
| 47 | |||
| 48 | A VM's page lists them under **Trusted CAs**, by the label you gave the CA and | ||
| 49 | its fingerprint. Match those against Settings → SSH Access to tell whether a | ||
| 50 | certificate you are about to sign will open that guest. | ||
| 51 | |||
| 52 | On the fleet page, a VM missing any CA your tenant now has is marked *stale | ||
| 53 | trust*. It still works with the CAs it was created against; it just cannot be | ||
| 54 | opened by every CA you hold. | ||
| 55 | |||
| 56 | ## Delegating access to eitri | ||
| 57 | |||
| 58 | A caller holding only a token has no CA and no private key, so it cannot sign | ||
| 59 | anything — and eitri holds no signing key for anyone, so it cannot sign on their | ||
| 60 | behalf either. Instead, you lend eitri a credential. | ||
| 61 | |||
| 62 | eitri generates an ephemeral keypair for your tenant, in memory only, and hands | ||
| 63 | you the public half: | ||
| 64 | |||
| 65 | ```sh | ||
| 66 | curl -X POST -H "Authorization: Bearer $EITRI_TOKEN" \ | ||
| 67 | https://eitri.example.com/api/v1/delegations | ||
| 68 | ``` | ||
| 69 | |||
| 70 | Sign it with your own CA, on your own terms: | ||
| 71 | |||
| 72 | ```sh | ||
| 73 | printf '%s\n' "<public_key from the response>" > eitri-delegation.pub | ||
| 74 | ssh-keygen -s ~/.ssh/eitri_user_ca -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub | ||
| 75 | ``` | ||
| 76 | |||
| 77 | `-n ubuntu` is not optional. A guest matches the certificate's principals | ||
| 78 | against the login user, so a certificate naming anything else is refused by | ||
| 79 | every guest. Post the result back: | ||
| 80 | |||
| 81 | ```sh | ||
| 82 | curl -X PUT -H "Authorization: Bearer $EITRI_TOKEN" \ | ||
| 83 | -H 'Content-Type: application/json' \ | ||
| 84 | -d "{\"certificate\": \"$(cat eitri-delegation-cert.pub)\"}" \ | ||
| 85 | https://eitri.example.com/api/v1/delegations | ||
| 86 | ``` | ||
| 87 | |||
| 88 | eitri now authenticates to your guests as that key plus that certificate, until | ||
| 89 | the certificate expires. It holds nothing else. `GET /api/v1/delegations` | ||
| 90 | reports the expiry; `DELETE` ends it immediately. So does restarting the | ||
| 91 | control plane — a delegation is in memory and nowhere else, which is the point. | ||
| 92 | |||
| 93 | Because the certificate chains to a CA you have already registered, guests | ||
| 94 | created **before** you delegated accept it. That is the difference from | ||
| 95 | registering a new CA: there is no ordering constraint, because nothing about the | ||
| 96 | guest's trust changes. | ||
| 97 | |||
| 98 | This is what makes [the remote MCP endpoint](mcp.md) work with nothing but a | ||
| 99 | PAT, where the same two steps are the `delegate_begin` and `delegate_complete` | ||
| 100 | tools. | ||
| 101 | |||
| 102 | ## One-liner | ||
| 103 | |||
| 104 | ```sh | ||
| 105 | export EITRI_URL=https://eitri.example.com | ||
| 106 | export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address | ||
| 107 | |||
| 108 | eitri ssh <vm-name> # opens a shell on the VM | ||
| 109 | eitri ssh <vm-name> uptime # runs a command and exits | ||
| 110 | ``` | ||
| 111 | |||
| 112 | You pass the bare `<vm-name>`, but the name that reaches the wire is always the | ||
| 113 | **gate connect name** `<tenant>.<vm-name>`—a VM's host cert carries exactly | ||
| 114 | that one principal, and `eitri ssh` verifies the dialed name against it under | ||
| 115 | strict checking, so a bare name would fail host verification. (The gate itself | ||
| 116 | also *resolves* a bare name within the connection's tenant, but the VM's cert | ||
| 117 | does not, so the client sends the namespaced form.) You never need to know your | ||
| 118 | tenant: `eitri ssh` derives it from your credential via `/me`, or takes it from | ||
| 119 | `EITRI_TENANT` when set (offline, and the escape hatch for a CA registered in | ||
| 120 | more than one tenant). | ||
| 121 | |||
| 122 | Environment variables. `EITRI_URL`, `EITRI_GATE`, `EITRI_CA`, `EITRI_TENANT` | ||
| 123 | and `EITRI_KEY` are per-invocation overrides above the config file `eitri init` | ||
| 124 | writes, so the chain is variable, then `~/.eitri/config.json`, then the default | ||
| 125 | below. `EITRI_TOKEN` and `EITRI_KNOWN_HOSTS` have no config-file rung—no | ||
| 126 | credential is ever written to disk, and the pin file is a cache rather than a | ||
| 127 | setting. | ||
| 128 | |||
| 129 | | Var | Meaning | | ||
| 130 | | ------------- | --------------------------------------------------------- | | ||
| 131 | | `EITRI_CONFIG`| Path to that config file (default `~/.eitri/config.json`) | | ||
| 132 | | `EITRI_URL` | Base URL of the eitri server (default the hosted `https://console.eitri.sh`) | | ||
| 133 | | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`); otherwise the plane's own `/me` answer, and the hosted `gate.eitri.sh:2222` only when `EITRI_URL` is the hosted plane | | ||
| 134 | | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) | | ||
| 135 | | `EITRI_TOKEN` | Personal access token, used only to look up your tenant for the connect name (skipped when `EITRI_TENANT` is set) | | ||
| 136 | | `EITRI_TENANT`| Optional: pins the tenant (offline, and the escape hatch when your CA is registered in more than one); otherwise derived from the token | | ||
| 137 | | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) | | ||
| 138 | | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) | | ||
| 139 | |||
| 140 | The SSH session authenticates with no API credential—your signing CA *is* the | ||
| 141 | credential. `eitri ssh` generates `~/.ssh/id_ed25519` if missing, self-signs a | ||
| 142 | 30-minute cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the | ||
| 143 | eitri host CA and pins it as `@cert-authority *` in a dedicated known_hosts | ||
| 144 | file, and execs `ssh` with both hops verified. | ||
| 145 | |||
| 146 | > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, | ||
| 147 | > i.e. the server's `ssh_gate_domain` (which defaults to the host part of | ||
| 148 | > `ssh_listen`). A mismatch is a hard host-verification failure, by design. | ||
| 149 | > A server whose `ssh_listen` binds every interface (`:2222`, `0.0.0.0:2222`) | ||
| 150 | > has no host part to default to, so `ssh_gate_domain` is required there and | ||
| 151 | > the server refuses to start without it. | ||
| 152 | |||
| 153 | ## Manual flow | ||
| 154 | |||
| 155 | The client is a thin wrapper over three steps you can run by hand: | ||
| 156 | |||
| 157 | 1. **Self-sign a cert** for your public key with your tenant CA—no server | ||
| 158 | involved: | ||
| 159 | |||
| 160 | ```sh | ||
| 161 | ssh-keygen -s ~/.ssh/eitri_user_ca -I "$(whoami)@$(hostname)" \ | ||
| 162 | -n ubuntu -V +30m ~/.ssh/id_ed25519.pub | ||
| 163 | ``` | ||
| 164 | |||
| 165 | 2. **Place the cert beside the key.** `ssh-keygen -s` writes | ||
| 166 | `id_ed25519-cert.pub` next to the key, and OpenSSH auto-offers a cert named | ||
| 167 | `<key>-cert.pub`—nothing further needed, no `ssh-add`. | ||
| 168 | |||
| 169 | 3. **Hop through the gate** to `ubuntu@<tenant>.<vm-name>`: | ||
| 170 | |||
| 171 | ```sh | ||
| 172 | ssh -J "$EITRI_GATE" ubuntu@<tenant>.<vm-name> | ||
| 173 | ``` | ||
| 174 | |||
| 175 | The inner user must be `ubuntu` (the cert principal). The outer gate hop | ||
| 176 | accepts any username. The gate derives your tenant from the CA that signed | ||
| 177 | your cert, resolves names within that tenant, and rejects a foreign-prefixed | ||
| 178 | name. | ||
| 179 | |||
| 180 | ## Certs are short-lived | ||
| 181 | |||
| 182 | Self-signed certs should carry a short validity (`-V +30m` above). When one | ||
| 183 | expires, ssh is simply rejected—re-run `eitri ssh` (or the signing step) | ||
| 184 | to refresh. A specific cert can also be revoked at the gate by serial before it | ||
| 185 | expires; see [credential-revocation.md](credential-revocation.md). | ||
| 186 | |||
| 187 | ## Host verification (via the CA) | ||
| 188 | |||
| 189 | You pin eitri's host CA once and let certificate verification stand in for | ||
| 190 | trust-on-first-use. Fetch the CA (public material, no token needed) and pin it | ||
| 191 | in a **dedicated** known_hosts file—never your main `~/.ssh/known_hosts`, | ||
| 192 | where a `*` wildcard CA would be trusted for *every* host you ssh to: | ||
| 193 | |||
| 194 | ```sh | ||
| 195 | curl -sS "$EITRI_URL/api/v1/ssh-ca" | jq -r .ca \ | ||
| 196 | | sed 's/^/@cert-authority * /' > ~/.ssh/eitri_known_hosts | ||
| 197 | ``` | ||
| 198 | |||
| 199 | Then both hops are verified against the CA with `StrictHostKeyChecking=yes`. A | ||
| 200 | command-line `-o` reaches only the *final* hop, so thread the same options to the | ||
| 201 | jump hop with an explicit `ProxyCommand` instead of `-J`: | ||
| 202 | |||
| 203 | ```sh | ||
| 204 | GATE_HOST=${EITRI_GATE%%:*}; GATE_PORT=${EITRI_GATE##*:} | ||
| 205 | [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 | ||
| 206 | KH=~/.ssh/eitri_known_hosts | ||
| 207 | ssh \ | ||
| 208 | -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile='$KH' -p $GATE_PORT ubuntu@$GATE_HOST" \ | ||
| 209 | -o StrictHostKeyChecking=yes \ | ||
| 210 | -o "UserKnownHostsFile=$KH" \ | ||
| 211 | ubuntu@<tenant>.<vm-name> | ||
| 212 | ``` | ||
| 213 | |||
| 214 | The inner `UserKnownHostsFile` is quoted twice over. The outer shell expands | ||
| 215 | `$KH` into the `ProxyCommand` string, and ssh then runs that string through a | ||
| 216 | shell of its own—so the single quotes are what survive to the inner shell and | ||
| 217 | keep a `$HOME` with a space in it one word. | ||
| 218 | |||
| 219 | The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), | ||
| 220 | and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the | ||
| 221 | inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by | ||
| 222 | CA, recycling a VM name or IP never produces a host-key-changed warning—the | ||
| 223 | new VM simply presents a fresh CA-signed cert for that name. `eitri ssh` | ||
| 224 | does all of this for you. | ||
| 225 | |||
| 226 | ## Related | ||
| 227 | |||
| 228 | - [credential-revocation.md](credential-revocation.md)—revoking a leaked user | ||
| 229 | cert or host credential | ||
| 230 | - [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity | ||
internal/cli/hosted_defaults_doc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -14,10 +14,10 @@ import ( | |||
| 14 | // test reads the real doc and fails if either the constant or the doc moves | 14 | // test reads the real doc and fails if either the constant or the doc moves |
| 15 | // without the other, so the two can never quietly disagree. | 15 | // without the other, so the two can never quietly disagree. |
| 16 | func TestDocsMatchHostedDefaults(t *testing.T) { | 16 | func TestDocsMatchHostedDefaults(t *testing.T) { |
| 17 | doc := string(readRepoFile(t, "docs/ssh-access.md")) | 17 | doc := string(readRepoFile(t, "docs/connecting.md")) |
| 18 | for _, want := range []string{defaultURL, defaultGate} { | 18 | for _, want := range []string{defaultURL, defaultGate} { |
| 19 | if !strings.Contains(doc, "`"+want+"`") { | 19 | if !strings.Contains(doc, "`"+want+"`") { |
| 20 | t.Errorf("docs/ssh-access.md no longer states the hosted default `%s` — "+ | 20 | t.Errorf("docs/connecting.md no longer states the hosted default `%s` — "+ |
| 21 | "update the doc and this test together when the constant changes", want) | 21 | "update the doc and this test together when the constant changes", want) |
| 22 | } | 22 | } |
| 23 | } | 23 | } |
internal/names/names_test.go
| Old | New | ||
|---|---|---|---|
| @@ -28,13 +28,13 @@ func TestIsNetworkName(t *testing.T) { | |||
| 28 | // TestDocsMatchNetworkNameGrammar is the docs-match tripwire for the network- | 28 | // TestDocsMatchNetworkNameGrammar is the docs-match tripwire for the network- |
| 29 | // name grammar. IsNetworkName is the one home for the rule (see its doc | 29 | // name grammar. IsNetworkName is the one home for the rule (see its doc |
| 30 | // comment), but the agent's flag parser (internal/agent/run/cli.go) and | 30 | // comment), but the agent's flag parser (internal/agent/run/cli.go) and |
| 31 | // docs/quickstart.md both restate it in prose for a human reader, in a form | 31 | // docs/networking.md both restate it in prose for a human reader, in a form |
| 32 | // that cannot import the Go function. This test reads the quickstart prose | 32 | // that cannot import the Go function. This test reads the quickstart prose |
| 33 | // and fails if its load-bearing fragments go missing, and separately proves | 33 | // and fails if its load-bearing fragments go missing, and separately proves |
| 34 | // the stated bounds still match IsNetworkName's behavior — so a change to | 34 | // the stated bounds still match IsNetworkName's behavior — so a change to |
| 35 | // the grammar cannot silently leave the docs describing the old rule. | 35 | // the grammar cannot silently leave the docs describing the old rule. |
| 36 | func TestDocsMatchNetworkNameGrammar(t *testing.T) { | 36 | func TestDocsMatchNetworkNameGrammar(t *testing.T) { |
| 37 | quickstart := readRepoFile(t, "docs/quickstart.md") | 37 | quickstart := readRepoFile(t, "docs/networking.md") |
| 38 | for _, want := range []string{ | 38 | for _, want := range []string{ |
| 39 | "1–32 of `[a-z0-9-]`", | 39 | "1–32 of `[a-z0-9-]`", |
| 40 | "no leading", | 40 | "no leading", |
| @@ -42,7 +42,7 @@ func TestDocsMatchNetworkNameGrammar(t *testing.T) { | |||
| 42 | "`nat` is reserved", | 42 | "`nat` is reserved", |
| 43 | } { | 43 | } { |
| 44 | if !strings.Contains(quickstart, want) { | 44 | if !strings.Contains(quickstart, want) { |
| 45 | t.Errorf("docs/quickstart.md is missing %q — the network-name grammar prose drifted from IsNetworkName", want) | 45 | t.Errorf("docs/networking.md is missing %q — the network-name grammar prose drifted from IsNetworkName", want) |
| 46 | } | 46 | } |
| 47 | } | 47 | } |
| 48 | 48 | ||
| @@ -51,13 +51,13 @@ func TestDocsMatchNetworkNameGrammar(t *testing.T) { | |||
| 51 | // actually accepts. TestIsNetworkName already covers this in depth; | 51 | // actually accepts. TestIsNetworkName already covers this in depth; |
| 52 | // these three checks pin the specific numbers the doc sentence quotes. | 52 | // these three checks pin the specific numbers the doc sentence quotes. |
| 53 | if !IsNetworkName(strings.Repeat("x", 32)) { | 53 | if !IsNetworkName(strings.Repeat("x", 32)) { |
| 54 | t.Error("IsNetworkName rejects a 32-char name — docs/quickstart.md's \"1–32\" would be wrong") | 54 | t.Error("IsNetworkName rejects a 32-char name — docs/networking.md's \"1–32\" would be wrong") |
| 55 | } | 55 | } |
| 56 | if IsNetworkName(strings.Repeat("x", 33)) { | 56 | if IsNetworkName(strings.Repeat("x", 33)) { |
| 57 | t.Error("IsNetworkName accepts a 33-char name — docs/quickstart.md's \"1–32\" would be wrong") | 57 | t.Error("IsNetworkName accepts a 33-char name — docs/networking.md's \"1–32\" would be wrong") |
| 58 | } | 58 | } |
| 59 | if IsNetworkName("nat") { | 59 | if IsNetworkName("nat") { |
| 60 | t.Error("IsNetworkName accepts \"nat\" — docs/quickstart.md's \"nat is reserved\" would be wrong") | 60 | t.Error("IsNetworkName accepts \"nat\" — docs/networking.md's \"nat is reserved\" would be wrong") |
| 61 | } | 61 | } |
| 62 | } | 62 | } |
| 63 | 63 | ||
internal/site/dl_test.go
| Old | New | ||
|---|---|---|---|
| @@ -82,7 +82,7 @@ func TestDownloadsPageUnsummedFilesGetPlaceholder(t *testing.T) { | |||
| 82 | if !strings.Contains(md, "| — |") { | 82 | if !strings.Contains(md, "| — |") { |
| 83 | t.Errorf("unsummed file missing placeholder:\n%s", md) | 83 | t.Errorf("unsummed file missing placeholder:\n%s", md) |
| 84 | } | 84 | } |
| 85 | html, err := render([]byte(md), nil) | 85 | html, _, err := render([]byte(md), nil) |
| 86 | if err != nil { | 86 | if err != nil { |
| 87 | t.Fatal(err) | 87 | t.Fatal(err) |
| 88 | } | 88 | } |
internal/site/render.go
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | "bytes" | 9 | "bytes" |
| 10 | "errors" | 10 | "errors" |
| 11 | "fmt" | 11 | "fmt" |
| 12 | "html/template" | ||
| 12 | "strings" | 13 | "strings" |
| 13 | 14 | ||
| 14 | "github.com/yuin/goldmark" | 15 | "github.com/yuin/goldmark" |
| @@ -63,23 +64,88 @@ func (r *linkRewriter) Transform(doc *ast.Document, _ text.Reader, _ parser.Cont | |||
| 63 | }) | 64 | }) |
| 64 | } | 65 | } |
| 65 | 66 | ||
| 66 | // render converts markdown to HTML, rewriting internal links via targets. | 67 | // section is one top-level heading of a page: what a table of contents lists, |
| 67 | // Any link to an unknown internal page is an error. | 68 | // and the anchor it links to. |
| 68 | func render(src []byte, targets map[string]string) ([]byte, error) { | 69 | type section struct{ ID, Title string } |
| 70 | |||
| 71 | // render converts markdown to HTML, rewriting internal links via targets, and | ||
| 72 | // reports the page's top-level sections. Any link to an unknown internal page | ||
| 73 | // is an error. | ||
| 74 | func render(src []byte, targets map[string]string) ([]byte, []section, error) { | ||
| 69 | rw := &linkRewriter{targets: targets} | 75 | rw := &linkRewriter{targets: targets} |
| 70 | md := goldmark.New( | 76 | md := goldmark.New( |
| 71 | goldmark.WithExtensions(extension.GFM), | 77 | goldmark.WithExtensions(extension.GFM), |
| 72 | goldmark.WithParserOptions(parser.WithASTTransformers(util.Prioritized(rw, 100))), | 78 | goldmark.WithParserOptions( |
| 79 | parser.WithASTTransformers(util.Prioritized(rw, 100)), | ||
| 80 | // Headings need stable ids before anything can link to them. | ||
| 81 | parser.WithAutoHeadingID(), | ||
| 82 | ), | ||
| 73 | goldmark.WithRendererOptions(ghtml.WithUnsafe()), | 83 | goldmark.WithRendererOptions(ghtml.WithUnsafe()), |
| 74 | ) | 84 | ) |
| 85 | doc := md.Parser().Parse(text.NewReader(src)) | ||
| 86 | if len(rw.errs) > 0 { | ||
| 87 | return nil, nil, errors.Join(rw.errs...) | ||
| 88 | } | ||
| 75 | var buf bytes.Buffer | 89 | var buf bytes.Buffer |
| 76 | if err := md.Convert(src, &buf); err != nil { | 90 | if err := md.Renderer().Render(&buf, src, doc); err != nil { |
| 77 | return nil, err | 91 | return nil, nil, err |
| 78 | } | 92 | } |
| 79 | if len(rw.errs) > 0 { | 93 | return buf.Bytes(), sections(doc, src), nil |
| 80 | return nil, errors.Join(rw.errs...) | 94 | } |
| 95 | |||
| 96 | // sections collects a page's level-2 headings in document order. | ||
| 97 | func sections(doc ast.Node, src []byte) []section { | ||
| 98 | var out []section | ||
| 99 | for n := doc.FirstChild(); n != nil; n = n.NextSibling() { | ||
| 100 | h, ok := n.(*ast.Heading) | ||
| 101 | if !ok || h.Level != 2 { | ||
| 102 | continue | ||
| 103 | } | ||
| 104 | id, ok := h.AttributeString("id") | ||
| 105 | if !ok { | ||
| 106 | continue | ||
| 107 | } | ||
| 108 | var title strings.Builder | ||
| 109 | _ = ast.Walk(h, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | ||
| 110 | if t, ok := n.(*ast.Text); ok && entering { | ||
| 111 | title.Write(t.Segment.Value(src)) | ||
| 112 | } | ||
| 113 | return ast.WalkContinue, nil | ||
| 114 | }) | ||
| 115 | out = append(out, section{ID: string(id.([]byte)), Title: title.String()}) | ||
| 116 | } | ||
| 117 | return out | ||
| 118 | } | ||
| 119 | |||
| 120 | // A table of contents earns its place on a page long enough to scroll and | ||
| 121 | // sectioned enough to skip around: a short page's contents list is longer than | ||
| 122 | // the reading it saves. | ||
| 123 | const ( | ||
| 124 | tocMinSections = 4 | ||
| 125 | tocMinBytes = 6000 | ||
| 126 | ) | ||
| 127 | |||
| 128 | // withTOC puts a table of contents in front of a page's first section, when | ||
| 129 | // the page is worth navigating. The list goes inside the content rather than | ||
| 130 | // around it because the page's own title is the first thing in there. | ||
| 131 | func withTOC(html []byte, secs []section) []byte { | ||
| 132 | if len(secs) < tocMinSections || len(html) < tocMinBytes { | ||
| 133 | return html | ||
| 134 | } | ||
| 135 | at := bytes.Index(html, []byte("<h2")) | ||
| 136 | if at < 0 { | ||
| 137 | return html | ||
| 138 | } | ||
| 139 | var toc bytes.Buffer | ||
| 140 | toc.WriteString(`<nav class="toc">` + "\n") | ||
| 141 | for _, s := range secs { | ||
| 142 | fmt.Fprintf(&toc, "<a href=\"#%s\">%s</a>\n", s.ID, template.HTMLEscapeString(s.Title)) | ||
| 81 | } | 143 | } |
| 82 | return buf.Bytes(), nil | 144 | toc.WriteString("</nav>\n") |
| 145 | out := make([]byte, 0, len(html)+toc.Len()) | ||
| 146 | out = append(out, html[:at]...) | ||
| 147 | out = append(out, toc.Bytes()...) | ||
| 148 | return append(out, html[at:]...) | ||
| 83 | } | 149 | } |
| 84 | 150 | ||
| 85 | // summaryLimit is how many runes of a page summary survive: enough for the | 151 | // summaryLimit is how many runes of a page summary survive: enough for the |
internal/site/render_test.go
| Old | New | ||
|---|---|---|---|
| @@ -13,7 +13,7 @@ var testTargets = map[string]string{ | |||
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | func TestRenderRewritesInternalLinks(t *testing.T) { | 15 | func TestRenderRewritesInternalLinks(t *testing.T) { |
| 16 | got, err := render([]byte("see [ssh](ssh-access.md) and [index](README.md)"), testTargets) | 16 | got, _, err := render([]byte("see [ssh](ssh-access.md) and [index](README.md)"), testTargets) |
| 17 | if err != nil { | 17 | if err != nil { |
| 18 | t.Fatal(err) | 18 | t.Fatal(err) |
| 19 | } | 19 | } |
| @@ -26,7 +26,7 @@ func TestRenderRewritesInternalLinks(t *testing.T) { | |||
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | func TestRenderPreservesFragments(t *testing.T) { | 28 | func TestRenderPreservesFragments(t *testing.T) { |
| 29 | got, err := render([]byte("[a](ssh-access.md#gate)"), testTargets) | 29 | got, _, err := render([]byte("[a](ssh-access.md#gate)"), testTargets) |
| 30 | if err != nil { | 30 | if err != nil { |
| 31 | t.Fatal(err) | 31 | t.Fatal(err) |
| 32 | } | 32 | } |
| @@ -37,7 +37,7 @@ func TestRenderPreservesFragments(t *testing.T) { | |||
| 37 | 37 | ||
| 38 | func TestRenderLeavesExternalAndAbsoluteAlone(t *testing.T) { | 38 | func TestRenderLeavesExternalAndAbsoluteAlone(t *testing.T) { |
| 39 | src := "[x](https://eitri.sh) [y](/dl/) [z](#local)" | 39 | src := "[x](https://eitri.sh) [y](/dl/) [z](#local)" |
| 40 | got, err := render([]byte(src), testTargets) | 40 | got, _, err := render([]byte(src), testTargets) |
| 41 | if err != nil { | 41 | if err != nil { |
| 42 | t.Fatal(err) | 42 | t.Fatal(err) |
| 43 | } | 43 | } |
| @@ -50,14 +50,14 @@ func TestRenderLeavesExternalAndAbsoluteAlone(t *testing.T) { | |||
| 50 | } | 50 | } |
| 51 | 51 | ||
| 52 | func TestRenderFailsOnUnknownInternalLink(t *testing.T) { | 52 | func TestRenderFailsOnUnknownInternalLink(t *testing.T) { |
| 53 | _, err := render([]byte("[gone](no-such-doc.md)"), testTargets) | 53 | _, _, err := render([]byte("[gone](no-such-doc.md)"), testTargets) |
| 54 | if err == nil || !strings.Contains(err.Error(), "no-such-doc.md") { | 54 | if err == nil || !strings.Contains(err.Error(), "no-such-doc.md") { |
| 55 | t.Fatalf("want broken-link error naming the target, got %v", err) | 55 | t.Fatalf("want broken-link error naming the target, got %v", err) |
| 56 | } | 56 | } |
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | func TestRenderTables(t *testing.T) { | 59 | func TestRenderTables(t *testing.T) { |
| 60 | got, err := render([]byte("| a | b |\n|---|---|\n| 1 | 2 |"), testTargets) | 60 | got, _, err := render([]byte("| a | b |\n|---|---|\n| 1 | 2 |"), testTargets) |
| 61 | if err != nil { | 61 | if err != nil { |
| 62 | t.Fatal(err) | 62 | t.Fatal(err) |
| 63 | } | 63 | } |
| @@ -67,7 +67,7 @@ func TestRenderTables(t *testing.T) { | |||
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | func TestRenderRewritesParentRelativeLinks(t *testing.T) { | 69 | func TestRenderRewritesParentRelativeLinks(t *testing.T) { |
| 70 | got, err := render([]byte("[r](../ROADMAP.md)"), testTargets) | 70 | got, _, err := render([]byte("[r](../ROADMAP.md)"), testTargets) |
| 71 | if err != nil { | 71 | if err != nil { |
| 72 | t.Fatal(err) | 72 | t.Fatal(err) |
| 73 | } | 73 | } |
| @@ -77,7 +77,7 @@ func TestRenderRewritesParentRelativeLinks(t *testing.T) { | |||
| 77 | } | 77 | } |
| 78 | 78 | ||
| 79 | func TestRenderNormalizesDotSlashPrefix(t *testing.T) { | 79 | func TestRenderNormalizesDotSlashPrefix(t *testing.T) { |
| 80 | got, err := render([]byte("[s](./ssh-access.md)"), testTargets) | 80 | got, _, err := render([]byte("[s](./ssh-access.md)"), testTargets) |
| 81 | if err != nil { | 81 | if err != nil { |
| 82 | t.Fatal(err) | 82 | t.Fatal(err) |
| 83 | } | 83 | } |
| @@ -87,7 +87,7 @@ func TestRenderNormalizesDotSlashPrefix(t *testing.T) { | |||
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | func TestRenderRewritesImageDestinations(t *testing.T) { | 89 | func TestRenderRewritesImageDestinations(t *testing.T) { |
| 90 | got, err := render([]byte(""), testTargets) | 90 | got, _, err := render([]byte(""), testTargets) |
| 91 | if err != nil { | 91 | if err != nil { |
| 92 | t.Fatal(err) | 92 | t.Fatal(err) |
| 93 | } | 93 | } |
| @@ -138,3 +138,59 @@ func TestSummaryOfProselessDocIsEmpty(t *testing.T) { | |||
| 138 | t.Errorf("summary = %q, want empty", got) | 138 | t.Errorf("summary = %q, want empty", got) |
| 139 | } | 139 | } |
| 140 | } | 140 | } |
| 141 | |||
| 142 | // A long, many-sectioned page gets a contents list in front of its first | ||
| 143 | // section — inside the content, because the page's own title is in there too. | ||
| 144 | func TestWithTOCListsSectionsOfALongPage(t *testing.T) { | ||
| 145 | var md strings.Builder | ||
| 146 | md.WriteString("# page\n\nopening prose\n") | ||
| 147 | for _, name := range []string{"Join a Linux host", "Join a Mac", "Sharp edges", "Related"} { | ||
| 148 | md.WriteString("\n## " + name + "\n\n" + strings.Repeat("filler prose. ", 200) + "\n") | ||
| 149 | } | ||
| 150 | html, secs, err := render([]byte(md.String()), testTargets) | ||
| 151 | if err != nil { | ||
| 152 | t.Fatal(err) | ||
| 153 | } | ||
| 154 | if len(secs) != 4 { | ||
| 155 | t.Fatalf("found %d sections, want 4: %v", len(secs), secs) | ||
| 156 | } | ||
| 157 | out := string(withTOC(html, secs)) | ||
| 158 | toc := strings.Index(out, `<nav class="toc">`) | ||
| 159 | if toc < 0 { | ||
| 160 | t.Fatalf("no table of contents:\n%s", out[:400]) | ||
| 161 | } | ||
| 162 | if h2 := strings.Index(out, "<h2"); toc > h2 { | ||
| 163 | t.Errorf("contents list sits after the first section (%d > %d)", toc, h2) | ||
| 164 | } | ||
| 165 | if h1 := strings.Index(out, "<h1"); h1 > toc { | ||
| 166 | t.Error("contents list sits above the page title") | ||
| 167 | } | ||
| 168 | for _, want := range []string{`href="#join-a-linux-host"`, `href="#join-a-mac"`, "Sharp edges"} { | ||
| 169 | if !strings.Contains(out, want) { | ||
| 170 | t.Errorf("contents list missing %s:\n%s", want, out[toc:toc+400]) | ||
| 171 | } | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | func TestWithTOCLeavesShortPagesAlone(t *testing.T) { | ||
| 176 | md := "# page\n\n## one\n\nprose\n\n## two\n\nprose\n\n## three\n\nprose\n\n## four\n\nprose\n" | ||
| 177 | html, secs, err := render([]byte(md), testTargets) | ||
| 178 | if err != nil { | ||
| 179 | t.Fatal(err) | ||
| 180 | } | ||
| 181 | if got := string(withTOC(html, secs)); strings.Contains(got, "toc") { | ||
| 182 | t.Errorf("short page got a contents list:\n%s", got) | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | // Sections are what the reader can jump to, so a heading with markup in it | ||
| 187 | // still has to produce plain link text. | ||
| 188 | func TestSectionsFlattenHeadingMarkup(t *testing.T) { | ||
| 189 | _, secs, err := render([]byte("# p\n\n## The `eitri` *client*\n\nprose\n"), testTargets) | ||
| 190 | if err != nil { | ||
| 191 | t.Fatal(err) | ||
| 192 | } | ||
| 193 | if len(secs) != 1 || secs[0].Title != "The eitri client" { | ||
| 194 | t.Errorf("sections = %v, want one titled \"The eitri client\"", secs) | ||
| 195 | } | ||
| 196 | } | ||
internal/site/site.go
| Old | New | ||
|---|---|---|---|
| @@ -14,8 +14,11 @@ import ( | |||
| 14 | // Build hard-fails if any listed doc is missing. | 14 | // Build hard-fails if any listed doc is missing. |
| 15 | var pages = []string{ | 15 | var pages = []string{ |
| 16 | "quickstart", | 16 | "quickstart", |
| 17 | "joining", | ||
| 18 | "connecting", | ||
| 19 | "networking", | ||
| 20 | "self-hosting", | ||
| 17 | "byo-idp", | 21 | "byo-idp", |
| 18 | "ssh-access", | ||
| 19 | "mcp", | 22 | "mcp", |
| 20 | "upgrade", | 23 | "upgrade", |
| 21 | "cert-rotation", | 24 | "cert-rotation", |
| @@ -86,11 +89,11 @@ func Build(cfg Config) error { | |||
| 86 | if err != nil { | 89 | if err != nil { |
| 87 | return "", "", err | 90 | return "", "", err |
| 88 | } | 91 | } |
| 89 | h, err := render(b, targets) | 92 | h, secs, err := render(b, targets) |
| 90 | if err != nil { | 93 | if err != nil { |
| 91 | return "", "", fmt.Errorf("%s: %w", src, err) | 94 | return "", "", fmt.Errorf("%s: %w", src, err) |
| 92 | } | 95 | } |
| 93 | return template.HTML(h), summary(b), nil | 96 | return template.HTML(withTOC(h, secs)), summary(b), nil |
| 94 | } | 97 | } |
| 95 | 98 | ||
| 96 | // preview draws the page's link-preview card and returns the canonical | 99 | // preview draws the page's link-preview card and returns the canonical |
| @@ -175,7 +178,7 @@ func Build(cfg Config) error { | |||
| 175 | if err != nil { | 178 | if err != nil { |
| 176 | return err | 179 | return err |
| 177 | } | 180 | } |
| 178 | h, err := render([]byte(dlMD), targets) | 181 | h, _, err := render([]byte(dlMD), targets) |
| 179 | if err != nil { | 182 | if err != nil { |
| 180 | return err | 183 | return err |
| 181 | } | 184 | } |
internal/site/site_test.go
| Old | New | ||
|---|---|---|---|
| @@ -149,7 +149,7 @@ func TestBuildFailsOnLinkToUnpublishedDoc(t *testing.T) { | |||
| 149 | root, docs, siteDir := writeFixture(t) | 149 | root, docs, siteDir := writeFixture(t) |
| 150 | // A published doc linking a repo-internal doc must fail the build, not | 150 | // A published doc linking a repo-internal doc must fail the build, not |
| 151 | // ship a dangling link. | 151 | // ship a dangling link. |
| 152 | if err := os.WriteFile(filepath.Join(docs, "ssh-access.md"), | 152 | if err := os.WriteFile(filepath.Join(docs, "connecting.md"), |
| 153 | []byte("[why](architecture.md)\n"), 0o644); err != nil { | 153 | []byte("[why](architecture.md)\n"), 0o644); err != nil { |
| 154 | t.Fatal(err) | 154 | t.Fatal(err) |
| 155 | } | 155 | } |
site/docs.md
| Old | New | ||
|---|---|---|---|
| @@ -2,17 +2,21 @@ | |||
| 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 the hosted plane |
| 6 | second host | 6 | - [joining](joining.md)—putting a machine into your fleet, Linux or Mac |
| 7 | - [connecting](connecting.md)—your CA, the client, and the jump gate in front | ||
| 8 | of every guest | ||
| 7 | 9 | ||
| 8 | **Use a fleet** | 10 | **Use a fleet** |
| 9 | 11 | ||
| 10 | - [ssh access](ssh-access.md)—reaching a VM through the jump gate with | 12 | - [networking](networking.md)—publishing a port a guest serves, or giving a VM |
| 11 | your own keys | 13 | an address on your own LAN |
| 12 | - [mcp](mcp.md)—let an AI agent create and drive VMs | 14 | - [mcp](mcp.md)—let an AI agent create and drive VMs |
| 13 | 15 | ||
| 14 | **Run a fleet** | 16 | **Run a fleet** |
| 15 | 17 | ||
| 18 | - [self-hosting](self-hosting.md)—running the whole control plane yourself | ||
| 19 | - [byo-idp](byo-idp.md)—your own identity provider instead of the bundled one | ||
| 16 | - [upgrading](upgrade.md)—agents from the console; the server; | 20 | - [upgrading](upgrade.md)—agents from the console; the server; |
| 17 | cloud-hypervisor | 21 | cloud-hypervisor |
| 18 | - [cert rotation](cert-rotation.md)—rotating the server certificate | 22 | - [cert rotation](cert-rotation.md)—rotating the server certificate |
site/nginx.conf
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,13 @@ server { | |||
| 19 | add_header Cache-Control "public, max-age=31536000, immutable"; | 19 | add_header Cache-Control "public, max-age=31536000, immutable"; |
| 20 | } | 20 | } |
| 21 | 21 | ||
| 22 | # /docs/ssh-access/ became /docs/connecting/ when the docs were split by | ||
| 23 | # task. The old path is linked from the console, from release notes, and | ||
| 24 | # from anywhere anyone pasted it. | ||
| 25 | location ~ ^/docs/ssh-access/?$ { | ||
| 26 | return 301 /docs/connecting/; | ||
| 27 | } | ||
| 28 | |||
| 22 | location / { | 29 | location / { |
| 23 | add_header Cache-Control "no-cache"; | 30 | add_header Cache-Control "no-cache"; |
| 24 | } | 31 | } |
site/style.css
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,10 @@ p, ul, ol { margin-bottom: 1em; } | |||
| 31 | ul, ol { padding-left: 2em; } | 31 | ul, ol { padding-left: 2em; } |
| 32 | li { margin-bottom: 0.25em; } | 32 | li { margin-bottom: 0.25em; } |
| 33 | 33 | ||
| 34 | /* The contents list of a long page: links, not bullets. */ | ||
| 35 | .toc { margin-bottom: 2em; } | ||
| 36 | .toc a { display: block; margin-bottom: 0.25em; } | ||
| 37 | |||
| 34 | pre { | 38 | pre { |
| 35 | padding: 1em; | 39 | padding: 1em; |
| 36 | margin-bottom: 1em; | 40 | margin-bottom: 1em; |
web/src/lib/SshConnect.svelte
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,6 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | // The connect recipes for one VM, with this plane's real values in them. | 2 | // The connect recipes for one VM, with this plane's real values in them. |
| 3 | // The one-shot command is the one docs/ssh-access.md prints — the gate hop | 3 | // The one-shot command is the one docs/connecting.md prints — the gate hop |
| 4 | // rides an explicit ProxyCommand rather than -J because command-line -o | 4 | // rides an explicit ProxyCommand rather than -J because command-line -o |
| 5 | // options reach only the final hop, and BOTH hops must verify the host | 5 | // options reach only the final hop, and BOTH hops must verify the host |
| 6 | // certificate they are presented. The same argv shape is what | 6 | // certificate they are presented. The same argv shape is what |
web/src/lib/fleet.svelte.ts
| Old | New | ||
|---|---|---|---|
| @@ -433,7 +433,7 @@ export function hostBundle(platform: HostPlatform, version: string): HostBundle | |||
| 433 | } | 433 | } |
| 434 | 434 | ||
| 435 | /** joinCommands is the enrolment recipe for one join token on one platform: | 435 | /** joinCommands is the enrolment recipe for one join token on one platform: |
| 436 | * fetch, verify, install, join, start. It is the same sequence docs/quickstart | 436 | * fetch, verify, install, join, start. It is the same sequence docs/joining |
| 437 | * prints, with this fleet's release in it. | 437 | * prints, with this fleet's release in it. |
| 438 | * | 438 | * |
| 439 | * The two platforms differ in more than a filename, which is why the flow asks | 439 | * The two platforms differ in more than a filename, which is why the flow asks |
| @@ -697,7 +697,7 @@ export function formatUptime(seconds: number): string { | |||
| 697 | /** agentLogHint is where this host keeps the agent's log, in the exact form you | 697 | /** agentLogHint is where this host keeps the agent's log, in the exact form you |
| 698 | * would type or open. A Linux host runs the agent under systemd and its log is | 698 | * would type or open. A Linux host runs the agent under systemd and its log is |
| 699 | * in the journal; a Mac runs it as a LaunchAgent, which has no journal and | 699 | * in the journal; a Mac runs it as a LaunchAgent, which has no journal and |
| 700 | * writes to a file (docs/quickstart.md). A host whose OS we have not been told | 700 | * writes to a file (docs/joining.md). A host whose OS we have not been told |
| 701 | * gets no hint at all rather than a guess: sending an operator to a command | 701 | * gets no hint at all rather than a guess: sending an operator to a command |
| 702 | * their machine does not have wastes exactly the time this message exists to | 702 | * their machine does not have wastes exactly the time this message exists to |
| 703 | * save. Returns '' in that case, and callers drop the parenthetical. */ | 703 | * save. Returns '' in that case, and callers drop the parenthetical. */ |