a73x

4c10e425

feat(site): eitri.sh — site, docs, releases, and the publish image

a73x   2026-07-26 14:21

Commit message
feat(site): eitri.sh — site, docs, releases, and the publish image

cmd/eitri-site renders the site from one template: a man-page landing, a
curated how-to doc set (quickstart, ssh-access, mcp, upgrade, cert-rotation,
credential-revocation, roadmap), and a downloads page. Inter-doc links are
validated against the published set — a link to a missing or internal doc
fails the build, and site-check makes that a merge gate. Repo-internal
material (architecture, ethos, decisions, the shape diagram) never
publishes.

make release cross-compiles tarballs for linux amd64/arm64, bare eitri-agent
binaries for the self-updater, SHA256SUMS, and manifest.json — produced
through the server's own manifest type so the wire contract cannot drift. A
dirty tree refuses; agents only ever upgrade to parseable tagged versions.

make site-image assembles nginx + site + /dl/<version> (with a latest
symlink) into one container image. Cache rules are Cloudflare-aware:
versioned artifacts immutable, /dl/latest/ and HTML no-cache so a new
release is visible the moment it rolls out. k8s rollout stays manual.

docs: quickstart (zero to a first VM, copy-pastable) and the upgrade guide
(agents from the console, the server, cloud-hypervisor).

