fd49ad63
feat: eitri.sh runs eitri — the hosted control plane
a73x 2026-07-29 18:10
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -13,7 +13,7 @@ LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret | |||
| 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 \ | 14 | lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check \ |
| 15 | site site-check ci deadcode \ | 15 | site site-check ci deadcode \ |
| 16 | deploy release hooks site-image | 16 | deploy release hooks site-image server-image |
| 17 | 17 | ||
| 18 | # 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 |
| 19 | # 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 |
| @@ -161,6 +161,10 @@ site-image: | |||
| 161 | $(MAKE) site SITE_DIST=dist/$(VERSION) | 161 | $(MAKE) site SITE_DIST=dist/$(VERSION) |
| 162 | ./scripts/site-image.sh | 162 | ./scripts/site-image.sh |
| 163 | 163 | ||
| 164 | # eitri-server production image (arm64) — the hosted control plane. | ||
| 165 | server-image: web | ||
| 166 | ./scripts/server-image.sh | ||
| 167 | |||
| 164 | # Whole-program dead-code gate: fails on any function unreachable from a real | 168 | # Whole-program dead-code gate: fails on any function unreachable from a real |
| 165 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what | 169 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what |
| 166 | # catches production code kept alive only by its own tests; the fix is to remove | 170 | # catches production code kept alive only by its own tests; the fix is to remove |
deploy/server/Dockerfile
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,7 @@ | |||
| 1 | # eitri-server production image: one static Go binary (pure-Go sqlite, no | ||
| 2 | # CGO) on distroless/static — CA roots included for OIDC discovery, runs as | ||
| 3 | # nonroot (uid 65532). The console SPA is embedded in the binary; state | ||
| 4 | # lives on the mounted PVC and config in the mounted server.json Secret. | ||
| 5 | FROM gcr.io/distroless/static-debian12:nonroot | ||
| 6 | COPY eitri-server /eitri-server | ||
| 7 | ENTRYPOINT ["/eitri-server"] | ||
deploy/server/README.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,81 @@ | |||
| 1 | # eitri-server on the eitri.sh cluster | ||
| 2 | |||
| 3 | One container runs the whole control plane (embedded console, API, OIDC | ||
| 4 | auth, QUIC sync, SSH gate), pinned to vnic-1 — the cluster's public-IP | ||
| 5 | node — with state on a local-path PVC and config from a Secret. | ||
| 6 | |||
| 7 | ## One-time bring-up | ||
| 8 | |||
| 9 | 1. **Google OAuth client** (GCP console → Credentials): Web application, | ||
| 10 | authorized redirect `https://console.eitri.sh/auth/callback`. Note the | ||
| 11 | client id + secret. | ||
| 12 | 2. **server.json** (never committed; lives only in the Secret): | ||
| 13 | |||
| 14 | ```json | ||
| 15 | { | ||
| 16 | "db_path": "/var/lib/eitri/eitri.db", | ||
| 17 | "http_listen": ":8081", | ||
| 18 | "quic_listen": ":8443", | ||
| 19 | "default_image_url": "https://cloud-images.ubuntu.com/resolute/20260720/resolute-server-cloudimg-amd64.img", | ||
| 20 | "default_image_sha256": "117816726abbdefc5ef3e38902e81a76f1c76c3610e709999d0885f9d5d9b477", | ||
| 21 | "advertise_http": "https://console.eitri.sh", | ||
| 22 | "advertise_quic": "sync.eitri.sh:8443", | ||
| 23 | "cidr_pool": "10.78.0.0/16", | ||
| 24 | "ssh_listen": ":2222", | ||
| 25 | "ssh_gate_domain": "gate.eitri.sh", | ||
| 26 | "ssh_ca_key": "/var/lib/eitri/ssh_ca", | ||
| 27 | "ssh_host_key": "/var/lib/eitri/ssh_host_key", | ||
| 28 | "host_secret": "<openssl rand -hex 32>", | ||
| 29 | "oidc": { | ||
| 30 | "issuer": "https://accounts.google.com", | ||
| 31 | "client_id": "<google client id>", | ||
| 32 | "client_secret": "<google client secret>", | ||
| 33 | "public_url": "https://console.eitri.sh" | ||
| 34 | } | ||
| 35 | } | ||
| 36 | ``` | ||
| 37 | |||
| 38 | ``` | ||
| 39 | kubectl -n eitri create secret generic eitri-server-config \ | ||
| 40 | --from-file=server.json=./server.json | ||
| 41 | ``` | ||
| 42 | |||
| 43 | The pool is deliberately 10.78/16 — the dev fleet is 10.77/16, so a | ||
| 44 | host flicked between fleets never carries colliding guest subnets. | ||
| 45 | 3. **Image**: `make server-image` (needs SERVER_IMAGE in deploy.env), then | ||
| 46 | `kubectl -n eitri set image deployment/eitri-server eitri-server=<SERVER_IMAGE>:<version>`. | ||
| 47 | 4. **Apply**: `kubectl apply -f pvc.yaml -f deployment.yaml -f service.yaml -f certificate.yaml -f ingressroute.yaml -f backup-cronjob.yaml`. The server creates its own SSH CA and gate host key on the PVC on first boot, so the gate comes up on its own — no key step is required. | ||
| 48 | 5. **Network** (operator): | ||
| 49 | - Oracle security list AND vnic-1 host firewall: open 8443/udp, 2222/tcp. | ||
| 50 | - Cloudflare DNS: `console.eitri.sh` proxied to the eitri.sh origin (repoint off the old tailnet record — confirm nothing dev-side still resolves it); `sync.eitri.sh` and `gate.eitri.sh` grey-cloud A records to vnic-1's public IP. | ||
| 51 | 6. **Backups**: nightly CronJob writes dated sqlite backups on the PVC; run `backup-pull.sh` from cron on an off-cluster machine — local-path storage does not survive the node, the off-node copy is the DR story. | ||
| 52 | |||
| 53 | ## Rollout | ||
| 54 | |||
| 55 | make server-image | ||
| 56 | kubectl -n eitri set image deployment/eitri-server eitri-server=<SERVER_IMAGE>:<version> | ||
| 57 | |||
| 58 | Single replica + Recreate: a rollout is a short outage; agents reconnect | ||
| 59 | and re-sync on their own (the sync registry is rebuilt from Hellos). | ||
| 60 | |||
| 61 | The pod runs hostNetwork: the QUIC sync socket (8443/udp) and SSH gate | ||
| 62 | (2222/tcp) bind vnic-1's interfaces directly — both flows carry pinned | ||
| 63 | end-to-end cryptography and tolerate no middlebox, including the CNI's | ||
| 64 | hostPort NAT, which conntrack-drops long-lived single-tuple UDP flows. | ||
| 65 | The console listens on :8081 (svclb claims host 8080); the Service maps | ||
| 66 | 8080 → 8081 so the IngressRoute is unaffected. | ||
| 67 | |||
| 68 | ## Re-homing a host from another fleet | ||
| 69 | |||
| 70 | A host that previously belonged to a different control plane carries that | ||
| 71 | fleet's snapshot epoch in its state dir and will refuse the new server's | ||
| 72 | lower-numbered snapshots ("epoch fence violation" in the server log). | ||
| 73 | After `eitri-agent join` against the new fleet: | ||
| 74 | |||
| 75 | sudo systemctl stop eitri-agent | ||
| 76 | sudo rm /var/lib/eitri-agent/epoch | ||
| 77 | sudo systemctl start eitri-agent | ||
| 78 | |||
| 79 | VMs from the old fleet are absent from the new fleet's desired state and | ||
| 80 | are reaped through the normal quarantine grace — re-home a host only when | ||
| 81 | its existing guests are disposable. | ||
deploy/server/backup-cronjob.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,31 @@ | |||
| 1 | apiVersion: batch/v1 | ||
| 2 | kind: CronJob | ||
| 3 | metadata: | ||
| 4 | name: eitri-server-backup | ||
| 5 | namespace: eitri | ||
| 6 | spec: | ||
| 7 | schedule: "20 3 * * *" | ||
| 8 | concurrencyPolicy: Forbid | ||
| 9 | jobTemplate: | ||
| 10 | spec: | ||
| 11 | template: | ||
| 12 | spec: | ||
| 13 | restartPolicy: Never | ||
| 14 | nodeSelector: | ||
| 15 | kubernetes.io/hostname: vnic-1 | ||
| 16 | containers: | ||
| 17 | - name: backup | ||
| 18 | image: alpine:3.20 | ||
| 19 | command: ["/bin/sh", "-c"] | ||
| 20 | args: | ||
| 21 | - | | ||
| 22 | set -e | ||
| 23 | apk add --no-cache sqlite | ||
| 24 | mkdir -p /data/backups | ||
| 25 | sqlite3 /data/eitri.db ".backup /data/backups/eitri-$(date +%F).db" | ||
| 26 | find /data/backups -name 'eitri-*.db' -mtime +14 -delete | ||
| 27 | volumeMounts: | ||
| 28 | - {name: data, mountPath: /data} | ||
| 29 | volumes: | ||
| 30 | - name: data | ||
| 31 | persistentVolumeClaim: {claimName: eitri-server-data} | ||
deploy/server/backup-pull.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,29 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Off-node half of the eitri-server backup: copy the nightly sqlite backups out | ||
| 3 | # of the PVC to this machine. Run from cron on a box with kubectl access; the | ||
| 4 | # PVC is local-path on a single node, so an off-node copy is the only thing that | ||
| 5 | # survives losing that node. | ||
| 6 | # DEST defaults to ~/eitri-backups; override: DEST=/mnt/backups ./backup-pull.sh | ||
| 7 | # | ||
| 8 | # The server image is distroless (no tar/shell), so `kubectl cp` against the | ||
| 9 | # server pod cannot work. Instead we run a short-lived alpine helper pinned to | ||
| 10 | # the same node, mounting the same PVC (local-path RWO permits co-mounting on | ||
| 11 | # one node), and stream the backups dir out through it. | ||
| 12 | set -euo pipefail | ||
| 13 | DEST="${DEST:-$HOME/eitri-backups}" | ||
| 14 | NS=eitri | ||
| 15 | POD=eitri-backup-pull | ||
| 16 | mkdir -p "$DEST" | ||
| 17 | |||
| 18 | cleanup() { kubectl -n "$NS" delete pod "$POD" --ignore-not-found --wait=false >/dev/null 2>&1 || true; } | ||
| 19 | trap cleanup EXIT | ||
| 20 | |||
| 21 | kubectl -n "$NS" delete pod "$POD" --ignore-not-found >/dev/null 2>&1 || true | ||
| 22 | kubectl -n "$NS" run "$POD" --image=alpine:3.20 --restart=Never \ | ||
| 23 | --overrides='{"spec":{"nodeSelector":{"kubernetes.io/hostname":"vnic-1"},"containers":[{"name":"'"$POD"'","image":"alpine:3.20","command":["sleep","3600"],"volumeMounts":[{"name":"data","mountPath":"/data","readOnly":true}]}],"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"eitri-server-data"}}]}}' \ | ||
| 24 | >/dev/null | ||
| 25 | kubectl -n "$NS" wait --for=condition=Ready "pod/$POD" --timeout=120s >/dev/null | ||
| 26 | |||
| 27 | kubectl -n "$NS" exec "$POD" -- tar cf - -C /data backups | tar xf - -C "$DEST" | ||
| 28 | find "$DEST" -name 'eitri-*.db' -mtime +60 -delete | ||
| 29 | echo "backup-pull: $DEST/backups" | ||
deploy/server/certificate.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,11 @@ | |||
| 1 | apiVersion: cert-manager.io/v1 | ||
| 2 | kind: Certificate | ||
| 3 | metadata: | ||
| 4 | name: console-tls | ||
| 5 | namespace: eitri | ||
| 6 | spec: | ||
| 7 | secretName: console-tls | ||
| 8 | dnsNames: [console.eitri.sh] | ||
| 9 | issuerRef: | ||
| 10 | name: letsencrypt-prod | ||
| 11 | kind: ClusterIssuer | ||
deploy/server/deployment.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,59 @@ | |||
| 1 | apiVersion: apps/v1 | ||
| 2 | kind: Deployment | ||
| 3 | metadata: | ||
| 4 | name: eitri-server | ||
| 5 | namespace: eitri | ||
| 6 | spec: | ||
| 7 | replicas: 1 | ||
| 8 | strategy: | ||
| 9 | type: Recreate # sqlite + in-memory sync registry: single process | ||
| 10 | selector: | ||
| 11 | matchLabels: {app: eitri-server} | ||
| 12 | template: | ||
| 13 | metadata: | ||
| 14 | labels: {app: eitri-server} | ||
| 15 | spec: | ||
| 16 | # vnic-1 is load-bearing twice over: the only node with a public IP | ||
| 17 | # (sync/gate listeners below), and local-path binds the PVC to the node | ||
| 18 | # the pod first lands on. | ||
| 19 | nodeSelector: | ||
| 20 | kubernetes.io/hostname: vnic-1 | ||
| 21 | # hostNetwork: the QUIC sync socket (8443/udp) and SSH gate (2222/tcp) | ||
| 22 | # bind the node's interfaces directly. Both flows carry their own pinned | ||
| 23 | # cryptography end-to-end and tolerate no middlebox — that includes the | ||
| 24 | # CNI's own hostPort NAT, which conntrack-drops long-lived single-tuple | ||
| 25 | # UDP flows. The console listens on :8081 because svclb already claims | ||
| 26 | # host 8080. | ||
| 27 | hostNetwork: true | ||
| 28 | dnsPolicy: ClusterFirstWithHostNet | ||
| 29 | imagePullSecrets: | ||
| 30 | - name: regcred | ||
| 31 | securityContext: | ||
| 32 | fsGroup: 65532 # distroless nonroot; PVC files must be writable | ||
| 33 | containers: | ||
| 34 | - name: eitri-server | ||
| 35 | image: REGISTRY/eitri-server:VERSION # set at rollout: kubectl -n eitri set image ... | ||
| 36 | # Always: a re-cut release reuses its version tag, and podman-pushed | ||
| 37 | # digests differ from the registry's — the tag plus a forced pull is | ||
| 38 | # the one rollout shape that never serves a stale image. | ||
| 39 | imagePullPolicy: Always | ||
| 40 | ports: | ||
| 41 | - {name: http, containerPort: 8081} | ||
| 42 | - {name: sync, containerPort: 8443, protocol: UDP} | ||
| 43 | - {name: gate, containerPort: 2222, protocol: TCP} | ||
| 44 | volumeMounts: | ||
| 45 | - {name: data, mountPath: /var/lib/eitri} | ||
| 46 | - {name: config, mountPath: /etc/eitri, readOnly: true} | ||
| 47 | livenessProbe: | ||
| 48 | httpGet: {path: /livez, port: 8081} | ||
| 49 | periodSeconds: 10 | ||
| 50 | readinessProbe: | ||
| 51 | httpGet: {path: /readyz, port: 8081} | ||
| 52 | periodSeconds: 10 | ||
| 53 | volumes: | ||
| 54 | - name: data | ||
| 55 | persistentVolumeClaim: {claimName: eitri-server-data} | ||
| 56 | - name: config | ||
| 57 | secret: | ||
| 58 | secretName: eitri-server-config | ||
| 59 | defaultMode: 0400 | ||
deploy/server/ingressroute.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,29 @@ | |||
| 1 | apiVersion: traefik.io/v1alpha1 | ||
| 2 | kind: IngressRoute | ||
| 3 | metadata: | ||
| 4 | name: console-https | ||
| 5 | namespace: eitri | ||
| 6 | spec: | ||
| 7 | entryPoints: [websecure] | ||
| 8 | routes: | ||
| 9 | - match: Host(`console.eitri.sh`) | ||
| 10 | kind: Rule | ||
| 11 | services: | ||
| 12 | - {name: eitri-server, port: 8080} | ||
| 13 | tls: | ||
| 14 | secretName: console-tls | ||
| 15 | --- | ||
| 16 | apiVersion: traefik.io/v1alpha1 | ||
| 17 | kind: IngressRoute | ||
| 18 | metadata: | ||
| 19 | name: console-http | ||
| 20 | namespace: eitri | ||
| 21 | spec: | ||
| 22 | entryPoints: [web] | ||
| 23 | routes: | ||
| 24 | - match: Host(`console.eitri.sh`) | ||
| 25 | kind: Rule | ||
| 26 | middlewares: | ||
| 27 | - name: redirect-https | ||
| 28 | services: | ||
| 29 | - {name: eitri-server, port: 8080} | ||
deploy/server/pvc.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,11 @@ | |||
| 1 | apiVersion: v1 | ||
| 2 | kind: PersistentVolumeClaim | ||
| 3 | metadata: | ||
| 4 | name: eitri-server-data | ||
| 5 | namespace: eitri | ||
| 6 | spec: | ||
| 7 | accessModes: [ReadWriteOnce] | ||
| 8 | storageClassName: local-path | ||
| 9 | resources: | ||
| 10 | requests: | ||
| 11 | storage: 5Gi | ||
deploy/server/service.yaml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,9 @@ | |||
| 1 | apiVersion: v1 | ||
| 2 | kind: Service | ||
| 3 | metadata: | ||
| 4 | name: eitri-server | ||
| 5 | namespace: eitri | ||
| 6 | spec: | ||
| 7 | selector: {app: eitri-server} | ||
| 8 | ports: | ||
| 9 | - {name: http, port: 8080, targetPort: 8081} | ||
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -2,10 +2,90 @@ | |||
| 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 | ||
| 8 | hardware; the console, sync, and SSH gate are already up. No server | ||
| 9 | install at all. | ||
| 10 | |||
| 11 | ### Create an account | ||
| 12 | |||
| 13 | Open <https://console.eitri.sh> and sign in with Google. Your first | ||
| 14 | sign-in creates your tenant. | ||
| 15 | |||
| 16 | ### Join a host | ||
| 17 | |||
| 18 | On the machine that will serve VMs (Linux, KVM — see "What you need" | ||
| 19 | under Self-hosting), download and verify the host bundle: | ||
| 20 | |||
| 21 | ```sh | ||
| 22 | V=v0.0.1 | ||
| 23 | curl -fsSLO "https://eitri.sh/dl/$V/eitri_${V}_linux_amd64.tar.gz" | ||
| 24 | curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS" | ||
| 25 | sha256sum -c SHA256SUMS --ignore-missing | ||
| 26 | tar xzf "eitri_${V}_linux_amd64.tar.gz" && cd "eitri_${V}_linux_amd64" | ||
| 27 | ``` | ||
| 28 | |||
| 29 | Then click **+ Add host** in the console and run the command it prints | ||
| 30 | from the unpacked bundle — it installs the agent and joins this machine | ||
| 31 | to your tenant: | ||
| 32 | |||
| 33 | ```sh | ||
| 34 | sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent | ||
| 35 | sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service | ||
| 36 | sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console> | ||
| 37 | sudo systemctl daemon-reload | ||
| 38 | sudo systemctl enable --now eitri-agent | ||
| 39 | ``` | ||
| 40 | |||
| 41 | The agent dials out — a machine behind NAT needs no open ports. It goes | ||
| 42 | online in the console within seconds. | ||
| 43 | |||
| 44 | ### Boot a VM | ||
| 45 | |||
| 46 | Console → **+ Create VM**, pick your host, create. Watch it boot in the | ||
| 47 | browser serial console. | ||
| 48 | |||
| 49 | ### SSH in | ||
| 50 | |||
| 51 | SSH access uses certificates signed by **your** CA — eitri never holds a | ||
| 52 | user key that can enter your VMs. On your laptop, download the client CLI, | ||
| 53 | then put a personal access token and your tenant handle in the environment: | ||
| 54 | |||
| 55 | ```sh | ||
| 56 | V=v0.0.1 | ||
| 57 | curl -fsSLO "https://eitri.sh/dl/$V/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd64.tar.gz" # arm64 laptop → _arm64 | ||
| 58 | tar xzf eitri-cli_${V}_*.tar.gz | ||
| 59 | sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri | ||
| 60 | |||
| 61 | export EITRI_URL=https://console.eitri.sh | ||
| 62 | export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens | ||
| 63 | export EITRI_TENANT=<your-tenant> # shown on the console's Settings page, under Identity | ||
| 64 | ``` | ||
| 65 | |||
| 66 | Register a user CA once — eitri gets the public key, never the private one: | ||
| 67 | |||
| 68 | ```sh | ||
| 69 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" | ||
| 70 | eitri ca upload "$EITRI_TENANT" ~/.ssh/eitri_user_ca.pub | ||
| 71 | ``` | ||
| 72 | |||
| 73 | Then SSH in. Unlike self-hosting, the console and the SSH gate are | ||
| 74 | separate names, so set the gate too: | ||
| 75 | |||
| 76 | ```sh | ||
| 77 | export EITRI_GATE=gate.eitri.sh:2222 | ||
| 78 | eitri ssh <vm-name> | ||
| 79 | eitri ssh <vm-name> uptime | ||
| 80 | ``` | ||
| 81 | |||
| 82 | ## Self-hosting | ||
| 83 | |||
| 84 | Run the whole control plane yourself — everything below is your own box. | ||
| 5 | eitri is three pieces: a server, an agent on every box that runs VMs, and | 85 | 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. | 86 | your laptop. `192.0.2.10` is the server below. Substitute yours. |
| 7 | 87 | ||
| 8 | ## What you need | 88 | ### What you need |
| 9 | 89 | ||
| 10 | Every VM host needs KVM (`ls -l /dev/kvm`) and `qemu-img` (Debian/Ubuntu: | 90 | Every VM host needs KVM (`ls -l /dev/kvm`) and `qemu-img` (Debian/Ubuntu: |
| 11 | `qemu-utils`, Fedora: `qemu-img`). The agent fetches cloud-hypervisor and the | 91 | `qemu-utils`, Fedora: `qemu-img`). The agent fetches cloud-hypervisor and the |
| @@ -36,7 +116,7 @@ sha256sum -c SHA256SUMS --ignore-missing | |||
| 36 | Plain files at stable URLs — no install script. `sha256sum -c` must say OK | 116 | Plain files at stable URLs — no install script. `sha256sum -c` must say OK |
| 37 | for every tarball before you unpack anything. | 117 | for every tarball before you unpack anything. |
| 38 | 118 | ||
| 39 | ## The server | 119 | ### The server |
| 40 | 120 | ||
| 41 | ```sh | 121 | ```sh |
| 42 | tar xzf eitri_*_linux_amd64.tar.gz && cd eitri_*_linux_amd64 | 122 | tar xzf eitri_*_linux_amd64.tar.gz && cd eitri_*_linux_amd64 |
| @@ -96,7 +176,7 @@ next. `public_url` is where browsers reach the console (the callback lands at | |||
| 96 | `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing | 176 | `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing |
| 97 | your own IdP instead of the bundled issuer: see [byo-idp.md](byo-idp.md). | 177 | your own IdP instead of the bundled issuer: see [byo-idp.md](byo-idp.md). |
| 98 | 178 | ||
| 99 | ## Sign-in | 179 | ### Sign-in |
| 100 | 180 | ||
| 101 | The console always signs in through OIDC. The bundled `eitri-oidc` issuer runs | 181 | The console always signs in through OIDC. The bundled `eitri-oidc` issuer runs |
| 102 | next to the server on loopback. Install it, write its config, and add yourself: | 182 | next to the server on loopback. Install it, write its config, and add yourself: |
| @@ -149,7 +229,7 @@ issuer stays on loopback. | |||
| 149 | Sign in at `http://192.0.2.10:8080` with the user you added. Your first | 229 | Sign in at `http://192.0.2.10:8080` with the user you added. Your first |
| 150 | sign-in creates your tenant. | 230 | sign-in creates your tenant. |
| 151 | 231 | ||
| 152 | ## Join a host | 232 | ### Join a host |
| 153 | 233 | ||
| 154 | Once per box that runs VMs. The server's box counts. | 234 | Once per box that runs VMs. The server's box counts. |
| 155 | 235 | ||
| @@ -167,7 +247,7 @@ sudo systemctl enable --now eitri-agent | |||
| 167 | The host goes **online** in the console. Logs: | 247 | The host goes **online** in the console. Logs: |
| 168 | `journalctl -u eitri-agent -f`. | 248 | `journalctl -u eitri-agent -f`. |
| 169 | 249 | ||
| 170 | ## Boot a VM | 250 | ### Boot a VM |
| 171 | 251 | ||
| 172 | VMs trust your SSH CA from birth, so register one first. eitri gets the | 252 | VMs trust your SSH CA from birth, so register one first. eitri gets the |
| 173 | public key, never the private one. On your laptop: | 253 | public key, never the private one. On your laptop: |
| @@ -190,7 +270,7 @@ email on first sign-in. | |||
| 190 | default image. Status reads `creating` while the image downloads and the | 270 | default image. Status reads `creating` while the image downloads and the |
| 191 | guest boots, then `ready`. Power reads `running`, an IP appears, you're on. | 271 | guest boots, then `ready`. Power reads `running`, an IP appears, you're on. |
| 192 | 272 | ||
| 193 | ## SSH in | 273 | ### SSH in |
| 194 | 274 | ||
| 195 | ```sh | 275 | ```sh |
| 196 | export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain | 276 | export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain |
| @@ -205,7 +285,7 @@ your CA, pins eitri's host CA, and jumps the gate to | |||
| 205 | `ubuntu@<your-tenant>.<vm-name>`. No token. [ssh-access.md](ssh-access.md) | 285 | `ubuntu@<your-tenant>.<vm-name>`. No token. [ssh-access.md](ssh-access.md) |
| 206 | shows it done by hand. | 286 | shows it done by hand. |
| 207 | 287 | ||
| 208 | ## More | 288 | ### More |
| 209 | 289 | ||
| 210 | - [ssh-access.md](ssh-access.md): the jump gate and the BYO-CA model | 290 | - [ssh-access.md](ssh-access.md): the jump gate and the BYO-CA model |
| 211 | - [upgrade.md](upgrade.md): upgrading agents, the server, cloud-hypervisor | 291 | - [upgrade.md](upgrade.md): upgrading agents, the server, cloud-hypervisor |
docs/releases.md
| Old | New | ||
|---|---|---|---|
| @@ -7,9 +7,10 @@ running VM. | |||
| 7 | 7 | ||
| 8 | ## v0.0.1 | 8 | ## v0.0.1 |
| 9 | 9 | ||
| 10 | The first release. eitri is a small self-hosted cloud: one control-plane | 10 | The first release. eitri is a small cloud backed by your own hardware: one |
| 11 | server, an agent on every Linux/KVM box you enroll, cloud-hypervisor microVMs, | 11 | control-plane server — hosted at eitri.sh or run yourself — an agent on every |
| 12 | a web console, and SSH into guests through an identity-checking jump gate. | 12 | Linux/KVM box you enroll, cloud-hypervisor microVMs, a web console, and SSH |
| 13 | into guests through an identity-checking jump gate. | ||
| 13 | 14 | ||
| 14 | **Fleet.** Enroll any Linux/KVM host with a one-shot join command from the | 15 | **Fleet.** Enroll any Linux/KVM host with a one-shot join command from the |
| 15 | console. The agent reconciles each VM in its own worker, admits VMs against | 16 | console. The agent reconciles each VM in its own worker, admits VMs against |
| @@ -34,6 +35,12 @@ private key. `eitri ssh` self-signs a short-lived certificate, pins eitri's | |||
| 34 | host CA, and jumps the gate to `<tenant>.<vm>`; certificates are revocable | 35 | host CA, and jumps the gate to `<tenant>.<vm>`; certificates are revocable |
| 35 | per serial. See [ssh-access.md](ssh-access.md). | 36 | per serial. See [ssh-access.md](ssh-access.md). |
| 36 | 37 | ||
| 38 | **Hosted.** Don't want to run the control plane? Sign in at | ||
| 39 | [console.eitri.sh](https://console.eitri.sh) with Google and join your own | ||
| 40 | machines — the same server the tarball ships, run for you. Your hardware | ||
| 41 | still serves every VM; the hosted plane only coordinates it. Self-hosting | ||
| 42 | stays a first-class path. | ||
| 43 | |||
| 37 | **Running it.** `eitri-server` and `eitri-oidc` ship with hardened systemd | 44 | **Running it.** `eitri-server` and `eitri-oidc` ship with hardened systemd |
| 38 | units and run as dedicated non-root users; the agent's unit documents exactly | 45 | units and run as dedicated non-root users; the agent's unit documents exactly |
| 39 | why it runs as root. Server and issuer state live under `/var/lib`, config | 46 | why it runs as root. Server and issuer state live under `/var/lib`, config |
scripts/deploy.env.example
| Old | New | ||
|---|---|---|---|
| @@ -83,3 +83,9 @@ FIRMWARE="/usr/share/eitri/CLOUDHV.fd" | |||
| 83 | # than staged by the site image. `make release` reads FIRMWARE_SRC — a local | 83 | # than staged by the site image. `make release` reads FIRMWARE_SRC — a local |
| 84 | # CLOUDHV.fd to mirror in — defaulting to $HOME/.cache/eitri/CLOUDHV.fd if | 84 | # CLOUDHV.fd to mirror in — defaulting to $HOME/.cache/eitri/CLOUDHV.fd if |
| 85 | # present; see scripts/release.sh. | 85 | # present; see scripts/release.sh. |
| 86 | |||
| 87 | # ── eitri-server production image (make server-image) ───────────────────────── | ||
| 88 | # Registry/repo for the hosted control-plane image; tagged with the release | ||
| 89 | # version. Required for `make server-image` (everything else here ignores it). | ||
| 90 | # The image is arm64 only — the pod is pinned to the cluster's arm64 node. | ||
| 91 | # SERVER_IMAGE="registry.example.com/eitri-server" | ||
scripts/server-image.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,31 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Build and push the eitri-server production image (arm64 — the pod is | ||
| 3 | # pinned to the cluster's arm64 public-IP node; amd64 installs are served by | ||
| 4 | # the release tarballs, not this image). | ||
| 5 | # | ||
| 6 | # Reads deploy.env (same file scripts/deploy.sh uses): | ||
| 7 | # SERVER_IMAGE registry/repo to push, e.g. registry.example/eitri-server (required) | ||
| 8 | # | ||
| 9 | # Expects `make web` to have run (internal/server/web/dist — the SPA is | ||
| 10 | # //go:embed'ed into the binary); the Makefile's server-image target orders it. | ||
| 11 | set -euo pipefail | ||
| 12 | cd "$(dirname "$0")/.." | ||
| 13 | |||
| 14 | ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}" | ||
| 15 | # shellcheck disable=SC1090 | ||
| 16 | [ -f "$ENV_FILE" ] && . "$ENV_FILE" | ||
| 17 | : "${SERVER_IMAGE:?server-image: set SERVER_IMAGE in $ENV_FILE}" | ||
| 18 | |||
| 19 | VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" | ||
| 20 | [ -f internal/server/web/dist/index.html ] || { echo "server-image: web dist missing — run make web" >&2; exit 1; } | ||
| 21 | |||
| 22 | LDFLAGS="-X github.com/a73x/eitri/internal/version.Version=$VERSION" | ||
| 23 | BUILD_DIR="$(mktemp -d)" | ||
| 24 | trap 'rm -rf "$BUILD_DIR"' EXIT | ||
| 25 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \ | ||
| 26 | -o "$BUILD_DIR/eitri-server" ./cmd/eitri-server | ||
| 27 | cp deploy/server/Dockerfile "$BUILD_DIR/" | ||
| 28 | |||
| 29 | docker build --platform linux/arm64 -t "$SERVER_IMAGE:$VERSION" "$BUILD_DIR" | ||
| 30 | docker push "$SERVER_IMAGE:$VERSION" | ||
| 31 | echo "server-image: pushed $SERVER_IMAGE:$VERSION — roll it out with kubectl -n eitri set image deployment/eitri-server eitri-server=$SERVER_IMAGE:$VERSION" | ||
site/index.md
| Old | New | ||
|---|---|---|---|
| @@ -6,8 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | eitri connects machines you already own into a private cloud. The | 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 | 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 | 9 | and they serve VMs the way a cloud does, without the bill. |
| 10 | landlord. | ||
| 11 | 10 | ||
| 12 | Boot a throwaway sandbox for a risky experiment. Keep a dev machine that | 11 | 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 | 12 | survives host reboots. Give an AI agent a VM where it can run wild. Delete |
site/template.html
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ | |||
| 12 | <a href="/"{{if eq .Section "home"}} class="active"{{end}}>home</a> | 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> | 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> | 14 | <a href="/dl/"{{if eq .Section "dl"}} class="active"{{end}}>downloads</a> |
| 15 | <a href="https://console.eitri.sh"{{if eq .Section "console"}} class="active"{{end}}>sign in</a> | ||
| 15 | </nav> | 16 | </nav> |
| 16 | 17 | ||
| 17 | {{.Content}} | 18 | {{.Content}} |