a73x

f6605653

feat(cli): the eitri client binary

a73x   2026-07-26 18:33

Commit message
feat(cli): the eitri client binary

One cross-compiled binary replaces the eitri-ssh and eitri-ca scripts:
eitri ssh <vm> self-signs a short-lived user certificate with the tenant's
own CA (natively — no ssh-keygen dependency), pins eitri's host CA in a
dedicated known_hosts, and execs the system ssh through the jump gate with
both hops strictly verified. eitri ca upload registers a tenant user CA.
The env contract (EITRI_URL, EITRI_GATE, EITRI_CA, EITRI_TENANT, EITRI_KEY,
EITRI_KNOWN_HOSTS) is unchanged from the scripts.

The mint derives the public key from the private key on disk, so a stale
.pub can never produce a cert/key mismatch; key generation is O_EXCL so
concurrent first runs cannot interleave; the fetched host CA must parse as
an SSH key before it is pinned, and is re-marshaled to one canonical line.
ProxyCommand paths are shell-quoted (spaced macOS homes) and the gate
splits IPv6-safely. The argv shape is pinned exactly by table tests.

Releases ship the client as eitri-cli_<version>_{linux,darwin}_{amd64,arm64}
tarballs — one binary each; macOS needs no separate treatment. The client
artifacts stay out of the agent-upgrade manifest.

