a73x

a9a7f4b6

docs: pages named for what the reader is doing

a73x   2026-08-18 18:53

Commit message
docs: pages named for what the reader is doing

The quickstart was 573 lines and two quickstarts: hosted and self-hosted,
each repeating join, boot and SSH, with two feature guides wedged in the
middle and thirty lines on why a Mac agent is a LaunchAgent before the
reader had joined anything. It is now sixty lines of the path most people
take, handing off four times.

What it hands off to is task-shaped. Joining a host is one page covering
Linux and Mac, whichever plane the machine reports to, with the Mac
reasoning kept but moved to sharp edges at the foot where it stops
interrupting the commands. Connecting is the old ssh-access page with the
laptop setup folded in front of the CA it signs with — one place that
explains getting the client, registering a CA and reaching a guest, for
both planes. Networking is the two guides that were never first-VM
material. Self-hosting is the plane you run yourself, and links to the
same joining and connecting pages instead of restating them.

Prose moved verbatim; the writing that is new is the sentence on each page
that hands the reader to the next one.

/docs/ssh-access/ is linked from the console and from release notes, so
nginx redirects it to /docs/connecting/ permanently. The guard tests that
read documented defaults out of the prose follow the prose: the VM sizes
now read from the quickstart, the network-name grammar from networking.

Long pages get a contents list generated from their own headings —
connecting, self-hosting, upgrade, mcp and the release notes qualify; a
page you can take in at a glance is left alone.

.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("![d](shape.html)"), testTargets) 90 got, _, err := render([]byte("![d](shape.html)"), 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. */