7ab204ff
feat(mcp): one surface — the plane serves /mcp, the stdio binary retires
a73x 2026-08-11 15:04
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -38,7 +38,6 @@ build: web | |||
| 38 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-server ./cmd/eitri-server | 38 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-server ./cmd/eitri-server |
| 39 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-oidc ./cmd/eitri-oidc | 39 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-oidc ./cmd/eitri-oidc |
| 40 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent | 40 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent |
| 41 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp | ||
| 42 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke | 41 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke |
| 43 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-site ./cmd/eitri-site | 42 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-site ./cmd/eitri-site |
| 44 | go build $(GO_LDFLAGS) -o $(BIN)/eitri ./cmd/eitri | 43 | go build $(GO_LDFLAGS) -o $(BIN)/eitri ./cmd/eitri |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -45,9 +45,8 @@ decoded to raw once, then reflink-copied per guest). | |||
| 45 | 45 | ||
| 46 | | Binary | Role | | 46 | | Binary | Role | |
| 47 | | --- | --- | | 47 | | --- | --- | |
| 48 | | `eitri-server` | Control plane: HTTP API, QUIC sync stream, and the SSH-CA jump gate. | | 48 | | `eitri-server` | Control plane: HTTP API, the MCP endpoint at `/mcp`, QUIC sync stream, and the SSH-CA jump gate. | |
| 49 | | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives the host's VMM. | | 49 | | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives the host's VMM. | |
| 50 | | `eitri-mcp` | MCP server exposing create/control/destroy VM tools to Claude. | | ||
| 51 | | `eitri` | Client CLI: signs an ephemeral cert with a tenant CA and reaches a guest through the gate. | | 50 | | `eitri` | Client CLI: signs an ephemeral cert with a tenant CA and reaches a guest through the gate. | |
| 52 | 51 | ||
| 53 | `eitri-shape` (regenerate the architecture graph) rounds out the binaries. | 52 | `eitri-shape` (regenerate the architecture graph) rounds out the binaries. |
| @@ -99,7 +98,7 @@ it's built this way ([decisions](docs/decisions.md)). What ships next is in | |||
| 99 | ## Repository layout | 98 | ## Repository layout |
| 100 | 99 | ||
| 101 | ``` | 100 | ``` |
| 102 | cmd/ entrypoints (eitri-server, eitri-agent, eitri-mcp, tooling) | 101 | cmd/ entrypoints (eitri-server, eitri-agent, eitri, tooling) |
| 103 | internal/ | 102 | internal/ |
| 104 | agent/ reconcile loop, VMM drivers (cloudhv, vfkit), DHCP, image cache, netenv | 103 | agent/ reconcile loop, VMM drivers (cloudhv, vfkit), DHCP, image cache, netenv |
| 105 | server/ API, QUIC sync service, SSH gate/CA, store, registry, hub | 104 | server/ API, QUIC sync service, SSH gate/CA, store, registry, hub |
cmd/eitri-mcp/main.go
| Old | New | ||
|---|---|---|---|
| @@ -1,27 +0,0 @@ | |||
| 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. It holds its OWN | ||
| 4 | // persistent user CA, uploads that CA's public key to its tenant once at | ||
| 5 | // startup, and self-signs short-lived user certs locally (BYO model — the | ||
| 6 | // server no longer mints user certs). All behavior lives in internal/mcpserver | ||
| 7 | // (RunCLI); this package is wiring only (arch R14). | ||
| 8 | package main | ||
| 9 | |||
| 10 | import ( | ||
| 11 | "fmt" | ||
| 12 | "os" | ||
| 13 | |||
| 14 | "github.com/a73x/eitri/internal/mcpserver" | ||
| 15 | "github.com/a73x/eitri/internal/version" | ||
| 16 | ) | ||
| 17 | |||
| 18 | func main() { | ||
| 19 | if len(os.Args) > 1 && os.Args[1] == "--version" { | ||
| 20 | fmt.Println(version.Version) | ||
| 21 | return | ||
| 22 | } | ||
| 23 | if err := mcpserver.RunCLI(os.Args[1:]); err != nil { | ||
| 24 | fmt.Fprintln(os.Stderr, "eitri-mcp:", err) | ||
| 25 | os.Exit(1) | ||
| 26 | } | ||
| 27 | } | ||
docs/README.md
| Old | New | ||
|---|---|---|---|
| @@ -25,7 +25,7 @@ By what you're trying to do: | |||
| 25 | 25 | ||
| 26 | - [ssh-access.md](ssh-access.md)—reaching a guest through the jump gate with | 26 | - [ssh-access.md](ssh-access.md)—reaching a guest through the jump gate with |
| 27 | your own tenant CA | 27 | your own tenant CA |
| 28 | - [mcp.md](mcp.md)—`eitri-mcp`, the MCP server that lets Claude drive VMs | 28 | - [mcp.md](mcp.md)—`/mcp`, the endpoint that lets Claude drive VMs |
| 29 | 29 | ||
| 30 | **Why it's this way** | 30 | **Why it's this way** |
| 31 | 31 | ||
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -215,7 +215,7 @@ server discarded it. What that costs was established by experiment on | |||
| 215 | and the agent then dials the address in its OWN record | 215 | and the agent then dials the address in its OWN record |
| 216 | (`internal/agent/syncclient`), which vfkit's `Address` does populate. What | 216 | (`internal/agent/syncclient`), which vfkit's `Address` does populate. What |
| 217 | breaks is every consumer of the stored `assigned_ip` — the console's IP column, | 217 | breaks is every consumer of the stored `assigned_ip` — the console's IP column, |
| 218 | eitri-mcp's wait-for-address (`internal/mcpserver/tools.go`) and the deploy | 218 | the MCP tools' wait-for-address (`internal/mcpserver/tools.go`) and the deploy |
| 219 | boot-gate (`internal/smoke/scenario.go`), and the last two HANG rather than | 219 | boot-gate (`internal/smoke/scenario.go`), and the last two HANG rather than |
| 220 | fail, because both poll for a field that will never arrive. A host whose OS owns | 220 | fail, because both poll for a field that will never arrive. A host whose OS owns |
| 221 | addressing has to tell the fleet which subnet its guests are on. | 221 | addressing has to tell the fleet which subnet its guests are on. |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -138,3 +138,15 @@ until a genuinely foreign backend wants in — a versioned plugin wire is a | |||
| 138 | binding point, and this repo has already paid once for building a binding | 138 | binding point, and this repo has already paid once for building a binding |
| 139 | point before its parties existed. And the deploy smoke is the seam's | 139 | point before its parties existed. And the deploy smoke is the seam's |
| 140 | conformance suite: a Provisioner is whatever passes it. | 140 | conformance suite: a Provisioner is whatever passes it. |
| 141 | |||
| 142 | ### One MCP surface: `/mcp` on the plane | ||
| 143 | |||
| 144 | eitri speaks MCP in exactly one place: `/mcp` on the control plane, authenticated | ||
| 145 | by a bearer PAT. Instead of a local stdio binary beside it and a public SSO front | ||
| 146 | ahead of it — three arrangements are three credential stories to keep honest, and | ||
| 147 | the extra two earn nothing. The stdio binary held its own user CA and its own | ||
| 148 | config file while the plane already authenticates PATs and holds no signing key | ||
| 149 | at all; a self-hoster gets an MCP server by self-hosting the plane, and a public | ||
| 150 | front targets this same endpoint rather than being a fourth thing. Reversed by a | ||
| 151 | genuine air-gapped need: guests to drive with no plane to reach. | ||
| 152 | Details in [mcp.md](mcp.md). | ||
docs/mcp.md
| Old | New | ||
|---|---|---|---|
| @@ -1,16 +1,13 @@ | |||
| 1 | # eitri-mcp: Claude ↔ eitri VMs | 1 | # MCP: Claude ↔ eitri VMs |
| 2 | 2 | ||
| 3 | eitri gives Claude explicit tools for creating and controlling VMs on a fleet, | 3 | eitri gives Claude explicit tools for creating and controlling VMs on a fleet. |
| 4 | over two transports: | 4 | There is one place it speaks MCP: `/mcp`, served by the control plane itself. |
| 5 | MCP streamable HTTP, authenticated with a personal access token. No install, no | ||
| 6 | config file, no CA of your own: a PAT is enough. | ||
| 5 | 7 | ||
| 6 | - **Local stdio** — `eitri-mcp`, a binary you run yourself. It | 8 | On the hosted plane that endpoint is `https://api.eitri.sh/mcp`. A plane you run |
| 7 | is an API client of the control plane plus SSH; it embeds no control-plane or | 9 | yourself serves the same endpoint on its own address, from the same binary — a |
| 8 | agent code, and it holds its own SSH user CA. | 10 | self-hosted eitri is a self-hosted MCP server, with nothing extra to install. |
| 9 | - **Remote HTTP** — `https://api.eitri.sh/mcp`, served by the control plane | ||
| 10 | itself. MCP streamable HTTP, authenticated with a personal access token. No | ||
| 11 | install, no config file, no CA of your own: a PAT is enough. | ||
| 12 | |||
| 13 | Both serve the same tools. | ||
| 14 | 11 | ||
| 15 | ## Tools | 12 | ## Tools |
| 16 | 13 | ||
| @@ -28,59 +25,15 @@ Both serve the same tools. | |||
| 28 | | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. | | 25 | | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. | |
| 29 | | `ca_upload` | Register your SSH user CA's public key with your tenant, with an optional label. Only the public half is sent. A guest trusts the CA set it was created with, so VMs that already exist will not accept certificates from a CA uploaded now. | | 26 | | `ca_upload` | Register your SSH user CA's public key with your tenant, with an optional label. Only the public half is sent. A guest trusts the CA set it was created with, so VMs that already exist will not accept certificates from a CA uploaded now. | |
| 30 | | `tenant_info` | Show this tenant's setup: registered CAs (fingerprint and label), whether a delegation is live and when it expires, and the gate address. Read-only. | | 27 | | `tenant_info` | Show this tenant's setup: registered CAs (fingerprint and label), whether a delegation is live and when it expires, and the gate address. Read-only. | |
| 31 | | `delegate_begin` | *(remote only)* Get the ephemeral public key eitri will authenticate with, the principal your certificate must carry, and the `ssh-keygen` line that signs it. | | 28 | | `delegate_begin` | Get the ephemeral public key eitri will authenticate with, the principal your certificate must carry, and the `ssh-keygen` line that signs it. | |
| 32 | | `delegate_complete` | *(remote only)* Hand back the signed certificate. eitri can then reach your VMs until it expires. | | 29 | | `delegate_complete` | Hand back the signed certificate. eitri can then reach your VMs until it expires. | |
| 33 | 30 | ||
| 34 | Deliberately absent: any host or fleet-level operation (enroll, decommission, | 31 | Deliberately absent: any host or fleet-level operation (enroll, decommission, |
| 35 | power management, image/firmware knobs). The worst case from a confused model | 32 | power management, image/firmware knobs). The worst case from a confused model |
| 36 | is VM churn, never fleet damage—and Claude Code's per-tool permission | 33 | is VM churn, never fleet damage—and Claude Code's per-tool permission |
| 37 | prompts gate every call regardless. | 34 | prompts gate every call regardless. |
| 38 | 35 | ||
| 39 | ## Setup: local stdio | 36 | ## Setup |
| 40 | |||
| 41 | 1. Build the `eitri-mcp` binary from the eitri source tree with `make build`; | ||
| 42 | it lands in `bin/`. | ||
| 43 | |||
| 44 | 2. Create `~/.config/eitri-mcp/config.json`: | ||
| 45 | |||
| 46 | ```json | ||
| 47 | { | ||
| 48 | "server_url": "http://127.0.0.1:8080", | ||
| 49 | "token_file": "~/.config/eitri-mcp/token", | ||
| 50 | "gate": "127.0.0.1:2223", | ||
| 51 | "vm_user": "ubuntu" | ||
| 52 | } | ||
| 53 | ``` | ||
| 54 | |||
| 55 | Fields: | ||
| 56 | - `server_url`—required, the eitri API base URL. | ||
| 57 | - `token_file`—required, path to a file holding a personal access token | ||
| 58 | (mint one in the console Settings page; read at startup, held in memory, | ||
| 59 | never surfaced in a tool result or error). | ||
| 60 | - `gate`—the SSH-CA jump gate address, `<gate-domain>:<port>`; the MCP | ||
| 61 | reaches all VMs by name through it. The host part must match the | ||
| 62 | gate's host certificate principal (the server's `ssh_gate_domain`, | ||
| 63 | which defaults to the `ssh_listen` host). | ||
| 64 | - `vm_user`—guest SSH user; defaults to `ubuntu` if omitted. | ||
| 65 | - `ca_key_path`—optional path to this client's persistent user CA | ||
| 66 | (load-or-create); defaults to a `user_ca` file next to the config. Its | ||
| 67 | public key self-registers with the tenant on first gate use, so a fresh | ||
| 68 | install needs only a PAT—no manual `eitri ca upload`. | ||
| 69 | - `tenant`—optional. The credential names the tenant (VM connect names are | ||
| 70 | derived from it), so leave this unset. Set it only when the PAT's user | ||
| 71 | CA/tenant mapping is ambiguous, e.g. a human or CA belonging to more than | ||
| 72 | one tenant. | ||
| 73 | |||
| 74 | The config path can be overridden with `--config` or `$EITRI_MCP_CONFIG`; | ||
| 75 | it defaults to `~/.config/eitri-mcp/config.json`. | ||
| 76 | |||
| 77 | 3. Register with Claude Code: | ||
| 78 | |||
| 79 | ``` | ||
| 80 | claude mcp add eitri -- /path/to/repo/bin/eitri-mcp | ||
| 81 | ``` | ||
| 82 | |||
| 83 | ## Setup: remote endpoint | ||
| 84 | 37 | ||
| 85 | Mint a personal access token in the console Settings page and point an MCP | 38 | Mint a personal access token in the console Settings page and point an MCP |
| 86 | client at the endpoint with that token as a bearer credential: | 39 | client at the endpoint with that token as a bearer credential: |
| @@ -125,31 +78,21 @@ notifications while it waits (for clients that ask for progress). | |||
| 125 | 78 | ||
| 126 | ## Access model | 79 | ## Access model |
| 127 | 80 | ||
| 128 | Both transports reach VMs by name over SSH, with certificates in both | 81 | eitri reaches VMs by name over SSH, with certificates in both directions and no |
| 129 | directions and no TOFU anywhere. In both, the user signing key stays with you: | 82 | TOFU anywhere. The user signing key stays with you: eitri is delegated a |
| 130 | they differ only in who does the signing and which path the connection takes. | 83 | credential, never a key. |
| 131 | 84 | ||
| 132 | **Local stdio** holds its own user CA (`ca_key_path`, load-or-create) and | 85 | eitri generates an ephemeral ed25519 keypair per tenant, in memory only, and |
| 133 | self-registers that CA's public key with its tenant on first use; eitri never | 86 | hands you the public half; you sign it with your own CA, on your own TTL and |
| 134 | sees the private half. Per connection it signs a short-lived user certificate | 87 | principals, and post the certificate back. eitri then authenticates as that |
| 135 | locally (principal `ubuntu`) with that CA, fetches the eitri host CA's public | 88 | key-plus-certificate: it reaches the guest's sshd over the VM host's own sync |
| 136 | key once via `GET /api/v1/ssh-ca`, dials the configured gate, and opens a tunnel | 89 | tunnel and verifies the guest's host certificate under `<tenant>.<vm>:22` |
| 137 | to `<tenant>.<vm>:22`. Host identity is verified on both hops with | 90 | against the host CA. No signing key ever exists on the server, the certificate |
| 138 | `ssh.CertChecker` against the host CA: the gate's certificate must carry its | 91 | expires, and a restart drops it. |
| 139 | configured domain as principal, and each VM's must carry the VM's connect name. | ||
| 140 | |||
| 141 | **The remote endpoint** is delegated a credential instead. eitri generates an | ||
| 142 | ephemeral ed25519 keypair per tenant, in memory only, and hands you the public | ||
| 143 | half; you sign it with your own CA, on your own TTL and principals, and post the | ||
| 144 | certificate back. eitri then authenticates as that key-plus-certificate: it | ||
| 145 | reaches the guest's sshd over the VM host's own sync tunnel and verifies the | ||
| 146 | guest's host certificate under `<tenant>.<vm>:22` against the host CA. No | ||
| 147 | signing key ever exists on the server, the certificate expires, and a restart | ||
| 148 | drops it. | ||
| 149 | 92 | ||
| 150 | Because the certificate chains to a CA the tenant already registered, it is | 93 | Because the certificate chains to a CA the tenant already registered, it is |
| 151 | accepted by every guest that trusts that CA — including ones created long | 94 | accepted by every guest that trusts that CA — including ones created long |
| 152 | before the delegation. Remote exec without a live delegation is refused with the | 95 | before the delegation. Exec without a live delegation is refused with the |
| 153 | three-step recipe rather than a generic authentication failure. | 96 | three-step recipe rather than a generic authentication failure. |
| 154 | 97 | ||
| 155 | VMs are addressed by their namespaced connect name, not IP, so recycled IPs and | 98 | VMs are addressed by their namespaced connect name, not IP, so recycled IPs and |
| @@ -175,7 +118,7 @@ result or error. | |||
| 175 | be aimed at a third party. The tool descriptions say so, so the model treats | 118 | be aimed at a third party. The tool descriptions say so, so the model treats |
| 176 | publishing as a deliberate act. DNS, TLS certs and routing remain out of | 119 | publishing as a deliberate act. DNS, TLS certs and routing remain out of |
| 177 | scope—the tools hand back a host address and a port. | 120 | scope—the tools hand back a host address and a port. |
| 178 | - The remote endpoint authenticates with a bearer PAT. Browser connectors | 121 | - The endpoint authenticates with a bearer PAT. Browser connectors |
| 179 | (claude.ai) need OAuth, which the endpoint does not speak. | 122 | (claude.ai) need OAuth, which the endpoint does not speak. |
| 180 | 123 | ||
| 181 | > **IMPORTANT—"ready" is not "booted."** `vm_create`'s `lifecycle=ready` | 124 | > **IMPORTANT—"ready" is not "booted."** `vm_create`'s `lifecycle=ready` |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -79,15 +79,6 @@ | |||
| 79 | ] | 79 | ] |
| 80 | }, | 80 | }, |
| 81 | { | 81 | { |
| 82 | "importPath": "cmd/eitri-mcp", | ||
| 83 | "plane": "binaries", | ||
| 84 | "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.", | ||
| 85 | "imports": [ | ||
| 86 | "internal/mcpserver", | ||
| 87 | "internal/version" | ||
| 88 | ] | ||
| 89 | }, | ||
| 90 | { | ||
| 91 | "importPath": "cmd/eitri-oidc", | 82 | "importPath": "cmd/eitri-oidc", |
| 92 | "plane": "binaries", | 83 | "plane": "binaries", |
| 93 | "synopsis": "eitri-oidc: the bundled OIDC issuer.", | 84 | "synopsis": "eitri-oidc: the bundled OIDC issuer.", |
| @@ -352,9 +343,8 @@ | |||
| 352 | { | 343 | { |
| 353 | "importPath": "internal/mcpserver", | 344 | "importPath": "internal/mcpserver", |
| 354 | "plane": "tooling", | 345 | "plane": "tooling", |
| 355 | "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", | 346 | "synopsis": "Package mcpserver implements eitri's MCP toolset: tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", |
| 356 | "imports": [ | 347 | "imports": [ |
| 357 | "internal/gateclient", | ||
| 358 | "internal/random", | 348 | "internal/random", |
| 359 | "internal/server/api/client", | 349 | "internal/server/api/client", |
| 360 | "internal/server/release" | 350 | "internal/server/release" |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -28,15 +28,6 @@ | |||
| 28 | ] | 28 | ] |
| 29 | }, | 29 | }, |
| 30 | { | 30 | { |
| 31 | "importPath": "cmd/eitri-mcp", | ||
| 32 | "plane": "binaries", | ||
| 33 | "synopsis": "Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: create/list/info/exec/write_file/read_file/destroy.", | ||
| 34 | "imports": [ | ||
| 35 | "internal/mcpserver", | ||
| 36 | "internal/version" | ||
| 37 | ] | ||
| 38 | }, | ||
| 39 | { | ||
| 40 | "importPath": "cmd/eitri-oidc", | 31 | "importPath": "cmd/eitri-oidc", |
| 41 | "plane": "binaries", | 32 | "plane": "binaries", |
| 42 | "synopsis": "eitri-oidc: the bundled OIDC issuer.", | 33 | "synopsis": "eitri-oidc: the bundled OIDC issuer.", |
| @@ -301,9 +292,8 @@ | |||
| 301 | { | 292 | { |
| 302 | "importPath": "internal/mcpserver", | 293 | "importPath": "internal/mcpserver", |
| 303 | "plane": "tooling", | 294 | "plane": "tooling", |
| 304 | "synopsis": "Package mcpserver implements the eitri-mcp server: MCP tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", | 295 | "synopsis": "Package mcpserver implements eitri's MCP toolset: tools that let a model create, control (SSH exec/files), and destroy eitri VMs.", |
| 305 | "imports": [ | 296 | "imports": [ |
| 306 | "internal/gateclient", | ||
| 307 | "internal/random", | 297 | "internal/random", |
| 308 | "internal/server/api/client", | 298 | "internal/server/api/client", |
| 309 | "internal/server/release" | 299 | "internal/server/release" |
internal/mcpserver/cli.go
| Old | New | ||
|---|---|---|---|
| @@ -1,140 +0,0 @@ | |||
| 1 | // cli.go is the eitri-mcp command line: it loads the config, loads (or creates) | ||
| 2 | // a persistent per-client user CA, and serves the MCP tools over stdio. The user | ||
| 3 | // CA self-registers with the caller's tenant on first gate use (see | ||
| 4 | // gateDialer.ensure), so a fresh install needs only a PAT. It lives here rather | ||
| 5 | // than in cmd/eitri-mcp so it is testable and coverage-gated (arch R14: main | ||
| 6 | // packages are wiring only). | ||
| 7 | |||
| 8 | package mcpserver | ||
| 9 | |||
| 10 | import ( | ||
| 11 | "context" | ||
| 12 | "crypto/ed25519" | ||
| 13 | "crypto/rand" | ||
| 14 | "encoding/pem" | ||
| 15 | "flag" | ||
| 16 | "fmt" | ||
| 17 | "os" | ||
| 18 | "os/signal" | ||
| 19 | "syscall" | ||
| 20 | |||
| 21 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 22 | "github.com/modelcontextprotocol/go-sdk/mcp" | ||
| 23 | "golang.org/x/crypto/ssh" | ||
| 24 | ) | ||
| 25 | |||
| 26 | // RunCLI dispatches the eitri-mcp command line (everything after the binary | ||
| 27 | // name, --version excluded — that stays in cmd/eitri-mcp). The config path | ||
| 28 | // defaults to EITRI_MCP_CONFIG, else ~/.config/eitri-mcp/config.json. | ||
| 29 | func RunCLI(args []string) error { | ||
| 30 | defaultCfg := "~/.config/eitri-mcp/config.json" | ||
| 31 | if env := os.Getenv("EITRI_MCP_CONFIG"); env != "" { | ||
| 32 | defaultCfg = env | ||
| 33 | } | ||
| 34 | fs := flag.NewFlagSet("eitri-mcp", flag.ExitOnError) | ||
| 35 | cfgPath := fs.String("config", defaultCfg, "path to eitri-mcp config.json") | ||
| 36 | if err := fs.Parse(args); err != nil { | ||
| 37 | return err | ||
| 38 | } | ||
| 39 | return run(*cfgPath) | ||
| 40 | } | ||
| 41 | |||
| 42 | func run(cfgPath string) error { | ||
| 43 | cfg, err := LoadConfig(cfgPath) | ||
| 44 | if err != nil { | ||
| 45 | return err | ||
| 46 | } | ||
| 47 | // This client owns its own persistent user CA (BYO model): it self-signs | ||
| 48 | // short-lived user certs locally rather than asking the server to mint them. | ||
| 49 | userCA, err := loadOrCreateCA(cfg.CAKeyPath) | ||
| 50 | if err != nil { | ||
| 51 | return fmt.Errorf("load mcp user CA: %w", err) | ||
| 52 | } | ||
| 53 | // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it | ||
| 54 | // authenticates with short-lived user certs it self-signs on demand with its | ||
| 55 | // own user CA, and verifies both hops' host certs against the eitri host CA. | ||
| 56 | // On first gate use it derives the caller's tenant (an empty cfg.Tenant) and | ||
| 57 | // registers this user CA's public key so VMs trust those certs — all backed by | ||
| 58 | // the same API client. The upload carries a per-install label so the console | ||
| 59 | // shows WHICH install holds signing power, and revocation stays per-install. | ||
| 60 | api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token, UserCALabel: caLabel()} | ||
| 61 | tools := &Tools{ | ||
| 62 | API: API{Client: api}, | ||
| 63 | Runner: NewRunner(newGateDialer(gateDialerConfig{ | ||
| 64 | Gate: cfg.Gate, | ||
| 65 | VMUser: cfg.VMUser, | ||
| 66 | API: api, | ||
| 67 | UserCA: userCA, | ||
| 68 | Tenant: cfg.Tenant, | ||
| 69 | })), | ||
| 70 | Gate: cfg.Gate, | ||
| 71 | VMUser: cfg.VMUser, | ||
| 72 | } | ||
| 73 | |||
| 74 | // No Registrar: this install signs with its own CA, so it has nothing to | ||
| 75 | // ask eitri to hold. | ||
| 76 | server := NewServer(tools, Options{}) | ||
| 77 | |||
| 78 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||
| 79 | defer stop() | ||
| 80 | |||
| 81 | // The user CA registers with the caller's tenant lazily, on the first gate | ||
| 82 | // connection (gateDialer.ensure), so a control-plane blip at startup can't stop the | ||
| 83 | // server from coming up and serving the non-SSH tools. | ||
| 84 | return server.Run(ctx, &mcp.StdioTransport{}) | ||
| 85 | } | ||
| 86 | |||
| 87 | // caLabel names this install's user CA in the tenant's CA list. Hostname is | ||
| 88 | // the natural per-install discriminator; a host that can't name itself still | ||
| 89 | // gets the binary's name rather than an anonymous row. | ||
| 90 | func caLabel() string { | ||
| 91 | if hn, err := os.Hostname(); err == nil && hn != "" { | ||
| 92 | return "eitri-mcp@" + hn | ||
| 93 | } | ||
| 94 | return "eitri-mcp" | ||
| 95 | } | ||
| 96 | |||
| 97 | // loadOrCreateCA returns a stable ssh.Signer for the key at path. If the file | ||
| 98 | // is absent it generates a fresh ed25519 key, writes it 0600 with O_EXCL (so a | ||
| 99 | // concurrent creator can't clobber it and a symlink can't be followed), and | ||
| 100 | // returns its signer; if present it parses and returns the existing key. This | ||
| 101 | // is deliberately local to the MCP (a pure API client) and does NOT import the | ||
| 102 | // server's sshca package. Never logs or returns key material in errors. | ||
| 103 | func loadOrCreateCA(path string) (ssh.Signer, error) { | ||
| 104 | pemBytes, err := os.ReadFile(path) | ||
| 105 | if err == nil { | ||
| 106 | signer, perr := ssh.ParsePrivateKey(pemBytes) | ||
| 107 | if perr != nil { | ||
| 108 | return nil, fmt.Errorf("parse ssh key %q: %w", path, perr) | ||
| 109 | } | ||
| 110 | return signer, nil | ||
| 111 | } | ||
| 112 | if !os.IsNotExist(err) { | ||
| 113 | return nil, fmt.Errorf("read ssh key %q: %w", path, err) | ||
| 114 | } | ||
| 115 | |||
| 116 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 117 | if err != nil { | ||
| 118 | return nil, fmt.Errorf("generate ssh key: %w", err) | ||
| 119 | } | ||
| 120 | block, err := ssh.MarshalPrivateKey(priv, "") | ||
| 121 | if err != nil { | ||
| 122 | return nil, fmt.Errorf("marshal ssh key: %w", err) | ||
| 123 | } | ||
| 124 | signer, err := ssh.NewSignerFromSigner(priv) | ||
| 125 | if err != nil { | ||
| 126 | return nil, fmt.Errorf("new signer: %w", err) | ||
| 127 | } | ||
| 128 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | ||
| 129 | if err != nil { | ||
| 130 | return nil, fmt.Errorf("create ssh key %q: %w", path, err) | ||
| 131 | } | ||
| 132 | if _, werr := f.Write(pem.EncodeToMemory(block)); werr != nil { | ||
| 133 | f.Close() | ||
| 134 | return nil, fmt.Errorf("write ssh key %q: %w", path, werr) | ||
| 135 | } | ||
| 136 | if cerr := f.Close(); cerr != nil { | ||
| 137 | return nil, fmt.Errorf("close ssh key %q: %w", path, cerr) | ||
| 138 | } | ||
| 139 | return signer, nil | ||
| 140 | } | ||
internal/mcpserver/cli_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,72 +0,0 @@ | |||
| 1 | package mcpserver | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "github.com/modelcontextprotocol/go-sdk/mcp" | ||
| 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // TestLoadOrCreateCACreatesThenLoads covers the two steady-state paths: a fresh | ||
| 16 | // key is generated 0600 when absent, and the identical key loads back when | ||
| 17 | // present. | ||
| 18 | func TestLoadOrCreateCACreatesThenLoads(t *testing.T) { | ||
| 19 | path := filepath.Join(t.TempDir(), "user_ca") | ||
| 20 | |||
| 21 | signer, err := loadOrCreateCA(path) | ||
| 22 | require.NoError(t, err) | ||
| 23 | require.NotNil(t, signer) | ||
| 24 | |||
| 25 | info, err := os.Stat(path) | ||
| 26 | require.NoError(t, err) | ||
| 27 | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "key material must be written 0600") | ||
| 28 | |||
| 29 | again, err := loadOrCreateCA(path) | ||
| 30 | require.NoError(t, err) | ||
| 31 | assert.Equal(t, signer.PublicKey().Marshal(), again.PublicKey().Marshal(), | ||
| 32 | "an existing key must load back unchanged, not be regenerated") | ||
| 33 | } | ||
| 34 | |||
| 35 | // TestCALabelNamesTheInstall pins the audit contract: the CA this client | ||
| 36 | // uploads is never an anonymous row — it carries the binary's name, and the | ||
| 37 | // hostname when one exists. | ||
| 38 | func TestCALabelNamesTheInstall(t *testing.T) { | ||
| 39 | label := caLabel() | ||
| 40 | assert.True(t, strings.HasPrefix(label, "eitri-mcp"), "label %q must name the client", label) | ||
| 41 | if hn, err := os.Hostname(); err == nil && hn != "" { | ||
| 42 | assert.Equal(t, "eitri-mcp@"+hn, label) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | // TestLoadOrCreateCARejectsGarbage rejects an unparseable key file without | ||
| 47 | // leaking its bytes in the error. | ||
| 48 | func TestLoadOrCreateCARejectsGarbage(t *testing.T) { | ||
| 49 | path := filepath.Join(t.TempDir(), "user_ca") | ||
| 50 | require.NoError(t, os.WriteFile(path, []byte("this is not a pem key"), 0o600)) | ||
| 51 | |||
| 52 | _, err := loadOrCreateCA(path) | ||
| 53 | require.Error(t, err) | ||
| 54 | assert.NotContains(t, err.Error(), "not a pem key", "key bytes must never appear in errors") | ||
| 55 | } | ||
| 56 | |||
| 57 | // TestLoadOrCreateCAReadError treats a non-not-exist read failure (a directory | ||
| 58 | // at the path) as a fatal error, not a signal to generate a key. | ||
| 59 | func TestLoadOrCreateCAReadError(t *testing.T) { | ||
| 60 | dir := t.TempDir() | ||
| 61 | _, err := loadOrCreateCA(dir) | ||
| 62 | require.Error(t, err) | ||
| 63 | } | ||
| 64 | |||
| 65 | // TestRegisterAddsTool pins the single SDK-generics adapter: it must wire a | ||
| 66 | // typed handler onto the server without panicking. | ||
| 67 | func TestRegisterAddsTool(t *testing.T) { | ||
| 68 | s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil) | ||
| 69 | register(s, "noop", "does nothing", func(context.Context, struct{}) (struct{}, error) { | ||
| 70 | return struct{}{}, nil | ||
| 71 | }) | ||
| 72 | } | ||
internal/mcpserver/config.go
| Old | New | ||
|---|---|---|---|
| @@ -1,84 +0,0 @@ | |||
| 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 speaks to it only through the shared API | ||
| 4 | // client (internal/server/api/client), never any other server internals, and | ||
| 5 | // the PAT it holds must never appear in tool results or errors. | ||
| 6 | package mcpserver | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "encoding/json" | ||
| 10 | "fmt" | ||
| 11 | "os" | ||
| 12 | "path/filepath" | ||
| 13 | "strings" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // Config is eitri-mcp's on-disk configuration. | ||
| 17 | type Config struct { | ||
| 18 | ServerURL string `json:"server_url"` // eitri API base, e.g. http://127.0.0.1:8080 | ||
| 19 | TokenFile string `json:"token_file"` // file holding the PAT (mint one in console Settings) | ||
| 20 | Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>" | ||
| 21 | VMUser string `json:"vm_user"` // guest user (default "ubuntu") | ||
| 22 | CAKeyPath string `json:"ca_key_path"` // this client's own user CA (load-or-create; default next to config) | ||
| 23 | Tenant string `json:"tenant"` // optional; the credential names the tenant. Set only when the PAT's CA/tenant mapping is ambiguous (a human or user CA in more than one tenant). | ||
| 24 | |||
| 25 | Token string `json:"-"` // loaded from TokenFile; never serialized | ||
| 26 | } | ||
| 27 | |||
| 28 | // LoadConfig reads and validates the config file and loads the PAT. | ||
| 29 | func LoadConfig(path string) (*Config, error) { | ||
| 30 | path = expandTilde(path) | ||
| 31 | // Resolve to an absolute path for stable error messages regardless of the | ||
| 32 | // process's cwd — the MCP host launches this server with an unpredictable | ||
| 33 | // working directory. | ||
| 34 | abs, err := filepath.Abs(path) | ||
| 35 | if err != nil { | ||
| 36 | return nil, fmt.Errorf("resolve config path: %w", err) | ||
| 37 | } | ||
| 38 | path = abs | ||
| 39 | raw, err := os.ReadFile(path) | ||
| 40 | if err != nil { | ||
| 41 | return nil, fmt.Errorf("read config: %w", err) | ||
| 42 | } | ||
| 43 | cfg := &Config{} | ||
| 44 | if err := json.Unmarshal(raw, cfg); err != nil { | ||
| 45 | return nil, fmt.Errorf("parse config %s: %w", path, err) | ||
| 46 | } | ||
| 47 | if cfg.VMUser == "" { | ||
| 48 | cfg.VMUser = "ubuntu" | ||
| 49 | } | ||
| 50 | // An empty tenant is VALID: the credential names the tenant (Me() derives it | ||
| 51 | // for connect names, and the tenant-less CA routes register on it). No default. | ||
| 52 | if cfg.CAKeyPath == "" { | ||
| 53 | cfg.CAKeyPath = filepath.Join(filepath.Dir(path), "user_ca") | ||
| 54 | } else { | ||
| 55 | cfg.CAKeyPath = expandTilde(cfg.CAKeyPath) | ||
| 56 | } | ||
| 57 | if cfg.ServerURL == "" { | ||
| 58 | return nil, fmt.Errorf("config %s: server_url is required", path) | ||
| 59 | } | ||
| 60 | if cfg.TokenFile == "" { | ||
| 61 | return nil, fmt.Errorf("config %s: token_file is required", path) | ||
| 62 | } | ||
| 63 | tok, err := os.ReadFile(expandTilde(cfg.TokenFile)) | ||
| 64 | if err != nil { | ||
| 65 | return nil, fmt.Errorf("read token: %w", err) | ||
| 66 | } | ||
| 67 | cfg.Token = strings.TrimSpace(string(tok)) | ||
| 68 | if cfg.Token == "" { | ||
| 69 | return nil, fmt.Errorf("token file %s is empty", cfg.TokenFile) | ||
| 70 | } | ||
| 71 | return cfg, nil | ||
| 72 | } | ||
| 73 | |||
| 74 | // expandTilde expands a leading "~/" only. Bare "~" and "~user/x" forms pass | ||
| 75 | // through unchanged; those then fail loudly at file open rather than being | ||
| 76 | // silently mishandled here. | ||
| 77 | func expandTilde(p string) string { | ||
| 78 | if strings.HasPrefix(p, "~/") { | ||
| 79 | if home, err := os.UserHomeDir(); err == nil { | ||
| 80 | return filepath.Join(home, p[2:]) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | return p | ||
| 84 | } | ||
internal/mcpserver/config_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,107 +0,0 @@ | |||
| 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 | "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.Token) // trimmed, loaded from file | ||
| 29 | } | ||
| 30 | |||
| 31 | func TestLoadConfigTenantOptionalNoDefault(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 | |||
| 37 | // Omitted tenant stays empty — the credential names it (no "default" fallback). | ||
| 38 | require.NoError(t, os.WriteFile(cfgPath, []byte(`{ | ||
| 39 | "server_url": "http://127.0.0.1:9999", | ||
| 40 | "token_file": "`+tok+`" | ||
| 41 | }`), 0o600)) | ||
| 42 | cfg, err := LoadConfig(cfgPath) | ||
| 43 | require.NoError(t, err) | ||
| 44 | assert.Empty(t, cfg.Tenant, "an omitted tenant must NOT default to a seeded tenant") | ||
| 45 | |||
| 46 | // An explicit tenant is preserved for the ambiguous multi-tenant corner. | ||
| 47 | require.NoError(t, os.WriteFile(cfgPath, []byte(`{ | ||
| 48 | "server_url": "http://127.0.0.1:9999", | ||
| 49 | "token_file": "`+tok+`", | ||
| 50 | "tenant": "team" | ||
| 51 | }`), 0o600)) | ||
| 52 | cfg, err = LoadConfig(cfgPath) | ||
| 53 | require.NoError(t, err) | ||
| 54 | assert.Equal(t, "team", cfg.Tenant) | ||
| 55 | } | ||
| 56 | |||
| 57 | func TestLoadConfigExplicitEmptyVMUserGetsDefault(t *testing.T) { | ||
| 58 | dir := t.TempDir() | ||
| 59 | tok := filepath.Join(dir, "token") | ||
| 60 | require.NoError(t, os.WriteFile(tok, []byte("sekret\n"), 0o600)) | ||
| 61 | cfgPath := filepath.Join(dir, "config.json") | ||
| 62 | require.NoError(t, os.WriteFile(cfgPath, []byte(`{ | ||
| 63 | "server_url": "http://127.0.0.1:9999", | ||
| 64 | "token_file": "`+tok+`", | ||
| 65 | "vm_user": "" | ||
| 66 | }`), 0o600)) | ||
| 67 | |||
| 68 | cfg, err := LoadConfig(cfgPath) | ||
| 69 | require.NoError(t, err) | ||
| 70 | assert.Equal(t, "ubuntu", cfg.VMUser, `explicit "vm_user": "" must not erase the default`) | ||
| 71 | } | ||
| 72 | |||
| 73 | func TestLoadConfigMissingRequired(t *testing.T) { | ||
| 74 | dir := t.TempDir() | ||
| 75 | cfgPath := filepath.Join(dir, "config.json") | ||
| 76 | require.NoError(t, os.WriteFile(cfgPath, []byte(`{"server_url": ""}`), 0o600)) | ||
| 77 | _, err := LoadConfig(cfgPath) | ||
| 78 | assert.ErrorContains(t, err, "server_url") | ||
| 79 | } | ||
| 80 | |||
| 81 | func TestLoadConfigMissingTokenFile(t *testing.T) { | ||
| 82 | dir := t.TempDir() | ||
| 83 | cfgPath := filepath.Join(dir, "config.json") | ||
| 84 | require.NoError(t, os.WriteFile(cfgPath, []byte(`{"server_url": "http://127.0.0.1:9999"}`), 0o600)) | ||
| 85 | _, err := LoadConfig(cfgPath) | ||
| 86 | assert.ErrorContains(t, err, "token_file") | ||
| 87 | } | ||
| 88 | |||
| 89 | func TestLoadConfigEmptyTokenFile(t *testing.T) { | ||
| 90 | dir := t.TempDir() | ||
| 91 | tok := filepath.Join(dir, "token") | ||
| 92 | require.NoError(t, os.WriteFile(tok, []byte(" \n"), 0o600)) | ||
| 93 | cfgPath := filepath.Join(dir, "config.json") | ||
| 94 | require.NoError(t, os.WriteFile(cfgPath, []byte(`{ | ||
| 95 | "server_url": "http://127.0.0.1:9999", | ||
| 96 | "token_file": "`+tok+`" | ||
| 97 | }`), 0o600)) | ||
| 98 | _, err := LoadConfig(cfgPath) | ||
| 99 | assert.ErrorContains(t, err, "empty") | ||
| 100 | } | ||
| 101 | |||
| 102 | func TestLoadConfigTildeExpansion(t *testing.T) { | ||
| 103 | home, err := os.UserHomeDir() | ||
| 104 | require.NoError(t, err) | ||
| 105 | assert.Equal(t, home+"/x", expandTilde("~/x")) | ||
| 106 | assert.Equal(t, "/abs/x", expandTilde("/abs/x")) | ||
| 107 | } | ||
internal/mcpserver/gatedial.go
| Old | New | ||
|---|---|---|---|
| @@ -1,139 +0,0 @@ | |||
| 1 | package mcpserver | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "strings" | ||
| 8 | "sync" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/a73x/eitri/internal/gateclient" | ||
| 12 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 13 | "golang.org/x/crypto/ssh" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // gateAPI is what the gate dialer needs from the control-plane client to | ||
| 17 | // prepare credentials before the first connection: derive the caller's tenant | ||
| 18 | // (Me), and verify/register this client's user CA (ListUserCAs + the embedded | ||
| 19 | // CertAuthority's UploadUserCA). One *client.Client value satisfies it. | ||
| 20 | type gateAPI interface { | ||
| 21 | gateclient.CertAuthority | ||
| 22 | Me() (client.Me, error) | ||
| 23 | ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error) | ||
| 24 | } | ||
| 25 | |||
| 26 | var _ gateAPI = (*client.Client)(nil) | ||
| 27 | |||
| 28 | // gateDialerConfig configures SSH access to VMs through the eitri SSH-CA jump | ||
| 29 | // gate. Gate credentials are prepared lazily on first use from API + UserCA + | ||
| 30 | // Tenant (see gateDialer.ensure); the Gate/VMUser fields shape the dial itself. | ||
| 31 | type gateDialerConfig struct { | ||
| 32 | Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host) | ||
| 33 | VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal) | ||
| 34 | API gateAPI // control-plane client backing tenant derivation, CA registration, host-CA fetch | ||
| 35 | UserCA ssh.Signer // this client's persistent user CA; signs user certs locally | ||
| 36 | Tenant string // configured tenant; "" ⇒ derive from the credential via Me() | ||
| 37 | Now func() time.Time // test seam; nil ⇒ time.Now | ||
| 38 | } | ||
| 39 | |||
| 40 | // gateDialer reaches VMs the way a client outside the fleet does: through the | ||
| 41 | // SSH-CA jump gate, authenticating with short-lived user certs it self-signs | ||
| 42 | // with its own persistent user CA, verifying both hops' host certs against the | ||
| 43 | // eitri host CA. Registering that CA with the caller's tenant is a local | ||
| 44 | // install's concern and lives here, not in the shared tool layer — the control | ||
| 45 | // plane holds its own signing key and needs none of it. | ||
| 46 | type gateDialer struct { | ||
| 47 | cfg gateDialerConfig | ||
| 48 | |||
| 49 | mu sync.Mutex | ||
| 50 | auth gateclient.Credentials // gate credentials, built once ensure() succeeds; tests may inject | ||
| 51 | } | ||
| 52 | |||
| 53 | func newGateDialer(cfg gateDialerConfig) *gateDialer { return &gateDialer{cfg: cfg} } | ||
| 54 | |||
| 55 | var _ VMDialer = (*gateDialer)(nil) | ||
| 56 | |||
| 57 | // ensure prepares gate credentials on first use and caches them in g.auth. It | ||
| 58 | // resolves the caller's tenant (the configured one, else derived from the | ||
| 59 | // credential via Me()) and makes sure this client's user CA is registered with | ||
| 60 | // that tenant so VMs trust the certs it signs. Idempotent across restarts — | ||
| 61 | // registration lists the tenant's user CAs and uploads only when its own | ||
| 62 | // fingerprint is absent. A failed ensure caches nothing, so the next call | ||
| 63 | // retries; its error names the manual fallback (`eitri ca upload`). Never logs or | ||
| 64 | // returns key material. | ||
| 65 | func (g *gateDialer) ensure(ctx context.Context) error { | ||
| 66 | g.mu.Lock() | ||
| 67 | defer g.mu.Unlock() | ||
| 68 | if g.auth != nil { | ||
| 69 | return nil | ||
| 70 | } | ||
| 71 | tenant := g.cfg.Tenant | ||
| 72 | if tenant == "" { | ||
| 73 | // The credential names the tenant; derive it for the connect name, whose | ||
| 74 | // <tenant>.<vmName> form the VM's host cert principal must match. | ||
| 75 | me, err := g.cfg.API.Me() | ||
| 76 | if err != nil { | ||
| 77 | return fmt.Errorf("resolving tenant from credential: %w", err) | ||
| 78 | } | ||
| 79 | if me.Tenant == "" { | ||
| 80 | return errors.New("credential resolves to no tenant") | ||
| 81 | } | ||
| 82 | tenant = me.Tenant | ||
| 83 | } | ||
| 84 | if err := g.registerUserCA(ctx); err != nil { | ||
| 85 | return err | ||
| 86 | } | ||
| 87 | g.auth = gateclient.NewGateAuth(g.cfg.API, g.cfg.UserCA, tenant, g.cfg.Now) | ||
| 88 | return nil | ||
| 89 | } | ||
| 90 | |||
| 91 | // registerUserCA idempotently registers this client's user-CA public key with | ||
| 92 | // the caller's tenant: it lists the registered CAs and uploads the local pubkey | ||
| 93 | // only if its fingerprint is absent. Routing follows the configured tenant — an | ||
| 94 | // empty tenant hits the tenant-less endpoints, which operate on the caller's own | ||
| 95 | // tenant (the credential names it). Errors name the manual fallback and never | ||
| 96 | // carry key material. | ||
| 97 | func (g *gateDialer) registerUserCA(ctx context.Context) error { | ||
| 98 | pub := g.cfg.UserCA.PublicKey() | ||
| 99 | fp := ssh.FingerprintSHA256(pub) | ||
| 100 | cas, err := g.cfg.API.ListUserCAs(ctx, g.cfg.Tenant) | ||
| 101 | if err != nil { | ||
| 102 | return fmt.Errorf("checking registered user CAs (run `eitri ca upload` to register manually): %w", err) | ||
| 103 | } | ||
| 104 | for _, ca := range cas { | ||
| 105 | if ca.Fingerprint == fp { | ||
| 106 | return nil | ||
| 107 | } | ||
| 108 | } | ||
| 109 | line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub))) | ||
| 110 | if err := g.cfg.API.UploadUserCA(ctx, g.cfg.Tenant, line); err != nil { | ||
| 111 | return fmt.Errorf("registering user CA (run `eitri ca upload` to register manually): %w", err) | ||
| 112 | } | ||
| 113 | return nil | ||
| 114 | } | ||
| 115 | |||
| 116 | // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>". It | ||
| 117 | // prepares gate credentials on first use, since the tenant half of the name may | ||
| 118 | // have to be derived from the credential. | ||
| 119 | func (g *gateDialer) ConnectName(ctx context.Context, vmName string) (string, error) { | ||
| 120 | if err := g.ensure(ctx); err != nil { | ||
| 121 | return "", err | ||
| 122 | } | ||
| 123 | g.mu.Lock() | ||
| 124 | auth := g.auth | ||
| 125 | g.mu.Unlock() | ||
| 126 | return auth.ConnectName(ctx, vmName) | ||
| 127 | } | ||
| 128 | |||
| 129 | // Dial reaches the VM named vmName through the eitri SSH-CA gate. See | ||
| 130 | // gateclient.Dial for the two-hop dial logic this delegates to. | ||
| 131 | func (g *gateDialer) Dial(ctx context.Context, vmName string) (*ssh.Client, error) { | ||
| 132 | if err := g.ensure(ctx); err != nil { | ||
| 133 | return nil, err | ||
| 134 | } | ||
| 135 | g.mu.Lock() | ||
| 136 | auth := g.auth | ||
| 137 | g.mu.Unlock() | ||
| 138 | return gateclient.Dial(ctx, gateclient.DialConfig{Gate: g.cfg.Gate, VMUser: g.cfg.VMUser, Auth: auth}, vmName) | ||
| 139 | } | ||
internal/mcpserver/server.go
| Old | New | ||
|---|---|---|---|
| @@ -1,3 +1,9 @@ | |||
| 1 | // Package mcpserver implements eitri's MCP toolset: tools that let a model | ||
| 2 | // create, control (SSH exec/files), and destroy eitri VMs. The control plane | ||
| 3 | // serves it at /mcp (internal/server/mcphttp). It is an API CLIENT of the | ||
| 4 | // control plane — it speaks to it only through the shared API client | ||
| 5 | // (internal/server/api/client), never any other server internals, and the PAT | ||
| 6 | // it holds must never appear in tool results or errors. | ||
| 1 | package mcpserver | 7 | package mcpserver |
| 2 | 8 | ||
| 3 | import ( | 9 | import ( |
| @@ -6,9 +12,7 @@ import ( | |||
| 6 | "github.com/modelcontextprotocol/go-sdk/mcp" | 12 | "github.com/modelcontextprotocol/go-sdk/mcp" |
| 7 | ) | 13 | ) |
| 8 | 14 | ||
| 9 | // Options selects the tools a transport exposes. The VM tools and ca_upload are | 15 | // Options is what a server needs beyond the tools themselves. |
| 10 | // common; the two delegation tools are remote-only — a local install holds its | ||
| 11 | // own CA and signs locally, so it has nothing to delegate. | ||
| 12 | type Options struct { | 16 | type Options struct { |
| 13 | Delegator Delegator // non-nil ⇒ expose the delegate tools | 17 | Delegator Delegator // non-nil ⇒ expose the delegate tools |
| 14 | SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection | 18 | SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection |
| @@ -49,8 +53,8 @@ type DelegateCompleteIn struct { | |||
| 49 | Certificate string `json:"certificate" jsonschema:"the contents of the *-cert.pub file your CA produced"` | 53 | Certificate string `json:"certificate" jsonschema:"the contents of the *-cert.pub file your CA produced"` |
| 50 | } | 54 | } |
| 51 | 55 | ||
| 52 | // NewServer builds the MCP server for one identity. Both transports go through | 56 | // NewServer builds the MCP server for one identity. Every caller goes through |
| 53 | // here, so the tool list cannot drift between them. | 57 | // here, so the tool list is the same one for everyone. |
| 54 | func NewServer(t *Tools, opts Options) *mcp.Server { | 58 | func NewServer(t *Tools, opts Options) *mcp.Server { |
| 55 | s := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, | 59 | s := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, |
| 56 | &mcp.ServerOptions{SchemaCache: opts.SchemaCache}) | 60 | &mcp.ServerOptions{SchemaCache: opts.SchemaCache}) |
internal/mcpserver/server_test.go
| Old | New | ||
|---|---|---|---|
| @@ -86,9 +86,9 @@ func describe(t *testing.T, cs *mcp.ClientSession, name string) string { | |||
| 86 | return "" | 86 | return "" |
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | // TestNewServerExposesTheCommonTools: a local stdio install gets the VM tools | 89 | // TestNewServerExposesTheCommonTools: without a Delegator the server offers the |
| 90 | // and ca_upload, and nothing else — it holds its own CA and signs locally, so | 90 | // VM tools and ca_upload, and nothing else — there is nothing to delegate |
| 91 | // it has nothing to delegate. | 91 | // through. |
| 92 | func TestNewServerExposesTheCommonTools(t *testing.T) { | 92 | func TestNewServerExposesTheCommonTools(t *testing.T) { |
| 93 | names := toolNames(t, NewServer(&Tools{}, Options{})) | 93 | names := toolNames(t, NewServer(&Tools{}, Options{})) |
| 94 | assert.ElementsMatch(t, commonTools, names) | 94 | assert.ElementsMatch(t, commonTools, names) |
| @@ -163,7 +163,8 @@ func TestDelegateFailuresSurfaceAsToolErrors(t *testing.T) { | |||
| 163 | } | 163 | } |
| 164 | 164 | ||
| 165 | // TestReportProgressIsANoOpWithoutAListener: the tool layer calls it | 165 | // TestReportProgressIsANoOpWithoutAListener: the tool layer calls it |
| 166 | // unconditionally, so the stdio path must not depend on one being installed. | 166 | // unconditionally, so a caller that asked for no progress must not depend on |
| 167 | // one being installed. | ||
| 167 | func TestReportProgressIsANoOpWithoutAListener(t *testing.T) { | 168 | func TestReportProgressIsANoOpWithoutAListener(t *testing.T) { |
| 168 | assert.NotPanics(t, func() { reportProgress(t.Context(), "still working") }) | 169 | assert.NotPanics(t, func() { reportProgress(t.Context(), "still working") }) |
| 169 | } | 170 | } |
internal/mcpserver/sshrun.go
| Old | New | ||
|---|---|---|---|
| @@ -19,11 +19,10 @@ import ( | |||
| 19 | const outputCap = 1 << 20 // 1 MiB | 19 | const outputCap = 1 << 20 // 1 MiB |
| 20 | 20 | ||
| 21 | // VMDialer reaches a VM by name and names it the way the caller's world spells | 21 | // VMDialer reaches a VM by name and names it the way the caller's world spells |
| 22 | // it. It is the one thing that differs between transports: the local stdio | 22 | // it. It is the seam between reaching a VM and doing anything to one: the |
| 23 | // install goes through the eitri SSH-CA jump gate with a certificate it | 23 | // control plane tunnels over the VM host's own sync connection with the |
| 24 | // self-signs, while the control plane tunnels over the VM host's own sync | 24 | // credential that tenant has delegated to it (internal/server/vmssh). |
| 25 | // connection with the credential that tenant has delegated to it. Everything | 25 | // Everything above this line — exec, SFTP, output capping — is transport-blind. |
| 26 | // above this line — exec, SFTP, output capping — is shared. | ||
| 27 | type VMDialer interface { | 26 | type VMDialer interface { |
| 28 | Dial(ctx context.Context, vmName string) (*ssh.Client, error) | 27 | Dial(ctx context.Context, vmName string) (*ssh.Client, error) |
| 29 | // ConnectName maps a bare VM name to the <tenant>.<name> form the gate | 28 | // ConnectName maps a bare VM name to the <tenant>.<name> form the gate |
internal/mcpserver/sshrun_test.go
| Old | New | ||
|---|---|---|---|
| @@ -8,13 +8,9 @@ import ( | |||
| 8 | "errors" | 8 | "errors" |
| 9 | "io" | 9 | "io" |
| 10 | "net" | 10 | "net" |
| 11 | "strings" | ||
| 12 | "sync" | ||
| 13 | "testing" | 11 | "testing" |
| 14 | "time" | 12 | "time" |
| 15 | 13 | ||
| 16 | "github.com/a73x/eitri/internal/gateclient" | ||
| 17 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 18 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" |
| 19 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" |
| 20 | "golang.org/x/crypto/ssh" | 16 | "golang.org/x/crypto/ssh" |
| @@ -49,8 +45,25 @@ func hostCertSigner(t *testing.T, ca ssh.Signer, principal string) ssh.Signer { | |||
| 49 | return cs | 45 | return cs |
| 50 | } | 46 | } |
| 51 | 47 | ||
| 48 | // userCertSigner builds a user-cert-backed signer for principal, signed by ca — | ||
| 49 | // the credential a VM's sshd accepts. | ||
| 50 | func userCertSigner(t *testing.T, ca ssh.Signer, principal string) ssh.Signer { | ||
| 51 | t.Helper() | ||
| 52 | userKey := newSigner(t) | ||
| 53 | cert := &ssh.Certificate{ | ||
| 54 | Key: userKey.PublicKey(), | ||
| 55 | CertType: ssh.UserCert, | ||
| 56 | ValidPrincipals: []string{principal}, | ||
| 57 | ValidBefore: ssh.CertTimeInfinity, | ||
| 58 | } | ||
| 59 | require.NoError(t, cert.SignCert(rand.Reader, ca)) | ||
| 60 | cs, err := ssh.NewCertSigner(cert, userKey) | ||
| 61 | require.NoError(t, err) | ||
| 62 | return cs | ||
| 63 | } | ||
| 64 | |||
| 52 | // caUserAuth accepts a client only if it presents a user cert signed by ca — | 65 | // caUserAuth accepts a client only if it presents a user cert signed by ca — |
| 53 | // mirroring the gate/VM sshd's TrustedUserCAKeys policy. | 66 | // mirroring the VM sshd's TrustedUserCAKeys policy. |
| 54 | func caUserAuth(ca ssh.PublicKey) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { | 67 | func caUserAuth(ca ssh.PublicKey) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { |
| 55 | checker := &ssh.CertChecker{ | 68 | checker := &ssh.CertChecker{ |
| 56 | IsUserAuthority: func(auth ssh.PublicKey) bool { | 69 | IsUserAuthority: func(auth ssh.PublicKey) bool { |
| @@ -140,282 +153,90 @@ func handleExecSession(ch ssh.Channel, reqs <-chan *ssh.Request, out string, cod | |||
| 140 | } | 153 | } |
| 141 | } | 154 | } |
| 142 | 155 | ||
| 143 | // startGate runs a minimal SSH-CA gate on a random loopback port: it presents | 156 | // testDialer stands in for whatever transport reached the VM. Runner is |
| 144 | // hostSigner's host cert, accepts CA-signed user certs, and on a direct-tcpip | 157 | // transport-blind, so its tests supply the plainest dialer there is: connect to |
| 145 | // channel (the only kind it honors) dials vmAddr and pumps bytes both ways — | 158 | // a fixed address with a CA-signed user cert. Reaching the right VM and |
| 146 | // mirroring sshgate.handleDirectTCPIP. Returns its listen address. | 159 | // verifying its host certificate belong to the real dialer, and are pinned |
| 147 | func startGate(t *testing.T, hostSigner ssh.Signer, userCA ssh.PublicKey, vmAddr string) string { | 160 | // there (internal/server/vmssh). |
| 148 | t.Helper() | 161 | type testDialer struct { |
| 149 | conf := &ssh.ServerConfig{PublicKeyCallback: caUserAuth(userCA)} | 162 | addr string |
| 150 | conf.AddHostKey(hostSigner) | 163 | tenant string |
| 151 | |||
| 152 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 153 | require.NoError(t, err) | ||
| 154 | t.Cleanup(func() { ln.Close() }) | ||
| 155 | |||
| 156 | go func() { | ||
| 157 | for { | ||
| 158 | nc, err := ln.Accept() | ||
| 159 | if err != nil { | ||
| 160 | return | ||
| 161 | } | ||
| 162 | go serveGate(nc, conf, vmAddr) | ||
| 163 | } | ||
| 164 | }() | ||
| 165 | return ln.Addr().String() | ||
| 166 | } | ||
| 167 | |||
| 168 | func serveGate(nc net.Conn, conf *ssh.ServerConfig, vmAddr string) { | ||
| 169 | sc, chans, reqs, err := ssh.NewServerConn(nc, conf) | ||
| 170 | if err != nil { | ||
| 171 | nc.Close() | ||
| 172 | return | ||
| 173 | } | ||
| 174 | defer sc.Close() | ||
| 175 | go ssh.DiscardRequests(reqs) | ||
| 176 | for newCh := range chans { | ||
| 177 | if newCh.ChannelType() != "direct-tcpip" { | ||
| 178 | newCh.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted") | ||
| 179 | continue | ||
| 180 | } | ||
| 181 | go gatePipe(newCh, vmAddr) | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | func gatePipe(newCh ssh.NewChannel, vmAddr string) { | ||
| 186 | var p struct { | ||
| 187 | HostToConnect string | ||
| 188 | PortToConnect uint32 | ||
| 189 | OriginatorIP string | ||
| 190 | OriginatorPort uint32 | ||
| 191 | } | ||
| 192 | if err := ssh.Unmarshal(newCh.ExtraData(), &p); err != nil { | ||
| 193 | newCh.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") | ||
| 194 | return | ||
| 195 | } | ||
| 196 | if p.PortToConnect != 22 { | ||
| 197 | newCh.Reject(ssh.Prohibited, "only port 22 is permitted") | ||
| 198 | return | ||
| 199 | } | ||
| 200 | target, err := net.Dial("tcp", vmAddr) | ||
| 201 | if err != nil { | ||
| 202 | newCh.Reject(ssh.ConnectionFailed, err.Error()) | ||
| 203 | return | ||
| 204 | } | ||
| 205 | ch, chReqs, err := newCh.Accept() | ||
| 206 | if err != nil { | ||
| 207 | target.Close() | ||
| 208 | return | ||
| 209 | } | ||
| 210 | go ssh.DiscardRequests(chReqs) | ||
| 211 | go func() { io.Copy(ch, target); ch.Close() }() | ||
| 212 | go func() { io.Copy(target, ch); target.Close() }() | ||
| 213 | } | ||
| 214 | |||
| 215 | // fakeGateCreds is a minimal gateclient.Credentials with a caller-chosen | ||
| 216 | // client signer and host verifier, for exercising client-auth rejection paths. | ||
| 217 | type fakeGateCreds struct { | ||
| 218 | signer ssh.Signer | 164 | signer ssh.Signer |
| 219 | hostCB ssh.HostKeyCallback | 165 | hostCA ssh.PublicKey |
| 220 | } | 166 | } |
| 221 | 167 | ||
| 222 | func (f fakeGateCreds) Signer(context.Context) (ssh.Signer, error) { return f.signer, nil } | 168 | func (d testDialer) ConnectName(_ context.Context, vmName string) (string, error) { |
| 223 | func (f fakeGateCreds) HostKeyCallback() ssh.HostKeyCallback { return f.hostCB } | 169 | return d.connectName(vmName), nil |
| 224 | func (f fakeGateCreds) ConnectName(_ context.Context, vmName string) (string, error) { | ||
| 225 | return "default." + vmName, nil | ||
| 226 | } | 170 | } |
| 227 | 171 | ||
| 228 | // fakeCA is a minimal gateclient.CertAuthority whose FetchSSHCA serves a | 172 | func (d testDialer) connectName(vmName string) string { return d.tenant + "." + vmName } |
| 229 | // fixed host-CA signer's public key; UploadUserCA is unused by these dial | ||
| 230 | // tests. The same signer doubles as the user CA passed to | ||
| 231 | // gateclient.NewGateAuth, mirroring gateauth_test.go's fakeCertAuthority. | ||
| 232 | type fakeCA struct{ caSigner ssh.Signer } | ||
| 233 | 173 | ||
| 234 | func (f fakeCA) FetchSSHCA(context.Context) (ssh.PublicKey, error) { | 174 | func (d testDialer) Dial(_ context.Context, vmName string) (*ssh.Client, error) { |
| 235 | return f.caSigner.PublicKey(), nil | 175 | name := d.connectName(vmName) |
| 176 | checker := &ssh.CertChecker{ | ||
| 177 | IsHostAuthority: func(auth ssh.PublicKey, _ string) bool { | ||
| 178 | return bytes.Equal(auth.Marshal(), d.hostCA.Marshal()) | ||
| 179 | }, | ||
| 180 | } | ||
| 181 | return ssh.Dial("tcp", d.addr, &ssh.ClientConfig{ | ||
| 182 | User: "ubuntu", | ||
| 183 | Auth: []ssh.AuthMethod{ssh.PublicKeys(d.signer)}, | ||
| 184 | HostKeyCallback: func(_ string, remote net.Addr, key ssh.PublicKey) error { | ||
| 185 | return checker.CheckHostKey(net.JoinHostPort(name, "22"), remote, key) | ||
| 186 | }, | ||
| 187 | Timeout: 10 * time.Second, | ||
| 188 | }) | ||
| 236 | } | 189 | } |
| 237 | func (f fakeCA) UploadUserCA(context.Context, string, string) error { return nil } | ||
| 238 | |||
| 239 | // ── tests ──────────────────────────────────────────────────────────────────── | ||
| 240 | 190 | ||
| 241 | func TestExecThroughGate(t *testing.T) { | 191 | // newTestRunner wires a Runner to a backing VM that answers every exec with out |
| 192 | // and code. | ||
| 193 | func newTestRunner(t *testing.T, out string, code int) *Runner { | ||
| 194 | t.Helper() | ||
| 242 | ca := newSigner(t) | 195 | ca := newSigner(t) |
| 243 | ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil) | 196 | addr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), out, code) |
| 197 | return NewRunner(testDialer{ | ||
| 198 | addr: addr, | ||
| 199 | tenant: "default", | ||
| 200 | signer: userCertSigner(t, ca, "ubuntu"), | ||
| 201 | hostCA: ca.PublicKey(), | ||
| 202 | }) | ||
| 203 | } | ||
| 244 | 204 | ||
| 245 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) | 205 | // ── tests ──────────────────────────────────────────────────────────────────── |
| 246 | // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>, | ||
| 247 | // verifies it under that host. | ||
| 248 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | ||
| 249 | 206 | ||
| 250 | r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga}) | 207 | func TestExecReturnsOutput(t *testing.T) { |
| 251 | res, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) | 208 | res, err := newTestRunner(t, "hi\n", 0).Exec(t.Context(), "testvm", "echo hi", 10*time.Second) |
| 252 | require.NoError(t, err) | 209 | require.NoError(t, err) |
| 253 | assert.Equal(t, "hi\n", res.Stdout) | 210 | assert.Equal(t, "hi\n", res.Stdout) |
| 254 | assert.Equal(t, 0, res.ExitCode) | 211 | assert.Equal(t, 0, res.ExitCode) |
| 212 | assert.False(t, res.Truncated) | ||
| 255 | } | 213 | } |
| 256 | 214 | ||
| 257 | func TestExecVMForeignCAHostCertRejected(t *testing.T) { | 215 | func TestExecNonZeroExitIsNotAnError(t *testing.T) { |
| 258 | ca := newSigner(t) | 216 | res, err := newTestRunner(t, "nope\n", 3).Exec(t.Context(), "testvm", "false", 10*time.Second) |
| 259 | ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil) | 217 | require.NoError(t, err, "a command that fails is a result, not a transport failure") |
| 260 | 218 | assert.Equal(t, 3, res.ExitCode) | |
| 261 | // The VM presents a host cert signed by a DIFFERENT CA. Its user-auth policy | 219 | assert.Equal(t, "nope\n", res.Stdout) |
| 262 | // still trusts the real CA, so the gate hop and client auth both succeed and | ||
| 263 | // this isolates the VM host-cert rejection. | ||
| 264 | foreignCA := newSigner(t) | ||
| 265 | vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "default.testvm"), ca.PublicKey(), "hi\n", 0) | ||
| 266 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | ||
| 267 | |||
| 268 | r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga}) | ||
| 269 | _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) | ||
| 270 | require.Error(t, err) | ||
| 271 | assert.Contains(t, err.Error(), "vm testvm ssh handshake", "expected the VM hop to reject the foreign-CA host cert") | ||
| 272 | } | ||
| 273 | |||
| 274 | func TestExecGateRejectsNonCAUserKey(t *testing.T) { | ||
| 275 | ca := newSigner(t) | ||
| 276 | ga := gateclient.NewGateAuth(fakeCA{caSigner: ca}, ca, "default", nil) | ||
| 277 | |||
| 278 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) | ||
| 279 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | ||
| 280 | |||
| 281 | // A plain (non-cert) client key: the gate only accepts CA-signed user certs, | ||
| 282 | // so its handshake must fail auth. Host verification still uses the real CA, | ||
| 283 | // isolating the client-auth rejection at the gate hop. | ||
| 284 | creds := fakeGateCreds{signer: newSigner(t), hostCB: ga.HostKeyCallback()} | ||
| 285 | r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: creds}) | ||
| 286 | _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) | ||
| 287 | require.Error(t, err) | ||
| 288 | assert.Contains(t, err.Error(), "gate "+gateAddr+" ssh handshake", "expected the gate hop to reject the non-CA user key") | ||
| 289 | } | ||
| 290 | |||
| 291 | // ── Runner.ensure: tenant derivation + idempotent CA self-registration ──────── | ||
| 292 | |||
| 293 | // fakeGateAPI is a minimal gateAPI for exercising Runner.ensure: it serves a | ||
| 294 | // host CA, records user-CA uploads, and returns scripted Me()/ListUserCAs | ||
| 295 | // results so tenant derivation and idempotent registration can be pinned without | ||
| 296 | // an httptest server. | ||
| 297 | type fakeGateAPI struct { | ||
| 298 | caSigner ssh.Signer | ||
| 299 | |||
| 300 | meTenant string | ||
| 301 | meErr error | ||
| 302 | listCAs []client.UserCA | ||
| 303 | listErrs int // leading ListUserCAs calls that fail (control-plane blip) | ||
| 304 | uploadErr error | ||
| 305 | |||
| 306 | mu sync.Mutex | ||
| 307 | meCalls int | ||
| 308 | listCalls int | ||
| 309 | uploads []string // uploaded CA lines, in order | ||
| 310 | lastTenant string // tenant arg of the last CA (list/upload) call | ||
| 311 | } | ||
| 312 | |||
| 313 | func (f *fakeGateAPI) FetchSSHCA(context.Context) (ssh.PublicKey, error) { | ||
| 314 | return f.caSigner.PublicKey(), nil | ||
| 315 | } | 220 | } |
| 316 | 221 | ||
| 317 | func (f *fakeGateAPI) Me() (client.Me, error) { | 222 | func TestExecReportsTruncationPastTheCap(t *testing.T) { |
| 318 | f.mu.Lock() | 223 | res, err := newTestRunner(t, string(bytes.Repeat([]byte("x"), outputCap+512)), 0). |
| 319 | defer f.mu.Unlock() | 224 | Exec(t.Context(), "testvm", "cat big", 30*time.Second) |
| 320 | f.meCalls++ | ||
| 321 | if f.meErr != nil { | ||
| 322 | return client.Me{}, f.meErr | ||
| 323 | } | ||
| 324 | return client.Me{Tenant: f.meTenant}, nil | ||
| 325 | } | ||
| 326 | |||
| 327 | func (f *fakeGateAPI) ListUserCAs(_ context.Context, tenant string) ([]client.UserCA, error) { | ||
| 328 | f.mu.Lock() | ||
| 329 | defer f.mu.Unlock() | ||
| 330 | f.listCalls++ | ||
| 331 | f.lastTenant = tenant | ||
| 332 | if f.listErrs > 0 { | ||
| 333 | f.listErrs-- | ||
| 334 | return nil, errors.New("control plane unavailable") | ||
| 335 | } | ||
| 336 | return f.listCAs, nil | ||
| 337 | } | ||
| 338 | |||
| 339 | func (f *fakeGateAPI) UploadUserCA(_ context.Context, tenant, line string) error { | ||
| 340 | f.mu.Lock() | ||
| 341 | defer f.mu.Unlock() | ||
| 342 | f.lastTenant = tenant | ||
| 343 | if f.uploadErr != nil { | ||
| 344 | return f.uploadErr | ||
| 345 | } | ||
| 346 | f.uploads = append(f.uploads, line) | ||
| 347 | return nil | ||
| 348 | } | ||
| 349 | |||
| 350 | func TestRunnerEnsureRegistersUserCAWhenAbsent(t *testing.T) { | ||
| 351 | userCA := newSigner(t) | ||
| 352 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme"} // listCAs empty ⇒ absent | ||
| 353 | r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA}) | ||
| 354 | |||
| 355 | name, err := r.ConnectName(t.Context(), "web-1") | ||
| 356 | require.NoError(t, err) | ||
| 357 | assert.Equal(t, "acme.web-1", name, "connect name uses the tenant derived from the credential") | ||
| 358 | |||
| 359 | api.mu.Lock() | ||
| 360 | defer api.mu.Unlock() | ||
| 361 | require.Len(t, api.uploads, 1, "an absent CA must be uploaded exactly once") | ||
| 362 | assert.Equal(t, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(userCA.PublicKey()))), api.uploads[0]) | ||
| 363 | assert.Equal(t, "", api.lastTenant, "an empty config tenant registers via the tenant-less route") | ||
| 364 | } | ||
| 365 | |||
| 366 | func TestRunnerEnsureSkipsUploadWhenFingerprintPresent(t *testing.T) { | ||
| 367 | userCA := newSigner(t) | ||
| 368 | fp := ssh.FingerprintSHA256(userCA.PublicKey()) | ||
| 369 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listCAs: []client.UserCA{{Fingerprint: fp, Label: "mcp"}}} | ||
| 370 | r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA}) | ||
| 371 | |||
| 372 | _, err := r.ConnectName(t.Context(), "web-1") | ||
| 373 | require.NoError(t, err) | 225 | require.NoError(t, err) |
| 374 | 226 | assert.Len(t, res.Stdout, outputCap, "captured output is capped") | |
| 375 | api.mu.Lock() | 227 | assert.True(t, res.Truncated, "the model must be told the output was cut") |
| 376 | defer api.mu.Unlock() | ||
| 377 | assert.Empty(t, api.uploads, "a CA already registered (matching fingerprint) must NOT be re-uploaded") | ||
| 378 | } | 228 | } |
| 379 | 229 | ||
| 380 | func TestRunnerEnsureExplicitTenantSkipsMe(t *testing.T) { | 230 | func TestExecSurfacesADialFailure(t *testing.T) { |
| 381 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "should-not-be-used"} | 231 | // A dialer that cannot reach the VM: the error is the transport's, and the |
| 382 | r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t), Tenant: "team"}) | 232 | // Runner passes it through rather than reporting an empty success. |
| 383 | 233 | r := NewRunner(testDialer{addr: "127.0.0.1:1", tenant: "default", signer: newSigner(t), hostCA: newSigner(t).PublicKey()}) | |
| 384 | name, err := r.ConnectName(t.Context(), "web-1") | 234 | _, err := r.Exec(t.Context(), "testvm", "echo hi", 5*time.Second) |
| 385 | require.NoError(t, err) | ||
| 386 | assert.Equal(t, "team.web-1", name, "an explicit tenant is used verbatim") | ||
| 387 | |||
| 388 | api.mu.Lock() | ||
| 389 | defer api.mu.Unlock() | ||
| 390 | assert.Zero(t, api.meCalls, "an explicit tenant must not call Me()") | ||
| 391 | assert.Equal(t, "team", api.lastTenant, "an explicit tenant pins the /tenants/{tenant} CA route") | ||
| 392 | } | ||
| 393 | |||
| 394 | func TestRunnerEnsureRegistrationFailureNamesFallback(t *testing.T) { | ||
| 395 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", uploadErr: errors.New("boom")} | ||
| 396 | r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)}) | ||
| 397 | |||
| 398 | _, err := r.ConnectName(t.Context(), "web-1") | ||
| 399 | require.Error(t, err) | 235 | require.Error(t, err) |
| 400 | assert.Contains(t, err.Error(), "eitri ca upload", "a registration failure must name the manual fallback") | ||
| 401 | } | 236 | } |
| 402 | 237 | ||
| 403 | func TestRunnerEnsureRetriesAfterFailureThenCaches(t *testing.T) { | 238 | func TestConnectNameComesFromTheDialer(t *testing.T) { |
| 404 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listErrs: 1} // first list fails | 239 | name, err := newTestRunner(t, "", 0).ConnectName(t.Context(), "web-1") |
| 405 | r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)}) | ||
| 406 | |||
| 407 | _, err := r.ConnectName(t.Context(), "web-1") | ||
| 408 | require.Error(t, err, "a failed ensure surfaces the error") | ||
| 409 | |||
| 410 | // The next call retries and succeeds; a third rides the cache. | ||
| 411 | _, err = r.ConnectName(t.Context(), "web-1") | ||
| 412 | require.NoError(t, err) | ||
| 413 | _, err = r.ConnectName(t.Context(), "web-1") | ||
| 414 | require.NoError(t, err) | 240 | require.NoError(t, err) |
| 415 | 241 | assert.Equal(t, "default.web-1", name) | |
| 416 | api.mu.Lock() | ||
| 417 | defer api.mu.Unlock() | ||
| 418 | assert.Equal(t, 2, api.listCalls, "one failed + one successful list; the cached third call lists nothing") | ||
| 419 | assert.Equal(t, 2, api.meCalls, "Me() re-runs on the retry, then not after caching") | ||
| 420 | assert.Len(t, api.uploads, 1, "the successful attempt uploads the absent CA once") | ||
| 421 | } | 242 | } |
internal/mcpserver/tools.go
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | "strings" | 10 | "strings" |
| 11 | "time" | 11 | "time" |
| 12 | 12 | ||
| 13 | "github.com/a73x/eitri/internal/gateclient" | ||
| 14 | "github.com/a73x/eitri/internal/random" | 13 | "github.com/a73x/eitri/internal/random" |
| 15 | "github.com/a73x/eitri/internal/server/api/client" | 14 | "github.com/a73x/eitri/internal/server/api/client" |
| 16 | "github.com/a73x/eitri/internal/server/release" | 15 | "github.com/a73x/eitri/internal/server/release" |
| @@ -34,9 +33,8 @@ type api interface { | |||
| 34 | } | 33 | } |
| 35 | 34 | ||
| 36 | // Exec is everything the tools do on a VM once something has reached it. It is | 35 | // Exec is everything the tools do on a VM once something has reached it. It is |
| 37 | // exported because the two transports supply it differently: the stdio binary | 36 | // exported because the transport supplies it: the control plane builds a Runner |
| 38 | // builds a Runner over the jump gate, the control plane one over the sync | 37 | // over the sync tunnel. |
| 39 | // tunnel. | ||
| 40 | type Exec interface { | 38 | type Exec interface { |
| 41 | Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) | 39 | Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) |
| 42 | WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error | 40 | WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error |
| @@ -99,12 +97,10 @@ func (a API) FirstEligibleHost(ctx context.Context) (client.Host, error) { | |||
| 99 | return client.Host{}, errors.New("no online hosts") | 97 | return client.Host{}, errors.New("no online hosts") |
| 100 | } | 98 | } |
| 101 | 99 | ||
| 102 | // The real client and runner must satisfy the seams unchanged, and the shared | 100 | // The real client and runner must satisfy the seams unchanged. |
| 103 | // API client must keep satisfying the gate's cert-authority seam. | ||
| 104 | var ( | 101 | var ( |
| 105 | _ api = API{} | 102 | _ api = API{} |
| 106 | _ Exec = (*Runner)(nil) | 103 | _ Exec = (*Runner)(nil) |
| 107 | _ gateclient.CertAuthority = (*client.Client)(nil) | ||
| 108 | ) | 104 | ) |
| 109 | 105 | ||
| 110 | // Tools implements the ten eitri VM tools over the API and SSH seams. | 106 | // Tools implements the ten eitri VM tools over the API and SSH seams. |
internal/server/boot/boot.go
| Old | New | ||
|---|---|---|---|
| @@ -232,8 +232,8 @@ func run(cfgPath string) error { | |||
| 232 | // Sign-in endpoints live OUTSIDE /api/ and its auth middleware: they are how | 232 | // Sign-in endpoints live OUTSIDE /api/ and its auth middleware: they are how |
| 233 | // a browser establishes a session in the first place (spec §2). | 233 | // a browser establishes a session in the first place (spec §2). |
| 234 | root.Handle("/auth/", a.AuthHandler()) | 234 | root.Handle("/auth/", a.AuthHandler()) |
| 235 | // The MCP endpoint: the same toolset the stdio binary serves, for a client | 235 | // The MCP endpoint: eitri's whole toolset, for a client anywhere on the |
| 236 | // anywhere on the internet holding nothing but a PAT. It speaks JSON-RPC | 236 | // internet holding nothing but a PAT. It speaks JSON-RPC |
| 237 | // rather than the REST contract, so like /auth/* it lives outside the route | 237 | // rather than the REST contract, so like /auth/* it lives outside the route |
| 238 | // table — but wrapped in the API's own authentication, so a caller reaching | 238 | // table — but wrapped in the API's own authentication, so a caller reaching |
| 239 | // it is the same authenticated principal /api/v1 would see. Both patterns are | 239 | // it is the same authenticated principal /api/v1 would see. Both patterns are |
internal/server/mcphttp/mcphttp.go
| Old | New | ||
|---|---|---|---|
| @@ -2,8 +2,7 @@ | |||
| 2 | // is MCP streamable HTTP, stateless: one JSON-RPC message per POST, no session | 2 | // is MCP streamable HTTP, stateless: one JSON-RPC message per POST, no session |
| 3 | // state, nothing server-initiated except progress on a call in flight. Identity | 3 | // state, nothing server-initiated except progress on a call in flight. Identity |
| 4 | // is per request — the bearer PAT the auth middleware already resolved — so a | 4 | // is per request — the bearer PAT the auth middleware already resolved — so a |
| 5 | // server is built per request, bound to that caller, and the tools it exposes | 5 | // server is built per request, bound to that caller. |
| 6 | // are the same ones the stdio binary exposes. | ||
| 7 | // | 6 | // |
| 8 | // A PAT is the whole credential. The tools call the API in-process through the | 7 | // A PAT is the whole credential. The tools call the API in-process through the |
| 9 | // same client every other consumer uses, so tenant filtering and authorization | 8 | // same client every other consumer uses, so tenant filtering and authorization |
internal/smoke/mcp_test.go
| Old | New | ||
|---|---|---|---|
| @@ -306,9 +306,9 @@ func TestProveMCPDestroysItsVMOnFailure(t *testing.T) { | |||
| 306 | assert.Contains(t, f.calls, "vm_destroy", "the leg must reap its own VM even when it fails") | 306 | assert.Contains(t, f.calls, "vm_destroy", "the leg must reap its own VM even when it fails") |
| 307 | } | 307 | } |
| 308 | 308 | ||
| 309 | // TestProveToolList covers the cheap leg every extra origin gets on its own: a | 309 | // TestProveToolList covers the cheap leg every extra origin gets on its own: an |
| 310 | // transport advertising fewer tools than the stdio binary means the two have | 310 | // origin advertising fewer tools than the toolset means that route reaches |
| 311 | // drifted apart, and a missing tool is named rather than counted. | 311 | // something else, and a missing tool is named rather than counted. |
| 312 | func TestProveToolList(t *testing.T) { | 312 | func TestProveToolList(t *testing.T) { |
| 313 | cases := []struct { | 313 | cases := []struct { |
| 314 | name string | 314 | name string |