.gitignore
Old New
@@ -28,3 +28,6 @@ deploy.env
28 # Local dev-process working docs — kept OUT of the repo by convention. 28 # Local dev-process working docs — kept OUT of the repo by convention.
29 /docs/superpowers/ 29 /docs/superpowers/
30 /scratchpad/ 30 /scratchpad/
31
32 /dist/
33 site/dist/
Makefile
Old New
@@ -11,8 +11,9 @@ DEADCODE_VERSION := v0.48.0
11 LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret 11 LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret
12 12
13 .PHONY: build build-go web test vet proto clean \ 13 .PHONY: build build-go web test vet proto clean \
14 lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check ci deadcode \ 14 lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check \
15 deploy hooks 15 site site-check ci deadcode \
16 deploy release hooks site-image
16 17
17 # Enable the repo's client-side merge gate: point git at .githooks, whose 18 # Enable the repo's client-side merge gate: point git at .githooks, whose
18 # pre-push hook runs `make ci` before any push that updates main. Run once per 19 # pre-push hook runs `make ci` before any push that updates main. Run once per
@@ -38,6 +39,7 @@ build: web
38 go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent 39 go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent
39 go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp 40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp
40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke 41 go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke
42 go build $(GO_LDFLAGS) -o $(BIN)/eitri-site ./cmd/eitri-site
41 43
42 test: 44 test:
43 go test -race ./... 45 go test -race ./...
@@ -74,6 +76,12 @@ api-check:
74 deploy: 76 deploy:
75 ./scripts/deploy.sh 77 ./scripts/deploy.sh
76 78
79 # Cross-compiled release tarballs + checksums + agent-upgrade manifest into
80 # dist/<version>/ (see scripts/release.sh). Refuses a dirty tree; releases
81 # that agents can upgrade to must be built from a clean TAGGED tree.
82 release: web
83 ./scripts/release.sh
84
77 # --- quality gates ----------------------------------------------------------- 85 # --- quality gates -----------------------------------------------------------
78 86
79 # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests 87 # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests
@@ -125,6 +133,32 @@ shape-check:
125 git diff --exit-code docs/shape.json docs/shape.html || \ 133 git diff --exit-code docs/shape.json docs/shape.html || \
126 { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; } 134 { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; }
127 135
136 # Generate the eitri.sh static site into site/dist. Picks up the newest
137 # dist/<version> (from `make release`) for the downloads page when one
138 # exists; renders a docs-only preview otherwise.
139 SITE_DIST = $(shell find dist -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1)
140 site:
141 rm -rf site/dist
142 go run ./cmd/eitri-site -docs docs -site site -out site/dist \
143 $(if $(SITE_DIST),-dist $(SITE_DIST))
144
145 # Merge gate: the site must build from the real docs tree — a broken
146 # inter-doc link fails here, keeping repo docs and eitri.sh in lockstep.
147 site-check:
148 @tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \
149 go run ./cmd/eitri-site -docs docs -site site -out $$tmp && \
150 for f in index.html style.css openapi.json docs/index.html docs/quickstart/index.html docs/upgrade/index.html dl/index.html; do \
151 test -f $$tmp/$$f || { echo "site-check: missing $$f"; exit 1; }; \
152 done && echo "site-check: ok"
153
154 # Build and push the eitri.sh site image (nginx + site + /dl artifacts).
155 # Needs SITE_IMAGE (and optionally SITE_FIRMWARE_SRC) in deploy.env.
156 # Rollout on k8s is manual and stays outside the repo.
157 site-image:
158 $(MAKE) release
159 $(MAKE) site SITE_DIST=dist/$(VERSION)
160 ./scripts/site-image.sh
161
128 # Whole-program dead-code gate: fails on any function unreachable from a real 162 # Whole-program dead-code gate: fails on any function unreachable from a real
129 # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what 163 # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what
130 # catches production code kept alive only by its own tests; the fix is to remove 164 # catches production code kept alive only by its own tests; the fix is to remove
@@ -139,7 +173,7 @@ deadcode:
139 # The merge gate. Mirrors the required checks in CI. `test` is the authoritative 173 # The merge gate. Mirrors the required checks in CI. `test` is the authoritative
140 # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch` 174 # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch`
141 # re-runs the fitness tests with -count=1 (the race run may serve them cached). 175 # re-runs the fitness tests with -count=1 (the race run may serve them cached).
142 ci: vet build-go arch lint test cover tidy-check proto-check api-check shape-check deadcode 176 ci: vet build-go arch lint test cover tidy-check proto-check api-check shape-check deadcode site-check
143 177
144 # Compile every Go package (no Node/web build needed — the embed dir ships a 178 # Compile every Go package (no Node/web build needed — the embed dir ships a
145 # placeholder, so the server builds and serves a "UI not built" notice). 179 # placeholder, so the server builds and serves a "UI not built" notice).
cmd/eitri-site/main.go
Old New
@@ -0,0 +1,70 @@
1 // Command eitri-site generates the eitri.sh static site (default) or the
2 // release manifest (the "manifest" subcommand). See internal/site.
3 package main
4
5 import (
6 "encoding/json"
7 "flag"
8 "fmt"
9 "os"
10
11 "github.com/a73x/eitri/internal/site"
12 "github.com/a73x/eitri/internal/version"
13 )
14
15 func main() {
16 if len(os.Args) > 1 && os.Args[1] == "--version" {
17 fmt.Println(version.Version)
18 return
19 }
20 if len(os.Args) > 1 && os.Args[1] == "manifest" {
21 if err := runManifest(os.Args[2:]); err != nil {
22 fmt.Fprintln(os.Stderr, "eitri-site manifest:", err)
23 os.Exit(1)
24 }
25 return
26 }
27 if err := runBuild(os.Args[1:]); err != nil {
28 fmt.Fprintln(os.Stderr, "eitri-site:", err)
29 os.Exit(1)
30 }
31 }
32
33 func runBuild(args []string) error {
34 fs := flag.NewFlagSet("eitri-site", flag.ExitOnError)
35 docs := fs.String("docs", "docs", "docs directory (markdown sources)")
36 siteDir := fs.String("site", "site", "site directory (index.md, template.html, style.css)")
37 dist := fs.String("dist", "", "optional dist/<version> dir with release artifacts")
38 out := fs.String("out", "site/dist", "output webroot")
39 if err := fs.Parse(args); err != nil {
40 return err
41 }
42 return site.Build(site.Config{DocsDir: *docs, SiteDir: *siteDir, DistDir: *dist, OutDir: *out})
43 }
44
45 func runManifest(args []string) error {
46 fs := flag.NewFlagSet("eitri-site manifest", flag.ExitOnError)
47 ver := fs.String("version", "", "release version (vX.Y.Z)")
48 dist := fs.String("dist", "", "dist/<version> dir holding bare agent binaries")
49 base := fs.String("base", "", "base URL artifacts are served from")
50 out := fs.String("out", "", "output path (default <dist>/manifest.json)")
51 if err := fs.Parse(args); err != nil {
52 return err
53 }
54 if *ver == "" || *dist == "" || *base == "" {
55 return fmt.Errorf("-version, -dist, and -base are required")
56 }
57 m, err := site.BuildManifest(*ver, *dist, *base)
58 if err != nil {
59 return err
60 }
61 raw, err := json.MarshalIndent(m, "", " ")
62 if err != nil {
63 return err
64 }
65 path := *out
66 if path == "" {
67 path = *dist + "/manifest.json"
68 }
69 return os.WriteFile(path, append(raw, '\n'), 0o644)
70 }
docs/README.md
Old New
@@ -17,8 +17,9 @@ By what you're trying to do:
17 and why expiry is never an emergency 17 and why expiry is never an emergency
18 - [credential-revocation.md](credential-revocation.md) — leaked host 18 - [credential-revocation.md](credential-revocation.md) — leaked host
19 credentials, leaked SSH certs, and the disaster levers 19 credentials, leaked SSH certs, and the disaster levers
20 - Quickstart and upgrade guide are planned for v0.0.1 — see 20 - [upgrade.md](upgrade.md) — upgrading agents from the console, the server,
21 [ROADMAP.md](../ROADMAP.md) 21 and cloud-hypervisor
22 - Quickstart is planned for v0.0.1 — see [ROADMAP.md](../ROADMAP.md)
22 23
23 **Use a fleet** 24 **Use a fleet**
24 25
docs/credential-revocation.md
Old New
@@ -44,7 +44,7 @@ mechanism.
44 44
45 Guest SSH access uses short-lived certificates self-signed with a tenant's own 45 Guest SSH access uses short-lived certificates self-signed with a tenant's own
46 user CA (see [ssh-access.md](ssh-access.md)) — eitri holds no user signing key. 46 user CA (see [ssh-access.md](ssh-access.md)) — eitri holds no user signing key.
47 The short validity you sign with (`hack/eitri-ssh` uses 30 minutes) is the 47 The short validity you sign with (`eitri-ssh` uses 30 minutes) is the
48 first line of defense: a leaked cert expires on its own. 48 first line of defense: a leaked cert expires on its own.
49 49
50 Before it does, a specific cert can be revoked at the gate by serial 50 Before it does, a specific cert can be revoked at the gate by serial
docs/faq.md
Old New
@@ -0,0 +1,13 @@
1 # FAQ
2
3 ## How do VMs get network access?
4
5 Outbound works out of the box: guests are NAT'd through their host and reach
6 the internet like any process on it. Inbound is deliberately minimal: guests
7 live on private per-host bridges, and the only built-in way in is the SSH
8 [jump gate](ssh-access.md).
9
10 To serve traffic from a VM, or to reach one directly from another network,
11 install [Tailscale](https://tailscale.com) (or WireGuard) inside it — it is
12 a normal Linux machine. A public gateway is on the
13 [roadmap](../ROADMAP.md).
docs/quickstart.md
Old New
@@ -0,0 +1,140 @@
1 # Quickstart
2
3 *From nothing to a VM you can SSH into*
4
5 eitri is three pieces: a server, an agent on every box that runs VMs, and
6 your laptop. `192.0.2.10` is the server below. Substitute yours.
7
8 ## What you need
9
10 Every VM host needs KVM (`ls -l /dev/kvm`), `qemu-img` (Debian/Ubuntu:
11 `qemu-utils`, Fedora: `qemu-img`), cloud-hypervisor, and the guest firmware:
12
13 ```sh
14 sudo install -m 0755 cloud-hypervisor /usr/local/bin/cloud-hypervisor
15 sudo install -D -m 0644 <(curl -fsSL https://eitri.sh/dl/firmware/CLOUDHV.fd) \
16 /usr/share/eitri/CLOUDHV.fd
17 ```
18
19 cloud-hypervisor is a static binary from the
20 [upstream releases](https://github.com/cloud-hypervisor/cloud-hypervisor/releases).
21
22 Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle
23 (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and
24 the agent's systemd unit. The client bundle (`eitri-ssh_<version>.tar.gz`)
25 has `eitri-ssh` and `eitri-ca` for your laptop. arm64 boxes take the arm64
26 bundle.
27
28 ## The server
29
30 ```sh
31 tar xzf eitri_*_linux_amd64.tar.gz && cd eitri_*_linux_amd64
32 sudo install -m 0755 eitri-server /usr/local/bin/eitri-server
33 sudo mkdir -p /etc/eitri /var/lib/eitri
34 ```
35
36 Set `SERVER_ADDR`, paste the rest:
37
38 ```sh
39 SERVER_ADDR=192.0.2.10
40 IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current
41 IMAGE_FILE=resolute-server-cloudimg-amd64.img
42 ADMIN_TOKEN=$(openssl rand -hex 32)
43 HOST_SECRET=$(openssl rand -hex 32)
44 IMAGE_SHA256=$(curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="$IMAGE_FILE" '$2 == "*" f {print $1}')
45
46 sudo tee /etc/eitri/server.json >/dev/null <<EOF
47 {
48 "http_listen": ":8080",
49 "quic_listen": ":8443",
50 "advertise_http": "http://$SERVER_ADDR:8080",
51 "advertise_quic": "$SERVER_ADDR:8443",
52 "db_path": "/var/lib/eitri/eitri.db",
53 "cidr_pool": "10.100.0.0/16",
54 "admin_token": "$ADMIN_TOKEN",
55 "host_secret": "$HOST_SECRET",
56 "default_image_url": "$IMAGE_DIR/$IMAGE_FILE",
57 "default_image_sha256": "$IMAGE_SHA256",
58 "ssh_listen": ":2222",
59 "ssh_gate_domain": "$SERVER_ADDR",
60 "ssh_ca_key": "/var/lib/eitri/ssh_ca",
61 "ssh_host_key": "/var/lib/eitri/ssh_host_key"
62 }
63 EOF
64
65 echo "console login token: $ADMIN_TOKEN" # keep this
66 ```
67
68 `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any
69 cloud-init disk image works as the default image; the Ubuntu one boots out of
70 the box.
71
72 Run it:
73
74 ```sh
75 sudo eitri-server --config /etc/eitri/server.json
76 ```
77
78 It runs in the foreground. nohup, tmux, or write a unit. It speaks plain
79 HTTP, so keep it on your LAN or put TLS in front. Open `8080/tcp` (console,
80 enroll), `8443/udp` (sync), `2222/tcp` (SSH gate).
81
82 Log in at `http://192.0.2.10:8080` with the token echoed above.
83
84 ## Join a host
85
86 Once per box that runs VMs. The server's box counts.
87
88 **+ Add host** in the console prints a one-shot join command. On the box,
89 from the unpacked host bundle:
90
91 ```sh
92 sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent
93 sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service
94 sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console>
95 sudo systemctl daemon-reload
96 sudo systemctl enable --now eitri-agent
97 ```
98
99 The host goes **online** in the console. Logs:
100 `journalctl -u eitri-agent -f`.
101
102 ## Boot a VM
103
104 VMs trust your SSH CA from birth, so register one first. eitri gets the
105 public key, never the private one. On your laptop:
106
107 ```sh
108 tar xzf eitri-ssh_*.tar.gz
109 sudo install -m 0755 eitri-ssh_*/eitri-ssh eitri-ssh_*/eitri-ca /usr/local/bin/
110
111 export EITRI_URL=http://192.0.2.10:8080
112 export EITRI_TOKEN=<console-login-token>
113
114 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
115 eitri-ca upload default ~/.ssh/eitri_user_ca.pub
116 ```
117
118 **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the
119 default image. Status reads `creating` while the image downloads and the
120 guest boots, then `ready`. Power reads `running`, an IP appears, you're on.
121
122 ## SSH in
123
124 ```sh
125 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain
126
127 eitri-ssh <vm-name>
128 eitri-ssh <vm-name> uptime
129 ```
130
131 `eitri-ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with
132 your CA, pins eitri's host CA, and jumps the gate to
133 `ubuntu@default.<vm-name>`. No token. [ssh-access.md](ssh-access.md) shows it
134 done by hand.
135
136 ## More
137
138 - [ssh-access.md](ssh-access.md): the jump gate and the BYO-CA model
139 - [upgrade.md](upgrade.md): upgrading agents, the server, cloud-hypervisor
140 - [credential-revocation.md](credential-revocation.md): when something leaks
docs/shape.html
Old New
@@ -121,6 +121,15 @@
121 ] 121 ]
122 }, 122 },
123 { 123 {
124 "importPath": "cmd/eitri-site",
125 "plane": "binaries",
126 "synopsis": "Command eitri-site generates the eitri.sh static site (default) or the release manifest (the \"manifest\" subcommand).",
127 "imports": [
128 "internal/site",
129 "internal/version"
130 ]
131 },
132 {
124 "importPath": "cmd/eitri-smoke", 133 "importPath": "cmd/eitri-smoke",
125 "plane": "binaries", 134 "plane": "binaries",
126 "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", 135 "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.",
@@ -424,6 +433,14 @@
424 "imports": [] 433 "imports": []
425 }, 434 },
426 { 435 {
436 "importPath": "internal/site",
437 "plane": "tooling",
438 "synopsis": "Package site generates the eitri.sh static site: docs/*.md and a markdown landing page rendered through one HTML template, plus the downloads page and the agent-upgrade release manifest.",
439 "imports": [
440 "internal/server/release"
441 ]
442 },
443 {
427 "importPath": "internal/transport", 444 "importPath": "internal/transport",
428 "plane": "wire", 445 "plane": "wire",
429 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", 446 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
docs/shape.json
Old New
@@ -70,6 +70,15 @@
70 ] 70 ]
71 }, 71 },
72 { 72 {
73 "importPath": "cmd/eitri-site",
74 "plane": "binaries",
75 "synopsis": "Command eitri-site generates the eitri.sh static site (default) or the release manifest (the \"manifest\" subcommand).",
76 "imports": [
77 "internal/site",
78 "internal/version"
79 ]
80 },
81 {
73 "importPath": "cmd/eitri-smoke", 82 "importPath": "cmd/eitri-smoke",
74 "plane": "binaries", 83 "plane": "binaries",
75 "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", 84 "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.",
@@ -373,6 +382,14 @@
373 "imports": [] 382 "imports": []
374 }, 383 },
375 { 384 {
385 "importPath": "internal/site",
386 "plane": "tooling",
387 "synopsis": "Package site generates the eitri.sh static site: docs/*.md and a markdown landing page rendered through one HTML template, plus the downloads page and the agent-upgrade release manifest.",
388 "imports": [
389 "internal/server/release"
390 ]
391 },
392 {
376 "importPath": "internal/transport", 393 "importPath": "internal/transport",
377 "plane": "wire", 394 "plane": "wire",
378 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", 395 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
docs/ssh-access.md
Old New
@@ -15,8 +15,8 @@ host-key-changed warnings when VM names or IPs are recycled.
15 15
16 Two CAs, two directions: **your tenant's user CA** (private key on your machine) 16 Two CAs, two directions: **your tenant's user CA** (private key on your machine)
17 signs what you present; **eitri's host CA** (private key on the server) signs 17 signs what you present; **eitri's host CA** (private key on the server) signs
18 what the gate and VMs present. See [decisions.md](decisions.md) for why eitri 18 what the gate and VMs present. eitri deliberately holds no user signing key —
19 holds no user signing key. 19 a server compromise cannot mint user credentials.
20 20
21 ## Bring your own CA (once per tenant) 21 ## Bring your own CA (once per tenant)
22 22
@@ -28,7 +28,7 @@ ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA"
28 28
29 export EITRI_URL=https://eitri.example.com 29 export EITRI_URL=https://eitri.example.com
30 export EITRI_TOKEN=<admin-bearer-token> 30 export EITRI_TOKEN=<admin-bearer-token>
31 hack/eitri-ca upload default ~/.ssh/eitri_user_ca.pub 31 eitri-ca upload default ~/.ssh/eitri_user_ca.pub
32 ``` 32 ```
33 33
34 The CA's private key never leaves your machine; the server stores only the 34 The CA's private key never leaves your machine; the server stores only the
@@ -42,12 +42,12 @@ tenant the signing CA was uploaded to.
42 export EITRI_URL=https://eitri.example.com 42 export EITRI_URL=https://eitri.example.com
43 export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address 43 export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address
44 44
45 hack/eitri-ssh <vm-name> # opens a shell on the VM 45 eitri-ssh <vm-name> # opens a shell on the VM
46 hack/eitri-ssh <vm-name> uptime # runs a command and exits 46 eitri-ssh <vm-name> uptime # runs a command and exits
47 ``` 47 ```
48 48
49 You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect 49 You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect
50 name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `hack/eitri-ssh` 50 name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `eitri-ssh`
51 builds it from `EITRI_TENANT`. 51 builds it from `EITRI_TENANT`.
52 52
53 Environment variables: 53 Environment variables:
@@ -101,7 +101,7 @@ The helper is a thin wrapper over three steps you can run by hand:
101 ## Certs are short-lived 101 ## Certs are short-lived
102 102
103 Self-signed certs should carry a short validity (`-V +30m` above). When one 103 Self-signed certs should carry a short validity (`-V +30m` above). When one
104 expires, ssh is simply rejected — re-run `hack/eitri-ssh` (or the signing step) 104 expires, ssh is simply rejected — re-run `eitri-ssh` (or the signing step)
105 to refresh. A specific cert can also be revoked at the gate by serial before it 105 to refresh. A specific cert can also be revoked at the gate by serial before it
106 expires; see [credential-revocation.md](credential-revocation.md). 106 expires; see [credential-revocation.md](credential-revocation.md).
107 107
@@ -139,7 +139,7 @@ The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it),
139 and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the 139 and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the
140 inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by 140 inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by
141 CA, recycling a VM name or IP never produces a host-key-changed warning — the 141 CA, recycling a VM name or IP never produces a host-key-changed warning — the
142 new VM simply presents a fresh CA-signed cert for that name. `hack/eitri-ssh` 142 new VM simply presents a fresh CA-signed cert for that name. `eitri-ssh`
143 does all of this for you. 143 does all of this for you.
144 144
145 ## Related 145 ## Related
docs/upgrade.md
Old New
@@ -0,0 +1,62 @@
1 # Upgrading
2
3 Three things version independently: the per-host agent, the server, and
4 cloud-hypervisor. None of them touch running VMs.
5
6 ## Agents, from the console
7
8 The fleet overview shows each host's agent version. When the server knows a
9 newer release (it polls `https://eitri.sh/dl/latest/manifest.json` daily), an
10 `↑` button appears next to hosts that are behind. Clicking it tells that one
11 agent to upgrade itself:
12
13 1. The agent downloads the new binary from eitri.sh and verifies its sha256
14 against the release manifest.
15 2. It swaps the binary in place — the old one is kept next to it as
16 `eitri-agent.prev` — and re-execs. The process keeps its PID; running VMs
17 are untouched and stay under the agent's care throughout.
18 3. The host reports its new version on the next sync, and the button
19 disappears.
20
21 If a download fails, nothing is swapped; click again to retry. To roll back by
22 hand, stop the agent, move `eitri-agent.prev` back over the binary, and start
23 it again.
24
25 **Requirements.** The button lights up only when the running agent reports a
26 release version (`vX.Y.Z`) — agents built from an untagged or dirty tree
27 report a git hash instead and are never offered upgrades. The server needs
28 `release_manifest_url` reachable; set it to `""` in the server config to
29 disable upgrade checks entirely.
30
31 ## The server
32
33 The console banner links here when the published release differs from the
34 running server's version. Server upgrades are manual and downtime is fine — agents keep
35 reconciling and VMs keep running while it's away:
36
37 1. Stop `eitri-server`.
38 2. Replace the binary with the new release.
39 3. Start it. The schema is applied idempotently on boot (existing tables are
40 left as-is), and agents reconnect on their own.
41
42 A newer server with older agents is safe: new fields in the sync protocol are
43 simply ignored by agents that predate them.
44
45 ## cloud-hypervisor
46
47 Swap `/usr/local/bin/cloud-hypervisor` on the host. New and restarted VMs use
48 the new binary; running VMs keep their old process until they stop. There is
49 no live handover for running guests.
50
51 ## Operations
52
53 The agent runs under systemd (`eitri-agent.service`). The unit sets
54 `KillMode=process` — that line is load-bearing: the default would kill every
55 cloud-hypervisor guest in the unit's cgroup whenever the agent stops. Logs:
56 `journalctl -u eitri-agent`.
57
58 ## Related
59
60 - [cert-rotation.md](cert-rotation.md) — rotating the server's QUIC identity
61 - [credential-revocation.md](credential-revocation.md) — the disaster levers
62 when credentials leak
go.mod
Old New
@@ -10,6 +10,7 @@ require (
10 github.com/pkg/sftp v1.13.11 10 github.com/pkg/sftp v1.13.11
11 github.com/quic-go/quic-go v0.48.2 11 github.com/quic-go/quic-go v0.48.2
12 github.com/stretchr/testify v1.11.1 12 github.com/stretchr/testify v1.11.1
13 github.com/yuin/goldmark v1.8.4
13 golang.org/x/crypto v0.54.0 14 golang.org/x/crypto v0.54.0
14 golang.org/x/sync v0.20.0 15 golang.org/x/sync v0.20.0
15 google.golang.org/protobuf v1.36.11 16 google.golang.org/protobuf v1.36.11
go.sum
Old New
@@ -85,6 +85,8 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
85 github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 85 github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
86 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 86 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
87 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 87 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
88 github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
89 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
88 go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= 90 go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
89 go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 91 go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
90 golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= 92 golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
internal/shape/classify.go
Old New
@@ -43,6 +43,7 @@ func classify(rel string) Plane {
43 strings.HasPrefix(rel, "internal/mcpserver"), 43 strings.HasPrefix(rel, "internal/mcpserver"),
44 strings.HasPrefix(rel, "internal/covsnap"), 44 strings.HasPrefix(rel, "internal/covsnap"),
45 strings.HasPrefix(rel, "internal/gateclient"), 45 strings.HasPrefix(rel, "internal/gateclient"),
46 strings.HasPrefix(rel, "internal/site"),
46 strings.HasPrefix(rel, "internal/shape"): 47 strings.HasPrefix(rel, "internal/shape"):
47 return PlaneTooling 48 return PlaneTooling
48 default: 49 default:
internal/site/dl.go
Old New
@@ -0,0 +1,88 @@
1 package site
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "sort"
8 "strings"
9 )
10
11 // downloadsMarkdown renders the /dl/ page body from a dist/<version> dir:
12 // every artifact linked at its stable /dl/<version>/ URL with size and
13 // sha256, plus the verification snippet. An empty distDir renders a
14 // docs-only preview note instead.
15 func downloadsMarkdown(distDir string) (string, error) {
16 if distDir == "" {
17 return "# downloads\n\nNo release is staged in this build. Release artifacts live under\n`/dl/<version>/`, with `/dl/latest/` pointing at the newest.\n\n" + apiSpecLine, nil
18 }
19 version := filepath.Base(distDir)
20 sums, err := parseSums(filepath.Join(distDir, "SHA256SUMS"))
21 if err != nil {
22 return "", fmt.Errorf("dist %s: %w", distDir, err)
23 }
24 entries, err := os.ReadDir(distDir)
25 if err != nil {
26 return "", err
27 }
28
29 var b strings.Builder
30 fmt.Fprintf(&b, "# downloads — %s\n\n", version)
31 b.WriteString("| file | size | sha256 |\n|---|---|---|\n")
32 names := make([]string, 0, len(entries))
33 for _, e := range entries {
34 if !e.IsDir() {
35 names = append(names, e.Name())
36 }
37 }
38 sort.Strings(names)
39 for _, name := range names {
40 info, err := os.Stat(filepath.Join(distDir, name))
41 if err != nil {
42 return "", err
43 }
44 // manifest.json and SHA256SUMS itself carry no checksum entry; an
45 // empty code span would render as literal backticks.
46 sha := "—"
47 if s := sums[name]; s != "" {
48 sha = "`" + s + "`"
49 }
50 fmt.Fprintf(&b, "| [%s](/dl/%s/%s) | %s | %s |\n",
51 name, version, name, humanSize(info.Size()), sha)
52 }
53 fmt.Fprintf(&b, "\nVerify after downloading (checksums: [SHA256SUMS](/dl/%s/SHA256SUMS)):\n\n", version)
54 b.WriteString(" sha256sum -c SHA256SUMS --ignore-missing\n")
55 b.WriteString("\n" + apiSpecLine)
56 return b.String(), nil
57 }
58
59 // apiSpecLine links the served API contract from the downloads page; the spec
60 // publishes at the site root in every build, dist or not.
61 const apiSpecLine = "[openapi.json](/openapi.json) — the server HTTP API, OpenAPI 3.1\n"
62
63 // parseSums reads sha256sum output: "<hex> <name>" per line.
64 func parseSums(path string) (map[string]string, error) {
65 raw, err := os.ReadFile(path)
66 if err != nil {
67 return nil, err
68 }
69 sums := map[string]string{}
70 for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
71 fields := strings.Fields(line)
72 if len(fields) == 2 {
73 sums[strings.TrimPrefix(fields[1], "*")] = fields[0]
74 }
75 }
76 return sums, nil
77 }
78
79 func humanSize(n int64) string {
80 switch {
81 case n >= 1<<20:
82 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
83 case n >= 1<<10:
84 return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10))
85 default:
86 return fmt.Sprintf("%d B", n)
87 }
88 }
internal/site/dl_test.go
Old New
@@ -0,0 +1,92 @@
1 package site
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 )
9
10 func fixtureDist(t *testing.T) string {
11 t.Helper()
12 dist := filepath.Join(t.TempDir(), "v0.0.1")
13 if err := os.MkdirAll(dist, 0o755); err != nil {
14 t.Fatal(err)
15 }
16 files := map[string]string{
17 "eitri_v0.0.1_linux_amd64.tar.gz": strings.Repeat("x", 2048),
18 "eitri-agent_linux_amd64": "binary",
19 "SHA256SUMS": "abc123 eitri_v0.0.1_linux_amd64.tar.gz\ndef456 eitri-agent_linux_amd64\n",
20 }
21 for name, body := range files {
22 if err := os.WriteFile(filepath.Join(dist, name), []byte(body), 0o644); err != nil {
23 t.Fatal(err)
24 }
25 }
26 return dist
27 }
28
29 func TestDownloadsPageListsArtifacts(t *testing.T) {
30 md, err := downloadsMarkdown(fixtureDist(t))
31 if err != nil {
32 t.Fatal(err)
33 }
34 for _, want := range []string{
35 "[eitri_v0.0.1_linux_amd64.tar.gz](/dl/v0.0.1/eitri_v0.0.1_linux_amd64.tar.gz)",
36 "abc123",
37 "sha256sum -c SHA256SUMS",
38 "[SHA256SUMS](/dl/v0.0.1/SHA256SUMS)",
39 "[openapi.json](/openapi.json)",
40 } {
41 if !strings.Contains(md, want) {
42 t.Errorf("downloads page missing %q:\n%s", want, md)
43 }
44 }
45 }
46
47 func TestDownloadsPageWithoutDist(t *testing.T) {
48 md, err := downloadsMarkdown("")
49 if err != nil {
50 t.Fatal(err)
51 }
52 if !strings.Contains(md, "No release is staged") {
53 t.Errorf("preview note missing:\n%s", md)
54 }
55 if !strings.Contains(md, "[openapi.json](/openapi.json)") {
56 t.Errorf("API spec link missing from preview:\n%s", md)
57 }
58 }
59
60 func TestDownloadsPageRequiresSums(t *testing.T) {
61 dist := filepath.Join(t.TempDir(), "v0.0.1")
62 if err := os.MkdirAll(dist, 0o755); err != nil {
63 t.Fatal(err)
64 }
65 if _, err := downloadsMarkdown(dist); err == nil {
66 t.Fatal("want error for dist dir without SHA256SUMS")
67 }
68 }
69
70 func TestDownloadsPageUnsummedFilesGetPlaceholder(t *testing.T) {
71 dist := fixtureDist(t)
72 if err := os.WriteFile(filepath.Join(dist, "manifest.json"), []byte("{}"), 0o644); err != nil {
73 t.Fatal(err)
74 }
75 md, err := downloadsMarkdown(dist)
76 if err != nil {
77 t.Fatal(err)
78 }
79 if strings.Contains(md, "``") {
80 t.Errorf("empty sha rendered as bare backticks:\n%s", md)
81 }
82 if !strings.Contains(md, "| — |") {
83 t.Errorf("unsummed file missing placeholder:\n%s", md)
84 }
85 html, err := render([]byte(md), nil)
86 if err != nil {
87 t.Fatal(err)
88 }
89 if strings.Contains(string(html), "``") {
90 t.Errorf("stray backticks in rendered HTML:\n%s", html)
91 }
92 }
internal/site/manifest.go
Old New
@@ -0,0 +1,70 @@
1 package site
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7 "io"
8 "net/url"
9 "os"
10 "path/filepath"
11 "regexp"
12
13 "github.com/a73x/eitri/internal/server/release"
14 )
15
16 // barePat matches the bare agent binaries the release stage drops in dist/:
17 // eitri-agent_<os>_<arch>. These are what the agent self-updater downloads.
18 var barePat = regexp.MustCompile(`^eitri-agent_([a-z0-9]+)_([a-z0-9]+)$`)
19
20 // BuildManifest scans distDir for bare eitri-agent binaries and produces the
21 // release manifest the server polls. Sharing release.Manifest with the
22 // consumer is deliberate: the wire contract lives in one type.
23 func BuildManifest(version, distDir, baseURL string) (release.Manifest, error) {
24 if version == "" {
25 return release.Manifest{}, fmt.Errorf("version required")
26 }
27 m := release.Manifest{
28 Version: version,
29 Artifacts: map[string]map[string]release.Artifact{"eitri-agent": {}},
30 }
31 entries, err := os.ReadDir(distDir)
32 if err != nil {
33 return release.Manifest{}, err
34 }
35 for _, e := range entries {
36 match := barePat.FindStringSubmatch(e.Name())
37 if e.IsDir() || match == nil {
38 continue
39 }
40 sum, err := fileSHA256(filepath.Join(distDir, e.Name()))
41 if err != nil {
42 return release.Manifest{}, err
43 }
44 u, err := url.JoinPath(baseURL, e.Name())
45 if err != nil {
46 return release.Manifest{}, err
47 }
48 m.Artifacts["eitri-agent"][match[1]+"/"+match[2]] = release.Artifact{
49 URL: u,
50 SHA256: sum,
51 }
52 }
53 if len(m.Artifacts["eitri-agent"]) == 0 {
54 return release.Manifest{}, fmt.Errorf("no bare eitri-agent binaries in %s", distDir)
55 }
56 return m, nil
57 }
58
59 func fileSHA256(path string) (string, error) {
60 f, err := os.Open(path)
61 if err != nil {
62 return "", err
63 }
64 defer f.Close()
65 h := sha256.New()
66 if _, err := io.Copy(h, f); err != nil {
67 return "", err
68 }
69 return hex.EncodeToString(h.Sum(nil)), nil
70 }
internal/site/manifest_test.go
Old New
@@ -0,0 +1,86 @@
1 package site
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "os"
8 "path/filepath"
9 "testing"
10
11 "github.com/a73x/eitri/internal/server/release"
12 )
13
14 func TestBuildManifestFromBareBinaries(t *testing.T) {
15 dist := t.TempDir()
16 body := []byte("fake agent binary")
17 for _, name := range []string{"eitri-agent_linux_amd64", "eitri-agent_linux_arm64"} {
18 if err := os.WriteFile(filepath.Join(dist, name), body, 0o755); err != nil {
19 t.Fatal(err)
20 }
21 }
22 // Non-bare files must be ignored.
23 if err := os.WriteFile(filepath.Join(dist, "eitri_v0.0.1_linux_amd64.tar.gz"), body, 0o644); err != nil {
24 t.Fatal(err)
25 }
26
27 m, err := BuildManifest("v0.0.1", dist, "https://eitri.sh/dl/v0.0.1")
28 if err != nil {
29 t.Fatal(err)
30 }
31 if m.Version != "v0.0.1" {
32 t.Errorf("version = %q", m.Version)
33 }
34 agents := m.Artifacts["eitri-agent"]
35 if len(agents) != 2 {
36 t.Fatalf("want 2 platforms, got %v", agents)
37 }
38 sum := sha256.Sum256(body)
39 want := release.Artifact{
40 URL: "https://eitri.sh/dl/v0.0.1/eitri-agent_linux_amd64",
41 SHA256: hex.EncodeToString(sum[:]),
42 }
43 if agents["linux/amd64"] != want {
44 t.Errorf("linux/amd64 = %+v, want %+v", agents["linux/amd64"], want)
45 }
46
47 // Round-trip: what we emit is exactly what the server-side type parses.
48 raw, err := json.Marshal(m)
49 if err != nil {
50 t.Fatal(err)
51 }
52 var back release.Manifest
53 if err := json.Unmarshal(raw, &back); err != nil {
54 t.Fatal(err)
55 }
56 if back.Artifacts["eitri-agent"]["linux/arm64"].SHA256 != agents["linux/arm64"].SHA256 {
57 t.Error("round-trip through release.Manifest lost data")
58 }
59 }
60
61 func TestBuildManifestRequiresBinaries(t *testing.T) {
62 if _, err := BuildManifest("v0.0.1", t.TempDir(), "https://eitri.sh/dl/v0.0.1"); err == nil {
63 t.Fatal("want error when no bare eitri-agent binaries exist")
64 }
65 }
66
67 func TestBuildManifestRequiresVersion(t *testing.T) {
68 if _, err := BuildManifest("", t.TempDir(), "https://eitri.sh/dl/v0.0.1"); err == nil {
69 t.Fatal("want error for empty version")
70 }
71 }
72
73 func TestBuildManifestNormalizesBaseURL(t *testing.T) {
74 dist := t.TempDir()
75 if err := os.WriteFile(filepath.Join(dist, "eitri-agent_linux_amd64"), []byte("x"), 0o755); err != nil {
76 t.Fatal(err)
77 }
78 m, err := BuildManifest("v0.0.1", dist, "https://eitri.sh/dl/v0.0.1/")
79 if err != nil {
80 t.Fatal(err)
81 }
82 got := m.Artifacts["eitri-agent"]["linux/amd64"].URL
83 if got != "https://eitri.sh/dl/v0.0.1/eitri-agent_linux_amd64" {
84 t.Errorf("trailing-slash base not normalized: %q", got)
85 }
86 }
internal/site/render.go
Old New
@@ -0,0 +1,83 @@
1 // Package site generates the eitri.sh static site: docs/*.md and a markdown
2 // landing page rendered through one HTML template, plus the downloads page
3 // and the agent-upgrade release manifest. Inter-doc links are rewritten to
4 // site paths; a link to a page that does not exist fails the build, which is
5 // the drift gate between repo docs and the published site.
6 package site
7
8 import (
9 "bytes"
10 "errors"
11 "fmt"
12 "strings"
13
14 "github.com/yuin/goldmark"
15 "github.com/yuin/goldmark/ast"
16 "github.com/yuin/goldmark/extension"
17 "github.com/yuin/goldmark/parser"
18 ghtml "github.com/yuin/goldmark/renderer/html"
19 "github.com/yuin/goldmark/text"
20 "github.com/yuin/goldmark/util"
21 )
22
23 // linkRewriter rewrites relative markdown link destinations to their site
24 // paths via targets, collecting an error per destination that maps to no
25 // known page. External (scheme), site-absolute (/...), and fragment-only
26 // links pass through untouched.
27 type linkRewriter struct {
28 targets map[string]string
29 errs []error
30 }
31
32 func (r *linkRewriter) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
33 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
34 if !entering {
35 return ast.WalkContinue, nil
36 }
37 var dest *[]byte
38 switch v := n.(type) {
39 case *ast.Link:
40 dest = &v.Destination
41 case *ast.Image:
42 dest = &v.Destination
43 default:
44 return ast.WalkContinue, nil
45 }
46 d := string(*dest)
47 if d == "" || strings.Contains(d, "://") || strings.HasPrefix(d, "mailto:") ||
48 strings.HasPrefix(d, "/") || strings.HasPrefix(d, "#") {
49 return ast.WalkContinue, nil
50 }
51 path, frag, _ := strings.Cut(d, "#")
52 path = strings.TrimPrefix(path, "./")
53 u, ok := r.targets[path]
54 if !ok {
55 r.errs = append(r.errs, fmt.Errorf("link to unknown page %q", d))
56 return ast.WalkContinue, nil
57 }
58 if frag != "" {
59 u += "#" + frag
60 }
61 *dest = []byte(u)
62 return ast.WalkContinue, nil
63 })
64 }
65
66 // render converts markdown to HTML, rewriting internal links via targets.
67 // Any link to an unknown internal page is an error.
68 func render(src []byte, targets map[string]string) ([]byte, error) {
69 rw := &linkRewriter{targets: targets}
70 md := goldmark.New(
71 goldmark.WithExtensions(extension.GFM),
72 goldmark.WithParserOptions(parser.WithASTTransformers(util.Prioritized(rw, 100))),
73 goldmark.WithRendererOptions(ghtml.WithUnsafe()),
74 )
75 var buf bytes.Buffer
76 if err := md.Convert(src, &buf); err != nil {
77 return nil, err
78 }
79 if len(rw.errs) > 0 {
80 return nil, errors.Join(rw.errs...)
81 }
82 return buf.Bytes(), nil
83 }
internal/site/render_test.go
Old New
@@ -0,0 +1,97 @@
1 package site
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 var testTargets = map[string]string{
9 "README.md": "/docs/",
10 "ssh-access.md": "/docs/ssh-access/",
11 "../ROADMAP.md": "/docs/roadmap/",
12 "shape.html": "/docs/shape.html",
13 }
14
15 func TestRenderRewritesInternalLinks(t *testing.T) {
16 got, err := render([]byte("see [ssh](ssh-access.md) and [index](README.md)"), testTargets)
17 if err != nil {
18 t.Fatal(err)
19 }
20 html := string(got)
21 for _, want := range []string{`href="/docs/ssh-access/"`, `href="/docs/"`} {
22 if !strings.Contains(html, want) {
23 t.Errorf("output missing %s:\n%s", want, html)
24 }
25 }
26 }
27
28 func TestRenderPreservesFragments(t *testing.T) {
29 got, err := render([]byte("[a](ssh-access.md#gate)"), testTargets)
30 if err != nil {
31 t.Fatal(err)
32 }
33 if !strings.Contains(string(got), `href="/docs/ssh-access/#gate"`) {
34 t.Errorf("fragment lost:\n%s", got)
35 }
36 }
37
38 func TestRenderLeavesExternalAndAbsoluteAlone(t *testing.T) {
39 src := "[x](https://eitri.sh) [y](/dl/) [z](#local)"
40 got, err := render([]byte(src), testTargets)
41 if err != nil {
42 t.Fatal(err)
43 }
44 html := string(got)
45 for _, want := range []string{`href="https://eitri.sh"`, `href="/dl/"`, `href="#local"`} {
46 if !strings.Contains(html, want) {
47 t.Errorf("output missing %s:\n%s", want, html)
48 }
49 }
50 }
51
52 func TestRenderFailsOnUnknownInternalLink(t *testing.T) {
53 _, err := render([]byte("[gone](no-such-doc.md)"), testTargets)
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)
56 }
57 }
58
59 func TestRenderTables(t *testing.T) {
60 got, err := render([]byte("| a | b |\n|---|---|\n| 1 | 2 |"), testTargets)
61 if err != nil {
62 t.Fatal(err)
63 }
64 if !strings.Contains(string(got), "<table>") {
65 t.Errorf("GFM tables not rendered:\n%s", got)
66 }
67 }
68
69 func TestRenderRewritesParentRelativeLinks(t *testing.T) {
70 got, err := render([]byte("[r](../ROADMAP.md)"), testTargets)
71 if err != nil {
72 t.Fatal(err)
73 }
74 if !strings.Contains(string(got), `href="/docs/roadmap/"`) {
75 t.Errorf("parent-relative link not rewritten:\n%s", got)
76 }
77 }
78
79 func TestRenderNormalizesDotSlashPrefix(t *testing.T) {
80 got, err := render([]byte("[s](./ssh-access.md)"), testTargets)
81 if err != nil {
82 t.Fatal(err)
83 }
84 if !strings.Contains(string(got), `href="/docs/ssh-access/"`) {
85 t.Errorf("./-prefixed link not rewritten:\n%s", got)
86 }
87 }
88
89 func TestRenderRewritesImageDestinations(t *testing.T) {
90 got, err := render([]byte("![d](shape.html)"), testTargets)
91 if err != nil {
92 t.Fatal(err)
93 }
94 if !strings.Contains(string(got), `src="/docs/shape.html"`) {
95 t.Errorf("image destination not rewritten:\n%s", got)
96 }
97 }
internal/site/site.go
Old New
@@ -0,0 +1,159 @@
1 package site
2
3 import (
4 "fmt"
5 "html/template"
6 "os"
7 "path/filepath"
8 )
9
10 // pages is the published doc set: how-to material for people running or using
11 // a fleet. The rest of docs/ (architecture, ethos, decisions, the shape
12 // diagram, superpowers/) is for people working on eitri and never publishes.
13 // Build hard-fails if any listed doc is missing.
14 var pages = []string{
15 "quickstart",
16 "ssh-access",
17 "mcp",
18 "upgrade",
19 "cert-rotation",
20 "credential-revocation",
21 "faq",
22 }
23
24 // Config locates the generator's inputs and output.
25 type Config struct {
26 DocsDir string // repo docs/ — markdown sources for the pages list
27 SiteDir string // site/ — index.md, docs.md, template.html, style.css
28 DistDir string // optional dist/<version> with release artifacts; "" renders a docs-only preview
29 OutDir string // webroot to emit
30 }
31
32 type pageData struct {
33 Title string
34 Section string // "home" | "docs" | "dl" — which nav entry is active
35 Content template.HTML
36 }
37
38 // Build renders the whole site into cfg.OutDir.
39 func Build(cfg Config) error {
40 tmplSrc, err := os.ReadFile(filepath.Join(cfg.SiteDir, "template.html"))
41 if err != nil {
42 return err
43 }
44 tmpl, err := template.New("page").Parse(string(tmplSrc))
45 if err != nil {
46 return err
47 }
48
49 targets := linkTargets()
50
51 page := func(outPath string, d pageData) error {
52 if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
53 return err
54 }
55 f, err := os.Create(outPath)
56 if err != nil {
57 return err
58 }
59 if err := tmpl.Execute(f, d); err != nil {
60 f.Close()
61 return err
62 }
63 return f.Close()
64 }
65 renderFile := func(src string) (template.HTML, error) {
66 b, err := os.ReadFile(src)
67 if err != nil {
68 return "", err
69 }
70 h, err := render(b, targets)
71 if err != nil {
72 return "", fmt.Errorf("%s: %w", src, err)
73 }
74 return template.HTML(h), nil
75 }
76
77 // Landing.
78 content, err := renderFile(filepath.Join(cfg.SiteDir, "index.md"))
79 if err != nil {
80 return err
81 }
82 if err := page(filepath.Join(cfg.OutDir, "index.html"),
83 pageData{Title: "eitri", Section: "home", Content: content}); err != nil {
84 return err
85 }
86
87 // Docs index (site-owned: the published set is a curated subset of docs/,
88 // so the repo's own docs/README.md would dangle links here) + one page per
89 // published doc + the roadmap.
90 if content, err = renderFile(filepath.Join(cfg.SiteDir, "docs.md")); err != nil {
91 return err
92 }
93 if err := page(filepath.Join(cfg.OutDir, "docs", "index.html"),
94 pageData{Title: "eitri — docs", Section: "docs", Content: content}); err != nil {
95 return err
96 }
97 for _, slug := range pages {
98 if content, err = renderFile(filepath.Join(cfg.DocsDir, slug+".md")); err != nil {
99 return err
100 }
101 if err := page(filepath.Join(cfg.OutDir, "docs", slug, "index.html"),
102 pageData{Title: "eitri — " + slug, Section: "docs", Content: content}); err != nil {
103 return err
104 }
105 }
106 if content, err = renderFile(filepath.Join(cfg.DocsDir, "..", "ROADMAP.md")); err != nil {
107 return err
108 }
109 if err := page(filepath.Join(cfg.OutDir, "docs", "roadmap", "index.html"),
110 pageData{Title: "eitri — roadmap", Section: "docs", Content: content}); err != nil {
111 return err
112 }
113
114 // Downloads page.
115 dlMD, err := downloadsMarkdown(cfg.DistDir)
116 if err != nil {
117 return err
118 }
119 h, err := render([]byte(dlMD), targets)
120 if err != nil {
121 return err
122 }
123 if err := page(filepath.Join(cfg.OutDir, "dl", "index.html"),
124 pageData{Title: "eitri — downloads", Section: "dl", Content: template.HTML(h)}); err != nil {
125 return err
126 }
127
128 // The API contract publishes verbatim at the site root when the docs
129 // tree carries it (site-check enforces presence in the real tree;
130 // synthetic docs trees may omit it).
131 spec, err := os.ReadFile(filepath.Join(cfg.DocsDir, "openapi.json"))
132 switch {
133 case err == nil:
134 if err := os.WriteFile(filepath.Join(cfg.OutDir, "openapi.json"), spec, 0o644); err != nil {
135 return err
136 }
137 case !os.IsNotExist(err):
138 return err
139 }
140
141 // The one stylesheet.
142 css, err := os.ReadFile(filepath.Join(cfg.SiteDir, "style.css"))
143 if err != nil {
144 return err
145 }
146 return os.WriteFile(filepath.Join(cfg.OutDir, "style.css"), css, 0o644)
147 }
148
149 // linkTargets maps markdown link destinations (as written in the sources) to
150 // published site paths — the vocabulary the link rewriter validates against.
151 // A doc outside the published set is deliberately absent: linking it from a
152 // published page is a build failure, not a dangling link.
153 func linkTargets() map[string]string {
154 t := map[string]string{"../ROADMAP.md": "/docs/roadmap/"}
155 for _, s := range pages {
156 t[s+".md"] = "/docs/" + s + "/"
157 }
158 return t
159 }
internal/site/site_test.go
Old New
@@ -0,0 +1,159 @@
1 package site
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 )
9
10 // writeFixture lays down a minimal docs/ + site/ tree covering every
11 // published page, plus repo-internal material that must never publish.
12 // Returns (root, docs, siteDir).
13 func writeFixture(t *testing.T) (string, string, string) {
14 t.Helper()
15 root := t.TempDir()
16 docs := filepath.Join(root, "docs")
17 siteDir := filepath.Join(root, "site")
18 for _, d := range []string{docs, siteDir, filepath.Join(docs, "superpowers")} {
19 if err := os.MkdirAll(d, 0o755); err != nil {
20 t.Fatal(err)
21 }
22 }
23 write := func(path, body string) {
24 t.Helper()
25 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
26 t.Fatal(err)
27 }
28 }
29 for _, slug := range pages {
30 write(filepath.Join(docs, slug+".md"), "# "+slug+"\n\nbody\n")
31 }
32 // Repo-internal docs sitting alongside the published set.
33 write(filepath.Join(docs, "architecture.md"), "# internal\n")
34 write(filepath.Join(docs, "shape.html"), "<html>internal</html>")
35 write(filepath.Join(docs, "superpowers", "secret.md"), "# nope\n")
36 write(filepath.Join(root, "ROADMAP.md"), "# roadmap\n")
37 write(filepath.Join(siteDir, "index.md"), "# eitri\n\n[q](quickstart.md)\n")
38 write(filepath.Join(siteDir, "docs.md"),
39 "# docs\n\n- [q](quickstart.md)\n- [r](../ROADMAP.md)\n")
40 write(filepath.Join(siteDir, "template.html"),
41 `<title>{{.Title}}</title><nav data-s="{{.Section}}"></nav>{{.Content}}`)
42 write(filepath.Join(siteDir, "style.css"), "body{}")
43 return root, docs, siteDir
44 }
45
46 // buildTree runs Build over the fixture and returns the output dir.
47 func buildTree(t *testing.T, distDir string) string {
48 t.Helper()
49 root, docs, siteDir := writeFixture(t)
50 out := filepath.Join(root, "out")
51 if err := Build(Config{DocsDir: docs, SiteDir: siteDir, DistDir: distDir, OutDir: out}); err != nil {
52 t.Fatal(err)
53 }
54 return out
55 }
56
57 func read(t *testing.T, path string) string {
58 t.Helper()
59 b, err := os.ReadFile(path)
60 if err != nil {
61 t.Fatal(err)
62 }
63 return string(b)
64 }
65
66 func TestBuildEmitsPublishedTree(t *testing.T) {
67 out := buildTree(t, "")
68 want := []string{"index.html", "style.css", "docs/index.html", "docs/roadmap/index.html", "dl/index.html"}
69 for _, slug := range pages {
70 want = append(want, "docs/"+slug+"/index.html")
71 }
72 for _, p := range want {
73 if _, err := os.Stat(filepath.Join(out, p)); err != nil {
74 t.Errorf("missing %s: %v", p, err)
75 }
76 }
77 }
78
79 func TestBuildNeverPublishesInternalDocs(t *testing.T) {
80 out := buildTree(t, "")
81 for _, p := range []string{
82 "docs/architecture", "docs/architecture/index.html",
83 "docs/shape.html",
84 "docs/superpowers", "docs/secret",
85 } {
86 if _, err := os.Stat(filepath.Join(out, p)); !os.IsNotExist(err) {
87 t.Errorf("%s must not be published (stat err = %v)", p, err)
88 }
89 }
90 }
91
92 func TestBuildRewritesDocLinks(t *testing.T) {
93 out := buildTree(t, "")
94 idx := read(t, filepath.Join(out, "docs", "index.html"))
95 for _, want := range []string{`href="/docs/quickstart/"`, `href="/docs/roadmap/"`} {
96 if !strings.Contains(idx, want) {
97 t.Errorf("docs index missing %s:\n%s", want, idx)
98 }
99 }
100 }
101
102 func TestBuildSectionsAndTitles(t *testing.T) {
103 out := buildTree(t, "")
104 if got := read(t, filepath.Join(out, "index.html")); !strings.Contains(got, `data-s="home"`) {
105 t.Errorf("landing section wrong:\n%s", got)
106 }
107 qs := read(t, filepath.Join(out, "docs", "quickstart", "index.html"))
108 if !strings.Contains(qs, `data-s="docs"`) || !strings.Contains(qs, "<title>eitri — quickstart</title>") {
109 t.Errorf("doc page section/title wrong:\n%s", qs)
110 }
111 }
112
113 func TestBuildPublishesAPISpec(t *testing.T) {
114 root, docs, siteDir := writeFixture(t)
115 spec := `{"openapi":"3.1.0"}`
116 if err := os.WriteFile(filepath.Join(docs, "openapi.json"), []byte(spec), 0o644); err != nil {
117 t.Fatal(err)
118 }
119 out := filepath.Join(root, "out")
120 if err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: out}); err != nil {
121 t.Fatal(err)
122 }
123 if got := read(t, filepath.Join(out, "openapi.json")); got != spec {
124 t.Errorf("published spec differs from source: got %q, want %q", got, spec)
125 }
126 }
127
128 func TestBuildWithoutAPISpec(t *testing.T) {
129 // Synthetic docs trees without a spec build fine — they just publish none.
130 out := buildTree(t, "")
131 if _, err := os.Stat(filepath.Join(out, "openapi.json")); !os.IsNotExist(err) {
132 t.Errorf("openapi.json must not be published without a source (stat err = %v)", err)
133 }
134 }
135
136 func TestBuildFailsOnLinkToUnpublishedDoc(t *testing.T) {
137 root, docs, siteDir := writeFixture(t)
138 // A published doc linking a repo-internal doc must fail the build, not
139 // ship a dangling link.
140 if err := os.WriteFile(filepath.Join(docs, "ssh-access.md"),
141 []byte("[why](architecture.md)\n"), 0o644); err != nil {
142 t.Fatal(err)
143 }
144 err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: filepath.Join(root, "out")})
145 if err == nil || !strings.Contains(err.Error(), "architecture.md") {
146 t.Fatalf("want unpublished-link failure naming architecture.md, got %v", err)
147 }
148 }
149
150 func TestBuildFailsOnMissingPublishedDoc(t *testing.T) {
151 root, docs, siteDir := writeFixture(t)
152 if err := os.Remove(filepath.Join(docs, "quickstart.md")); err != nil {
153 t.Fatal(err)
154 }
155 err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: filepath.Join(root, "out")})
156 if err == nil {
157 t.Fatal("want failure when a published doc is missing")
158 }
159 }
scripts/coverage.sh
Old New
@@ -37,6 +37,7 @@ declare -A FLOOR=(
37 [internal/server/web]=90 37 [internal/server/web]=90
38 [internal/transport]=77 38 [internal/transport]=77
39 [internal/shape]=88 39 [internal/shape]=88
40 [internal/site]=80
40 ) 41 )
41 42
42 profile="$(mktemp)" 43 profile="$(mktemp)"
scripts/deploy.env.example
Old New
@@ -61,3 +61,14 @@ FIRMWARE="/usr/share/eitri/CLOUDHV.fd"
61 # Host resource caps live here (0/unset = offer the whole machine): reserve 61 # Host resource caps live here (0/unset = offer the whole machine): reserve
62 # headroom by capping the CPU/mem/disk the agent advertises AND enforces. 62 # headroom by capping the CPU/mem/disk the agent advertises AND enforces.
63 # AGENT_EXTRA_FLAGS="--max-vcpus 8 --max-mem-mb 16384 --max-disk-gb 200" 63 # AGENT_EXTRA_FLAGS="--max-vcpus 8 --max-mem-mb 16384 --max-disk-gb 200"
64
65 # ── eitri.sh site image (make site-image) ─────────────────────────────────────
66 # Registry/repo for the static-site image; tagged with the release version.
67 # Required for `make site-image` (everything else in this file ignores it).
68 # SITE_IMAGE="registry.example.com/eitri-site"
69 # Image platform for the site image; set to your cluster's node arch.
70 # SITE_PLATFORM="linux/arm64"
71 # Guest firmware ships inside dist/<version> (manifested, sha-pinned) rather
72 # than staged by the site image. `make release` reads FIRMWARE_SRC — a local
73 # CLOUDHV.fd to mirror in — defaulting to $HOME/.cache/eitri/CLOUDHV.fd if
74 # present; see scripts/release.sh.
scripts/release.sh
Old New
@@ -0,0 +1,73 @@
1 #!/usr/bin/env bash
2 # Cross-compiled release artifacts for eitri.sh, into dist/<version>/:
3 # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit
4 # eitri-ssh_<v>.tar.gz client bundle: eitri-ssh + eitri-ca (portable bash)
5 # eitri-agent_linux_{amd64,arm64} bare binaries — what the agent
6 # self-updater downloads and sha-verifies
7 # SHA256SUMS over everything above
8 # manifest.json agent-upgrade manifest (shared type
9 # with internal/server/release)
10 #
11 # MANIFEST_BASE (env, optional) overrides the URL base written into
12 # manifest.json — default https://eitri.sh/dl/<version>; set it for staging.
13 #
14 # Releases come from clean tagged trees: a -dirty version refuses outright
15 # (the agent-upgrade path compares versions numerically, so an unparsable
16 # version silently disables the upgrade button fleet-wide); an untagged HEAD
17 # warns but proceeds, for staging runs.
18 set -euo pipefail
19 cd "$(dirname "$0")/.."
20
21 VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
22 case "$VERSION" in
23 *-dirty|dev)
24 echo "release: refusing to build from a dirty/untracked tree ($VERSION)" >&2
25 exit 1 ;;
26 esac
27 if ! git describe --tags --exact-match >/dev/null 2>&1; then
28 echo "release: WARNING — HEAD is not a tag ($VERSION); agents never upgrade to unparsable versions" >&2
29 fi
30
31 [ -x hack/eitri-ssh ] && [ -x hack/eitri-ca ] || {
32 echo "release: hack/eitri-ssh or hack/eitri-ca missing/not executable (client bundle)" >&2
33 exit 1
34 }
35
36 LDFLAGS="-X github.com/a73x/eitri/internal/version.Version=$VERSION"
37 OUT="dist/$VERSION"
38 rm -rf "$OUT"
39 mkdir -p "$OUT"
40
41 STAGE_ROOT="$(mktemp -d)"
42 trap 'rm -rf "$STAGE_ROOT"' EXIT
43
44 for arch in amd64 arm64; do
45 bundle="eitri_${VERSION}_linux_${arch}"
46 stage="$STAGE_ROOT/$arch"
47 mkdir -p "$stage/$bundle"
48 echo "==> building linux/$arch"
49 CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \
50 -o "$stage/$bundle/eitri-server" ./cmd/eitri-server
51 CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \
52 -o "$stage/$bundle/eitri-agent" ./cmd/eitri-agent
53 cp scripts/eitri-agent.service "$stage/$bundle/"
54 tar -C "$stage" -czf "$OUT/$bundle.tar.gz" "$bundle"
55 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}"
56 done
57
58 stage="$STAGE_ROOT/ssh"
59 mkdir -p "$stage/eitri-ssh_$VERSION"
60 cp hack/eitri-ssh hack/eitri-ca "$stage/eitri-ssh_$VERSION/"
61 tar -C "$stage" -czf "$OUT/eitri-ssh_$VERSION.tar.gz" "eitri-ssh_$VERSION"
62
63 # The API contract rides in the release too (drift-gated in ci, so the
64 # committed copy is authoritative).
65 cp docs/openapi.json "$OUT/openapi.json"
66
67 (cd "$OUT" && LC_ALL=C sha256sum -- * > SHA256SUMS)
68
69 go run ./cmd/eitri-site manifest -version "$VERSION" -dist "$OUT" \
70 -base "${MANIFEST_BASE:-https://eitri.sh/dl/$VERSION}"
71
72 echo "==> release staged in $OUT"
73 ls -lh "$OUT"
scripts/site-image.sh
Old New
@@ -0,0 +1,33 @@
1 #!/usr/bin/env bash
2 # Assemble the eitri.sh webroot and build/push the site image.
3 #
4 # Reads deploy.env (same file scripts/deploy.sh uses):
5 # SITE_IMAGE registry/repo to push, e.g. registry.example/eitri-site (required)
6 # SITE_PLATFORM image platform, e.g. linux/arm64 (optional; the Dockerfile
7 # is COPY-only, so cross-building needs no emulation)
8 #
9 # Expects `make release` (dist/<version>) and `make site` (site/dist) to have
10 # run — the Makefile's site-image target orders all three. Firmware and the
11 # pinned cloud-hypervisor ship inside dist/<version> itself (see
12 # scripts/release.sh), so there is nothing extra to stage here.
13 set -euo pipefail
14 cd "$(dirname "$0")/.."
15
16 ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}"
17 # shellcheck disable=SC1090
18 [ -f "$ENV_FILE" ] && . "$ENV_FILE"
19 : "${SITE_IMAGE:?site-image: set SITE_IMAGE in $ENV_FILE}"
20
21 VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
22 [ -d "dist/$VERSION" ] || { echo "site-image: dist/$VERSION missing — run make release" >&2; exit 1; }
23 [ -f site/dist/index.html ] || { echo "site-image: site/dist missing — run make site" >&2; exit 1; }
24
25 # Stage /dl: versioned artifacts + the latest symlink.
26 rm -rf site/dist/dl/v* site/dist/dl/latest
27 mkdir -p site/dist/dl
28 cp -r "dist/$VERSION" "site/dist/dl/$VERSION"
29 ln -sfn "$VERSION" site/dist/dl/latest
30
31 docker build ${SITE_PLATFORM:+--platform "$SITE_PLATFORM"} -f site/Dockerfile -t "$SITE_IMAGE:$VERSION" .
32 docker push "$SITE_IMAGE:$VERSION"
33 echo "site-image: pushed $SITE_IMAGE:$VERSION — roll it out on k8s manually"
site/Dockerfile
Old New
@@ -0,0 +1,3 @@
1 FROM docker.io/library/nginx:alpine
2 COPY site/nginx.conf /etc/nginx/conf.d/default.conf
3 COPY site/dist/ /usr/share/nginx/html/
site/docs.md
Old New
@@ -0,0 +1,29 @@
1 # docs
2
3 **Get started**
4
5 - [quickstart](quickstart.md) — zero to a first VM on one host, then a
6 second host
7
8 **Use a fleet**
9
10 - [ssh access](ssh-access.md) — reaching a VM through the jump gate with
11 your own keys
12 - [mcp](mcp.md) — let an AI agent create and drive VMs
13
14 **Run a fleet**
15
16 - [upgrading](upgrade.md) — agents from the console; the server;
17 cloud-hypervisor
18 - [cert rotation](cert-rotation.md) — rotating the server certificate
19 without an outage
20 - [credential revocation](credential-revocation.md) — the levers when
21 something leaks
22
23 **FAQ**
24
25 - [faq](faq.md) — networking, and other sharp edges
26
27 **Where it's going**
28
29 - [roadmap](../ROADMAP.md)
site/index.md
Old New
@@ -0,0 +1,27 @@
1 # eitri — the cloud you already own
2
3 *The ease of use of the cloud, backed by your own hardware*
4
5 ## DESCRIPTION
6
7 eitri connects machines you already own into a private cloud. The
8 workstation you replaced, the mini PC in a drawer — point eitri at them
9 and they serve VMs the way a cloud does, without the bill. No account, no
10 landlord.
11
12 Boot a throwaway sandbox for a risky experiment. Keep a dev machine that
13 survives host reboots. Give an AI agent a VM where it can run wild. Delete
14 any of it when you're done.
15
16 ## HOW IT WORKS
17
18 Run the server on one box; join the rest with one command each. Click
19 **+ Create VM** and a fresh VM boots in seconds — open its console in the
20 browser, or SSH in with certificates signed by your own CA (eitri never
21 holds your keys).
22
23 ## GETTING IT
24
25 Prebuilt tarballs and checksums: [downloads](/dl/). No installer — unpack,
26 run, done. The [quickstart](quickstart.md) takes you from zero to a first
27 VM; the [FAQ](faq.md) covers the sharp edges.
site/nginx.conf
Old New
@@ -0,0 +1,25 @@
1 server {
2 listen 8080;
3 server_name _;
4 root /usr/share/nginx/html;
5 index index.html;
6
7 # The moving alias: a stale cached manifest would mask releases from
8 # every fleet (servers re-poll it daily).
9 location ^~ /dl/latest/ {
10 autoindex on;
11 add_header Cache-Control "no-cache";
12 }
13
14 # Versioned artifacts are content-addressed by SHA256SUMS: immutable.
15 # NB: each location sets its own add_header — nginx location-level
16 # add_header REPLACES inherited ones, so add new headers everywhere.
17 location ~ ^/dl/v[0-9] {
18 autoindex on;
19 add_header Cache-Control "public, max-age=31536000, immutable";
20 }
21
22 location / {
23 add_header Cache-Control "no-cache";
24 }
25 }
site/style.css
Old New
@@ -0,0 +1,36 @@
1 * { margin: 0; padding: 0; box-sizing: border-box; }
2
3 body {
4 font-family: monospace;
5 max-width: 72ch;
6 margin: 0 auto;
7 padding: 1em;
8 line-height: 1.5;
9 }
10
11 a { color: inherit; }
12
13 nav { margin-bottom: 2em; }
14 nav a { margin-right: 1em; }
15 nav a.active { font-weight: bold; text-decoration: none; }
16
17 h1 { margin-bottom: 0.5em; }
18 h2 { margin-top: 1.5em; margin-bottom: 0.5em; }
19 h3 { margin-top: 1em; margin-bottom: 0.5em; }
20
21 p, ul, ol { margin-bottom: 1em; }
22 ul, ol { padding-left: 2em; }
23 li { margin-bottom: 0.25em; }
24
25 pre {
26 padding: 1em;
27 margin-bottom: 1em;
28 overflow-x: auto;
29 border: 1px solid;
30 }
31
32 table { border-collapse: collapse; margin-bottom: 1em; }
33 th, td { border: 1px solid; padding: 0.25em 0.75em; text-align: left; }
34
35 code { word-break: break-all; }
36 pre code { word-break: normal; }
site/template.html
Old New
@@ -0,0 +1,19 @@
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>{{.Title}}</title>
7 <link rel="stylesheet" href="/style.css">
8 </head>
9 <body>
10
11 <nav>
12 <a href="/"{{if eq .Section "home"}} class="active"{{end}}>home</a>
13 <a href="/docs/"{{if eq .Section "docs"}} class="active"{{end}}>docs</a>
14 <a href="/dl/"{{if eq .Section "dl"}} class="active"{{end}}>downloads</a>
15 </nav>
16
17 {{.Content}}
18 </body>
19 </html>