Makefile
Old New
@@ -40,6 +40,7 @@ build: web
40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp 40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp
41 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 42 go build $(GO_LDFLAGS) -o $(BIN)/eitri-site ./cmd/eitri-site
43 go build $(GO_LDFLAGS) -o $(BIN)/eitri ./cmd/eitri
43 44
44 test: 45 test:
45 go test -race ./... 46 go test -race ./...
README.md
Old New
@@ -45,7 +45,7 @@ create), and a content-addressed image cache (each base image is downloaded and
45 | `eitri-server` | Control plane: HTTP API, QUIC sync stream, and the SSH-CA jump gate. | 45 | `eitri-server` | Control plane: HTTP API, QUIC sync stream, and the SSH-CA jump gate. |
46 | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives cloud-hypervisor. | 46 | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives cloud-hypervisor. |
47 | `eitri-mcp` | MCP server exposing create/control/destroy VM tools to Claude. | 47 | `eitri-mcp` | MCP server exposing create/control/destroy VM tools to Claude. |
48 | `hack/eitri-ssh` | Client that signs an ephemeral cert with a tenant CA and reaches a guest through the gate. | 48 | `eitri` | Client CLI: signs an ephemeral cert with a tenant CA and reaches a guest through the gate. |
49 49
50 `eitri-shape` (regenerate the architecture graph) rounds out the binaries. 50 `eitri-shape` (regenerate the architecture graph) rounds out the binaries.
51 51
@@ -56,10 +56,10 @@ its own isolated namespace with its own SSH user CA — eitri holds no tenant us
56 signing key. You reach a guest by name: 56 signing key. You reach a guest by name:
57 57
58 ``` 58 ```
59 eitri-ssh <tenant>.<vm-name> 59 eitri ssh <vm-name>
60 ``` 60 ```
61 61
62 `eitri-ssh` self-signs a short-lived certificate with your tenant's user CA and 62 `eitri ssh` self-signs a short-lived certificate with your tenant's user CA and
63 jumps through the server's gate, which authorizes the connection against the 63 jumps through the server's gate, which authorizes the connection against the
64 tenant derived from the signing CA. Guest host certificates are namespaced the 64 tenant derived from the signing CA. Guest host certificates are namespaced the
65 same way, so names never collide across tenants. 65 same way, so names never collide across tenants.
cmd/eitri/main.go
Old New
@@ -0,0 +1,86 @@
1 // Command eitri is the end-user client: SSH into fleet VMs through the
2 // jump gate with self-signed short-lived certs (eitri ssh) and register
3 // tenant user CAs (eitri ca upload). See internal/cli and docs/ssh-access.md.
4 package main
5
6 import (
7 "context"
8 "fmt"
9 "os"
10
11 "github.com/a73x/eitri/internal/cli"
12 "github.com/a73x/eitri/internal/version"
13 )
14
15 const usage = `usage:
16 eitri ssh <vm> [ssh args / remote command...]
17 eitri ca upload [<tenant>] <ca-public-key-file>
18 eitri --version
19
20 env: EITRI_URL, EITRI_GATE (required for ssh); EITRI_TOKEN (required for ca);
21 EITRI_CA, EITRI_TENANT, EITRI_KEY, EITRI_KNOWN_HOSTS (optional)`
22
23 func main() {
24 if len(os.Args) > 1 && os.Args[1] == "--version" {
25 fmt.Println(version.Version)
26 return
27 }
28 if len(os.Args) < 2 {
29 fmt.Fprintln(os.Stderr, usage)
30 os.Exit(2)
31 }
32 var err error
33 switch os.Args[1] {
34 case "ssh":
35 err = runSSH(os.Args[2:])
36 case "ca":
37 err = runCA(os.Args[2:])
38 default:
39 fmt.Fprintln(os.Stderr, usage)
40 os.Exit(2)
41 }
42 if err != nil {
43 fmt.Fprintln(os.Stderr, "eitri:", err)
44 os.Exit(1)
45 }
46 }
47
48 func runSSH(args []string) error {
49 if len(args) >= 1 && (args[0] == "-h" || args[0] == "--help") {
50 fmt.Println("usage: eitri ssh <vm> [ssh args / remote command...]")
51 return nil
52 }
53 if len(args) < 1 {
54 return fmt.Errorf("usage: eitri ssh <vm> [ssh args / remote command...]")
55 }
56 env, err := cli.FromEnv()
57 if err != nil {
58 return err
59 }
60 return cli.RunSSH(context.Background(), env, args[0], args[1:])
61 }
62
63 func runCA(args []string) error {
64 if len(args) < 1 || args[0] != "upload" {
65 return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>")
66 }
67 rest := args[1:]
68 if len(rest) < 1 || len(rest) > 2 {
69 return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>")
70 }
71 tenant, pub := "default", rest[0]
72 if len(rest) == 2 {
73 tenant, pub = rest[0], rest[1]
74 }
75 url := os.Getenv("EITRI_URL")
76 token := os.Getenv("EITRI_TOKEN")
77 if url == "" || token == "" {
78 return fmt.Errorf("set EITRI_URL and EITRI_TOKEN (admin bearer token)")
79 }
80 out, err := cli.UploadUserCA(context.Background(), url, token, tenant, pub)
81 if err != nil {
82 return err
83 }
84 fmt.Println(out)
85 return nil
86 }
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 (`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/quickstart.md
Old New
@@ -15,9 +15,9 @@ manage them by hand instead, disable it in `/etc/default/eitri-agent`:
15 15
16 Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle 16 Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle
17 (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and 17 (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and
18 the agent's systemd unit. The client bundle (`eitri-ssh_<version>.tar.gz`) 18 the agent's systemd unit. The client bundle
19 has `eitri-ssh` and `eitri-ca` for your laptop. arm64 boxes take the arm64 19 (`eitri-cli_<version>_<os>_<arch>.tar.gz`) is the single `eitri` binary for
20 bundle. 20 your laptop, built for linux and macOS. arm64 boxes take the arm64 bundle.
21 21
22 ## The server 22 ## The server
23 23
@@ -99,14 +99,14 @@ VMs trust your SSH CA from birth, so register one first. eitri gets the
99 public key, never the private one. On your laptop: 99 public key, never the private one. On your laptop:
100 100
101 ```sh 101 ```sh
102 tar xzf eitri-ssh_*.tar.gz 102 tar xzf eitri-cli_*_$(uname -s | tr A-Z a-z)_*.tar.gz
103 sudo install -m 0755 eitri-ssh_*/eitri-ssh eitri-ssh_*/eitri-ca /usr/local/bin/ 103 sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri
104 104
105 export EITRI_URL=http://192.0.2.10:8080 105 export EITRI_URL=http://192.0.2.10:8080
106 export EITRI_TOKEN=<console-login-token> 106 export EITRI_TOKEN=<console-login-token>
107 107
108 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" 108 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
109 eitri-ca upload default ~/.ssh/eitri_user_ca.pub 109 eitri ca upload default ~/.ssh/eitri_user_ca.pub
110 ``` 110 ```
111 111
112 **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the 112 **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the
@@ -118,11 +118,11 @@ guest boots, then `ready`. Power reads `running`, an IP appears, you're on.
118 ```sh 118 ```sh
119 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain 119 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain
120 120
121 eitri-ssh <vm-name> 121 eitri ssh <vm-name>
122 eitri-ssh <vm-name> uptime 122 eitri ssh <vm-name> uptime
123 ``` 123 ```
124 124
125 `eitri-ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with 125 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with
126 your CA, pins eitri's host CA, and jumps the gate to 126 your CA, pins eitri's host CA, and jumps the gate to
127 `ubuntu@default.<vm-name>`. No token. [ssh-access.md](ssh-access.md) shows it 127 `ubuntu@default.<vm-name>`. No token. [ssh-access.md](ssh-access.md) shows it
128 done by hand. 128 done by hand.
docs/shape.html
Old New
@@ -53,6 +53,15 @@
53 "module": "github.com/a73x/eitri", 53 "module": "github.com/a73x/eitri",
54 "packages": [ 54 "packages": [
55 { 55 {
56 "importPath": "cmd/eitri",
57 "plane": "binaries",
58 "synopsis": "Command eitri is the end-user client: SSH into fleet VMs through the jump gate with self-signed short-lived certs (eitri ssh) and register tenant user CAs (eitri ca upload).",
59 "imports": [
60 "internal/cli",
61 "internal/version"
62 ]
63 },
64 {
56 "importPath": "cmd/eitri-agent", 65 "importPath": "cmd/eitri-agent",
57 "plane": "binaries", 66 "plane": "binaries",
58 "synopsis": "eitri-agent: BYO-hardware agent.", 67 "synopsis": "eitri-agent: BYO-hardware agent.",
@@ -266,6 +275,14 @@
266 "imports": [] 275 "imports": []
267 }, 276 },
268 { 277 {
278 "importPath": "internal/cli",
279 "plane": "tooling",
280 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.",
281 "imports": [
282 "internal/server/api/client"
283 ]
284 },
285 {
269 "importPath": "internal/cloudinit", 286 "importPath": "internal/cloudinit",
270 "plane": "wire", 287 "plane": "wire",
271 "synopsis": "Package cloudinit merges eitri's structured VM inputs into user-supplied cloud-init user-data.", 288 "synopsis": "Package cloudinit merges eitri's structured VM inputs into user-supplied cloud-init user-data.",
@@ -345,7 +362,7 @@
345 { 362 {
346 "importPath": "internal/server/api/client", 363 "importPath": "internal/server/api/client",
347 "plane": "control", 364 "plane": "control",
348 "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server, smoke gate) goes through.", 365 "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server, smoke gate, CLI) goes through.",
349 "imports": [ 366 "imports": [
350 "internal/server/api/types" 367 "internal/server/api/types"
351 ] 368 ]
docs/shape.json
Old New
@@ -2,6 +2,15 @@
2 "module": "github.com/a73x/eitri", 2 "module": "github.com/a73x/eitri",
3 "packages": [ 3 "packages": [
4 { 4 {
5 "importPath": "cmd/eitri",
6 "plane": "binaries",
7 "synopsis": "Command eitri is the end-user client: SSH into fleet VMs through the jump gate with self-signed short-lived certs (eitri ssh) and register tenant user CAs (eitri ca upload).",
8 "imports": [
9 "internal/cli",
10 "internal/version"
11 ]
12 },
13 {
5 "importPath": "cmd/eitri-agent", 14 "importPath": "cmd/eitri-agent",
6 "plane": "binaries", 15 "plane": "binaries",
7 "synopsis": "eitri-agent: BYO-hardware agent.", 16 "synopsis": "eitri-agent: BYO-hardware agent.",
@@ -215,6 +224,14 @@
215 "imports": [] 224 "imports": []
216 }, 225 },
217 { 226 {
227 "importPath": "internal/cli",
228 "plane": "tooling",
229 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.",
230 "imports": [
231 "internal/server/api/client"
232 ]
233 },
234 {
218 "importPath": "internal/cloudinit", 235 "importPath": "internal/cloudinit",
219 "plane": "wire", 236 "plane": "wire",
220 "synopsis": "Package cloudinit merges eitri's structured VM inputs into user-supplied cloud-init user-data.", 237 "synopsis": "Package cloudinit merges eitri's structured VM inputs into user-supplied cloud-init user-data.",
@@ -294,7 +311,7 @@
294 { 311 {
295 "importPath": "internal/server/api/client", 312 "importPath": "internal/server/api/client",
296 "plane": "control", 313 "plane": "control",
297 "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server, smoke gate) goes through.", 314 "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server, smoke gate, CLI) goes through.",
298 "imports": [ 315 "imports": [
299 "internal/server/api/types" 316 "internal/server/api/types"
300 ] 317 ]
docs/ssh-access.md
Old New
@@ -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 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 eitri-ssh <vm-name> # opens a shell on the VM 45 eitri ssh <vm-name> # opens a shell on the VM
46 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); `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:
@@ -73,7 +73,7 @@ and pins it as `@cert-authority *` in a dedicated known_hosts file, and execs
73 73
74 ## Manual flow 74 ## Manual flow
75 75
76 The helper is a thin wrapper over three steps you can run by hand: 76 The client is a thin wrapper over three steps you can run by hand:
77 77
78 1. **Self-sign a cert** for your public key with your tenant CA — no server 78 1. **Self-sign a cert** for your public key with your tenant CA — no server
79 involved: 79 involved:
@@ -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 `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. `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
go.mod
Old New
@@ -13,6 +13,7 @@ require (
13 github.com/yuin/goldmark v1.8.4 13 github.com/yuin/goldmark v1.8.4
14 golang.org/x/crypto v0.54.0 14 golang.org/x/crypto v0.54.0
15 golang.org/x/sync v0.20.0 15 golang.org/x/sync v0.20.0
16 golang.org/x/term v0.45.0
16 google.golang.org/protobuf v1.36.11 17 google.golang.org/protobuf v1.36.11
17 gopkg.in/yaml.v3 v3.0.1 18 gopkg.in/yaml.v3 v3.0.1
18 modernc.org/sqlite v1.52.0 19 modernc.org/sqlite v1.52.0
hack/eitri-ca
Old New
@@ -1,14 +0,0 @@
1 #!/usr/bin/env bash
2 # eitri-ca — register a BYO user-CA public key with a tenant.
3 # eitri-ca upload [<tenant>] <ca-public-key-file>
4 # Env: EITRI_URL, EITRI_TOKEN (admin). Tenant defaults to "default".
5 set -eu
6 [ "${1:-}" = upload ] || { echo "usage: eitri-ca upload [<tenant>] <ca.pub>" >&2; exit 2; }
7 shift
8 if [ "$#" -eq 2 ]; then TENANT=$1; PUBFILE=$2; else TENANT=default; PUBFILE=$1; fi
9 : "${EITRI_URL:?}" "${EITRI_TOKEN:?}"
10 PUB=$(cat "$PUBFILE")
11 curl -fsS -X POST -H "Authorization: Bearer $EITRI_TOKEN" -H 'Content-Type: application/json' \
12 -d "{\"public_key\":$(printf '%s' "$PUB" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')}" \
13 "$EITRI_URL/api/v1/tenants/$TENANT/user-cas"
14 echo
hack/eitri-ssh
Old New
@@ -1,141 +0,0 @@
1 #!/usr/bin/env bash
2 #
3 # eitri-ssh — self-sign a short-lived SSH user cert with YOUR OWN tenant user CA
4 # and SSH into a VM through the eitri jump gate in one shot. The server never
5 # mints user certs (and never sees your CA's private key) — it only holds the
6 # CA's public key, uploaded once via `eitri-ca upload`.
7 #
8 # Usage:
9 # eitri-ssh <vm-name> [extra ssh args/command...]
10 # eitri-ssh --help
11 #
12 # Environment:
13 # EITRI_URL Base URL of the eitri server (e.g. https://eitri.example.com)
14 # EITRI_GATE Jump gate address for `ssh -J` (e.g. eitri.example.com:2222)
15 # EITRI_CA Path to your tenant user-CA PRIVATE key (default ~/.ssh/eitri_user_ca)
16 # EITRI_TENANT Tenant your user CA was uploaded to (default "default")
17 # EITRI_KEY Optional path to the SSH private key (default ~/.ssh/id_ed25519)
18 # EITRI_KNOWN_HOSTS Optional eitri-managed known_hosts file
19 # (default ~/.ssh/eitri_known_hosts)
20 #
21 # The self-signed cert is written beside the key as "<key>-cert.pub", which
22 # OpenSSH auto-offers. The inner login user is always "ubuntu" (the cert
23 # principal); the outer gate hop accepts any username. Certs are short-lived —
24 # just re-run to refresh.
25 #
26 # VMs are dialed by their gate connect name <tenant>.<vm-name> (which is also
27 # the VM's host-cert principal); eitri-ssh builds it from EITRI_TENANT, so you
28 # pass just the bare <vm-name>.
29 #
30 # Host verification is by CERTIFICATE, not TOFU: the helper fetches eitri's HOST
31 # CA public key (from the same `/api/v1/ssh-ca` endpoint, now serving the host
32 # CA) and pins it as a `@cert-authority *` entry in a DEDICATED known_hosts file
33 # (never your main ~/.ssh/known_hosts — a wildcard cert authority there would
34 # trust eitri's CA for every host you ssh to). Both the gate hop and the VM hop
35 # are then verified against that CA with StrictHostKeyChecking=yes. See
36 # docs/ssh-access.md.
37
38 set -eu
39
40 usage() {
41 sed -n '3,19p' "$0" | sed 's/^# \{0,1\}//'
42 exit "${1:-0}"
43 }
44
45 case "${1:-}" in
46 -h | --help | "") usage 0 ;;
47 esac
48
49 VM=$1
50 shift
51
52 : "${EITRI_URL:?set EITRI_URL to the eitri server base URL}"
53 : "${EITRI_GATE:?set EITRI_GATE to the jump gate host:port}"
54 KEY=${EITRI_KEY:-$HOME/.ssh/id_ed25519}
55 # A DEDICATED known_hosts for the `@cert-authority *` pin — deliberately NOT the
56 # user's main known_hosts, where a wildcard CA would apply to every ssh target.
57 KNOWN_HOSTS=${EITRI_KNOWN_HOSTS:-$HOME/.ssh/eitri_known_hosts}
58 : "${EITRI_CA:=$HOME/.ssh/eitri_user_ca}" # member's user-CA PRIVATE key
59 : "${EITRI_TENANT:=default}"
60
61 # json_field <name>: extract a top-level JSON string field from stdin. Uses jq
62 # when available, else a sed fallback. The capture is NON-GREEDY (`[^"]*`, not
63 # `.*`) so a multi-field body doesn't swallow through to the last quote.
64 # `[^"]*` is safe because the server's JSON string values contain no raw double
65 # quotes; `\n` escapes are unescaped so a multi-line value survives.
66 json_field() {
67 if command -v jq >/dev/null 2>&1; then
68 jq -r ".$1"
69 else
70 sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | sed 's/\\n/\n/g'
71 fi
72 }
73
74 # 1. Ensure a keypair exists.
75 if [ ! -f "$KEY" ]; then
76 echo "eitri-ssh: generating SSH key at $KEY" >&2
77 ssh-keygen -t ed25519 -N '' -f "$KEY" >/dev/null
78 fi
79
80 # 2. Self-sign a short-lived cert for our public key with OUR OWN user CA — no
81 # server mint involved. The server never sees (or holds) a user private key;
82 # it only ever saw the CA's PUBLIC key at `eitri-ca upload` time.
83 [ -f "$EITRI_CA" ] || { echo "eitri-ssh: no user CA at $EITRI_CA (generate one and 'eitri-ca upload')" >&2; exit 1; }
84 ssh-keygen -s "$EITRI_CA" -I "$(whoami)@$(hostname)" -n ubuntu -V +30m "$KEY.pub" >/dev/null
85
86 # 3. Build the gate connect name <tenant>.<vm>. The gate resolves names WITHIN
87 # a tenant and each VM's host-cert principal is the namespaced name, so the
88 # connect name must carry the tenant prefix. The tenant is no longer told
89 # to us by a mint response — it's just EITRI_TENANT, since our CA already
90 # only signs for the one tenant it was uploaded to.
91 TARGET="$EITRI_TENANT.$VM"
92
93 # 4. Fetch the eitri HOST CA public key and pin it as a `@cert-authority *`
94 # entry so BOTH hops are verified by certificate (no TOFU). The CA endpoint
95 # is public (no token). We overwrite the dedicated known_hosts each run so
96 # it always reflects the current CA — this file holds nothing but the eitri
97 # pin.
98 ca_resp=$(curl -sS -w '\n%{http_code}' "$EITRI_URL/api/v1/ssh-ca")
99 ca_code=${ca_resp##*$'\n'}
100 ca_body=${ca_resp%$'\n'*}
101 if [ "$ca_code" != "200" ]; then
102 echo "eitri-ssh: fetch CA failed (HTTP $ca_code): $ca_body" >&2
103 exit 1
104 fi
105 ca=$(printf '%s' "$ca_body" | json_field ca)
106 if [ -z "$ca" ] || [ "$ca" = "null" ]; then
107 echo "eitri-ssh: could not extract CA key from response: $ca_body" >&2
108 exit 1
109 fi
110 mkdir -p "$(dirname "$KNOWN_HOSTS")"
111 # Trim any trailing newline the CA line carries, then write the single pin.
112 printf '@cert-authority * %s\n' "$(printf '%s' "$ca" | tr -d '\r\n')" >"$KNOWN_HOSTS"
113
114 # 5. Hop through the gate to ubuntu@<vm>. Pass through any extra args/command.
115 #
116 # We do NOT use `ssh -J`: command-line `-o` options (host-key checking,
117 # known_hosts, key) reach ONLY the final hop, so on a machine with no tty the
118 # jump hop would fall back to the default policy. Instead we build an explicit
119 # ProxyCommand for the jump hop that carries the SAME host-key options as the
120 # final hop, so BOTH hops verify the presented host cert against the eitri CA
121 # with StrictHostKeyChecking=yes.
122 #
123 # The gate host cert's principal must match $GATE_HOST (eitri's
124 # ssh_gate_domain); each VM's host cert principal is its <tenant>.<vm-name>
125 # connect name, which eitri-ssh builds from EITRI_TENANT. A mismatch is a
126 # hard failure, not a prompt — that is the point.
127 GATE_HOST=${EITRI_GATE%%:*}
128 GATE_PORT=${EITRI_GATE##*:}
129 [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22
130
131 PROXY="ssh -W %h:%p \
132 -o StrictHostKeyChecking=yes \
133 -o UserKnownHostsFile=$KNOWN_HOSTS \
134 -i $KEY -p $GATE_PORT ubuntu@$GATE_HOST"
135
136 exec ssh \
137 -o "ProxyCommand=$PROXY" \
138 -o StrictHostKeyChecking=yes \
139 -o "UserKnownHostsFile=$KNOWN_HOSTS" \
140 -i "$KEY" \
141 "ubuntu@$TARGET" "$@"
internal/arch/execwalk_test.go
Old New
@@ -46,7 +46,13 @@ func TestExecViolationsDetectsWrappers(t *testing.T) {
46 }) 46 })
47 47
48 t.Run("agent: allowed package is exempt and does not taint importers", func(t *testing.T) { 48 t.Run("agent: allowed package is exempt and does not taint importers", func(t *testing.T) {
49 allowed := map[string]bool{m + "/internal/agent/cloudhv": true} 49 allowed := map[string]bool{
50 m + "/internal/agent/cloudhv": true,
51 // internal/cli: interactive sessions must be the real OpenSSH
52 // client (TTY, escapes, agent forwarding); internal/cli execs it
53 // deliberately.
54 m + "/internal/cli": true,
55 }
50 v := execViolations(graph, m, "internal/agent/", allowed) 56 v := execViolations(graph, m, "internal/agent/", allowed)
51 // Includes the documented design decision: spawnhelper (a non-allowed 57 // Includes the documented design decision: spawnhelper (a non-allowed
52 // exec user) is reachable ONLY through cloudhv, so it is sanctioned by 58 // exec user) is reachable ONLY through cloudhv, so it is sanctioned by
internal/cli/ca.go
Old New
@@ -0,0 +1,32 @@
1 package cli
2
3 import (
4 "context"
5 "fmt"
6 "os"
7
8 "golang.org/x/crypto/ssh"
9
10 "github.com/a73x/eitri/internal/server/api/client"
11 )
12
13 // UploadUserCA registers the tenant user-CA public key in pubFile with the
14 // server (admin-authenticated). The key is parse-validated locally — garbage
15 // is rejected before it leaves the machine — and the returned summary line
16 // carries the locally computed fingerprint (identical to the server's echo:
17 // both fingerprint the same key).
18 func UploadUserCA(ctx context.Context, baseURL, token, tenant, pubFile string) (string, error) {
19 pub, err := os.ReadFile(pubFile)
20 if err != nil {
21 return "", err
22 }
23 pk, _, _, _, err := ssh.ParseAuthorizedKey(pub)
24 if err != nil {
25 return "", fmt.Errorf("%s: not an SSH public key: %w", pubFile, err)
26 }
27 c := &client.Client{BaseURL: baseURL, Token: token}
28 if err := c.UploadUserCA(ctx, tenant, string(pub)); err != nil {
29 return "", err
30 }
31 return fmt.Sprintf("registered user CA %s for tenant %s", ssh.FingerprintSHA256(pk), tenant), nil
32 }
internal/cli/ca_test.go
Old New
@@ -0,0 +1,81 @@
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "golang.org/x/crypto/ssh"
15 )
16
17 // A real (public) ed25519 key line — uploads are parse-validated locally.
18 const testUserCALine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZ8BFXvSU9tCz3sm5uuXG8UXsRWCkEBHYBJk8OjJgeA me@laptop\n"
19
20 func TestUploadUserCA(t *testing.T) {
21 var gotPath, gotAuth, gotKey string
22 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23 gotPath = r.URL.Path
24 gotAuth = r.Header.Get("Authorization")
25 body, _ := io.ReadAll(r.Body)
26 var m map[string]string
27 json.Unmarshal(body, &m)
28 gotKey = m["public_key"]
29 w.Write([]byte(`{"fingerprint":"SHA256:server-echo"}`))
30 }))
31 defer srv.Close()
32
33 pub := filepath.Join(t.TempDir(), "ca.pub")
34 os.WriteFile(pub, []byte(testUserCALine), 0o644)
35
36 out, err := UploadUserCA(context.Background(), srv.URL, "tok123", "default", pub)
37 if err != nil {
38 t.Fatal(err)
39 }
40 if gotPath != "/api/v1/tenants/default/user-cas" || gotAuth != "Bearer tok123" {
41 t.Errorf("request: %s %s", gotPath, gotAuth)
42 }
43 if gotKey != testUserCALine {
44 t.Errorf("public_key = %q", gotKey)
45 }
46 // The summary line carries the LOCALLY computed fingerprint of the
47 // uploaded key (same value the server echoes) and the tenant.
48 pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testUserCALine))
49 if err != nil {
50 t.Fatal(err)
51 }
52 if !strings.Contains(out, ssh.FingerprintSHA256(pk)) || !strings.Contains(out, "default") {
53 t.Errorf("out = %q", out)
54 }
55 }
56
57 func TestUploadUserCAErrorSurfacesBody(t *testing.T) {
58 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59 http.Error(w, "tenant not found", http.StatusNotFound)
60 }))
61 defer srv.Close()
62 pub := filepath.Join(t.TempDir(), "ca.pub")
63 os.WriteFile(pub, []byte(testUserCALine), 0o644)
64 _, err := UploadUserCA(context.Background(), srv.URL, "t", "nope", pub)
65 if err == nil || !strings.Contains(err.Error(), "tenant not found") {
66 t.Fatalf("want body in error, got %v", err)
67 }
68 }
69
70 func TestUploadUserCARejectsGarbageLocally(t *testing.T) {
71 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72 t.Error("garbage key must be rejected before any request leaves the machine")
73 }))
74 defer srv.Close()
75 pub := filepath.Join(t.TempDir(), "ca.pub")
76 os.WriteFile(pub, []byte("not a key at all\n"), 0o644)
77 _, err := UploadUserCA(context.Background(), srv.URL, "t", "default", pub)
78 if err == nil || !strings.Contains(err.Error(), "not an SSH public key") {
79 t.Fatalf("want local parse error, got %v", err)
80 }
81 }
internal/cli/env.go
Old New
@@ -0,0 +1,54 @@
1 // Package cli implements the eitri client binary: self-signed short-lived SSH
2 // certs with the tenant's own user CA, host verification pinned to eitri's
3 // host CA, sessions through the system ssh, and tenant CA registration. It is
4 // the compiled successor of the hack/ scripts and keeps their env contract.
5 // The crypto boundary is unchanged: eitri never sees a user private key.
6 package cli
7
8 import (
9 "fmt"
10 "os"
11 "path/filepath"
12 )
13
14 // Env is the client configuration, resolved from EITRI_* variables.
15 type Env struct {
16 URL string // server base URL (required)
17 Gate string // gate host[:port] (required)
18 CA string // tenant user-CA private key path
19 Tenant string
20 Key string // user SSH private key path
21 KnownHosts string // dedicated pin file — never the user's main known_hosts
22 }
23
24 // FromEnv resolves the env contract shared with the former scripts:
25 // EITRI_URL and EITRI_GATE are required; the rest default under $HOME/.ssh.
26 func FromEnv() (Env, error) {
27 e := Env{
28 URL: os.Getenv("EITRI_URL"),
29 Gate: os.Getenv("EITRI_GATE"),
30 CA: os.Getenv("EITRI_CA"),
31 Tenant: os.Getenv("EITRI_TENANT"),
32 Key: os.Getenv("EITRI_KEY"),
33 KnownHosts: os.Getenv("EITRI_KNOWN_HOSTS"),
34 }
35 if e.URL == "" || e.Gate == "" {
36 return Env{}, fmt.Errorf("set EITRI_URL (server base URL) and EITRI_GATE (gate host:port)")
37 }
38 home, err := os.UserHomeDir()
39 if err != nil {
40 return Env{}, err
41 }
42 def := func(p *string, name string) {
43 if *p == "" {
44 *p = filepath.Join(home, ".ssh", name)
45 }
46 }
47 def(&e.CA, "eitri_user_ca")
48 def(&e.Key, "id_ed25519")
49 def(&e.KnownHosts, "eitri_known_hosts")
50 if e.Tenant == "" {
51 e.Tenant = "default"
52 }
53 return e, nil
54 }
internal/cli/env_test.go
Old New
@@ -0,0 +1,41 @@
1 package cli
2
3 import (
4 "path/filepath"
5 "testing"
6 )
7
8 func TestFromEnvDefaults(t *testing.T) {
9 t.Setenv("EITRI_URL", "http://192.0.2.10:8080")
10 t.Setenv("EITRI_GATE", "192.0.2.10:2222")
11 for _, v := range []string{"EITRI_CA", "EITRI_TENANT", "EITRI_KEY", "EITRI_KNOWN_HOSTS"} {
12 t.Setenv(v, "")
13 }
14 t.Setenv("HOME", "/home/u")
15
16 e, err := FromEnv()
17 if err != nil {
18 t.Fatal(err)
19 }
20 if e.URL != "http://192.0.2.10:8080" || e.Gate != "192.0.2.10:2222" {
21 t.Errorf("required fields: %+v", e)
22 }
23 for got, want := range map[string]string{
24 e.CA: filepath.Join("/home/u", ".ssh", "eitri_user_ca"),
25 e.Tenant: "default",
26 e.Key: filepath.Join("/home/u", ".ssh", "id_ed25519"),
27 e.KnownHosts: filepath.Join("/home/u", ".ssh", "eitri_known_hosts"),
28 } {
29 if got != want {
30 t.Errorf("default: got %q want %q", got, want)
31 }
32 }
33 }
34
35 func TestFromEnvRequiresURLAndGate(t *testing.T) {
36 t.Setenv("EITRI_URL", "")
37 t.Setenv("EITRI_GATE", "")
38 if _, err := FromEnv(); err == nil {
39 t.Fatal("want error without EITRI_URL/EITRI_GATE")
40 }
41 }
internal/cli/mint.go
Old New
@@ -0,0 +1,158 @@
1 package cli
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "encoding/binary"
7 "encoding/pem"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "time"
13
14 "golang.org/x/crypto/ssh"
15 "golang.org/x/term"
16 )
17
18 // EnsureKeypair generates an ed25519 keypair at keyPath (+ .pub) if absent.
19 // An existing key is never touched. The private key is created O_EXCL so a
20 // concurrent first run cannot interleave two generations; the .pub (a
21 // convenience copy — minting and ssh derive the public key from the private
22 // key) is written after, by whichever call won the create.
23 func EnsureKeypair(keyPath string) error {
24 if _, err := os.Stat(keyPath); err == nil {
25 return nil
26 } else if !os.IsNotExist(err) {
27 return err
28 }
29 pub, priv, err := ed25519.GenerateKey(rand.Reader)
30 if err != nil {
31 return err
32 }
33 block, err := ssh.MarshalPrivateKey(priv, "")
34 if err != nil {
35 return err
36 }
37 if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
38 return err
39 }
40 f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
41 if errors.Is(err, os.ErrExist) {
42 return nil // another eitri run won the race; its pair is authoritative
43 }
44 if err != nil {
45 return err
46 }
47 if _, err := f.Write(pem.EncodeToMemory(block)); err != nil {
48 f.Close()
49 return err
50 }
51 if err := f.Close(); err != nil {
52 return err
53 }
54 sshPub, err := ssh.NewPublicKey(pub)
55 if err != nil {
56 return err
57 }
58 fmt.Fprintf(os.Stderr, "eitri: generated SSH key at %s\n", keyPath)
59 return os.WriteFile(keyPath+".pub", ssh.MarshalAuthorizedKey(sshPub), 0o644)
60 }
61
62 // MintCert self-signs a short-lived user certificate for keyPath's public key
63 // with the tenant user CA at caPath, writing <keyPath>-cert.pub (which
64 // OpenSSH auto-offers). Mirrors internal/gateclient's mint, but for the
65 // user's persistent key and on-disk cert rather than an in-memory ephemeral.
66 // The five extensions are ssh-keygen's signing defaults — pty allocation
67 // breaks without them.
68 func MintCert(caPath, keyPath, keyID string) error {
69 raw, err := os.ReadFile(caPath)
70 if err != nil {
71 if os.IsNotExist(err) {
72 return fmt.Errorf("no user CA at %s — generate one (ssh-keygen -t ed25519 -f %s) and register it with 'eitri ca upload'", caPath, caPath)
73 }
74 return err
75 }
76 signer, err := ssh.ParsePrivateKey(raw)
77 if err != nil {
78 var pmerr *ssh.PassphraseMissingError
79 if !errors.As(err, &pmerr) {
80 return fmt.Errorf("parse user CA %s: %w", caPath, err)
81 }
82 if !term.IsTerminal(int(os.Stdin.Fd())) {
83 return fmt.Errorf("user CA %s is passphrase-protected; run interactively to enter it", caPath)
84 }
85 fmt.Fprintf(os.Stderr, "passphrase for %s: ", caPath)
86 pw, perr := term.ReadPassword(int(os.Stdin.Fd()))
87 fmt.Fprintln(os.Stderr)
88 if perr != nil {
89 return perr
90 }
91 signer, err = ssh.ParsePrivateKeyWithPassphrase(raw, pw)
92 if err != nil {
93 return fmt.Errorf("parse user CA %s: %w", caPath, err)
94 }
95 }
96
97 pub, err := userPublicKey(keyPath)
98 if err != nil {
99 return err
100 }
101
102 var serial uint64
103 if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil {
104 return err
105 }
106 now := time.Now()
107 cert := &ssh.Certificate{
108 Key: pub,
109 Serial: serial,
110 CertType: ssh.UserCert,
111 KeyId: keyID,
112 ValidPrincipals: []string{"ubuntu"},
113 ValidAfter: uint64(now.Add(-time.Minute).Unix()),
114 ValidBefore: uint64(now.Add(30 * time.Minute).Unix()),
115 Permissions: ssh.Permissions{Extensions: map[string]string{
116 "permit-X11-forwarding": "",
117 "permit-agent-forwarding": "",
118 "permit-port-forwarding": "",
119 "permit-pty": "",
120 "permit-user-rc": "",
121 }},
122 }
123 if err := cert.SignCert(rand.Reader, signer); err != nil {
124 return err
125 }
126 return os.WriteFile(keyPath+"-cert.pub", ssh.MarshalAuthorizedKey(cert), 0o644)
127 }
128
129 // userPublicKey derives the user's public key from the private key at
130 // keyPath, falling back to <keyPath>.pub when the private key is
131 // passphrase-encrypted (ssh prompts for the passphrase itself at connect
132 // time; minting only needs the public half).
133 func userPublicKey(keyPath string) (ssh.PublicKey, error) {
134 keyRaw, err := os.ReadFile(keyPath)
135 if err != nil {
136 return nil, err
137 }
138 signer, err := ssh.ParsePrivateKey(keyRaw)
139 if err == nil {
140 return signer.PublicKey(), nil
141 }
142 var pmerr *ssh.PassphraseMissingError
143 if !errors.As(err, &pmerr) {
144 return nil, fmt.Errorf("parse %s: %w", keyPath, err)
145 }
146 if pmerr.PublicKey != nil { // openssh format embeds the public key unencrypted
147 return pmerr.PublicKey, nil
148 }
149 pubRaw, err := os.ReadFile(keyPath + ".pub")
150 if err != nil {
151 return nil, fmt.Errorf("%s is passphrase-encrypted and %s.pub is unreadable: %w", keyPath, keyPath, err)
152 }
153 pub, _, _, _, perr := ssh.ParseAuthorizedKey(pubRaw)
154 if perr != nil {
155 return nil, fmt.Errorf("parse %s.pub: %w", keyPath, perr)
156 }
157 return pub, nil
158 }
internal/cli/mint_test.go
Old New
@@ -0,0 +1,215 @@
1 package cli
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "encoding/pem"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "golang.org/x/crypto/ssh"
14 )
15
16 // newCA writes an unencrypted ed25519 CA private key and returns its path
17 // and public key.
18 func newCA(t *testing.T) (string, ssh.PublicKey) {
19 t.Helper()
20 dir := t.TempDir()
21 caPath := filepath.Join(dir, "ca")
22 if err := EnsureKeypair(caPath); err != nil {
23 t.Fatal(err)
24 }
25 pub, err := os.ReadFile(caPath + ".pub")
26 if err != nil {
27 t.Fatal(err)
28 }
29 k, _, _, _, err := ssh.ParseAuthorizedKey(pub)
30 if err != nil {
31 t.Fatal(err)
32 }
33 return caPath, k
34 }
35
36 func TestEnsureKeypairGeneratesOnceAndParses(t *testing.T) {
37 key := filepath.Join(t.TempDir(), "id_ed25519")
38 if err := EnsureKeypair(key); err != nil {
39 t.Fatal(err)
40 }
41 priv1, err := os.ReadFile(key)
42 if err != nil {
43 t.Fatal(err)
44 }
45 if _, err := ssh.ParsePrivateKey(priv1); err != nil {
46 t.Fatalf("generated key unparsable: %v", err)
47 }
48 if fi, _ := os.Stat(key); fi.Mode().Perm() != 0o600 {
49 t.Errorf("key mode = %v", fi.Mode().Perm())
50 }
51 // Second call must not touch the existing key.
52 if err := EnsureKeypair(key); err != nil {
53 t.Fatal(err)
54 }
55 priv2, _ := os.ReadFile(key)
56 if string(priv1) != string(priv2) {
57 t.Error("EnsureKeypair overwrote an existing key")
58 }
59 }
60
61 func TestMintCertShape(t *testing.T) {
62 caPath, caPub := newCA(t)
63 key := filepath.Join(t.TempDir(), "id_ed25519")
64 if err := EnsureKeypair(key); err != nil {
65 t.Fatal(err)
66 }
67
68 if err := MintCert(caPath, key, "tester@box"); err != nil {
69 t.Fatal(err)
70 }
71
72 raw, err := os.ReadFile(key + "-cert.pub")
73 if err != nil {
74 t.Fatal(err)
75 }
76 k, _, _, _, err := ssh.ParseAuthorizedKey(raw)
77 if err != nil {
78 t.Fatal(err)
79 }
80 cert, ok := k.(*ssh.Certificate)
81 if !ok {
82 t.Fatalf("not a certificate: %T", k)
83 }
84 if cert.CertType != ssh.UserCert || cert.KeyId != "tester@box" {
85 t.Errorf("type/keyid: %v %q", cert.CertType, cert.KeyId)
86 }
87 if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != "ubuntu" {
88 t.Errorf("principals: %v", cert.ValidPrincipals)
89 }
90 now := time.Now().Unix()
91 if int64(cert.ValidAfter) > now || int64(cert.ValidBefore) < now+25*60 || int64(cert.ValidBefore) > now+35*60 {
92 t.Errorf("validity window: after=%d before=%d now=%d", cert.ValidAfter, cert.ValidBefore, now)
93 }
94 for _, ext := range []string{"permit-X11-forwarding", "permit-agent-forwarding", "permit-port-forwarding", "permit-pty", "permit-user-rc"} {
95 if _, ok := cert.Permissions.Extensions[ext]; !ok {
96 t.Errorf("missing extension %s (pty allocation breaks without defaults)", ext)
97 }
98 }
99 // Signed by OUR CA.
100 checker := ssh.CertChecker{IsUserAuthority: func(a ssh.PublicKey) bool {
101 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(a))) ==
102 strings.TrimSpace(string(ssh.MarshalAuthorizedKey(caPub)))
103 }}
104 if err := checker.CheckCert("ubuntu", cert); err != nil {
105 t.Errorf("cert not accepted by its own CA: %v", err)
106 }
107 }
108
109 func TestMintCertMissingCA(t *testing.T) {
110 key := filepath.Join(t.TempDir(), "id_ed25519")
111 if err := EnsureKeypair(key); err != nil {
112 t.Fatal(err)
113 }
114 err := MintCert(filepath.Join(t.TempDir(), "nope"), key, "x")
115 if err == nil || !strings.Contains(err.Error(), "eitri ca upload") {
116 t.Fatalf("want actionable no-CA error, got %v", err)
117 }
118 }
119
120 func TestMintCertDerivesPubFromPrivateKey(t *testing.T) {
121 caPath, _ := newCA(t)
122 key := filepath.Join(t.TempDir(), "id_ed25519")
123 if err := EnsureKeypair(key); err != nil {
124 t.Fatal(err)
125 }
126 // A stale or missing .pub must not matter: the mint derives from the key.
127 if err := os.Remove(key + ".pub"); err != nil {
128 t.Fatal(err)
129 }
130 if err := MintCert(caPath, key, "x"); err != nil {
131 t.Fatalf("mint with missing .pub: %v", err)
132 }
133 raw, err := os.ReadFile(key + "-cert.pub")
134 if err != nil {
135 t.Fatal(err)
136 }
137 k, _, _, _, err := ssh.ParseAuthorizedKey(raw)
138 if err != nil {
139 t.Fatal(err)
140 }
141 cert := k.(*ssh.Certificate)
142 priv, _ := os.ReadFile(key)
143 signer, _ := ssh.ParsePrivateKey(priv)
144 if string(cert.Key.Marshal()) != string(signer.PublicKey().Marshal()) {
145 t.Error("cert key does not match the private key on disk")
146 }
147 }
148
149 func TestMintCertEncryptedCANonInteractive(t *testing.T) {
150 dir := t.TempDir()
151 caPath := filepath.Join(dir, "ca")
152 _, priv, err := ed25519GenerateForTest()
153 if err != nil {
154 t.Fatal(err)
155 }
156 block, err := ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte("secret"))
157 if err != nil {
158 t.Fatal(err)
159 }
160 if err := os.WriteFile(caPath, pem.EncodeToMemory(block), 0o600); err != nil {
161 t.Fatal(err)
162 }
163 key := filepath.Join(dir, "id_ed25519")
164 if err := EnsureKeypair(key); err != nil {
165 t.Fatal(err)
166 }
167 // Non-interactive stdin (go test): must fail fast with a helpful error,
168 // never hang on a passphrase prompt.
169 err = MintCert(caPath, key, "x")
170 if err == nil || !strings.Contains(err.Error(), "passphrase") {
171 t.Fatalf("want passphrase-protected error, got %v", err)
172 }
173 }
174
175 func TestMintCertEncryptedUserKeyUsesEmbeddedPublicKey(t *testing.T) {
176 caPath, _ := newCA(t)
177 dir := t.TempDir()
178 key := filepath.Join(dir, "id_ed25519")
179 pub, priv, err := ed25519GenerateForTest()
180 if err != nil {
181 t.Fatal(err)
182 }
183 // Passphrase-encrypted USER key: minting must still work — the openssh
184 // format embeds the public key unencrypted, and only the public half is
185 // needed (ssh prompts for the passphrase itself at connect time).
186 block, err := ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte("secret"))
187 if err != nil {
188 t.Fatal(err)
189 }
190 if err := os.WriteFile(key, pem.EncodeToMemory(block), 0o600); err != nil {
191 t.Fatal(err)
192 }
193 if err := MintCert(caPath, key, "x"); err != nil {
194 t.Fatalf("mint with encrypted user key: %v", err)
195 }
196 raw, err := os.ReadFile(key + "-cert.pub")
197 if err != nil {
198 t.Fatal(err)
199 }
200 k, _, _, _, err := ssh.ParseAuthorizedKey(raw)
201 if err != nil {
202 t.Fatal(err)
203 }
204 sshPub, err := ssh.NewPublicKey(pub)
205 if err != nil {
206 t.Fatal(err)
207 }
208 if string(k.(*ssh.Certificate).Key.Marshal()) != string(sshPub.Marshal()) {
209 t.Error("cert key does not match the encrypted private key's public half")
210 }
211 }
212
213 func ed25519GenerateForTest() (ed25519.PublicKey, ed25519.PrivateKey, error) {
214 return ed25519.GenerateKey(rand.Reader)
215 }
internal/cli/pin.go
Old New
@@ -0,0 +1,43 @@
1 package cli
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "golang.org/x/crypto/ssh"
11
12 "github.com/a73x/eitri/internal/server/api/client"
13 )
14
15 // WriteHostCAPin fetches eitri's host-CA public key (public endpoint) and
16 // overwrites pinPath with the single `@cert-authority *` line. A dedicated
17 // file, never the user's main known_hosts: a wildcard cert authority there
18 // would trust eitri's CA for every host the user sshes to.
19 func WriteHostCAPin(ctx context.Context, baseURL, pinPath string) error {
20 c := &client.Client{BaseURL: baseURL}
21 line, err := c.FetchSSHCALine(ctx)
22 if err != nil {
23 return err
24 }
25 // The client already parse-validated the line; this re-parse feeds the
26 // canonical re-marshal — one clean line whatever whitespace arrived —
27 // while preserving the server's comment (e.g. "eitri-host-ca").
28 caKey, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
29 if err != nil {
30 return fmt.Errorf("fetch host CA: not an SSH public key: %w", err)
31 }
32 if err := os.MkdirAll(filepath.Dir(pinPath), 0o700); err != nil {
33 return err
34 }
35 marshaled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(caKey)))
36 var pin string
37 if comment != "" {
38 pin = "@cert-authority * " + marshaled + " " + comment + "\n"
39 } else {
40 pin = "@cert-authority * " + marshaled + "\n"
41 }
42 return os.WriteFile(pinPath, []byte(pin), 0o644)
43 }
internal/cli/pin_test.go
Old New
@@ -0,0 +1,58 @@
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "testing"
11 )
12
13 func TestWriteHostCAPin(t *testing.T) {
14 // A real (public) ed25519 key line — the pin now refuses unparsable CAs.
15 const caLine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZ8BFXvSU9tCz3sm5uuXG8UXsRWCkEBHYBJk8OjJgeA eitri-host-ca"
16 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17 if r.URL.Path != "/api/v1/ssh-ca" {
18 http.NotFound(w, r)
19 return
20 }
21 json.NewEncoder(w).Encode(map[string]string{"ca": caLine + "\r\n"})
22 }))
23 defer srv.Close()
24
25 pin := filepath.Join(t.TempDir(), "sub", "eitri_known_hosts")
26 // Trailing-slash base URL must work too.
27 if err := WriteHostCAPin(context.Background(), srv.URL+"/", pin); err != nil {
28 t.Fatal(err)
29 }
30 got, err := os.ReadFile(pin)
31 if err != nil {
32 t.Fatal(err)
33 }
34 want := "@cert-authority * " + caLine + "\n"
35 if string(got) != want {
36 t.Errorf("pin = %q, want %q", got, want)
37 }
38 if err := WriteHostCAPin(context.Background(), srv.URL, pin); err != nil {
39 t.Fatal(err)
40 }
41 if b, _ := os.ReadFile(pin); string(b) != want {
42 t.Errorf("pin after rewrite = %q", b)
43 }
44 }
45
46 func TestWriteHostCAPinRejectsGarbage(t *testing.T) {
47 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48 w.Write([]byte(`{"ca":"not a key at all"}`))
49 }))
50 defer srv.Close()
51 pin := filepath.Join(t.TempDir(), "kh")
52 if err := WriteHostCAPin(context.Background(), srv.URL, pin); err == nil {
53 t.Fatal("want error for unparsable CA")
54 }
55 if _, err := os.Stat(pin); !os.IsNotExist(err) {
56 t.Error("garbage CA must not be written to the pin file")
57 }
58 }
internal/cli/sshcmd.go
Old New
@@ -0,0 +1,77 @@
1 package cli
2
3 import (
4 "context"
5 "fmt"
6 "net"
7 "os"
8 "os/exec"
9 "os/user"
10 "strings"
11 "syscall"
12 )
13
14 // SSHArgv builds the system-ssh invocation for a session to <tenant>.<vm>.
15 // The gate hop rides an explicit ProxyCommand — NOT -J — because command-line
16 // -o options reach only the final hop; both hops must verify the presented
17 // host certificate against the pinned eitri CA with strict checking. The
18 // ProxyCommand value is run by the user's shell, so embedded paths are
19 // single-quoted (spaced $HOME paths are normal on macOS). The vm name reaches
20 // the shell via ssh's %h expansion; modern OpenSSH rejects hostnames with
21 // shell metacharacters itself — that hardening, not this code, is what blocks
22 // injection there. This argv shape is load-bearing; change it only with the
23 // table tests.
24 func SSHArgv(e Env, vm string, extra []string) []string {
25 gateHost, gatePort, err := net.SplitHostPort(e.Gate)
26 if err != nil {
27 gateHost, gatePort = e.Gate, "22"
28 }
29 proxy := fmt.Sprintf(
30 "ssh -W %%h:%%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -i %s -p %s ubuntu@%s",
31 shq(e.KnownHosts), shq(e.Key), gatePort, gateHost)
32 argv := []string{
33 "ssh",
34 "-o", "ProxyCommand=" + proxy,
35 "-o", "StrictHostKeyChecking=yes",
36 "-o", "UserKnownHostsFile=" + e.KnownHosts,
37 "-i", e.Key,
38 "ubuntu@" + e.Tenant + "." + vm,
39 }
40 return append(argv, extra...)
41 }
42
43 // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via
44 // the user's shell).
45 func shq(s string) string {
46 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
47 }
48
49 // keyID labels minted certs user@host, matching the scripts' -I value.
50 func keyID() string {
51 name := "eitri"
52 if u, err := user.Current(); err == nil && u.Username != "" {
53 name = u.Username
54 }
55 host, _ := os.Hostname()
56 return name + "@" + host
57 }
58
59 // RunSSH prepares credentials (keypair, cert, host-CA pin) and replaces this
60 // process with the system ssh. Exec (not a child process) so the TTY, signals,
61 // and exit code belong to ssh itself.
62 func RunSSH(ctx context.Context, e Env, vm string, extra []string) error {
63 if err := EnsureKeypair(e.Key); err != nil {
64 return err
65 }
66 if err := MintCert(e.CA, e.Key, keyID()); err != nil {
67 return err
68 }
69 if err := WriteHostCAPin(ctx, e.URL, e.KnownHosts); err != nil {
70 return err
71 }
72 sshPath, err := exec.LookPath("ssh")
73 if err != nil {
74 return fmt.Errorf("ssh not found on PATH — install an OpenSSH client")
75 }
76 return syscall.Exec(sshPath, SSHArgv(e, vm, extra), os.Environ())
77 }
internal/cli/sshcmd_test.go
Old New
@@ -0,0 +1,75 @@
1 package cli
2
3 import (
4 "slices"
5 "strings"
6 "testing"
7 )
8
9 func testEnv() Env {
10 return Env{
11 URL: "http://s:8080", Gate: "gate.example:2222", Tenant: "default",
12 Key: "/home/u/.ssh/id_ed25519", KnownHosts: "/home/u/.ssh/eitri_known_hosts",
13 }
14 }
15
16 // The argv shape is load-bearing (two verified hops); pin it exactly.
17 func TestSSHArgvTwoVerifiedHops(t *testing.T) {
18 got := SSHArgv(testEnv(), "dev", nil)
19 wantProxy := "ssh -W %h:%p -o StrictHostKeyChecking=yes" +
20 " -o UserKnownHostsFile='/home/u/.ssh/eitri_known_hosts'" +
21 " -i '/home/u/.ssh/id_ed25519' -p 2222 ubuntu@gate.example"
22 want := []string{
23 "ssh",
24 "-o", "ProxyCommand=" + wantProxy,
25 "-o", "StrictHostKeyChecking=yes",
26 "-o", "UserKnownHostsFile=/home/u/.ssh/eitri_known_hosts",
27 "-i", "/home/u/.ssh/id_ed25519",
28 "ubuntu@default.dev",
29 }
30 if !slices.Equal(got, want) {
31 t.Errorf("argv:\n got %q\nwant %q", got, want)
32 }
33 }
34
35 func TestSSHArgvGateDefaultPort(t *testing.T) {
36 e := testEnv()
37 e.Gate = "gate.example"
38 got := SSHArgv(e, "dev", nil)
39 wantProxy := "ssh -W %h:%p -o StrictHostKeyChecking=yes" +
40 " -o UserKnownHostsFile='/home/u/.ssh/eitri_known_hosts'" +
41 " -i '/home/u/.ssh/id_ed25519' -p 22 ubuntu@gate.example"
42 if got[2] != "ProxyCommand="+wantProxy {
43 t.Errorf("proxy = %q, want %q", got[2], "ProxyCommand="+wantProxy)
44 }
45 }
46
47 func TestSSHArgvIPv6Gate(t *testing.T) {
48 e := testEnv()
49 e.Gate = "[::1]:2222"
50 got := SSHArgv(e, "dev", nil)
51 if want := " -p 2222 ubuntu@::1"; !slices.ContainsFunc(got, func(s string) bool {
52 return len(s) > len(want) && s[len(s)-len(want):] == want
53 }) {
54 t.Errorf("ipv6 gate not split host/port correctly: %q", got[2])
55 }
56 }
57
58 func TestSSHArgvSpacedPathsAreQuoted(t *testing.T) {
59 e := testEnv()
60 e.Key = "/Users/My Name/.ssh/id_ed25519"
61 got := SSHArgv(e, "dev", nil)
62 if want := "-i '/Users/My Name/.ssh/id_ed25519'"; !slices.ContainsFunc(got, func(s string) bool {
63 return strings.Contains(s, want)
64 }) {
65 t.Errorf("spaced key path not quoted in proxy: %q", got[2])
66 }
67 }
68
69 func TestSSHArgvExtraArgsPassThrough(t *testing.T) {
70 got := SSHArgv(testEnv(), "dev", []string{"uptime", "-p"})
71 n := len(got)
72 if got[n-2] != "uptime" || got[n-1] != "-p" {
73 t.Errorf("extra args not trailing: %v", got[n-3:])
74 }
75 }
internal/server/api/client/client.go
Old New
@@ -1,8 +1,8 @@
1 // Package client is THE Go client for the eitri control-plane HTTP API — the 1 // Package client is THE Go client for the eitri control-plane HTTP API — the
2 // one consumer every in-repo caller (MCP server, smoke gate) goes through. 2 // one consumer every in-repo caller (MCP server, smoke gate, CLI) goes
3 // Its method set is exactly what those consumers use, nothing more: a new 3 // through. Its method set is exactly what those consumers use, nothing more:
4 // endpoint call starts by adding a method here (an arch fitness rule enforces 4 // a new endpoint call starts by adding a method here (an arch fitness rule
5 // that no other package speaks the API's HTTP directly). 5 // enforces that no other package speaks the API's HTTP directly).
6 // 6 //
7 // The wire shapes come from internal/server/api/types; the ones consumers 7 // The wire shapes come from internal/server/api/types; the ones consumers
8 // need are re-exported as aliases so callers import only this package. 8 // need are re-exported as aliases so callers import only this package.
internal/server/api/client/client_test.go
Old New
@@ -22,7 +22,7 @@ import (
22 var _ gateclient.CertAuthority = (*client.Client)(nil) 22 var _ gateclient.CertAuthority = (*client.Client)(nil)
23 23
24 // testCALine is a valid ed25519 authorized_keys line WITH a trailing comment; 24 // testCALine is a valid ed25519 authorized_keys line WITH a trailing comment;
25 // FetchSSHCALine must return it verbatim. 25 // FetchSSHCALine must return it verbatim (the cli's pin file keeps the comment).
26 const testCALine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZK1zVJTG0Opn0BktxOpCYhRXRPMFhZDwoT1PVCM1Sq eitri-host-ca" 26 const testCALine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZK1zVJTG0Opn0BktxOpCYhRXRPMFhZDwoT1PVCM1Sq eitri-host-ca"
27 27
28 // capture records what the handler saw so tests can assert on the request. 28 // capture records what the handler saw so tests can assert on the request.
internal/shape/classify.go
Old New
@@ -45,7 +45,8 @@ func classify(rel string) Plane {
45 strings.HasPrefix(rel, "internal/covsnap"), 45 strings.HasPrefix(rel, "internal/covsnap"),
46 strings.HasPrefix(rel, "internal/gateclient"), 46 strings.HasPrefix(rel, "internal/gateclient"),
47 strings.HasPrefix(rel, "internal/site"), 47 strings.HasPrefix(rel, "internal/site"),
48 strings.HasPrefix(rel, "internal/shape"): 48 strings.HasPrefix(rel, "internal/shape"),
49 strings.HasPrefix(rel, "internal/cli"):
49 return PlaneTooling 50 return PlaneTooling
50 default: 51 default:
51 return PlaneUnclassified 52 return PlaneUnclassified
scripts/coverage.sh
Old New
@@ -39,6 +39,7 @@ declare -A FLOOR=(
39 [internal/transport]=77 39 [internal/transport]=77
40 [internal/shape]=88 40 [internal/shape]=88
41 [internal/site]=80 41 [internal/site]=80
42 [internal/cli]=60
42 ) 43 )
43 44
44 profile="$(mktemp)" 45 profile="$(mktemp)"
scripts/release.sh
Old New
@@ -1,7 +1,7 @@
1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 # Cross-compiled release artifacts for eitri.sh, into dist/<version>/: 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 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) 4 # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin
5 # eitri-agent_linux_{amd64,arm64} bare binaries — what the agent 5 # eitri-agent_linux_{amd64,arm64} bare binaries — what the agent
6 # self-updater downloads and sha-verifies 6 # self-updater downloads and sha-verifies
7 # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream 7 # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream
@@ -36,11 +36,6 @@ if ! git describe --tags --exact-match >/dev/null 2>&1; then
36 echo "release: WARNING — HEAD is not a tag ($VERSION); agents never upgrade to unparsable versions" >&2 36 echo "release: WARNING — HEAD is not a tag ($VERSION); agents never upgrade to unparsable versions" >&2
37 fi 37 fi
38 38
39 [ -x hack/eitri-ssh ] && [ -x hack/eitri-ca ] || {
40 echo "release: hack/eitri-ssh or hack/eitri-ca missing/not executable (client bundle)" >&2
41 exit 1
42 }
43
44 LDFLAGS="-X github.com/a73x/eitri/internal/version.Version=$VERSION" 39 LDFLAGS="-X github.com/a73x/eitri/internal/version.Version=$VERSION"
45 OUT="dist/$VERSION" 40 OUT="dist/$VERSION"
46 rm -rf "$OUT" 41 rm -rf "$OUT"
@@ -63,10 +58,17 @@ for arch in amd64 arm64; do
63 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}" 58 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}"
64 done 59 done
65 60
66 stage="$STAGE_ROOT/ssh" 61 # Client CLI, cross-compiled for laptops (pure Go, CGO-free).
67 mkdir -p "$stage/eitri-ssh_$VERSION" 62 for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
68 cp hack/eitri-ssh hack/eitri-ca "$stage/eitri-ssh_$VERSION/" 63 os=${platform%%/*} arch=${platform##*/}
69 tar -C "$stage" -czf "$OUT/eitri-ssh_$VERSION.tar.gz" "eitri-ssh_$VERSION" 64 bundle="eitri-cli_${VERSION}_${os}_${arch}"
65 stage="$STAGE_ROOT/cli-$os-$arch"
66 mkdir -p "$stage/$bundle"
67 echo "==> building client $platform"
68 CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \
69 -o "$stage/$bundle/eitri" ./cmd/eitri
70 tar -C "$stage" -czf "$OUT/$bundle.tar.gz" "$bundle"
71 done
70 72
71 # Mirror the pinned cloud-hypervisor (agents bootstrap it from the manifest). 73 # Mirror the pinned cloud-hypervisor (agents bootstrap it from the manifest).
72 ch_cache="${CH_CACHE:-$HOME/.cache/eitri/ch}/$CH_VERSION" 74 ch_cache="${CH_CACHE:-$HOME/.cache/eitri/ch}/$CH_VERSION"
web/src/routes/+page.svelte
Old New
@@ -313,7 +313,7 @@
313 Generate a CA, paste its public key below, then connect: 313 Generate a CA, paste its public key below, then connect:
314 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code> 314 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code>
315 <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code> 315 <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code>
316 <code>EITRI_CA=~/.ssh/eitri_user_ca eitri-ssh &lt;vm-name&gt;</code> 316 <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh &lt;vm-name&gt;</code>
317 </div> 317 </div>
318 {:else} 318 {:else}
319 <table> 319 <table>