a73x

9a302830

feat(eitri-mcp): MCP server for Claude-driven VM sandboxes via the SSH-CA gate

a73x   2026-07-25 12:41

Commit message
feat(eitri-mcp): MCP server for Claude-driven VM sandboxes via the SSH-CA gate

.golangci.yml
Old New
@@ -61,6 +61,9 @@ linters:
61 # CAs ARE signers); returning the concrete key type would be worse. A 61 # CAs ARE signers); returning the concrete key type would be worse. A
62 # documented seam, per this tier's policy. 62 # documented seam, per this tier's policy.
63 - golang.org/x/crypto/ssh.Signer 63 - golang.org/x/crypto/ssh.Signer
64 # ssh.PublicKey is likewise the idiomatic x/crypto/ssh type for a fetched
65 # CA / host key; the concrete key types are unexported. Same seam.
66 - golang.org/x/crypto/ssh.PublicKey
64 depguard: 67 depguard:
65 rules: 68 rules:
66 # R1 cross-plane bans apply to production code only; integration tests 69 # R1 cross-plane bans apply to production code only; integration tests
Makefile
Old New
@@ -27,6 +27,7 @@ build: web
27 go build -o $(BIN)/eitri-agent ./cmd/eitri-agent 27 go build -o $(BIN)/eitri-agent ./cmd/eitri-agent
28 go build -o $(BIN)/eitri-devstack ./cmd/eitri-devstack 28 go build -o $(BIN)/eitri-devstack ./cmd/eitri-devstack
29 go build -o $(BIN)/eitri-sandbox ./cmd/eitri-sandbox 29 go build -o $(BIN)/eitri-sandbox ./cmd/eitri-sandbox
30 go build -o $(BIN)/eitri-mcp ./cmd/eitri-mcp
30 31
31 test: 32 test:
32 go test -race ./... 33 go test -race ./...
cmd/eitri-mcp/main.go
Old New
@@ -0,0 +1,83 @@
1 // Command eitri-mcp is an MCP server exposing eitri VM tools to Claude:
2 // create/list/info/exec/write_file/read_file/destroy. It is a client of the
3 // eitri API plus SSH; it embeds no control-plane code. Wiring only — logic
4 // lives in internal/mcpserver.
5 package main
6
7 import (
8 "context"
9 "flag"
10 "fmt"
11 "os"
12 "os/signal"
13 "syscall"
14
15 "github.com/a73x/eitri/internal/mcpserver"
16 "github.com/a73x/eitri/internal/server/api/client"
17 "github.com/modelcontextprotocol/go-sdk/mcp"
18 )
19
20 func main() {
21 defaultCfg := "~/.config/eitri-mcp/config.json"
22 if env := os.Getenv("EITRI_MCP_CONFIG"); env != "" {
23 defaultCfg = env
24 }
25 cfgPath := flag.String("config", defaultCfg, "path to eitri-mcp config.json")
26 flag.Parse()
27
28 if err := run(*cfgPath); err != nil {
29 fmt.Fprintln(os.Stderr, "eitri-mcp:", err)
30 os.Exit(1)
31 }
32 }
33
34 func run(cfgPath string) error {
35 cfg, err := mcpserver.LoadConfig(cfgPath)
36 if err != nil {
37 return err
38 }
39 // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it
40 // authenticates with short-lived CA-signed user certs (minted on demand) and
41 // verifies both hops' host certs against the eitri CA. GateAuth is backed by
42 // the same API client.
43 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.AdminToken}
44 tools := &mcpserver.Tools{
45 API: mcpserver.API{Client: api},
46 Runner: mcpserver.NewRunner(mcpserver.RunnerConfig{
47 Gate: cfg.Gate,
48 Auth: mcpserver.NewGateAuth(api, nil),
49 VMUser: cfg.VMUser,
50 }),
51 Gate: cfg.Gate,
52 VMUser: cfg.VMUser,
53 }
54
55 server := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, nil)
56 register(server, "vm_create", "Create an eitri VM (persistent). Waits for ready+cloud-init by default.", tools.VMCreate)
57 register(server, "vm_list", "List all VMs on the eitri fleet.", tools.VMList)
58 register(server, "vm_info", "Show one VM's state and how to reach it.", tools.VMInfo)
59 register(server, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", tools.VMExec)
60 register(server, "vm_write_file", "Write content to a file in a VM (parents created).", tools.VMWriteFile)
61 register(server, "vm_read_file", "Read a file from a VM (capped at 1 MiB).", tools.VMReadFile)
62 register(server, "vm_destroy", "Destroy a VM by id or EXACT name. Explicit-only; never called automatically.", tools.VMDestroy)
63
64 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
65 defer stop()
66 return server.Run(ctx, &mcp.StdioTransport{})
67 }
68
69 // register adapts a Tools method to the SDK. This is the ONLY place that
70 // touches SDK generics; if the SDK's handler signature changes, change it here.
71 //
72 // Note on the hand-off contract: the SDK drops the Out value when the handler
73 // returns a non-nil error — StructuredContent is left unset and only err.Error()
74 // reaches the model (as IsError text content). VMCreate's degraded-path errors
75 // are self-sufficient (they name the VM id+name), so the model can still find
76 // and destroy the VM from the error text.
77 func register[In, Out any](s *mcp.Server, name, desc string, fn func(context.Context, In) (Out, error)) {
78 mcp.AddTool(s, &mcp.Tool{Name: name, Description: desc},
79 func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
80 out, err := fn(ctx, in)
81 return nil, out, err
82 })
83 }
docs/mcp.md
Old New
@@ -0,0 +1,125 @@
1 # eitri-mcp: Claude ↔ eitri VMs
2
3 `eitri-mcp` (`cmd/eitri-mcp`) is a stdio MCP server that gives Claude seven
4 explicit tools for creating and controlling VMs on an eitri fleet. It is an
5 API client of the eitri control plane plus SSH — it embeds no control-plane
6 or agent code.
7
8 ## Tools
9
10 | Tool | Purpose |
11 |---|---|
12 | `vm_create` | Create a persistent VM; by default waits for `lifecycle=ready` + IP, then for guest SSH and `cloud-init status --wait` to finish. |
13 | `vm_list` | List all VMs on the fleet (id, name, lifecycle, IP, size). |
14 | `vm_info` | Show one VM's state plus a ready-to-use `ssh` command. |
15 | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. |
16 | `vm_write_file` | Write content to a path in a VM over SFTP (parent dirs created). |
17 | `vm_read_file` | Read a file from a VM over SFTP (capped at 1 MiB, truncation flagged). |
18 | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only — never called automatically. |
19
20 Deliberately absent: any host or fleet-level operation (enroll, decommission,
21 power management, image/firmware knobs). The worst case from a confused model
22 is VM churn, never fleet damage — and Claude Code's per-tool permission
23 prompts gate every call regardless.
24
25 ## Setup
26
27 1. Build the binary:
28
29 ```
30 make build
31 ```
32
33 (or `go build ./cmd/eitri-mcp`) — this produces `bin/eitri-mcp`.
34
35 2. Create `~/.config/eitri-mcp/config.json`:
36
37 ```json
38 {
39 "server_url": "http://127.0.0.1:8080",
40 "admin_token_file": "~/eitri-deploy/admin-token",
41 "gate": "127.0.0.1:2223",
42 "vm_user": "ubuntu"
43 }
44 ```
45
46 Fields (see `internal/mcpserver/config.go`):
47 - `server_url` — required, the eitri API base URL.
48 - `admin_token_file` — required, path to a file holding the bearer token
49 (read at startup, held in memory, never surfaced in a tool result or
50 error).
51 - `gate` — the SSH-CA jump gate address, `<gate-domain>:<port>`; the MCP
52 reaches all VMs by name through it. The host part must match the
53 gate's host certificate principal (the server's `ssh_gate_domain`,
54 which defaults to the `ssh_listen` host).
55 - `vm_user` — guest SSH user; defaults to `ubuntu` if omitted.
56
57 The config path can be overridden with `--config` or `$EITRI_MCP_CONFIG`;
58 it defaults to `~/.config/eitri-mcp/config.json`.
59
60 3. Register with Claude Code:
61
62 ```
63 claude mcp add eitri -- /path/to/repo/bin/eitri-mcp
64 ```
65
66 ## Access model
67
68 eitri-mcp reaches VMs by name through eitri's SSH-CA jump gate — there is no
69 injected key and no TOFU. On demand, it generates an ephemeral SSH keypair
70 in memory (never written to disk) and mints a short-lived user certificate
71 for it (principal `ubuntu`, ~10-30 min TTL) by calling
72 `POST /api/v1/ssh-certs` with the admin bearer token; the cert is
73 auto-refreshed as it nears expiry. It also fetches the eitri SSH CA's public
74 key once via `GET /api/v1/ssh-ca` and caches it. To reach a VM, it dials the
75 gate (the configured `gate` address), authenticates with the user
76 certificate, and opens a tunnel to `<vm-name>:22` — VMs are addressed by
77 name, not IP, so recycled IPs and host-key churn are not a concern. Host
78 identity is verified on both hops using `ssh.CertChecker` against the eitri
79 CA: the gate's host certificate must carry its configured domain as
80 principal, and each VM's host certificate must carry the VM's name. The
81 guest trusts the CA-signed user certificate via `TrustedUserCAKeys`
82 (provisioned server-side when the gate is enabled), so no per-VM
83 `authorized_key` injection is needed. The admin token is only ever used to
84 mint certificates — actual SSH traffic uses the certificate, and the token
85 itself is never surfaced in a tool result or error.
86
87 ## Semantics
88
89 - Every VM is created with `persistent: true`. There is **no TTL and no
90 reaper** — VMs live until something explicitly destroys them. `vm_destroy`
91 (exact id or name, no wildcards, no bulk) is the only kill path, and Claude
92 is instructed to treat it as explicit-only, never automatic cleanup.
93 - The tool surface has no host or fleet operations by design — see the tools
94 table above.
95 - Service exposure (ports, DNS, TLS certs, routing) is out of scope: the
96 tools hand back a host and an `ssh` command; getting a service reachable
97 from outside the VM is the caller's business.
98 - The claude.ai connector (streamable HTTP transport + auth + ingress) is
99 phase 2 and not built — today's transport is stdio, for Claude Code only.
100 Phase 2's VM access is expected to reuse the same short-lived-certificate
101 flow through the gate, just with gate ingress reachable from claude.ai
102 instead of only from the MCP's host.
103
104 > **IMPORTANT — "ready" is not "booted."** `vm_create`'s `lifecycle=ready`
105 > means cloud-hypervisor is up and the VM has an allocated IP; it does **not**
106 > mean the guest has finished booting Linux, brought up its NIC, or started
107 > `sshd`. `vm_create` (with the default `wait: true`) accounts for this: it
108 > polls for `ready` + IP, then retries SSH until it connects, then runs
109 > `cloud-init status --wait` before returning — so a normal `vm_create` call
110 > only returns once the guest is genuinely usable. But if you reach a
111 > just-created VM some other way (e.g. its IP from `vm_list`/`vm_info`
112 > immediately after creation, or `wait: false`), it may still be mid-boot and
113 > refuse connections for a short window.
114
115 ## Testing this yourself
116
117 Unit tests (`internal/mcpserver/*_test.go`) cover the tools against fake API
118 and SSH seams. A tag-gated integration test
119 (`internal/integration/mcp_smoke_test.go`) launches the real `eitri-mcp`
120 binary as a subprocess speaking stdio MCP and drives create → exec →
121 write/read file → destroy against a real VM:
122
123 ```
124 sudo -v && go test -tags=smoke -timeout=25m -count=1 ./internal/integration -run TestMCPSmoke -v
125 ```
docs/shape.html
Old New
@@ -78,6 +78,15 @@
78 ] 78 ]
79 }, 79 },
80 { 80 {
81 "importPath": "cmd/eitri-mcp",
82 "plane": "binaries",
83 "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.",
84 "imports": [
85 "internal/mcpserver",
86 "internal/server/api/client"
87 ]
88 },
89 {
81 "importPath": "cmd/eitri-server", 90 "importPath": "cmd/eitri-server",
82 "plane": "binaries", 91 "plane": "binaries",
83 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", 92 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).",
@@ -206,6 +215,15 @@
206 "imports": [] 215 "imports": []
207 }, 216 },
208 { 217 {
218 "importPath": "internal/mcpserver",
219 "plane": "tooling",
220 "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.",
221 "imports": [
222 "internal/random",
223 "internal/server/api/client"
224 ]
225 },
226 {
209 "importPath": "internal/names", 227 "importPath": "internal/names",
210 "plane": "wire", 228 "plane": "wire",
211 "synopsis": "Package names validates the DNS-label shape shared across planes: a VM's name doubles as its guest hostname, so it must be a valid RFC-1123 label.", 229 "synopsis": "Package names validates the DNS-label shape shared across planes: a VM's name doubles as its guest hostname, so it must be a valid RFC-1123 label.",
@@ -241,6 +259,14 @@
241 ] 259 ]
242 }, 260 },
243 { 261 {
262 "importPath": "internal/server/api/client",
263 "plane": "control",
264 "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server) goes through.",
265 "imports": [
266 "internal/server/api/types"
267 ]
268 },
269 {
244 "importPath": "internal/server/api/spec", 270 "importPath": "internal/server/api/spec",
245 "plane": "control", 271 "plane": "control",
246 "synopsis": "Package spec projects the api route table into an OpenAPI 3.1 document.", 272 "synopsis": "Package spec projects the api route table into an OpenAPI 3.1 document.",
docs/shape.json
Old New
@@ -27,6 +27,15 @@
27 ] 27 ]
28 }, 28 },
29 { 29 {
30 "importPath": "cmd/eitri-mcp",
31 "plane": "binaries",
32 "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.",
33 "imports": [
34 "internal/mcpserver",
35 "internal/server/api/client"
36 ]
37 },
38 {
30 "importPath": "cmd/eitri-server", 39 "importPath": "cmd/eitri-server",
31 "plane": "binaries", 40 "plane": "binaries",
32 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", 41 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).",
@@ -155,6 +164,15 @@
155 "imports": [] 164 "imports": []
156 }, 165 },
157 { 166 {
167 "importPath": "internal/mcpserver",
168 "plane": "tooling",
169 "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.",
170 "imports": [
171 "internal/random",
172 "internal/server/api/client"
173 ]
174 },
175 {
158 "importPath": "internal/names", 176 "importPath": "internal/names",
159 "plane": "wire", 177 "plane": "wire",
160 "synopsis": "Package names validates the DNS-label shape shared across planes: a VM's name doubles as its guest hostname, so it must be a valid RFC-1123 label.", 178 "synopsis": "Package names validates the DNS-label shape shared across planes: a VM's name doubles as its guest hostname, so it must be a valid RFC-1123 label.",
@@ -190,6 +208,14 @@
190 ] 208 ]
191 }, 209 },
192 { 210 {
211 "importPath": "internal/server/api/client",
212 "plane": "control",
213 "synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server) goes through.",
214 "imports": [
215 "internal/server/api/types"
216 ]
217 },
218 {
193 "importPath": "internal/server/api/spec", 219 "importPath": "internal/server/api/spec",
194 "plane": "control", 220 "plane": "control",
195 "synopsis": "Package spec projects the api route table into an OpenAPI 3.1 document.", 221 "synopsis": "Package spec projects the api route table into an OpenAPI 3.1 document.",
go.mod
Old New
@@ -5,9 +5,11 @@ go 1.26.4
5 require ( 5 require (
6 github.com/coder/websocket v1.8.15 6 github.com/coder/websocket v1.8.15
7 github.com/diskfs/go-diskfs v1.9.3 7 github.com/diskfs/go-diskfs v1.9.3
8 github.com/modelcontextprotocol/go-sdk v1.6.1
9 github.com/pkg/sftp v1.13.11
8 github.com/quic-go/quic-go v0.48.2 10 github.com/quic-go/quic-go v0.48.2
9 github.com/stretchr/testify v1.11.1 11 github.com/stretchr/testify v1.11.1
10 golang.org/x/crypto v0.48.0 12 golang.org/x/crypto v0.54.0
11 google.golang.org/protobuf v1.36.11 13 google.golang.org/protobuf v1.36.11
12 gopkg.in/yaml.v3 v3.0.1 14 gopkg.in/yaml.v3 v3.0.1
13 modernc.org/sqlite v1.52.0 15 modernc.org/sqlite v1.52.0
@@ -22,9 +24,11 @@ require (
22 github.com/go-logr/logr v1.4.3 // indirect 24 github.com/go-logr/logr v1.4.3 // indirect
23 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 25 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
24 github.com/golang/protobuf v1.5.4 // indirect 26 github.com/golang/protobuf v1.5.4 // indirect
27 github.com/google/jsonschema-go v0.4.3 // indirect
25 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect 28 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
26 github.com/google/uuid v1.6.0 // indirect 29 github.com/google/uuid v1.6.0 // indirect
27 github.com/klauspost/compress v1.18.5 // indirect 30 github.com/klauspost/compress v1.18.5 // indirect
31 github.com/kr/fs v0.1.0 // indirect
28 github.com/mattn/go-isatty v0.0.20 // indirect 32 github.com/mattn/go-isatty v0.0.20 // indirect
29 github.com/ncruces/go-strftime v1.0.0 // indirect 33 github.com/ncruces/go-strftime v1.0.0 // indirect
30 github.com/onsi/ginkgo/v2 v2.9.5 // indirect 34 github.com/onsi/ginkgo/v2 v2.9.5 // indirect
@@ -32,14 +36,18 @@ require (
32 github.com/pkg/xattr v0.4.12 // indirect 36 github.com/pkg/xattr v0.4.12 // indirect
33 github.com/pmezard/go-difflib v1.0.0 // indirect 37 github.com/pmezard/go-difflib v1.0.0 // indirect
34 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 38 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
39 github.com/segmentio/asm v1.1.3 // indirect
40 github.com/segmentio/encoding v0.5.4 // indirect
35 github.com/sirupsen/logrus v1.9.4 // indirect 41 github.com/sirupsen/logrus v1.9.4 // indirect
36 github.com/ulikunitz/xz v0.5.15 // indirect 42 github.com/ulikunitz/xz v0.5.15 // indirect
43 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
37 go.uber.org/mock v0.4.0 // indirect 44 go.uber.org/mock v0.4.0 // indirect
38 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 45 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
39 golang.org/x/mod v0.33.0 // indirect 46 golang.org/x/mod v0.33.0 // indirect
40 golang.org/x/net v0.51.0 // indirect 47 golang.org/x/net v0.56.0 // indirect
48 golang.org/x/oauth2 v0.35.0 // indirect
41 golang.org/x/sync v0.20.0 // indirect 49 golang.org/x/sync v0.20.0 // indirect
42 golang.org/x/sys v0.43.0 // indirect 50 golang.org/x/sys v0.47.0 // indirect
43 golang.org/x/tools v0.42.0 // indirect 51 golang.org/x/tools v0.42.0 // indirect
44 modernc.org/libc v1.72.3 // indirect 52 modernc.org/libc v1.72.3 // indirect
45 modernc.org/mathutil v1.7.1 // indirect 53 modernc.org/mathutil v1.7.1 // indirect
go.sum
Old New
@@ -19,10 +19,14 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe
19 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 19 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
20 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 20 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
21 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 21 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
22 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
23 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
22 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 24 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
23 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 25 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
24 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 26 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
25 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 27 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
28 github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
29 github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
26 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 30 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
27 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 31 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
28 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -31,8 +35,12 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
31 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 35 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
32 github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= 36 github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
33 github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 37 github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
38 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
39 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
34 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 40 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
35 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 41 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
42 github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
43 github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
36 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 44 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
37 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 45 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
38 github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= 46 github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
@@ -41,6 +49,8 @@ github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
41 github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 49 github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
42 github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= 50 github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
43 github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 51 github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
52 github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
53 github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
44 github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= 54 github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
45 github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= 55 github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
46 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 56 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -49,6 +59,10 @@ github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KW
49 github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs= 59 github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
50 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 60 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
51 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 61 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
62 github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
63 github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
64 github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
65 github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
52 github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= 66 github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
53 github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 67 github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
54 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 68 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -57,27 +71,31 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
57 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 71 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
58 github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= 72 github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
59 github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 73 github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
74 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
75 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
60 go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= 76 go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
61 go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 77 go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
62 golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= 78 golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
63 golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 79 golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
64 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 80 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
65 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 81 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
66 golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 82 golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
67 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 83 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
68 golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= 84 golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
69 golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 85 golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
86 golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
87 golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
70 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 88 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
71 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 89 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
72 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
73 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
74 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
75 golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= 93 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
76 golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 94 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
77 golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= 95 golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
78 golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 96 golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
79 golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= 97 golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
80 golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 98 golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
81 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 99 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
82 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 100 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
83 golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= 101 golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
internal/arch/arch_test.go
Old New
@@ -183,6 +183,31 @@ func TestOnlyCloudhvImportsOsExecInDataPlane(t *testing.T) {
183 } 183 }
184 } 184 }
185 185
186 // The API contract is consumed through the client. internal/server/api/types
187 // is the wire contract, but no package outside internal/server/* may import it —
188 // consumers get the wire structs via internal/server/api/client's re-exported
189 // aliases (client.Host and friends), so the client is the only door.
190 func TestAPIContractIsConsumedThroughTheClient(t *testing.T) {
191 g := internalImports(t)
192 target := module + "/internal/server/api/types"
193 imported := false
194 for pkg, deps := range g {
195 for _, d := range deps {
196 if d != target {
197 continue
198 }
199 imported = true
200 if !has(pkg, "internal/server/") {
201 t.Errorf("package %s must not import %s — consume the API through internal/server/api/client instead", short(pkg), short(target))
202 }
203 }
204 }
205 if !imported {
206 // A rename of the types package would silently pass every sweep; guard it.
207 t.Errorf("sweep found no importer of %s at all — did the contract package move?", short(target))
208 }
209 }
210
186 // internal/server/api/types is a leaf — stdlib imports only. The contract 211 // internal/server/api/types is a leaf — stdlib imports only. The contract
187 // is consumed by the spec generator, the client, and the handlers; a single 212 // is consumed by the spec generator, the client, and the handlers; a single
188 // internal import would drag server internals into every consumer at once and 213 // internal import would drag server internals into every consumer at once and
internal/mcpserver/api_test.go
Old New
@@ -0,0 +1,184 @@
1 package mcpserver
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13 "golang.org/x/crypto/ssh"
14
15 "github.com/a73x/eitri/internal/server/api/client"
16 )
17
18 func fakeAPI(t *testing.T, handler http.HandlerFunc) API {
19 t.Helper()
20 srv := httptest.NewServer(handler)
21 t.Cleanup(srv.Close)
22 return API{Client: &client.Client{BaseURL: srv.URL, Token: "tok123"}}
23 }
24
25 func TestListVMs(t *testing.T) {
26 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
27 assert.Equal(t, "GET", r.Method)
28 assert.Equal(t, "/api/v1/vms", r.URL.Path)
29 assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
30 json.NewEncoder(w).Encode([]map[string]any{
31 {"id": "abc", "name": "claude-x", "lifecycle": "ready", "assigned_ip": "10.77.1.5", "host_id": "h1"},
32 })
33 })
34 vms, err := c.ListVMs(t.Context())
35 require.NoError(t, err)
36 require.Len(t, vms, 1)
37 assert.Equal(t, client.VM{ID: "abc", Name: "claude-x", Lifecycle: "ready", AssignedIP: "10.77.1.5", HostID: "h1"}, vms[0])
38 }
39
40 func TestCreateVMSendsRequestAndParsesID(t *testing.T) {
41 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
42 assert.Equal(t, "POST", r.Method)
43 assert.Equal(t, "/api/v1/vms", r.URL.Path)
44 var req map[string]any
45 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
46 assert.Equal(t, true, req["persistent"])
47 assert.Equal(t, "h1", req["host_id"])
48 assert.Equal(t, "ssh-ed25519 AAA test", req["ssh_authorized_key"])
49 json.NewEncoder(w).Encode(map[string]string{"id": "new1", "name": req["name"].(string)})
50 })
51 got, err := c.CreateVM(t.Context(), client.CreateVMRequest{
52 HostID: "h1", Name: "claude-abc123", VCPUs: 2, MemMB: 2048, DiskGB: 20,
53 SSHAuthorizedKey: "ssh-ed25519 AAA test", Persistent: true,
54 })
55 require.NoError(t, err)
56 assert.Equal(t, "new1", got.ID)
57 }
58
59 func TestDeleteVM(t *testing.T) {
60 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
61 assert.Equal(t, "DELETE", r.Method)
62 assert.Equal(t, "/api/v1/vms/abc", r.URL.Path)
63 w.WriteHeader(http.StatusNoContent)
64 })
65 require.NoError(t, c.DeleteVM(t.Context(), "abc"))
66 }
67
68 func TestFirstOnlineHost(t *testing.T) {
69 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
70 json.NewEncoder(w).Encode([]map[string]any{
71 {"id": "h0", "name": "down", "online": false},
72 {"id": "h1", "name": "mewtwo", "online": true},
73 })
74 })
75 h, err := c.FirstOnlineHost(t.Context())
76 require.NoError(t, err)
77 assert.Equal(t, "h1", h.ID)
78 }
79
80 func TestAPIErrorSurfacesBodyNotToken(t *testing.T) {
81 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
82 http.Error(w, "invalid name", http.StatusBadRequest)
83 })
84 _, err := c.CreateVM(t.Context(), client.CreateVMRequest{})
85 require.Error(t, err)
86 assert.Contains(t, err.Error(), "invalid name")
87 assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors")
88 }
89
90 // genTestKey returns a freshly generated ed25519 ssh.PublicKey.
91 func genTestKey(t *testing.T) ssh.PublicKey {
92 t.Helper()
93 pub, _, err := ed25519.GenerateKey(rand.Reader)
94 require.NoError(t, err)
95 sshPub, err := ssh.NewPublicKey(pub)
96 require.NoError(t, err)
97 return sshPub
98 }
99
100 func TestFetchSSHCA(t *testing.T) {
101 caPub := genTestKey(t)
102 caLine := string(ssh.MarshalAuthorizedKey(caPub))
103 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
104 assert.Equal(t, "GET", r.Method)
105 assert.Equal(t, "/api/v1/ssh-ca", r.URL.Path)
106 assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
107 json.NewEncoder(w).Encode(map[string]string{"ca": caLine})
108 })
109 got, err := c.FetchSSHCA(t.Context())
110 require.NoError(t, err)
111 assert.Equal(t, caPub.Marshal(), got.Marshal())
112 }
113
114 func TestFetchSSHCANotEnabled(t *testing.T) {
115 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
116 assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
117 http.Error(w, "ssh jump gate not enabled", http.StatusNotFound)
118 })
119 _, err := c.FetchSSHCA(t.Context())
120 require.Error(t, err)
121 assert.NotContains(t, err.Error(), "404", "raw status code must not be surfaced")
122 assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors")
123 assert.Contains(t, err.Error(), "not enabled", "error should clearly explain the gate is off")
124 }
125
126 func TestMintUserCert(t *testing.T) {
127 caPub, caPriv, err := ed25519.GenerateKey(rand.Reader)
128 require.NoError(t, err)
129 _ = caPub
130 caSigner, err := ssh.NewSignerFromKey(caPriv)
131 require.NoError(t, err)
132
133 userPub := genTestKey(t)
134
135 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
136 assert.Equal(t, "POST", r.Method)
137 assert.Equal(t, "/api/v1/ssh-certs", r.URL.Path)
138 assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
139 var req map[string]any
140 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
141 assert.Equal(t, string(ssh.MarshalAuthorizedKey(userPub)), req["public_key"].(string)+"\n")
142
143 cert := &ssh.Certificate{
144 Key: userPub,
145 Serial: 1,
146 CertType: ssh.UserCert,
147 KeyId: "ubuntu",
148 ValidPrincipals: []string{"ubuntu"},
149 ValidAfter: 0,
150 ValidBefore: ssh.CertTimeInfinity,
151 }
152 require.NoError(t, cert.SignCert(rand.Reader, caSigner))
153 json.NewEncoder(w).Encode(map[string]string{
154 "certificate": string(ssh.MarshalAuthorizedKey(cert)),
155 })
156 })
157
158 got, err := c.MintUserCert(t.Context(), userPub)
159 require.NoError(t, err)
160 require.NotNil(t, got)
161 assert.Equal(t, uint64(1), got.Serial)
162 assert.Equal(t, []string{"ubuntu"}, got.ValidPrincipals)
163 assert.Equal(t, userPub.Marshal(), got.Key.Marshal())
164 }
165
166 func TestMintUserCertErrorDoesNotLeakToken(t *testing.T) {
167 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
168 http.Error(w, "internal error", http.StatusInternalServerError)
169 })
170 _, err := c.MintUserCert(t.Context(), genTestKey(t))
171 require.Error(t, err)
172 assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors")
173 }
174
175 func TestMintUserCertRejectsNonCertResponse(t *testing.T) {
176 notACert := genTestKey(t)
177 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
178 json.NewEncoder(w).Encode(map[string]string{
179 "certificate": string(ssh.MarshalAuthorizedKey(notACert)),
180 })
181 })
182 _, err := c.MintUserCert(t.Context(), genTestKey(t))
183 require.Error(t, err)
184 }
internal/mcpserver/config.go
Old New
@@ -0,0 +1,74 @@
1 // Package mcpserver implements the eitri-mcp server: MCP tools that let a
2 // model create, control (SSH exec/files), and destroy eitri VMs. It is an API
3 // CLIENT of the control plane — it must not import internal/server, and the
4 // admin token it holds must never appear in tool results or errors.
5 package mcpserver
6
7 import (
8 "encoding/json"
9 "fmt"
10 "os"
11 "path/filepath"
12 "strings"
13 )
14
15 // Config is eitri-mcp's on-disk configuration.
16 type Config struct {
17 ServerURL string `json:"server_url"` // eitri API base, e.g. http://127.0.0.1:8080
18 AdminTokenFile string `json:"admin_token_file"` // file holding the bearer token
19 Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>"
20 VMUser string `json:"vm_user"` // guest user (default "ubuntu")
21
22 AdminToken string `json:"-"` // loaded from AdminTokenFile; never serialized
23 }
24
25 // LoadConfig reads and validates the config file and loads the admin token.
26 func LoadConfig(path string) (*Config, error) {
27 path = expandTilde(path)
28 // Resolve to an absolute path for stable error messages regardless of the
29 // process's cwd — the MCP host launches this server with an unpredictable
30 // working directory.
31 abs, err := filepath.Abs(path)
32 if err != nil {
33 return nil, fmt.Errorf("resolve config path: %w", err)
34 }
35 path = abs
36 raw, err := os.ReadFile(path)
37 if err != nil {
38 return nil, fmt.Errorf("read config: %w", err)
39 }
40 cfg := &Config{}
41 if err := json.Unmarshal(raw, cfg); err != nil {
42 return nil, fmt.Errorf("parse config %s: %w", path, err)
43 }
44 if cfg.VMUser == "" {
45 cfg.VMUser = "ubuntu"
46 }
47 if cfg.ServerURL == "" {
48 return nil, fmt.Errorf("config %s: server_url is required", path)
49 }
50 if cfg.AdminTokenFile == "" {
51 return nil, fmt.Errorf("config %s: admin_token_file is required", path)
52 }
53 tok, err := os.ReadFile(expandTilde(cfg.AdminTokenFile))
54 if err != nil {
55 return nil, fmt.Errorf("read admin token: %w", err)
56 }
57 cfg.AdminToken = strings.TrimSpace(string(tok))
58 if cfg.AdminToken == "" {
59 return nil, fmt.Errorf("admin token file %s is empty", cfg.AdminTokenFile)
60 }
61 return cfg, nil
62 }
63
64 // expandTilde expands a leading "~/" only. Bare "~" and "~user/x" forms pass
65 // through unchanged; those then fail loudly at file open rather than being
66 // silently mishandled here.
67 func expandTilde(p string) string {
68 if strings.HasPrefix(p, "~/") {
69 if home, err := os.UserHomeDir(); err == nil {
70 return filepath.Join(home, p[2:])
71 }
72 }
73 return p
74 }
internal/mcpserver/config_test.go
Old New
@@ -0,0 +1,81 @@
1 package mcpserver
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 func TestLoadConfigDefaultsAndExpansion(t *testing.T) {
13 dir := t.TempDir()
14 tok := filepath.Join(dir, "token")
15 require.NoError(t, os.WriteFile(tok, []byte("sekret\n"), 0o600))
16 cfgPath := filepath.Join(dir, "config.json")
17 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
18 "server_url": "http://127.0.0.1:9999",
19 "admin_token_file": "`+tok+`",
20 "gate": "gate.example.com:2222"
21 }`), 0o600))
22
23 cfg, err := LoadConfig(cfgPath)
24 require.NoError(t, err)
25 assert.Equal(t, "http://127.0.0.1:9999", cfg.ServerURL)
26 assert.Equal(t, "gate.example.com:2222", cfg.Gate)
27 assert.Equal(t, "ubuntu", cfg.VMUser) // default
28 assert.Equal(t, "sekret", cfg.AdminToken) // trimmed, loaded from file
29 }
30
31 func TestLoadConfigExplicitEmptyVMUserGetsDefault(t *testing.T) {
32 dir := t.TempDir()
33 tok := filepath.Join(dir, "token")
34 require.NoError(t, os.WriteFile(tok, []byte("sekret\n"), 0o600))
35 cfgPath := filepath.Join(dir, "config.json")
36 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
37 "server_url": "http://127.0.0.1:9999",
38 "admin_token_file": "`+tok+`",
39 "vm_user": ""
40 }`), 0o600))
41
42 cfg, err := LoadConfig(cfgPath)
43 require.NoError(t, err)
44 assert.Equal(t, "ubuntu", cfg.VMUser, `explicit "vm_user": "" must not erase the default`)
45 }
46
47 func TestLoadConfigMissingRequired(t *testing.T) {
48 dir := t.TempDir()
49 cfgPath := filepath.Join(dir, "config.json")
50 require.NoError(t, os.WriteFile(cfgPath, []byte(`{"server_url": ""}`), 0o600))
51 _, err := LoadConfig(cfgPath)
52 assert.ErrorContains(t, err, "server_url")
53 }
54
55 func TestLoadConfigMissingAdminTokenFile(t *testing.T) {
56 dir := t.TempDir()
57 cfgPath := filepath.Join(dir, "config.json")
58 require.NoError(t, os.WriteFile(cfgPath, []byte(`{"server_url": "http://127.0.0.1:9999"}`), 0o600))
59 _, err := LoadConfig(cfgPath)
60 assert.ErrorContains(t, err, "admin_token_file")
61 }
62
63 func TestLoadConfigEmptyAdminTokenFile(t *testing.T) {
64 dir := t.TempDir()
65 tok := filepath.Join(dir, "token")
66 require.NoError(t, os.WriteFile(tok, []byte(" \n"), 0o600))
67 cfgPath := filepath.Join(dir, "config.json")
68 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
69 "server_url": "http://127.0.0.1:9999",
70 "admin_token_file": "`+tok+`"
71 }`), 0o600))
72 _, err := LoadConfig(cfgPath)
73 assert.ErrorContains(t, err, "empty")
74 }
75
76 func TestLoadConfigTildeExpansion(t *testing.T) {
77 home, err := os.UserHomeDir()
78 require.NoError(t, err)
79 assert.Equal(t, home+"/x", expandTilde("~/x"))
80 assert.Equal(t, "/abs/x", expandTilde("/abs/x"))
81 }
internal/mcpserver/gateauth.go
Old New
@@ -0,0 +1,152 @@
1 package mcpserver
2
3 import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "crypto/rand"
8 "fmt"
9 "sync"
10 "time"
11
12 "golang.org/x/crypto/ssh"
13 )
14
15 // CertAuthority is the subset of the eitri API client GateAuth needs: enough
16 // to fetch the SSH CA's public key and mint short-lived user certificates.
17 // It's declared here (rather than depending on the shared API client
18 // directly) so tests can fake it in-memory without spinning up an httptest
19 // server.
20 type CertAuthority interface {
21 FetchSSHCA(ctx context.Context) (ssh.PublicKey, error)
22 MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error)
23 }
24
25 // GateAuth is a concurrency-safe credential cache for authenticating to the
26 // eitri SSH-CA jump gate and the VMs behind it. It holds an ephemeral
27 // (never-persisted) ed25519 keypair generated once at first use, mints a
28 // short-lived user certificate for it on demand (refreshing shortly before
29 // expiry), and verifies host certificates against the eitri CA.
30 type GateAuth struct {
31 api CertAuthority
32 now func() time.Time
33
34 mu sync.Mutex
35 ephemeral ssh.Signer // ephemeral SSH keypair; generated lazily, once
36 ca ssh.PublicKey // eitri SSH CA; fetched lazily, once
37 cert *ssh.Certificate
38 certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert
39 }
40
41 // NewGateAuth constructs a GateAuth backed by api. If now is nil, time.Now
42 // is used. The ephemeral keypair and CA key are NOT fetched here; both are
43 // established lazily on first use so construction cannot fail.
44 func NewGateAuth(api CertAuthority, now func() time.Time) *GateAuth {
45 if now == nil {
46 now = time.Now
47 }
48 return &GateAuth{api: api, now: now}
49 }
50
51 // Signer returns an ssh.Signer backed by a cached, cert-signed identity,
52 // minting (or re-minting, if the cached cert is missing or expires within a
53 // minute) as needed.
54 func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) {
55 g.mu.Lock()
56 defer g.mu.Unlock()
57
58 if g.ephemeral == nil {
59 signer, err := newEphemeralSigner()
60 if err != nil {
61 return nil, fmt.Errorf("generating ephemeral SSH key: %w", err)
62 }
63 g.ephemeral = signer
64 }
65
66 if g.needsMintLocked() {
67 // g.mu is deliberately held across this network call: it single-flights
68 // minting so concurrent Signer callers reuse one in-flight request
69 // rather than stampeding the CA with duplicate mints. Do not "fix" this
70 // into a per-call unlock — that reintroduces a thundering herd.
71 cert, err := g.api.MintUserCert(ctx, g.ephemeral.PublicKey())
72 if err != nil {
73 return nil, fmt.Errorf("minting user certificate: %w", err)
74 }
75 certSigner, err := ssh.NewCertSigner(cert, g.ephemeral)
76 if err != nil {
77 return nil, fmt.Errorf("wrapping minted certificate: %w", err)
78 }
79 g.cert = cert
80 g.certSigner = certSigner
81 }
82
83 return g.certSigner, nil
84 }
85
86 // needsMintLocked reports whether the cached cert is absent or expires
87 // within a minute of now(). Callers must hold g.mu.
88 func (g *GateAuth) needsMintLocked() bool {
89 if g.cert == nil {
90 return true
91 }
92 if g.cert.ValidBefore == ssh.CertTimeInfinity {
93 return false
94 }
95 return g.now().Add(time.Minute).Unix() >= int64(g.cert.ValidBefore)
96 }
97
98 // HostKeyCallback returns an ssh.HostKeyCallback that accepts only host
99 // certificates signed by the eitri CA, lazily fetching the CA (once) on
100 // first invocation.
101 func (g *GateAuth) HostKeyCallback() ssh.HostKeyCallback {
102 checker := &ssh.CertChecker{
103 IsHostAuthority: func(auth ssh.PublicKey, address string) bool {
104 // ssh.HostKeyCallback has no ctx param, so caller cancellation
105 // cannot reach here; the fetch deadline is the API client's HTTP
106 // client timeout (30s), not the SSH handshake context.
107 ca, err := g.caKey(context.Background())
108 if err != nil {
109 return false
110 }
111 return caEquals(auth, ca)
112 },
113 }
114 return checker.CheckHostKey
115 }
116
117 // caKey returns the cached eitri CA public key, fetching it (once) if not
118 // already cached.
119 func (g *GateAuth) caKey(ctx context.Context) (ssh.PublicKey, error) {
120 g.mu.Lock()
121 defer g.mu.Unlock()
122
123 if g.ca != nil {
124 return g.ca, nil
125 }
126 // g.mu is deliberately held across this network call: single-flight fetch
127 // so concurrent callbacks share one FetchSSHCA rather than stampeding.
128 ca, err := g.api.FetchSSHCA(ctx)
129 if err != nil {
130 return nil, fmt.Errorf("fetching SSH CA: %w", err)
131 }
132 g.ca = ca
133 return g.ca, nil
134 }
135
136 // caEquals reports whether two SSH public keys are the same key, by
137 // comparing their wire encodings.
138 func caEquals(a, b ssh.PublicKey) bool {
139 if a == nil || b == nil {
140 return false
141 }
142 return bytes.Equal(a.Marshal(), b.Marshal())
143 }
144
145 // newEphemeralSigner generates a fresh, never-persisted ed25519 SSH signer.
146 func newEphemeralSigner() (ssh.Signer, error) {
147 _, priv, err := ed25519.GenerateKey(rand.Reader)
148 if err != nil {
149 return nil, err
150 }
151 return ssh.NewSignerFromSigner(priv)
152 }
internal/mcpserver/gateauth_test.go
Old New
@@ -0,0 +1,252 @@
1 package mcpserver
2
3 import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "errors"
8 "net"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 "golang.org/x/crypto/ssh"
16 )
17
18 // fakeCertAuthority is an in-memory CertAuthority backed by a real ed25519
19 // CA signer, so tests exercise real cert signing/verification without an
20 // httptest server.
21 type fakeCertAuthority struct {
22 caSigner ssh.Signer
23
24 mu sync.Mutex
25 fetchCACalls int
26 mintCertCalls int
27 nextValidBefore uint64 // configurable expiry for the next minted cert
28 fetchErr error // if set, FetchSSHCA returns it
29 mintErr error // if set, MintUserCert returns it
30 }
31
32 func newFakeCertAuthority(t *testing.T) *fakeCertAuthority {
33 t.Helper()
34 _, priv, err := ed25519.GenerateKey(rand.Reader)
35 require.NoError(t, err)
36 signer, err := ssh.NewSignerFromSigner(priv)
37 require.NoError(t, err)
38 return &fakeCertAuthority{caSigner: signer, nextValidBefore: ssh.CertTimeInfinity}
39 }
40
41 func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
42 f.mu.Lock()
43 defer f.mu.Unlock()
44 f.fetchCACalls++
45 if f.fetchErr != nil {
46 return nil, f.fetchErr
47 }
48 return f.caSigner.PublicKey(), nil
49 }
50
51 func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error) {
52 f.mu.Lock()
53 validBefore := f.nextValidBefore
54 f.mintCertCalls++
55 mintErr := f.mintErr
56 f.mu.Unlock()
57
58 if mintErr != nil {
59 return nil, mintErr
60 }
61
62 cert := &ssh.Certificate{
63 Key: pub,
64 CertType: ssh.UserCert,
65 ValidPrincipals: []string{"ubuntu"},
66 ValidAfter: 0,
67 ValidBefore: validBefore,
68 }
69 if err := cert.SignCert(rand.Reader, f.caSigner); err != nil {
70 return nil, err
71 }
72 return cert, nil
73 }
74
75 func (f *fakeCertAuthority) setNextValidBefore(v uint64) {
76 f.mu.Lock()
77 defer f.mu.Unlock()
78 f.nextValidBefore = v
79 }
80
81 func (f *fakeCertAuthority) setFetchErr(err error) {
82 f.mu.Lock()
83 defer f.mu.Unlock()
84 f.fetchErr = err
85 }
86
87 func (f *fakeCertAuthority) setMintErr(err error) {
88 f.mu.Lock()
89 defer f.mu.Unlock()
90 f.mintErr = err
91 }
92
93 func (f *fakeCertAuthority) counts() (fetchCA, mintCert int) {
94 f.mu.Lock()
95 defer f.mu.Unlock()
96 return f.fetchCACalls, f.mintCertCalls
97 }
98
99 // hostCert builds and signs a host certificate for a fresh ephemeral host
100 // key, using ca as the signing authority.
101 func hostCert(t *testing.T, ca ssh.Signer) *ssh.Certificate {
102 t.Helper()
103 pub, _, err := ed25519.GenerateKey(rand.Reader)
104 require.NoError(t, err)
105 sshPub, err := ssh.NewPublicKey(pub)
106 require.NoError(t, err)
107 cert := &ssh.Certificate{
108 Key: sshPub,
109 CertType: ssh.HostCert,
110 ValidPrincipals: []string{"vm-name"},
111 ValidBefore: ssh.CertTimeInfinity,
112 }
113 require.NoError(t, cert.SignCert(rand.Reader, ca))
114 return cert
115 }
116
117 func TestGateAuthSignerMintsOnceAndReuses(t *testing.T) {
118 fake := newFakeCertAuthority(t)
119 fake.setNextValidBefore(ssh.CertTimeInfinity)
120 ga := NewGateAuth(fake, nil)
121
122 s1, err := ga.Signer(t.Context())
123 require.NoError(t, err)
124 s2, err := ga.Signer(t.Context())
125 require.NoError(t, err)
126
127 _, mintCalls := fake.counts()
128 assert.Equal(t, 1, mintCalls, "expected exactly one mint across two Signer calls")
129 assert.Same(t, s1, s2, "expected the same cached signer to be returned")
130 assert.Regexp(t, `-cert-v01@openssh\.com$`, s1.PublicKey().Type())
131 }
132
133 func TestGateAuthSignerRefreshesNearExpiry(t *testing.T) {
134 fake := newFakeCertAuthority(t)
135 current := time.Unix(1_700_000_000, 0)
136 clock := func() time.Time { return current }
137 ga := NewGateAuth(fake, clock)
138
139 // First cert expires in 30s from "now" — within the 1-minute refresh
140 // window on the very next call.
141 fake.setNextValidBefore(uint64(current.Add(30 * time.Second).Unix()))
142 _, err := ga.Signer(t.Context())
143 require.NoError(t, err)
144
145 _, mintCalls := fake.counts()
146 require.Equal(t, 1, mintCalls)
147
148 // Second call, same "now": remaining validity (30s) < 1 minute, so this
149 // must re-mint.
150 fake.setNextValidBefore(uint64(current.Add(2 * time.Hour).Unix()))
151 _, err = ga.Signer(t.Context())
152 require.NoError(t, err)
153 _, mintCalls = fake.counts()
154 assert.Equal(t, 2, mintCalls, "expected re-mint when cached cert expires within a minute")
155
156 // Third call, same "now": remaining validity is now 2h, well beyond a
157 // minute, so this must NOT re-mint.
158 _, err = ga.Signer(t.Context())
159 require.NoError(t, err)
160 _, mintCalls = fake.counts()
161 assert.Equal(t, 2, mintCalls, "expected no re-mint when cached cert has >1min remaining")
162 }
163
164 func TestGateAuthHostKeyCallbackAcceptsCASignedHostCert(t *testing.T) {
165 fake := newFakeCertAuthority(t)
166 ga := NewGateAuth(fake, nil)
167
168 cert := hostCert(t, fake.caSigner)
169 err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
170 assert.NoError(t, err)
171 }
172
173 func TestGateAuthHostKeyCallbackRejectsForeignCAHostCert(t *testing.T) {
174 fake := newFakeCertAuthority(t)
175 ga := NewGateAuth(fake, nil)
176
177 _, foreignPriv, err := ed25519.GenerateKey(rand.Reader)
178 require.NoError(t, err)
179 foreignCA, err := ssh.NewSignerFromSigner(foreignPriv)
180 require.NoError(t, err)
181
182 cert := hostCert(t, foreignCA)
183 err = ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
184 assert.Error(t, err)
185 }
186
187 func TestGateAuthHostKeyCallbackRejectsBareHostKey(t *testing.T) {
188 fake := newFakeCertAuthority(t)
189 ga := NewGateAuth(fake, nil)
190
191 pub, _, err := ed25519.GenerateKey(rand.Reader)
192 require.NoError(t, err)
193 sshPub, err := ssh.NewPublicKey(pub)
194 require.NoError(t, err)
195
196 err = ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, sshPub)
197 assert.Error(t, err)
198 }
199
200 func TestGateAuthFetchesCAOnlyOnce(t *testing.T) {
201 fake := newFakeCertAuthority(t)
202 fake.setNextValidBefore(ssh.CertTimeInfinity)
203 ga := NewGateAuth(fake, nil)
204
205 cb := ga.HostKeyCallback()
206 cert := hostCert(t, fake.caSigner)
207 require.NoError(t, cb("vm-name:22", &net.TCPAddr{}, cert))
208 require.NoError(t, cb("vm-name:22", &net.TCPAddr{}, cert))
209
210 _, err := ga.Signer(t.Context())
211 require.NoError(t, err)
212 _, err = ga.Signer(t.Context())
213 require.NoError(t, err)
214
215 fetchCA, _ := fake.counts()
216 assert.Equal(t, 1, fetchCA, "expected the CA to be fetched exactly once")
217 }
218
219 func TestGateAuthHostKeyCallbackRejectsWhenCAFetchFails(t *testing.T) {
220 fake := newFakeCertAuthority(t)
221 fake.setFetchErr(errors.New("ssh-ca gate is not enabled"))
222 ga := NewGateAuth(fake, nil)
223
224 // A perfectly valid, CA-signed host cert must STILL be rejected when we
225 // cannot fetch the CA to verify against it — failing closed, never open.
226 cert := hostCert(t, fake.caSigner)
227 err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
228 assert.Error(t, err, "host must be rejected when the CA cannot be fetched")
229 }
230
231 func TestGateAuthSignerMintErrorDoesNotPoisonCache(t *testing.T) {
232 fake := newFakeCertAuthority(t)
233 fake.setNextValidBefore(ssh.CertTimeInfinity)
234 fake.setMintErr(errors.New("mint boom"))
235 ga := NewGateAuth(fake, nil)
236
237 // First call: mint fails, error is surfaced, nothing is cached.
238 _, err := ga.Signer(t.Context())
239 require.Error(t, err)
240
241 // Recovery: with minting working again, a subsequent call must mint fresh
242 // and succeed — proving the failed attempt did not poison the cache with a
243 // broken cert/signer.
244 fake.setMintErr(nil)
245 s, err := ga.Signer(t.Context())
246 require.NoError(t, err)
247 require.NotNil(t, s)
248 assert.Regexp(t, `-cert-v01@openssh\.com$`, s.PublicKey().Type())
249
250 _, mintCalls := fake.counts()
251 assert.Equal(t, 2, mintCalls, "expected the failed mint to retry, not serve a poisoned cache")
252 }
internal/mcpserver/sshrun.go
Old New
@@ -0,0 +1,250 @@
1 package mcpserver
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "io/fs"
10 "net"
11 "os"
12 "path"
13 "time"
14
15 "github.com/pkg/sftp"
16 "golang.org/x/crypto/ssh"
17 )
18
19 // outputCap bounds captured exec/file bytes returned to the model.
20 const outputCap = 1 << 20 // 1 MiB
21
22 // GateCredentials provides the cert-backed client signer and the CA host-key
23 // verifier the Runner authenticates with. *GateAuth satisfies it.
24 type GateCredentials interface {
25 Signer(ctx context.Context) (ssh.Signer, error)
26 HostKeyCallback() ssh.HostKeyCallback
27 }
28
29 // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate.
30 type RunnerConfig struct {
31 Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host)
32 Auth GateCredentials // minted user-cert signer + CA host verifier
33 VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal)
34 }
35
36 // ExecResult is a completed remote command.
37 type ExecResult struct {
38 Stdout string
39 Stderr string
40 ExitCode int
41 Truncated bool
42 }
43
44 // Runner executes commands and transfers files on VMs over SSH, reaching each
45 // VM by NAME through eitri's SSH-CA jump gate. There is no TOFU/known_hosts:
46 // both hops are verified against the eitri CA — the gate presents a CA-signed
47 // host cert for its own domain, the VM a CA-signed host cert for its name — and
48 // the client authenticates with a short-lived CA-signed user cert.
49 type Runner struct {
50 cfg RunnerConfig
51 }
52
53 func NewRunner(cfg RunnerConfig) *Runner { return &Runner{cfg: cfg} }
54
55 // Exec runs cmd on the VM named vmName (reached through the gate). A non-zero
56 // remote exit is NOT an error — it's in ExitCode.
57 func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) {
58 ctx, cancel := context.WithTimeout(ctx, timeout)
59 defer cancel()
60 client, err := r.dial(ctx, vmName)
61 if err != nil {
62 return ExecResult{}, err
63 }
64 defer client.Close()
65
66 sess, err := client.NewSession()
67 if err != nil {
68 return ExecResult{}, fmt.Errorf("ssh session: %w", err)
69 }
70 defer sess.Close()
71
72 var stdout, stderr cappedBuf
73 sess.Stdout, sess.Stderr = &stdout, &stderr
74
75 done := make(chan error, 1)
76 go func() { done <- sess.Run(cmd) }()
77 select {
78 case <-ctx.Done():
79 _ = sess.Close()
80 return ExecResult{}, fmt.Errorf("exec timed out after %s", timeout)
81 case err = <-done:
82 }
83 res := ExecResult{Stdout: stdout.String(), Stderr: stderr.String(), Truncated: stdout.truncated || stderr.truncated}
84 var exitErr *ssh.ExitError
85 if errors.As(err, &exitErr) {
86 res.ExitCode = exitErr.ExitStatus()
87 return res, nil
88 }
89 if err != nil {
90 return res, fmt.Errorf("exec: %w", err)
91 }
92 return res, nil
93 }
94
95 // dial reaches the VM named vmName through the eitri SSH-CA gate: a client
96 // handshake with the gate (CA-verified host cert, CA-signed user cert), a
97 // direct-tcpip tunnel to <vmName>:22 (the only port the gate permits), then a
98 // second handshake directly with the VM's sshd over that tunnel. Error messages
99 // distinguish gate-unreachable/gate-handshake from VM-unreachable/VM-handshake.
100 // The caller must Close the returned *ssh.Client.
101 func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) {
102 signer, err := r.cfg.Auth.Signer(ctx)
103 if err != nil {
104 return nil, fmt.Errorf("minting gate credentials: %w", err)
105 }
106 hostCB := r.cfg.Auth.HostKeyCallback()
107
108 // Both hops share the same client config: the same CA-signed user cert
109 // authenticates to the gate and to the VM, and the same callback verifies
110 // both host certs against the eitri CA. The gate ignores the outer username,
111 // so using the VM login user throughout is harmless.
112 clientConf := &ssh.ClientConfig{
113 User: r.cfg.VMUser,
114 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
115 HostKeyCallback: hostCB,
116 Timeout: 15 * time.Second,
117 }
118
119 // Gate hop. Dial the gate verbatim and verify its host cert under the SAME
120 // address: the operator sets Gate to "<gate-domain>:<port>", and the gate's
121 // host cert principal is that domain, so ssh.CertChecker (in hostCB) matches.
122 dialer := net.Dialer{Timeout: 15 * time.Second}
123 conn, err := dialer.DialContext(ctx, "tcp", r.cfg.Gate)
124 if err != nil {
125 return nil, fmt.Errorf("gate %s unreachable: %w", r.cfg.Gate, err)
126 }
127 gnc, gchans, greqs, err := ssh.NewClientConn(conn, r.cfg.Gate, clientConf)
128 if err != nil {
129 _ = conn.Close()
130 return nil, fmt.Errorf("gate %s ssh handshake: %w", r.cfg.Gate, err)
131 }
132 gateClient := ssh.NewClient(gnc, gchans, greqs)
133
134 // VM hop. Open the direct-tcpip tunnel to <vmName>:22 through the gate. A
135 // connection failure here is expected during the vm_create pre-sshd boot
136 // window (the guest hasn't started sshd yet), and the caller retries.
137 vmAddr := vmName + ":22"
138 vmConn, err := gateClient.DialContext(ctx, "tcp", vmAddr)
139 if err != nil {
140 gateClient.Close()
141 return nil, fmt.Errorf("vm %s unreachable through the gate: %w", vmName, err)
142 }
143 // Verify the VM's host cert under <vmName>:22: its host-cert principal is the
144 // VM name, so ssh.CertChecker matches on the host portion of this address.
145 nc, chans, reqs, err := ssh.NewClientConn(vmConn, vmAddr, clientConf)
146 if err != nil {
147 gateClient.Close()
148 return nil, fmt.Errorf("vm %s ssh handshake: %w", vmName, err)
149 }
150 client := ssh.NewClient(nc, chans, reqs)
151 // Tie the gate client's lifetime to the VM client's: when the VM client
152 // closes (or the VM drops), tear down the tunnel and the gate connection.
153 go func() { _ = client.Wait(); gateClient.Close() }()
154 return client, nil
155 }
156
157 // cappedBuf captures at most outputCap bytes and records truncation.
158 type cappedBuf struct {
159 buf bytes.Buffer
160 truncated bool
161 }
162
163 func (b *cappedBuf) Write(p []byte) (int, error) {
164 room := outputCap - b.buf.Len()
165 if room <= 0 {
166 b.truncated = true
167 return len(p), nil
168 }
169 if len(p) > room {
170 b.buf.Write(p[:room])
171 b.truncated = true
172 return len(p), nil
173 }
174 return b.buf.Write(p)
175 }
176
177 func (b *cappedBuf) String() string { return b.buf.String() }
178
179 // WriteFile writes data to remotePath on the VM named vmName over SFTP. Missing
180 // parent dirs are created at the server default mode; the file itself is set to
181 // mode. The chmod is applied to the freshly-created file BEFORE any bytes are
182 // written, so a restrictive mode (e.g. 0600) is never briefly world-readable on
183 // the guest during the write (pkg/sftp's OpenFile takes no mode argument).
184 func (r *Runner) WriteFile(ctx context.Context, vmName, remotePath string, data []byte, mode fs.FileMode) error {
185 client, sf, err := r.sftp(ctx, vmName)
186 if err != nil {
187 return err
188 }
189 defer client.Close()
190 defer sf.Close()
191 if dir := path.Dir(remotePath); dir != "." && dir != "/" {
192 if err := sf.MkdirAll(dir); err != nil {
193 return fmt.Errorf("mkdir %s: %w", dir, err)
194 }
195 }
196 f, err := sf.OpenFile(remotePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
197 if err != nil {
198 return fmt.Errorf("create %s: %w", remotePath, err)
199 }
200 if err := f.Chmod(mode); err != nil {
201 f.Close()
202 return fmt.Errorf("chmod %s: %w", remotePath, err)
203 }
204 if _, err := f.Write(data); err != nil {
205 f.Close()
206 return fmt.Errorf("write %s: %w", remotePath, err)
207 }
208 return f.Close()
209 }
210
211 // ReadFile reads at most outputCap bytes from remotePath on the VM named
212 // vmName; truncated reports whether the file was larger.
213 func (r *Runner) ReadFile(ctx context.Context, vmName, remotePath string) (data []byte, truncated bool, err error) {
214 client, sf, err := r.sftp(ctx, vmName)
215 if err != nil {
216 return nil, false, err
217 }
218 defer client.Close()
219 defer sf.Close()
220 f, err := sf.Open(remotePath)
221 if err != nil {
222 return nil, false, fmt.Errorf("open %s: %w", remotePath, err)
223 }
224 defer f.Close()
225 buf := make([]byte, outputCap+1)
226 n, rerr := io.ReadFull(f, buf)
227 if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
228 return nil, false, fmt.Errorf("read %s: %w", remotePath, rerr)
229 }
230 if n > outputCap {
231 return buf[:outputCap], true, nil
232 }
233 return buf[:n], false, nil
234 }
235
236 // sftp dials the VM named vmName through the gate and wraps the connection in
237 // an sftp.Client. The caller must Close both the returned *ssh.Client and
238 // *sftp.Client.
239 func (r *Runner) sftp(ctx context.Context, vmName string) (*ssh.Client, *sftp.Client, error) {
240 client, err := r.dial(ctx, vmName)
241 if err != nil {
242 return nil, nil, err
243 }
244 sf, err := sftp.NewClient(client)
245 if err != nil {
246 client.Close()
247 return nil, nil, fmt.Errorf("sftp: %w", err)
248 }
249 return client, sf, nil
250 }
internal/mcpserver/sshrun_test.go
Old New
@@ -0,0 +1,271 @@
1 package mcpserver
2
3 import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "errors"
8 "io"
9 "net"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 "golang.org/x/crypto/ssh"
16 )
17
18 // ── test SSH-CA scaffolding ──────────────────────────────────────────────────
19
20 // newSigner returns a fresh ed25519 ssh.Signer.
21 func newSigner(t *testing.T) ssh.Signer {
22 t.Helper()
23 _, priv, err := ed25519.GenerateKey(rand.Reader)
24 require.NoError(t, err)
25 s, err := ssh.NewSignerFromSigner(priv)
26 require.NoError(t, err)
27 return s
28 }
29
30 // hostCertSigner builds a host-cert-backed signer for principal, signed by ca.
31 // A server AddHostKey'd with it presents that host cert during the handshake.
32 func hostCertSigner(t *testing.T, ca ssh.Signer, principal string) ssh.Signer {
33 t.Helper()
34 hostKey := newSigner(t)
35 cert := &ssh.Certificate{
36 Key: hostKey.PublicKey(),
37 CertType: ssh.HostCert,
38 ValidPrincipals: []string{principal},
39 ValidBefore: ssh.CertTimeInfinity,
40 }
41 require.NoError(t, cert.SignCert(rand.Reader, ca))
42 cs, err := ssh.NewCertSigner(cert, hostKey)
43 require.NoError(t, err)
44 return cs
45 }
46
47 // caUserAuth accepts a client only if it presents a user cert signed by ca —
48 // mirroring the gate/VM sshd's TrustedUserCAKeys policy.
49 func caUserAuth(ca ssh.PublicKey) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
50 checker := &ssh.CertChecker{
51 IsUserAuthority: func(auth ssh.PublicKey) bool { return caEquals(auth, ca) },
52 }
53 return func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
54 cert, ok := key.(*ssh.Certificate)
55 if !ok {
56 return nil, errors.New("only certificate authentication is accepted")
57 }
58 if cert.CertType != ssh.UserCert {
59 return nil, errors.New("not a user certificate")
60 }
61 if !checker.IsUserAuthority(cert.SignatureKey) {
62 return nil, errors.New("certificate not signed by the test CA")
63 }
64 if len(cert.ValidPrincipals) == 0 {
65 return nil, errors.New("certificate has no principals")
66 }
67 // Feed CheckCert one of the cert's own principals so only CA-signature +
68 // validity gate authentication, not the arbitrary outer username.
69 if err := checker.CheckCert(cert.ValidPrincipals[0], cert); err != nil {
70 return nil, err
71 }
72 return &ssh.Permissions{}, nil
73 }
74 }
75
76 // startBackingVM runs a minimal VM sshd on a random loopback port: it presents
77 // hostSigner's host cert, accepts CA-signed user certs, and answers a single
78 // "exec" request with out + code. Returns its listen address.
79 func startBackingVM(t *testing.T, hostSigner ssh.Signer, userCA ssh.PublicKey, out string, code int) string {
80 t.Helper()
81 conf := &ssh.ServerConfig{PublicKeyCallback: caUserAuth(userCA)}
82 conf.AddHostKey(hostSigner)
83
84 ln, err := net.Listen("tcp", "127.0.0.1:0")
85 require.NoError(t, err)
86 t.Cleanup(func() { ln.Close() })
87
88 go func() {
89 for {
90 nc, err := ln.Accept()
91 if err != nil {
92 return
93 }
94 go serveBackingVM(nc, conf, out, code)
95 }
96 }()
97 return ln.Addr().String()
98 }
99
100 func serveBackingVM(nc net.Conn, conf *ssh.ServerConfig, out string, code int) {
101 sc, chans, reqs, err := ssh.NewServerConn(nc, conf)
102 if err != nil {
103 nc.Close()
104 return
105 }
106 defer sc.Close()
107 go ssh.DiscardRequests(reqs)
108 for newCh := range chans {
109 if newCh.ChannelType() != "session" {
110 newCh.Reject(ssh.UnknownChannelType, "only session")
111 continue
112 }
113 ch, chReqs, err := newCh.Accept()
114 if err != nil {
115 continue
116 }
117 go handleExecSession(ch, chReqs, out, code)
118 }
119 }
120
121 func handleExecSession(ch ssh.Channel, reqs <-chan *ssh.Request, out string, code int) {
122 defer ch.Close()
123 for req := range reqs {
124 if req.Type == "exec" {
125 req.Reply(true, nil)
126 io.WriteString(ch, out)
127 ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
128 return
129 }
130 if req.WantReply {
131 req.Reply(false, nil)
132 }
133 }
134 }
135
136 // startGate runs a minimal SSH-CA gate on a random loopback port: it presents
137 // hostSigner's host cert, accepts CA-signed user certs, and on a direct-tcpip
138 // channel (the only kind it honors) dials vmAddr and pumps bytes both ways —
139 // mirroring sshgate.handleDirectTCPIP. Returns its listen address.
140 func startGate(t *testing.T, hostSigner ssh.Signer, userCA ssh.PublicKey, vmAddr string) string {
141 t.Helper()
142 conf := &ssh.ServerConfig{PublicKeyCallback: caUserAuth(userCA)}
143 conf.AddHostKey(hostSigner)
144
145 ln, err := net.Listen("tcp", "127.0.0.1:0")
146 require.NoError(t, err)
147 t.Cleanup(func() { ln.Close() })
148
149 go func() {
150 for {
151 nc, err := ln.Accept()
152 if err != nil {
153 return
154 }
155 go serveGate(nc, conf, vmAddr)
156 }
157 }()
158 return ln.Addr().String()
159 }
160
161 func serveGate(nc net.Conn, conf *ssh.ServerConfig, vmAddr string) {
162 sc, chans, reqs, err := ssh.NewServerConn(nc, conf)
163 if err != nil {
164 nc.Close()
165 return
166 }
167 defer sc.Close()
168 go ssh.DiscardRequests(reqs)
169 for newCh := range chans {
170 if newCh.ChannelType() != "direct-tcpip" {
171 newCh.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted")
172 continue
173 }
174 go gatePipe(newCh, vmAddr)
175 }
176 }
177
178 func gatePipe(newCh ssh.NewChannel, vmAddr string) {
179 var p struct {
180 HostToConnect string
181 PortToConnect uint32
182 OriginatorIP string
183 OriginatorPort uint32
184 }
185 if err := ssh.Unmarshal(newCh.ExtraData(), &p); err != nil {
186 newCh.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request")
187 return
188 }
189 if p.PortToConnect != 22 {
190 newCh.Reject(ssh.Prohibited, "only port 22 is permitted")
191 return
192 }
193 target, err := net.Dial("tcp", vmAddr)
194 if err != nil {
195 newCh.Reject(ssh.ConnectionFailed, err.Error())
196 return
197 }
198 ch, chReqs, err := newCh.Accept()
199 if err != nil {
200 target.Close()
201 return
202 }
203 go ssh.DiscardRequests(chReqs)
204 go func() { io.Copy(ch, target); ch.Close() }()
205 go func() { io.Copy(target, ch); target.Close() }()
206 }
207
208 // fakeGateCreds is a minimal GateCredentials with a caller-chosen client signer
209 // and host verifier, for exercising client-auth rejection paths.
210 type fakeGateCreds struct {
211 signer ssh.Signer
212 hostCB ssh.HostKeyCallback
213 }
214
215 func (f fakeGateCreds) Signer(context.Context) (ssh.Signer, error) { return f.signer, nil }
216 func (f fakeGateCreds) HostKeyCallback() ssh.HostKeyCallback { return f.hostCB }
217
218 // ── tests ────────────────────────────────────────────────────────────────────
219
220 func TestExecThroughGate(t *testing.T) {
221 fake := newFakeCertAuthority(t)
222 ga := NewGateAuth(fake, nil)
223 ca := fake.caSigner
224
225 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "testvm"), ca.PublicKey(), "hi\n", 0)
226 // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>,
227 // verifies it under that host.
228 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
229
230 r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: ga, VMUser: "ubuntu"})
231 res, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
232 require.NoError(t, err)
233 assert.Equal(t, "hi\n", res.Stdout)
234 assert.Equal(t, 0, res.ExitCode)
235 }
236
237 func TestExecVMForeignCAHostCertRejected(t *testing.T) {
238 fake := newFakeCertAuthority(t)
239 ga := NewGateAuth(fake, nil)
240 ca := fake.caSigner
241
242 // The VM presents a host cert signed by a DIFFERENT CA. Its user-auth policy
243 // still trusts the real CA, so the gate hop and client auth both succeed and
244 // this isolates the VM host-cert rejection.
245 foreignCA := newSigner(t)
246 vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "testvm"), ca.PublicKey(), "hi\n", 0)
247 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
248
249 r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: ga, VMUser: "ubuntu"})
250 _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
251 require.Error(t, err)
252 assert.Contains(t, err.Error(), "vm testvm ssh handshake", "expected the VM hop to reject the foreign-CA host cert")
253 }
254
255 func TestExecGateRejectsNonCAUserKey(t *testing.T) {
256 fake := newFakeCertAuthority(t)
257 ga := NewGateAuth(fake, nil)
258 ca := fake.caSigner
259
260 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "testvm"), ca.PublicKey(), "hi\n", 0)
261 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
262
263 // A plain (non-cert) client key: the gate only accepts CA-signed user certs,
264 // so its handshake must fail auth. Host verification still uses the real CA,
265 // isolating the client-auth rejection at the gate hop.
266 creds := fakeGateCreds{signer: newSigner(t), hostCB: ga.HostKeyCallback()}
267 r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: creds, VMUser: "ubuntu"})
268 _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
269 require.Error(t, err)
270 assert.Contains(t, err.Error(), "gate "+gateAddr+" ssh handshake", "expected the gate hop to reject the non-CA user key")
271 }
internal/mcpserver/tools.go
Old New
@@ -0,0 +1,433 @@
1 package mcpserver
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io/fs"
8 "time"
9
10 "github.com/a73x/eitri/internal/random"
11 "github.com/a73x/eitri/internal/server/api/client"
12 )
13
14 // api and runner are the two seams Tools composes; API and Runner satisfy
15 // them, fakes replace them in tests.
16 type api interface {
17 ListVMs(ctx context.Context) ([]client.VM, error)
18 CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error)
19 DeleteVM(ctx context.Context, id string) error
20 FirstOnlineHost(ctx context.Context) (client.Host, error)
21 }
22
23 type runner interface {
24 Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error)
25 WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error
26 ReadFile(ctx context.Context, vmName, path string) ([]byte, bool, error)
27 }
28
29 // API is the shared eitri API client plus the one piece of MCP placement
30 // policy the raw client doesn't carry: FirstOnlineHost.
31 type API struct {
32 *client.Client
33 }
34
35 // FirstOnlineHost returns the first online host — the default placement
36 // target when the caller doesn't name one. Ordering is server-defined;
37 // callers must not assume stability across calls.
38 func (a API) FirstOnlineHost(ctx context.Context) (client.Host, error) {
39 hosts, err := a.ListHosts(ctx)
40 if err != nil {
41 return client.Host{}, err
42 }
43 for _, h := range hosts {
44 if h.Online {
45 return h, nil
46 }
47 }
48 return client.Host{}, errors.New("no online hosts")
49 }
50
51 // The real client and runner must satisfy the seams unchanged, and the shared
52 // API client must keep satisfying the gate's cert-authority seam.
53 var (
54 _ api = API{}
55 _ runner = (*Runner)(nil)
56 _ CertAuthority = (*client.Client)(nil)
57 )
58
59 // Tools implements the seven eitri-mcp tools over the API and SSH seams.
60 type Tools struct {
61 API api
62 Runner runner
63 Gate string // gate address, for the ssh command hint only
64 VMUser string
65
66 PollEvery time.Duration // create-wait poll interval (default 2s)
67 WaitTimeout time.Duration // create-wait ceiling (default 10m)
68 }
69
70 func (t *Tools) pollEvery() time.Duration {
71 if t.PollEvery > 0 {
72 return t.PollEvery
73 }
74 return 2 * time.Second
75 }
76
77 func (t *Tools) waitTimeout() time.Duration {
78 if t.WaitTimeout > 0 {
79 return t.WaitTimeout
80 }
81 return 10 * time.Minute
82 }
83
84 // ── vm_create ────────────────────────────────────────────────────────────────
85
86 type VMCreateIn struct {
87 Name string `json:"name,omitempty" jsonschema:"VM name (RFC-1123 label); default claude-<hex>"`
88 Host string `json:"host,omitempty" jsonschema:"host name to place on; default first online host"`
89 VCPUs int64 `json:"vcpus,omitempty" jsonschema:"default 2"`
90 MemMB int64 `json:"mem_mb,omitempty" jsonschema:"default 2048"`
91 DiskGB int64 `json:"disk_gb,omitempty" jsonschema:"default 20"`
92 CloudInit string `json:"cloud_init,omitempty" jsonschema:"optional user cloud-init"`
93 Wait *bool `json:"wait,omitempty" jsonschema:"wait for ready+cloud-init (default true)"`
94 }
95
96 type VMCreateOut struct {
97 ID string `json:"id"`
98 Name string `json:"name"`
99 IP string `json:"ip,omitempty"`
100 SSHCommand string `json:"ssh_command,omitempty"`
101 }
102
103 func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error) {
104 req := client.CreateVMRequest{
105 Name: in.Name,
106 CloudInit: in.CloudInit,
107 VCPUs: in.VCPUs,
108 MemMB: in.MemMB,
109 DiskGB: in.DiskGB,
110 Persistent: true, // spec: long-lived VMs are first-class; no reaper
111 }
112 if req.Name == "" {
113 req.Name = "claude-" + random.Hex(3)
114 }
115 if req.VCPUs == 0 {
116 req.VCPUs = 2
117 }
118 if req.MemMB == 0 {
119 req.MemMB = 2048
120 }
121 if req.DiskGB == 0 {
122 req.DiskGB = 20
123 }
124 if in.Host != "" {
125 vm, err := t.resolveHost(ctx, in.Host)
126 if err != nil {
127 return VMCreateOut{}, err
128 }
129 req.HostID = vm
130 } else {
131 h, err := t.API.FirstOnlineHost(ctx)
132 if err != nil {
133 return VMCreateOut{}, err
134 }
135 req.HostID = h.ID
136 }
137
138 created, err := t.API.CreateVM(ctx, req)
139 if err != nil {
140 return VMCreateOut{}, fmt.Errorf("create vm: %w", err)
141 }
142 out := VMCreateOut{ID: created.ID, Name: created.Name}
143 if in.Wait != nil && !*in.Wait {
144 return out, nil
145 }
146
147 // One shared deadline bounds the whole wait (ready + cloud-init) by
148 // WaitTimeout, rather than letting each phase burn a full budget.
149 deadline := time.Now().Add(t.waitTimeout())
150 ip, err := t.waitReady(ctx, created.ID, created.Name, deadline)
151 if err != nil {
152 // Spec: report state, never auto-destroy — the VM may just be slow.
153 return out, err
154 }
155 out.IP = ip
156 out.SSHCommand = t.sshCommand(created.Name)
157 // "ready" means cloud-hypervisor is up and the IP is ALLOCATED — NOT that the
158 // guest has booted Linux, brought up its NIC, and started sshd. The first SSH
159 // dials can therefore hit "no route to host"/"connection refused" while the
160 // guest is still in firmware/early boot. So retry the cloud-init wait,
161 // tolerating connection-level failures, until the shared deadline. The loop is
162 // purely for the pre-sshd window: once SSH connects, "cloud-init status --wait"
163 // itself blocks until cloud-init finishes, settling packages/runcmd.
164 //
165 // Any Exec error is treated as "guest not SSH-reachable yet" — within
166 // vm_create the only expected transient is the guest booting, and a persistent
167 // non-connection error still terminates cleanly at the deadline with lastErr in
168 // the message. We deliberately do NOT classify error strings.
169 //
170 // Error messages name the VM id AND name so the model can still find and
171 // destroy the degraded VM even if the MCP wrapper drops the structured
172 // result when err != nil.
173 var lastErr error
174 for {
175 // Host identity is now verified against the eitri CA (the VM presents a
176 // CA-signed host cert for its name), so there is no per-IP known_hosts pin
177 // to evict between retries — the guest regenerating its host key during
178 // cloud-init is transparent as long as the new key is a CA-signed cert.
179 // Give the command the time left in the shared budget (floored so a
180 // nearly-exhausted budget still gets a real chance).
181 remaining := time.Until(deadline)
182 if remaining < 30*time.Second {
183 remaining = 30 * time.Second
184 }
185 res, execErr := t.Runner.Exec(ctx, created.Name, "cloud-init status --wait", remaining)
186 if execErr == nil {
187 if res.ExitCode != 0 {
188 return out, fmt.Errorf("vm %s (%s) ready at %s but cloud-init exited %d: %s", created.ID, created.Name, ip, res.ExitCode, res.Stderr)
189 }
190 return out, nil
191 }
192 lastErr = execErr
193 // Stop once we are past the deadline, or the next sleep would carry us
194 // past it — no point sleeping only to give up.
195 if !time.Now().Add(t.pollEvery()).Before(deadline) {
196 return out, fmt.Errorf("vm %s (%s) ready at %s but never became SSH-reachable within %s: %w", created.ID, created.Name, ip, t.waitTimeout(), lastErr)
197 }
198 select {
199 case <-ctx.Done():
200 return out, ctx.Err()
201 case <-time.After(t.pollEvery()):
202 }
203 }
204 }
205
206 // waitReady polls until the VM reaches "ready" with an IP, or the shared
207 // deadline expires. Transient ListVMs failures (control-plane restart/blip/
208 // 5xx) do NOT abort the wait — they are stashed and polling continues, per the
209 // tool's "do not assume failure" contract. Only real cancellation (ctx.Done)
210 // aborts immediately.
211 func (t *Tools) waitReady(ctx context.Context, id, name string, deadline time.Time) (string, error) {
212 last := ""
213 sawListing := false
214 var lastErr error
215 for time.Now().Before(deadline) {
216 vms, err := t.API.ListVMs(ctx)
217 if err != nil {
218 lastErr = err
219 } else {
220 sawListing = true
221 for _, vm := range vms {
222 if vm.ID != id {
223 continue
224 }
225 last = vm.Lifecycle
226 if vm.Lifecycle == "ready" && vm.AssignedIP != "" {
227 return vm.AssignedIP, nil
228 }
229 }
230 }
231 select {
232 case <-ctx.Done():
233 return "", ctx.Err()
234 case <-time.After(t.pollEvery()):
235 }
236 }
237 if sawListing {
238 return "", fmt.Errorf("vm %s (%s) not ready after %s (last lifecycle %q); it may still come up — check vm_info, do not assume failure", id, name, t.waitTimeout(), last)
239 }
240 return "", fmt.Errorf("vm %s (%s) not ready after %s (control-plane never listed successfully; last error: %v); it may still come up — check vm_info, do not assume failure", id, name, t.waitTimeout(), lastErr)
241 }
242
243 func (t *Tools) resolveHost(ctx context.Context, name string) (string, error) {
244 // v1 places every VM on the first online host; the api seam exposes only
245 // FirstOnlineHost. A caller-named host must therefore match it. Extend the
246 // seam with real host-by-name lookup when multi-host placement is needed.
247 h, err := t.API.FirstOnlineHost(ctx)
248 if err != nil {
249 return "", err
250 }
251 if h.Name != name && h.ID != name {
252 return "", fmt.Errorf("unknown host %q (v1 places on the first online host %q; pass no host to use it)", name, h.Name)
253 }
254 return h.ID, nil
255 }
256
257 func (t *Tools) sshCommand(name string) string {
258 if t.Gate != "" {
259 return fmt.Sprintf("ssh -J %s %s@%s", t.Gate, t.VMUser, name)
260 }
261 return fmt.Sprintf("ssh %s@%s", t.VMUser, name)
262 }
263
264 // ── vm_list / vm_info ────────────────────────────────────────────────────────
265
266 type VMListIn struct{}
267
268 type VMListOut struct {
269 VMs []client.VM `json:"vms"`
270 }
271
272 func (t *Tools) VMList(ctx context.Context, _ VMListIn) (VMListOut, error) {
273 vms, err := t.API.ListVMs(ctx)
274 if err != nil {
275 return VMListOut{}, err
276 }
277 return VMListOut{VMs: vms}, nil
278 }
279
280 type VMInfoIn struct {
281 VM string `json:"vm" jsonschema:"VM id or exact name"`
282 }
283
284 type VMInfoOut struct {
285 VM client.VM `json:"vm"`
286 SSHCommand string `json:"ssh_command,omitempty"`
287 }
288
289 func (t *Tools) VMInfo(ctx context.Context, in VMInfoIn) (VMInfoOut, error) {
290 vm, err := t.resolveVM(ctx, in.VM)
291 if err != nil {
292 return VMInfoOut{}, err
293 }
294 out := VMInfoOut{VM: vm}
295 if vm.Lifecycle == "ready" {
296 out.SSHCommand = t.sshCommand(vm.Name)
297 }
298 return out, nil
299 }
300
301 // ── vm_exec ──────────────────────────────────────────────────────────────────
302
303 type VMExecIn struct {
304 VM string `json:"vm" jsonschema:"VM id or exact name"`
305 Command string `json:"command" jsonschema:"shell command to run as the VM user"`
306 TimeoutS int `json:"timeout_s,omitempty" jsonschema:"default 120"`
307 }
308
309 type VMExecOut struct {
310 Stdout string `json:"stdout"`
311 Stderr string `json:"stderr"`
312 ExitCode int `json:"exit_code"`
313 Truncated bool `json:"truncated,omitempty"`
314 }
315
316 func (t *Tools) VMExec(ctx context.Context, in VMExecIn) (VMExecOut, error) {
317 vm, err := t.resolveVM(ctx, in.VM)
318 if err != nil {
319 return VMExecOut{}, err
320 }
321 if vm.Lifecycle != "ready" {
322 return VMExecOut{}, fmt.Errorf("vm %s is not ready (lifecycle %q)", vm.Name, vm.Lifecycle)
323 }
324 timeout := 120 * time.Second
325 if in.TimeoutS > 0 {
326 timeout = time.Duration(in.TimeoutS) * time.Second
327 }
328 res, err := t.Runner.Exec(ctx, vm.Name, in.Command, timeout)
329 if err != nil {
330 return VMExecOut{}, err
331 }
332 return VMExecOut(res), nil
333 }
334
335 // ── vm_write_file / vm_read_file ─────────────────────────────────────────────
336
337 type VMWriteFileIn struct {
338 VM string `json:"vm" jsonschema:"VM id or exact name"`
339 Path string `json:"path" jsonschema:"absolute path in the VM"`
340 Content string `json:"content" jsonschema:"file contents (UTF-8 text)"`
341 Mode string `json:"mode,omitempty" jsonschema:"octal file mode, default 0644"`
342 }
343
344 type VMWriteFileOut struct {
345 Path string `json:"path"`
346 Bytes int `json:"bytes"`
347 }
348
349 func (t *Tools) VMWriteFile(ctx context.Context, in VMWriteFileIn) (VMWriteFileOut, error) {
350 vm, err := t.resolveVM(ctx, in.VM)
351 if err != nil {
352 return VMWriteFileOut{}, err
353 }
354 if vm.Lifecycle != "ready" {
355 return VMWriteFileOut{}, fmt.Errorf("vm %s is not ready (lifecycle %q)", vm.Name, vm.Lifecycle)
356 }
357 mode := fs.FileMode(0o644)
358 if in.Mode != "" {
359 var m uint32
360 if _, err := fmt.Sscanf(in.Mode, "%o", &m); err != nil {
361 return VMWriteFileOut{}, fmt.Errorf("bad mode %q: %w", in.Mode, err)
362 }
363 mode = fs.FileMode(m)
364 }
365 if err := t.Runner.WriteFile(ctx, vm.Name, in.Path, []byte(in.Content), mode); err != nil {
366 return VMWriteFileOut{}, err
367 }
368 return VMWriteFileOut{Path: in.Path, Bytes: len(in.Content)}, nil
369 }
370
371 type VMReadFileIn struct {
372 VM string `json:"vm" jsonschema:"VM id or exact name"`
373 Path string `json:"path" jsonschema:"absolute path in the VM"`
374 }
375
376 type VMReadFileOut struct {
377 Content string `json:"content"`
378 Truncated bool `json:"truncated,omitempty"`
379 }
380
381 func (t *Tools) VMReadFile(ctx context.Context, in VMReadFileIn) (VMReadFileOut, error) {
382 vm, err := t.resolveVM(ctx, in.VM)
383 if err != nil {
384 return VMReadFileOut{}, err
385 }
386 if vm.Lifecycle != "ready" {
387 return VMReadFileOut{}, fmt.Errorf("vm %s is not ready (lifecycle %q)", vm.Name, vm.Lifecycle)
388 }
389 data, truncated, err := t.Runner.ReadFile(ctx, vm.Name, in.Path)
390 if err != nil {
391 return VMReadFileOut{}, err
392 }
393 return VMReadFileOut{Content: string(data), Truncated: truncated}, nil
394 }
395
396 // ── vm_destroy ───────────────────────────────────────────────────────────────
397
398 type VMDestroyIn struct {
399 VM string `json:"vm" jsonschema:"VM id or EXACT name; destruction is explicit-only"`
400 }
401
402 type VMDestroyOut struct {
403 ID string `json:"id"`
404 Name string `json:"name"`
405 }
406
407 func (t *Tools) VMDestroy(ctx context.Context, in VMDestroyIn) (VMDestroyOut, error) {
408 vm, err := t.resolveVM(ctx, in.VM)
409 if err != nil {
410 return VMDestroyOut{}, err
411 }
412 if err := t.API.DeleteVM(ctx, vm.ID); err != nil {
413 return VMDestroyOut{}, err
414 }
415 return VMDestroyOut{ID: vm.ID, Name: vm.Name}, nil
416 }
417
418 // resolveVM matches id or exact name against the live VM list.
419 func (t *Tools) resolveVM(ctx context.Context, idOrName string) (client.VM, error) {
420 if idOrName == "" {
421 return client.VM{}, fmt.Errorf("vm is required (id or exact name)")
422 }
423 vms, err := t.API.ListVMs(ctx)
424 if err != nil {
425 return client.VM{}, err
426 }
427 for _, vm := range vms {
428 if vm.ID == idOrName || vm.Name == idOrName {
429 return vm, nil
430 }
431 }
432 return client.VM{}, fmt.Errorf("no VM with id or name %q", idOrName)
433 }
internal/mcpserver/tools_test.go
Old New
@@ -0,0 +1,307 @@
1 package mcpserver
2
3 import (
4 "context"
5 "fmt"
6 "io/fs"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "github.com/a73x/eitri/internal/server/api/client"
14 )
15
16 type fakeToolsAPI struct {
17 vms []client.VM
18 created []client.CreateVMRequest
19 deleted []string
20 // lifecycle sequence returned across successive ListVMs calls for the
21 // created VM, letting tests script the create→ready wait.
22 phases []string
23 calls int
24 // number of leading ListVMs calls that fail (control-plane blip); after
25 // they are exhausted the phases sequence takes over.
26 listErrs int
27 }
28
29 func (f *fakeToolsAPI) ListVMs(ctx context.Context) ([]client.VM, error) {
30 if f.listErrs > 0 {
31 f.listErrs--
32 return nil, fmt.Errorf("control plane unavailable")
33 }
34 if len(f.phases) > 0 {
35 i := f.calls
36 if i >= len(f.phases) {
37 i = len(f.phases) - 1
38 }
39 f.calls++
40 vm := client.VM{ID: "new1", Name: "claude-abc", Lifecycle: f.phases[i]}
41 if f.phases[i] == "ready" {
42 vm.AssignedIP = "10.77.1.9"
43 }
44 return append(append([]client.VM{}, f.vms...), vm), nil
45 }
46 return f.vms, nil
47 }
48 func (f *fakeToolsAPI) CreateVM(ctx context.Context, r client.CreateVMRequest) (client.CreateVMResponse, error) {
49 f.created = append(f.created, r)
50 return client.CreateVMResponse{ID: "new1", Name: "claude-abc"}, nil
51 }
52 func (f *fakeToolsAPI) DeleteVM(ctx context.Context, id string) error {
53 f.deleted = append(f.deleted, id)
54 return nil
55 }
56 func (f *fakeToolsAPI) FirstOnlineHost(ctx context.Context) (client.Host, error) {
57 return client.Host{ID: "h1", Name: "mewtwo", Online: true}, nil
58 }
59
60 type fakeRunner struct {
61 execs []string
62 out ExecResult
63 err error
64 // number of leading Exec calls that fail with a connection-style error (the
65 // guest still booting, pre-sshd); after they are exhausted the configured
66 // out/err takes over. Mirrors fakeToolsAPI.listErrs.
67 execErrs int
68 files map[string][]byte
69 reads []string // vmName+"|"+path passed to ReadFile, in order
70 }
71
72 func (f *fakeRunner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) {
73 f.execs = append(f.execs, vmName+"|"+cmd)
74 if f.execErrs > 0 {
75 f.execErrs--
76 return ExecResult{}, fmt.Errorf("vm %s unreachable: connect: no route to host", vmName)
77 }
78 return f.out, f.err
79 }
80 func (f *fakeRunner) WriteFile(ctx context.Context, vmName, p string, data []byte, mode fs.FileMode) error {
81 if f.files == nil {
82 f.files = map[string][]byte{}
83 }
84 f.files[p] = data
85 return nil
86 }
87 func (f *fakeRunner) ReadFile(ctx context.Context, vmName, p string) ([]byte, bool, error) {
88 f.reads = append(f.reads, vmName+"|"+p)
89 d, ok := f.files[p]
90 if !ok {
91 return nil, false, fmt.Errorf("open %s: not found", p)
92 }
93 return d, false, nil
94 }
95
96 func newTestTools(api *fakeToolsAPI, r *fakeRunner) *Tools {
97 return &Tools{
98 API: api, Runner: r,
99 Gate: "localhost:2223",
100 VMUser: "ubuntu",
101 PollEvery: time.Millisecond, // fast tests
102 }
103 }
104
105 func TestCreateWaitsForReadyAndCloudInit(t *testing.T) {
106 api := &fakeToolsAPI{phases: []string{"creating", "creating", "ready"}}
107 run := &fakeRunner{out: ExecResult{ExitCode: 0}}
108 tl := newTestTools(api, run)
109
110 out, err := tl.VMCreate(t.Context(), VMCreateIn{})
111 require.NoError(t, err)
112 assert.Equal(t, "new1", out.ID)
113 assert.Equal(t, "10.77.1.9", out.IP)
114 assert.Contains(t, out.SSHCommand, "-J localhost:2223")
115 assert.Contains(t, out.SSHCommand, "ubuntu@claude-abc")
116
117 require.Len(t, api.created, 1)
118 req := api.created[0]
119 assert.True(t, req.Persistent, "spec: persistent always true")
120 assert.Equal(t, int64(2), req.VCPUs)
121 assert.Equal(t, int64(2048), req.MemMB)
122 assert.Equal(t, int64(20), req.DiskGB)
123 assert.Equal(t, "h1", req.HostID)
124 assert.Empty(t, req.SSHAuthorizedKey, "no key injection under the CA model")
125
126 require.Len(t, run.execs, 1)
127 assert.Equal(t, []string{"claude-abc|cloud-init status --wait"}, run.execs, "addressed by name, not IP")
128 }
129
130 func TestCreateRetriesThroughPreSSHDBootWindow(t *testing.T) {
131 // "ready" means the IP is allocated, NOT that the guest has booted sshd, so
132 // the first SSH dials can fail while the guest is still coming up. Fail the
133 // first two Exec attempts (pre-sshd) and succeed on the third; vm_create must
134 // retry through the transient failures rather than give up.
135 api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
136 run := &fakeRunner{out: ExecResult{ExitCode: 0}, execErrs: 2}
137 tl := newTestTools(api, run)
138
139 out, err := tl.VMCreate(t.Context(), VMCreateIn{})
140 require.NoError(t, err)
141 assert.Equal(t, "10.77.1.9", out.IP)
142 require.Len(t, run.execs, 3, "two transient failures then a success")
143 }
144
145 func TestCreateRidesThroughTransientListErrors(t *testing.T) {
146 // First two polls fail (control-plane blip), then the VM reports ready.
147 api := &fakeToolsAPI{listErrs: 2, phases: []string{"creating", "ready"}}
148 run := &fakeRunner{out: ExecResult{ExitCode: 0}}
149 tl := newTestTools(api, run)
150
151 out, err := tl.VMCreate(t.Context(), VMCreateIn{})
152 require.NoError(t, err, "transient ListVMs errors must not abandon the wait")
153 assert.Equal(t, "new1", out.ID)
154 assert.Equal(t, "10.77.1.9", out.IP)
155 require.Len(t, run.execs, 1)
156 assert.Contains(t, run.execs[0], "cloud-init status --wait")
157 }
158
159 func TestCreateRetriesUntilSSHReachable(t *testing.T) {
160 // VM reaches "ready" (IP allocated) but sshd is not up yet: the first two SSH
161 // dials fail with a connection error, the third connects and cloud-init runs.
162 api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
163 run := &fakeRunner{out: ExecResult{ExitCode: 0}, execErrs: 2}
164 tl := newTestTools(api, run)
165
166 out, err := tl.VMCreate(t.Context(), VMCreateIn{})
167 require.NoError(t, err, "must ride out the pre-sshd window, not fail on the first dial")
168 assert.Equal(t, "10.77.1.9", out.IP)
169 require.Len(t, run.execs, 3, "two connection failures then a success")
170 assert.Contains(t, run.execs[2], "cloud-init status --wait")
171 assert.Empty(t, api.deleted, "spec: never auto-destroy")
172 }
173
174 func TestCreateSSHNeverReachableReports(t *testing.T) {
175 // VM is "ready" but never becomes SSH-reachable within the budget.
176 api := &fakeToolsAPI{phases: []string{"ready"}}
177 run := &fakeRunner{err: fmt.Errorf("dial tcp 10.77.1.9:22: connect: no route to host")}
178 tl := newTestTools(api, run)
179 tl.WaitTimeout = 15 * time.Millisecond
180
181 out, err := tl.VMCreate(t.Context(), VMCreateIn{})
182 require.Error(t, err)
183 assert.Contains(t, err.Error(), "never became SSH-reachable")
184 assert.Contains(t, err.Error(), "new1", "degraded error must name the VM id so the model can destroy it")
185 assert.Equal(t, "10.77.1.9", out.IP, "out stays populated so the model can act on it")
186 assert.Empty(t, api.deleted, "spec: never auto-destroy on unreachable")
187 }
188
189 func TestCreateReportsCloudInitFailure(t *testing.T) {
190 // SSH connects and cloud-init RUNS but exits non-zero. Unlike a connection
191 // failure this must NOT be retried: cloud-init already ran, so report the
192 // degraded VM after exactly one Exec.
193 api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
194 run := &fakeRunner{out: ExecResult{ExitCode: 1, Stderr: "boom"}}
195 tl := newTestTools(api, run)
196
197 out, err := tl.VMCreate(t.Context(), VMCreateIn{})
198 require.Error(t, err)
199 assert.Contains(t, err.Error(), "cloud-init exited 1")
200 assert.Contains(t, err.Error(), "new1", "error must name the VM id")
201 assert.Contains(t, err.Error(), "claude-abc", "error must name the VM name")
202 assert.Equal(t, "10.77.1.9", out.IP, "out stays populated so the model can act on it")
203 assert.Empty(t, api.deleted, "spec: never auto-destroy")
204 require.Len(t, run.execs, 1, "a ran-but-failed cloud-init must NOT be retried")
205 }
206
207 func TestCreateNoWaitReturnsImmediately(t *testing.T) {
208 api := &fakeToolsAPI{}
209 run := &fakeRunner{}
210 tl := newTestTools(api, run)
211 no := false
212 out, err := tl.VMCreate(t.Context(), VMCreateIn{Wait: &no})
213 require.NoError(t, err)
214 assert.Equal(t, "new1", out.ID)
215 assert.Empty(t, out.IP)
216 assert.Empty(t, run.execs)
217 }
218
219 func TestCreateWaitTimeoutDoesNotDestroy(t *testing.T) {
220 api := &fakeToolsAPI{phases: []string{"creating"}} // never ready
221 run := &fakeRunner{}
222 tl := newTestTools(api, run)
223 tl.WaitTimeout = 10 * time.Millisecond
224 _, err := tl.VMCreate(t.Context(), VMCreateIn{})
225 require.Error(t, err)
226 assert.Contains(t, err.Error(), "creating") // current phase reported
227 assert.Empty(t, api.deleted, "spec: never auto-destroy on timeout")
228 }
229
230 func TestExecResolvesNameAndFormatsResult(t *testing.T) {
231 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
232 run := &fakeRunner{out: ExecResult{Stdout: "ok\n", ExitCode: 0}}
233 tl := newTestTools(api, run)
234
235 out, err := tl.VMExec(t.Context(), VMExecIn{VM: "web-1", Command: "echo ok"})
236 require.NoError(t, err)
237 assert.Equal(t, 0, out.ExitCode)
238 assert.Equal(t, "ok\n", out.Stdout)
239 assert.Equal(t, []string{"web-1|echo ok"}, run.execs, "addressed by name, not IP")
240 }
241
242 func TestExecUnknownVM(t *testing.T) {
243 tl := newTestTools(&fakeToolsAPI{}, &fakeRunner{})
244 _, err := tl.VMExec(t.Context(), VMExecIn{VM: "nope", Command: "x"})
245 assert.ErrorContains(t, err, "no VM with id or name")
246 }
247
248 func TestExecNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
249 for _, lifecycle := range []string{"creating", "stopped", "failed", "deleting", ""} {
250 lifecycle := lifecycle
251 t.Run(lifecycle, func(t *testing.T) {
252 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: lifecycle}}}
253 run := &fakeRunner{}
254 tl := newTestTools(api, run)
255
256 _, err := tl.VMExec(t.Context(), VMExecIn{VM: "web-1", Command: "echo ok"})
257 require.Error(t, err)
258 assert.ErrorContains(t, err, "not ready")
259 assert.Empty(t, run.execs, "runner must not be called on a non-ready VM")
260 })
261 }
262 }
263
264 func TestWriteFileNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
265 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "creating"}}}
266 run := &fakeRunner{}
267 tl := newTestTools(api, run)
268
269 _, err := tl.VMWriteFile(t.Context(), VMWriteFileIn{VM: "web-1", Path: "/app/x", Content: "data"})
270 require.Error(t, err)
271 assert.ErrorContains(t, err, "not ready")
272 assert.Empty(t, run.files, "runner must not be called on a non-ready VM")
273 }
274
275 func TestReadFileNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
276 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "stopped"}}}
277 run := &fakeRunner{files: map[string][]byte{"/app/x": []byte("data")}}
278 tl := newTestTools(api, run)
279
280 _, err := tl.VMReadFile(t.Context(), VMReadFileIn{VM: "web-1", Path: "/app/x"})
281 require.Error(t, err)
282 assert.ErrorContains(t, err, "not ready")
283 assert.Empty(t, run.reads, "runner must not be called on a non-ready VM")
284 }
285
286 func TestDestroyRequiresExactMatch(t *testing.T) {
287 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1"}}}
288 tl := newTestTools(api, &fakeRunner{})
289 _, err := tl.VMDestroy(t.Context(), VMDestroyIn{VM: "web"})
290 require.Error(t, err)
291 out, err := tl.VMDestroy(t.Context(), VMDestroyIn{VM: "web-1"})
292 require.NoError(t, err)
293 assert.Equal(t, "abc123", out.ID)
294 assert.Equal(t, []string{"abc123"}, api.deleted)
295 }
296
297 func TestWriteAndReadFileTools(t *testing.T) {
298 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
299 run := &fakeRunner{}
300 tl := newTestTools(api, run)
301
302 _, err := tl.VMWriteFile(t.Context(), VMWriteFileIn{VM: "abc123", Path: "/app/x", Content: "data"})
303 require.NoError(t, err)
304 rd, err := tl.VMReadFile(t.Context(), VMReadFileIn{VM: "web-1", Path: "/app/x"})
305 require.NoError(t, err)
306 assert.Equal(t, "data", rd.Content)
307 }
internal/server/api/client/client.go
Old New
@@ -0,0 +1,187 @@
1 // Package client is THE Go client for the eitri control-plane HTTP API — the
2 // one consumer every in-repo caller (MCP server) goes through. Its method set
3 // is exactly what those consumers use, nothing more: a new endpoint call
4 // starts by adding a method here (an arch fitness rule enforces that no other
5 // package speaks the API's HTTP directly).
6 //
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.
9 package client
10
11 import (
12 "bytes"
13 "context"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "io"
18 "net/http"
19 "net/url"
20 "strings"
21 "time"
22
23 "golang.org/x/crypto/ssh"
24
25 "github.com/a73x/eitri/internal/server/api/types"
26 )
27
28 // Wire-contract aliases, so consumers don't import the types package.
29 type (
30 Host = types.Host
31 VM = types.VM
32 CreateVMRequest = types.CreateVMRequest
33 CreateVMResponse = types.CreateVMResponse
34 )
35
36 // Client calls the eitri API at BaseURL, authenticating with Token (sent as a
37 // Bearer header when non-empty). The zero value plus a BaseURL is a working
38 // client; a nil HTTP falls back to a 30s-timeout http.Client.
39 type Client struct {
40 BaseURL string
41 Token string
42 HTTP *http.Client
43 }
44
45 // Error is the typed failure for any non-2xx API response, carrying the
46 // request identity, the HTTP status, and the (truncated) response body.
47 type Error struct {
48 Method string
49 Path string
50 Status int
51 Body string
52 }
53
54 func (e *Error) Error() string {
55 return fmt.Sprintf("client: %s %s: %d: %s", e.Method, e.Path, e.Status, e.Body)
56 }
57
58 // gateOffError is the 404-on-/api/v1/ssh-ca translation: its message says the
59 // gate is off (never "404"), while still unwrapping to the underlying *Error
60 // so errors.As callers can see the status.
61 type gateOffError struct{ cause *Error }
62
63 func (e *gateOffError) Error() string { return "ssh-ca gate is not enabled on this eitri server" }
64 func (e *gateOffError) Unwrap() error { return e.cause }
65
66 // do performs one API round trip: method+path against BaseURL, JSON-encoding
67 // in when non-nil, decoding the response into out when non-nil. Non-2xx
68 // responses become a *Error. The URL is built by plain concatenation — paths
69 // here are always well-formed absolute /api/... strings whose variable
70 // segments the callers have already PathEscaped.
71 func (c *Client) do(ctx context.Context, method, path string, in, out any) error {
72 var body io.Reader
73 if in != nil {
74 b, err := json.Marshal(in)
75 if err != nil {
76 return fmt.Errorf("client: %s %s: encoding request: %w", method, path, err)
77 }
78 body = bytes.NewReader(b)
79 }
80 req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, body)
81 if err != nil {
82 return fmt.Errorf("client: %s %s: %w", method, path, err)
83 }
84 if in != nil {
85 req.Header.Set("Content-Type", "application/json")
86 }
87 if c.Token != "" {
88 req.Header.Set("Authorization", "Bearer "+c.Token)
89 }
90 httpc := c.HTTP
91 if httpc == nil {
92 httpc = &http.Client{Timeout: 30 * time.Second}
93 }
94 resp, err := httpc.Do(req)
95 if err != nil {
96 return fmt.Errorf("client: %s %s: %w", method, path, err)
97 }
98 defer resp.Body.Close()
99 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
100 msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
101 return &Error{Method: method, Path: path, Status: resp.StatusCode, Body: strings.TrimSpace(string(msg))}
102 }
103 if out == nil {
104 return nil
105 }
106 if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
107 return fmt.Errorf("client: %s %s: decoding response: %w", method, path, err)
108 }
109 return nil
110 }
111
112 // ListHosts returns the fleet's hosts.
113 func (c *Client) ListHosts(ctx context.Context) ([]Host, error) {
114 var hosts []Host
115 return hosts, c.do(ctx, http.MethodGet, "/api/v1/hosts", nil, &hosts)
116 }
117
118 // ListVMs returns every VM the caller can see.
119 func (c *Client) ListVMs(ctx context.Context) ([]VM, error) {
120 var vms []VM
121 return vms, c.do(ctx, http.MethodGet, "/api/v1/vms", nil, &vms)
122 }
123
124 // CreateVM asks the server to create a VM; the server fills one-click
125 // defaults for everything req leaves zero except host_id.
126 func (c *Client) CreateVM(ctx context.Context, req CreateVMRequest) (CreateVMResponse, error) {
127 var out CreateVMResponse
128 return out, c.do(ctx, http.MethodPost, "/api/v1/vms", req, &out)
129 }
130
131 // DeleteVM marks the VM for teardown; the agent reaps it asynchronously.
132 func (c *Client) DeleteVM(ctx context.Context, id string) error {
133 return c.do(ctx, http.MethodDelete, "/api/v1/vms/"+url.PathEscape(id), nil, nil)
134 }
135
136 // FetchSSHCALine retrieves the eitri SSH CA public key as the VERBATIM
137 // authorized_keys line the server serves — trailing comment and all — after
138 // parse-validating it (never hand back a line ssh can't read). A 404 means
139 // the SSH-CA jump gate isn't enabled; that case is surfaced as a clear,
140 // gate-specific error rather than a raw HTTP status.
141 func (c *Client) FetchSSHCALine(ctx context.Context) (string, error) {
142 var out types.SSHCAResponse
143 if err := c.do(ctx, http.MethodGet, "/api/v1/ssh-ca", nil, &out); err != nil {
144 var apiErr *Error
145 if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
146 return "", &gateOffError{cause: apiErr}
147 }
148 return "", err
149 }
150 if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.CA)); err != nil {
151 return "", fmt.Errorf("client: parsing ssh CA key: %w", err)
152 }
153 return out.CA, nil
154 }
155
156 // FetchSSHCA is FetchSSHCALine, parsed: the CA as an ssh.PublicKey, for
157 // callers that verify host certs.
158 func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
159 line, err := c.FetchSSHCALine(ctx)
160 if err != nil {
161 return nil, err
162 }
163 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
164 if err != nil {
165 return nil, fmt.Errorf("client: parsing ssh CA key: %w", err)
166 }
167 return pub, nil
168 }
169
170 // MintUserCert asks the eitri server to mint a short-lived SSH user
171 // certificate for pub, signed by the server's CA.
172 func (c *Client) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error) {
173 req := types.SSHCertRequest{PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))}
174 var out types.SSHCertResponse
175 if err := c.do(ctx, http.MethodPost, "/api/v1/ssh-certs", req, &out); err != nil {
176 return nil, err
177 }
178 parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.Certificate))
179 if err != nil {
180 return nil, fmt.Errorf("client: parsing minted certificate: %w", err)
181 }
182 cert, ok := parsed.(*ssh.Certificate)
183 if !ok {
184 return nil, errors.New("client: server response is not an SSH certificate")
185 }
186 return cert, nil
187 }
internal/server/api/client/client_test.go
Old New
@@ -0,0 +1,291 @@
1 package client_test
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12
13 "golang.org/x/crypto/ssh"
14
15 "github.com/a73x/eitri/internal/server/api/client"
16 "github.com/a73x/eitri/internal/server/api/types"
17 )
18
19 // testCALine is a valid ed25519 authorized_keys line WITH a trailing comment;
20 // FetchSSHCALine must return it verbatim.
21 const testCALine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZK1zVJTG0Opn0BktxOpCYhRXRPMFhZDwoT1PVCM1Sq eitri-host-ca"
22
23 // capture records what the handler saw so tests can assert on the request.
24 type capture struct {
25 method string
26 path string // escaped path, so %2F survives inspection
27 auth string
28 ctype string
29 body []byte
30 }
31
32 // serve starts an httptest server that records each request into *capture and
33 // responds with status and body.
34 func serve(t *testing.T, cap *capture, status int, body string) *httptest.Server {
35 t.Helper()
36 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37 cap.method = r.Method
38 cap.path = r.URL.EscapedPath()
39 cap.auth = r.Header.Get("Authorization")
40 cap.ctype = r.Header.Get("Content-Type")
41 cap.body, _ = io.ReadAll(r.Body)
42 w.WriteHeader(status)
43 io.WriteString(w, body)
44 }))
45 t.Cleanup(srv.Close)
46 return srv
47 }
48
49 func TestListHosts(t *testing.T) {
50 var cap capture
51 srv := serve(t, &cap, http.StatusOK, `[{"id":"h1","name":"mewtwo","online":true}]`)
52 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
53
54 hosts, err := c.ListHosts(context.Background())
55 if err != nil {
56 t.Fatalf("ListHosts: %v", err)
57 }
58 if cap.method != http.MethodGet || cap.path != "/api/v1/hosts" {
59 t.Errorf("request = %s %s, want GET /api/v1/hosts", cap.method, cap.path)
60 }
61 if cap.auth != "Bearer tok" {
62 t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer tok")
63 }
64 if len(hosts) != 1 || hosts[0].ID != "h1" || hosts[0].Name != "mewtwo" || !hosts[0].Online {
65 t.Errorf("hosts = %+v, want one host h1/mewtwo/online", hosts)
66 }
67 }
68
69 func TestListVMs(t *testing.T) {
70 var cap capture
71 srv := serve(t, &cap, http.StatusOK, `[{"id":"v1","name":"dev","lifecycle":"ready","assigned_ip":"10.77.1.2"}]`)
72 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
73
74 vms, err := c.ListVMs(context.Background())
75 if err != nil {
76 t.Fatalf("ListVMs: %v", err)
77 }
78 if cap.method != http.MethodGet || cap.path != "/api/v1/vms" {
79 t.Errorf("request = %s %s, want GET /api/v1/vms", cap.method, cap.path)
80 }
81 if cap.auth != "Bearer tok" {
82 t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer tok")
83 }
84 if len(vms) != 1 || vms[0].ID != "v1" || vms[0].Lifecycle != "ready" || vms[0].AssignedIP != "10.77.1.2" {
85 t.Errorf("vms = %+v, want one ready VM v1 at 10.77.1.2", vms)
86 }
87 }
88
89 func TestCreateVM(t *testing.T) {
90 var cap capture
91 srv := serve(t, &cap, http.StatusCreated, `{"id":"v9","name":"smoke"}`)
92 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
93
94 out, err := c.CreateVM(context.Background(), client.CreateVMRequest{HostID: "h1", Name: "smoke", VCPUs: 2})
95 if err != nil {
96 t.Fatalf("CreateVM: %v", err)
97 }
98 if cap.method != http.MethodPost || cap.path != "/api/v1/vms" {
99 t.Errorf("request = %s %s, want POST /api/v1/vms", cap.method, cap.path)
100 }
101 if cap.ctype != "application/json" {
102 t.Errorf("Content-Type = %q, want application/json", cap.ctype)
103 }
104 var req types.CreateVMRequest
105 if err := json.Unmarshal(cap.body, &req); err != nil {
106 t.Fatalf("request body did not decode as CreateVMRequest: %v", err)
107 }
108 if req.HostID != "h1" || req.Name != "smoke" || req.VCPUs != 2 {
109 t.Errorf("request body = %+v, want host h1 / name smoke / 2 vcpus", req)
110 }
111 if out.ID != "v9" || out.Name != "smoke" {
112 t.Errorf("response = %+v, want id v9 name smoke", out)
113 }
114 }
115
116 func TestDeleteVM(t *testing.T) {
117 var cap capture
118 srv := serve(t, &cap, http.StatusNoContent, "")
119 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
120
121 if err := c.DeleteVM(context.Background(), "vm-123"); err != nil {
122 t.Fatalf("DeleteVM: %v", err)
123 }
124 if cap.method != http.MethodDelete || cap.path != "/api/v1/vms/vm-123" {
125 t.Errorf("request = %s %s, want DELETE /api/v1/vms/vm-123", cap.method, cap.path)
126 }
127 }
128
129 func TestDeleteVMEscapesID(t *testing.T) {
130 var cap capture
131 srv := serve(t, &cap, http.StatusNoContent, "")
132 c := &client.Client{BaseURL: srv.URL}
133
134 if err := c.DeleteVM(context.Background(), "a b/c"); err != nil {
135 t.Fatalf("DeleteVM: %v", err)
136 }
137 if want := "/api/v1/vms/a%20b%2Fc"; cap.path != want {
138 t.Errorf("path = %q, want %q", cap.path, want)
139 }
140 }
141
142 func TestNoAuthHeaderWhenTokenEmpty(t *testing.T) {
143 var cap capture
144 srv := serve(t, &cap, http.StatusOK, `[]`)
145 c := &client.Client{BaseURL: srv.URL}
146
147 if _, err := c.ListHosts(context.Background()); err != nil {
148 t.Fatalf("ListHosts: %v", err)
149 }
150 if cap.auth != "" {
151 t.Errorf("Authorization = %q, want unset when Token is empty", cap.auth)
152 }
153 }
154
155 func TestBaseURLTrailingSlash(t *testing.T) {
156 var cap capture
157 srv := serve(t, &cap, http.StatusOK, `[]`)
158 c := &client.Client{BaseURL: srv.URL + "/"}
159
160 if _, err := c.ListVMs(context.Background()); err != nil {
161 t.Fatalf("ListVMs: %v", err)
162 }
163 if cap.path != "/api/v1/vms" {
164 t.Errorf("path = %q, want /api/v1/vms (trailing BaseURL slash trimmed)", cap.path)
165 }
166 }
167
168 func TestNon2xxReturnsTypedError(t *testing.T) {
169 var cap capture
170 srv := serve(t, &cap, http.StatusConflict, "insufficient host capacity")
171 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
172
173 _, err := c.CreateVM(context.Background(), client.CreateVMRequest{HostID: "h1"})
174 if err == nil {
175 t.Fatal("CreateVM on 409: got nil error")
176 }
177 var apiErr *client.Error
178 if !errors.As(err, &apiErr) {
179 t.Fatalf("errors.As found no *client.Error in %v", err)
180 }
181 if apiErr.Status != http.StatusConflict {
182 t.Errorf("Status = %d, want 409", apiErr.Status)
183 }
184 if apiErr.Method != http.MethodPost || apiErr.Path != "/api/v1/vms" {
185 t.Errorf("Method/Path = %s %s, want POST /api/v1/vms", apiErr.Method, apiErr.Path)
186 }
187 if !strings.Contains(err.Error(), "409") || !strings.Contains(err.Error(), "insufficient host capacity") {
188 t.Errorf("error %q should contain the status code and the response body", err)
189 }
190 }
191
192 func TestContextCancellationAborts(t *testing.T) {
193 release := make(chan struct{})
194 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195 <-release
196 }))
197 t.Cleanup(func() { close(release); srv.Close() })
198 c := &client.Client{BaseURL: srv.URL}
199
200 ctx, cancel := context.WithCancel(context.Background())
201 cancel()
202 if _, err := c.ListHosts(ctx); !errors.Is(err, context.Canceled) {
203 t.Fatalf("ListHosts with canceled ctx: err = %v, want context.Canceled", err)
204 }
205 }
206
207 func TestFetchSSHCALineVerbatim(t *testing.T) {
208 var cap capture
209 body, _ := json.Marshal(map[string]string{"ca": testCALine})
210 srv := serve(t, &cap, http.StatusOK, string(body))
211 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
212
213 line, err := c.FetchSSHCALine(context.Background())
214 if err != nil {
215 t.Fatalf("FetchSSHCALine: %v", err)
216 }
217 if cap.method != http.MethodGet || cap.path != "/api/v1/ssh-ca" {
218 t.Errorf("request = %s %s, want GET /api/v1/ssh-ca", cap.method, cap.path)
219 }
220 if cap.auth != "Bearer tok" {
221 t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer tok")
222 }
223 if line != testCALine {
224 t.Errorf("line = %q, want the verbatim server line %q (comment preserved)", line, testCALine)
225 }
226 }
227
228 func TestFetchSSHCAParsesKey(t *testing.T) {
229 var cap capture
230 body, _ := json.Marshal(map[string]string{"ca": testCALine})
231 srv := serve(t, &cap, http.StatusOK, string(body))
232 c := &client.Client{BaseURL: srv.URL}
233
234 pub, err := c.FetchSSHCA(context.Background())
235 if err != nil {
236 t.Fatalf("FetchSSHCA: %v", err)
237 }
238 want, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testCALine))
239 if err != nil {
240 t.Fatalf("parsing test fixture: %v", err)
241 }
242 if string(pub.Marshal()) != string(want.Marshal()) {
243 t.Error("FetchSSHCA returned a different key than the server sent")
244 }
245 }
246
247 func TestFetchSSHCA404IsGateOff(t *testing.T) {
248 for _, tc := range []struct {
249 name string
250 call func(c *client.Client) error
251 }{
252 {"FetchSSHCALine", func(c *client.Client) error { _, err := c.FetchSSHCALine(context.Background()); return err }},
253 {"FetchSSHCA", func(c *client.Client) error { _, err := c.FetchSSHCA(context.Background()); return err }},
254 } {
255 t.Run(tc.name, func(t *testing.T) {
256 var cap capture
257 srv := serve(t, &cap, http.StatusNotFound, "not found")
258 err := tc.call(&client.Client{BaseURL: srv.URL})
259 if err == nil {
260 t.Fatal("404: got nil error")
261 }
262 // The full wording is pinned: mcpserver's tests and human eyes
263 // both read this message, so a rewording must be deliberate.
264 if got, want := err.Error(), "ssh-ca gate is not enabled on this eitri server"; got != want {
265 t.Errorf("gate-off message = %q, want %q", got, want)
266 }
267 var apiErr *client.Error
268 if !errors.As(err, &apiErr) || apiErr.Status != http.StatusNotFound {
269 t.Errorf("errors.As should still find the underlying *client.Error with Status 404, got %v", err)
270 }
271 })
272 }
273 }
274
275 func TestFetchSSHCAGarbage(t *testing.T) {
276 for _, tc := range []struct {
277 name string
278 call func(c *client.Client) error
279 }{
280 {"FetchSSHCALine", func(c *client.Client) error { _, err := c.FetchSSHCALine(context.Background()); return err }},
281 {"FetchSSHCA", func(c *client.Client) error { _, err := c.FetchSSHCA(context.Background()); return err }},
282 } {
283 t.Run(tc.name, func(t *testing.T) {
284 var cap capture
285 srv := serve(t, &cap, http.StatusOK, `{"ca":"not an ssh key"}`)
286 if err := tc.call(&client.Client{BaseURL: srv.URL}); err == nil {
287 t.Fatal("garbage CA: got nil error")
288 }
289 })
290 }
291 }
internal/shape/classify.go
Old New
@@ -40,6 +40,7 @@ func classify(rel string) Plane {
40 return PlaneBinaries 40 return PlaneBinaries
41 case strings.HasPrefix(rel, "internal/arch"), 41 case strings.HasPrefix(rel, "internal/arch"),
42 strings.HasPrefix(rel, "internal/integration"), 42 strings.HasPrefix(rel, "internal/integration"),
43 strings.HasPrefix(rel, "internal/mcpserver"),
43 strings.HasPrefix(rel, "internal/shape"): 44 strings.HasPrefix(rel, "internal/shape"):
44 return PlaneTooling 45 return PlaneTooling
45 default: 46 default:
scripts/coverage.sh
Old New
@@ -24,6 +24,7 @@ declare -A FLOOR=(
24 [internal/agent/cloudhv]=40 24 [internal/agent/cloudhv]=40
25 [internal/agent/syncclient]=74 25 [internal/agent/syncclient]=74
26 [internal/server/api]=73 26 [internal/server/api]=73
27 [internal/server/api/client]=73
27 [internal/server/api/spec]=81 28 [internal/server/api/spec]=81
28 [internal/server/store]=73 29 [internal/server/store]=73
29 [internal/server/registry]=95 30 [internal/server/registry]=95