a73x

a79a3c06

docs: the site says what you get, not why we built it that way

a73x   2026-08-19 04:14

Commit message
docs: the site says what you get, not why we built it that way

eitri.sh is user-facing, so its pages describe features and what will
happen, and stop arguing for the design. The trust model's two-CAs-two-
directions preamble, the reasoning behind an unprivileged Mac agent, the
service-user separation and the shell-quoting essay are reasoning, not
instruction: they move to decisions.md, which never publishes.

A why stays where it changes what the reader does — register a CA before
you create a VM, publish only onto a network you are content to serve,
keep server.json off the disk it protects. Joining a Mac keeps three
facts a reader acts on (arm64 guests, addresses from macOS, an agent that
runs with the session) and loses the four paragraphs explaining them.

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