a79a3c06
docs: the site says what you get, not why we built it that way
a73x 2026-08-19 04:14
Commit message
docs/connecting.md
| Old | New | ||
|---|---|---|---|
| @@ -140,14 +140,11 @@ curl -X PUT -H "Authorization: Bearer $EITRI_TOKEN" \ | |||
| 140 | ``` | 140 | ``` |
| 141 | 141 | ||
| 142 | eitri now authenticates to your guests as that key plus that certificate, until | 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` | 143 | the certificate expires. `GET /api/v1/delegations` reports the expiry; `DELETE` |
| 144 | reports the expiry; `DELETE` ends it immediately. So does restarting the | 144 | ends it immediately, and so does restarting the control plane. |
| 145 | control plane — a delegation is in memory and nowhere else, which is the point. | ||
| 146 | 145 | ||
| 147 | Because the certificate chains to a CA you have already registered, guests | 146 | Guests created **before** you delegated accept it too—unlike registering a new |
| 148 | created **before** you delegated accept it. That is the difference from | 147 | CA, there is no ordering constraint here. |
| 149 | registering a new CA: there is no ordering constraint, because nothing about the | ||
| 150 | guest's trust changes. | ||
| 151 | 148 | ||
| 152 | This is what makes [the remote MCP endpoint](mcp.md) work with nothing but a | 149 | 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` | 150 | PAT, where the same two steps are the `delegate_begin` and `delegate_complete` |
| @@ -271,10 +268,9 @@ ssh \ | |||
| 271 | ubuntu@<tenant>.<vm-name> | 268 | ubuntu@<tenant>.<vm-name> |
| 272 | ``` | 269 | ``` |
| 273 | 270 | ||
| 274 | The inner `UserKnownHostsFile` is quoted twice over. The outer shell expands | 271 | Keep the inner quoting if you edit this: ssh runs the `ProxyCommand` through a |
| 275 | `$KH` into the `ProxyCommand` string, and ssh then runs that string through a | 272 | shell of its own, and the single quotes are what keep a `$HOME` with a space in |
| 276 | shell of its own—so the single quotes are what survive to the inner shell and | 273 | it one word. |
| 277 | keep a `$HOME` with a space in it one word. | ||
| 278 | 274 | ||
| 279 | The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), | 275 | 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 | 276 | and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -201,3 +201,40 @@ same idea, on a second row for it. Both are gone: one Network row now carries | |||
| 201 | the operator's own name as its value, joined to the discovered address once | 201 | the operator's own name as its value, joined to the discovered address once |
| 202 | the network's DHCP answers (`lan · 192.168.0.37`). eitri displays the | 202 | the network's DHCP answers (`lan · 192.168.0.37`). eitri displays the |
| 203 | operator's vocabulary rather than inventing its own. | 203 | operator's vocabulary rather than inventing its own. |
| 204 | |||
| 205 | ### A Mac agent is unprivileged, and a LaunchAgent | ||
| 206 | |||
| 207 | The macOS agent runs as the logged-in user, installed through | ||
| 208 | `eitri-agent-launchagent.sh`, with its binary and state in that account's own | ||
| 209 | directories. Instead of the root-owned systemd shape the Linux agent takes. | ||
| 210 | On macOS eitri owns none of the host's networking—Virtualization.framework | ||
| 211 | owns the guest network and vfkit carries the entitlement for it—so there is | ||
| 212 | nothing to be root for; and a Mac host is nearly always somebody's Mac, where | ||
| 213 | an agent belongs alongside the person already logged into it. It is also what | ||
| 214 | lets the agent replace its own binary on upgrade, which a root-owned prefix | ||
| 215 | would quietly prevent. | ||
| 216 | |||
| 217 | ### vfkit comes from Homebrew, not from the fleet | ||
| 218 | |||
| 219 | The agent fetches cloud-hypervisor and guest firmware itself on Linux, but a | ||
| 220 | Mac host installs vfkit by hand. Instead of bootstrapping it like the Linux | ||
| 221 | hypervisor. vfkit only runs carrying Apple's virtualization entitlement, an | ||
| 222 | entitlement lives in a code signature, and signing is not something a fetched | ||
| 223 | artifact can carry across—so the signed copy has to come from a distributor | ||
| 224 | Apple's rules already fit. | ||
| 225 | |||
| 226 | ### Service users are separate per key they hold | ||
| 227 | |||
| 228 | The server runs as `eitri`, the bundled issuer as `eitri-oidc`, each with its | ||
| 229 | own state directory. Instead of one shared service account. The issuer holds | ||
| 230 | password hashes and the sign-in signing key; the server holds the KEK, the host | ||
| 231 | CA and the gate host key. Separate accounts keep either one's key material | ||
| 232 | unreadable by the other service. | ||
| 233 | |||
| 234 | ### The published trust story is what you get, not why | ||
| 235 | |||
| 236 | Site pages say what a reader can do and what will happen; the reasoning behind | ||
| 237 | the shape—two CAs in opposite directions, delegation held in memory, an | ||
| 238 | unprivileged Mac agent—lives here and in [architecture.md](architecture.md), | ||
| 239 | which never publish. A user-facing page that argues with itself is a page the | ||
| 240 | reader has to finish before they can start. | ||
docs/joining.md
| Old | New | ||
|---|---|---|---|
| @@ -58,10 +58,9 @@ and you install that yourself: | |||
| 58 | brew install vfkit | 58 | brew install vfkit |
| 59 | ``` | 59 | ``` |
| 60 | 60 | ||
| 61 | The agent installs cloud-hypervisor on a Linux host but cannot do the same | 61 | Homebrew's is the copy to use. Without vfkit, VMs placed on this host fail at |
| 62 | here: vfkit only works carrying Apple's virtualization entitlement, and an | 62 | once, saying so; the agent installs cloud-hypervisor for you on Linux, but it |
| 63 | entitlement lives in a code signature. Homebrew's copy is signed. Without it, | 63 | cannot install this. |
| 64 | VMs placed on this host fail at once, saying so. | ||
| 65 | 64 | ||
| 66 | Take the darwin bundle instead of the linux one: | 65 | Take the darwin bundle instead of the linux one: |
| 67 | `eitri-agent_<version>_darwin_arm64.tar.gz`, the agent and its LaunchAgent | 66 | `eitri-agent_<version>_darwin_arm64.tar.gz`, the agent and its LaunchAgent |
| @@ -95,31 +94,19 @@ Network. Without | |||
| 95 | it, guests boot and take a vmnet lease but stay unreachable through the | 94 | it, guests boot and take a vmnet lease but stay unreachable through the |
| 96 | gate—`eitri ssh` fails with "cannot reach VM". | 95 | gate—`eitri ssh` fails with "cannot reach VM". |
| 97 | 96 | ||
| 98 | Keep the binary somewhere this account owns. The agent replaces its own binary | 97 | Keep the binary somewhere this account owns—the agent replaces it in place |
| 99 | when it upgrades, so a root-owned prefix like `/usr/local/bin` would look tidy | 98 | when it upgrades, and the installer refuses a root-owned prefix like |
| 100 | and quietly disable every future upgrade—the installer refuses rather than let | 99 | `/usr/local/bin` rather than leave you unable to upgrade. |
| 101 | that happen. | 100 | |
| 102 | 101 | ## What a Mac host does differently | |
| 103 | ## Sharp edges on a Mac | 102 | |
| 104 | 103 | - **arm64 guests only.** A VM created without an explicit image gets the arm64 | |
| 105 | Unprivileged throughout, because on macOS eitri owns none of the host's | 104 | default; if you name an image, name an arm64 one—nothing downstream checks, |
| 106 | networking: Virtualization.framework owns the guest network and vfkit carries | 105 | and one built for the wrong architecture boots into nothing. |
| 107 | the entitlement for it. Contrast the Linux agent, which is root because it owns | 106 | - **Guest addresses come from macOS.** A Mac's guests sit on vmnet's subnet, |
| 108 | the bridge, the taps, `/dev/kvm` and the nftables rules. | 107 | which is what the console shows. `eitri ssh`, the console and reconcile are |
| 109 | 108 | the same as anywhere else. | |
| 110 | A **LaunchAgent**, not a LaunchDaemon, because a Mac host is nearly always | 109 | - **The agent runs with your session**, unprivileged, as a LaunchAgent. For a |
| 111 | somebody's Mac and an agent runs where that person already is. For a dedicated | 110 | machine that must run guests with nobody logged in, move the generated plist |
| 112 | machine that must run guests with nobody logged in, move the generated plist to | 111 | to `/Library/LaunchDaemons` and add a `UserName` key naming this account; |
| 113 | `/Library/LaunchDaemons` and add a `UserName` key naming this account; leave the | 112 | leave the binary and state where they are. |
| 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 | ||
|---|---|---|---|
| @@ -31,13 +31,11 @@ different services. | |||
| 31 | `active` means the host's socket is bound. Whether anything answers on the | 31 | `active` means the host's socket is bound. Whether anything answers on the |
| 32 | guest's 8080 is the guest's business. | 32 | guest's 8080 is the guest's business. |
| 33 | 33 | ||
| 34 | A UDP exposure asks for one thing more of you than a TCP one. UDP has no | 34 | A UDP exposure asks for more care than a TCP one. UDP has no handshake, so |
| 35 | handshake, so nothing proves a datagram came from the address it claims: the | 35 | nothing proves a datagram came from the address it claims, and the host sends |
| 36 | host sends the guest's reply wherever that source header says, which makes a | 36 | the guest's reply wherever that source header points—an outsider can aim a |
| 37 | published UDP port something an outsider can aim at a third party. That is the | 37 | published UDP port at a third party. Publish UDP only onto a network you are |
| 38 | same bargain the rest of a published port already is—reaching the host is | 38 | content to serve. |
| 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 | 39 | ||
| 42 | Remove the row to take the port down. Deleting the VM takes its exposures with | 40 | Remove the row to take the port down. Deleting the VM takes its exposures with |
| 43 | it. | 41 | it. |
docs/self-hosting.md
| Old | New | ||
|---|---|---|---|
| @@ -56,8 +56,8 @@ sudo useradd --system --home-dir /var/lib/eitri --shell /usr/sbin/nologin eitri | |||
| 56 | sudo mkdir -p /etc/eitri | 56 | sudo mkdir -p /etc/eitri |
| 57 | ``` | 57 | ``` |
| 58 | 58 | ||
| 59 | The server runs as the dedicated `eitri` user—it needs no privilege (every | 59 | The server runs as the dedicated `eitri` user, with no privilege and every |
| 60 | listener is on an unprivileged port); its state directory `/var/lib/eitri` is | 60 | listener on an unprivileged port; its state directory `/var/lib/eitri` is |
| 61 | created and owned for it by the unit. | 61 | created and owned for it by the unit. |
| 62 | 62 | ||
| 63 | Set `SERVER_ADDR`, paste the rest: | 63 | Set `SERVER_ADDR`, paste the rest: |
| @@ -100,9 +100,8 @@ sudo chgrp eitri /etc/eitri/server.json && sudo chmod 0640 /etc/eitri/server.jso | |||
| 100 | ``` | 100 | ``` |
| 101 | 101 | ||
| 102 | The chmod matters: `server.json` carries `host_secret` and | 102 | The chmod matters: `server.json` carries `host_secret` and |
| 103 | `key_encryption_key`—the key that encrypts everything eitri signs with, the host | 103 | `key_encryption_key`, so it is root-owned and readable only through the `eitri` |
| 104 | CA and gate host key in `/var/lib/eitri` included—so it is root-owned and | 104 | group—never world-readable. |
| 105 | readable only via the `eitri` group, not world-readable. | ||
| 106 | 105 | ||
| 107 | Keep a copy of `server.json` somewhere other than the machine it runs on, and | 106 | 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 | 107 | somewhere other than your backups of `/var/lib/eitri`. Separating the two is |
| @@ -161,12 +160,10 @@ sudo systemctl enable --now eitri-oidc | |||
| 161 | sudo -u eitri-oidc eitri-oidc user add you@example.com # prompts for a password | 160 | sudo -u eitri-oidc eitri-oidc user add you@example.com # prompts for a password |
| 162 | ``` | 161 | ``` |
| 163 | 162 | ||
| 164 | The issuer handles passwords and holds the sign-in signing key, so it runs as | 163 | The issuer runs as its own `eitri-oidc` user, separate from the server's, with |
| 165 | its own `eitri-oidc` user—separate even from the server's `eitri` user, | 164 | its user file and signing key in `/var/lib/eitri-oidc`, created and owned for it |
| 166 | keeping the key material unreadable by any other service. Its mutable state | 165 | by the unit. Run `user add` through `sudo -u eitri-oidc` so the files it writes |
| 167 | (user file, autogenerated signing key) lives in `/var/lib/eitri-oidc`, created | 166 | stay readable by the service. |
| 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 | 167 | ||
| 171 | `redirect_url` must equal the server's `oidc.public_url` + `/auth/callback`. On | 168 | `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 | 169 | this single box the loopback `issuer`/`public_url` work because your browser is |