f6605653
feat(cli): the eitri client binary
a73x 2026-07-26 18:33
Commit message
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 <vm-name></code> | 316 | <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh <vm-name></code> |
| 317 | </div> | 317 | </div> |
| 318 | {:else} | 318 | {:else} |
| 319 | <table> | 319 | <table> |