9a302830
feat(eitri-mcp): MCP server for Claude-driven VM sandboxes via the SSH-CA gate
a73x 2026-07-25 12:41
Commit message
.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 |