a73x

212f6ed1

feat(server): a PAT is enough — public MCP at /mcp

a73x   2026-08-08 13:20

Commit message
feat(server): a PAT is enough — public MCP at /mcp

An LLM client anywhere on the internet points at the control plane's /mcp
endpoint with a personal access token and gets the whole eitri toolset: create
a VM, run commands in it, publish a port, hand back the address. No local
install, no config file, no CA of its own.

The transport is MCP streamable HTTP, stateless, served by eitri-server itself
behind the same authentication /api/v1 uses. Identity is per request: the
bearer PAT the middleware resolves selects the tools' tenant, and the tools
call the API in-process through the shared client, so tenant filtering and
authorization are the API's own and there is no second path to keep in step.
The stdio binary and the HTTP endpoint build their toolset from one
constructor, so the two cannot drift.

Reaching a guest needs a signing key, and a caller holding only a token has
none. eitri has none to lend either: it holds no signing key for anyone. So the
direction reverses. eitri generates an ephemeral keypair per tenant, in memory
and nowhere else, and offers the public half; the caller signs it with their own
CA, on their own TTL and principals, and posts the certificate back. eitri then
authenticates to that tenant's guests as key-plus-certificate until it expires,
and holds nothing else. Its most privileged possession is a credential that runs
out, and a restart is a revocation.

Because the delegated certificate chains to a CA the tenant has already
registered, guests created long before the delegation accept it — delegating
changes what eitri can do, never what a guest trusts. Every rule about what
makes a certificate acceptable lives in one leaf package: it must be a user
certificate, over the key this delegation issued, signed by a CA registered to
this tenant, naming the guest login user, and inside its validity window. Each
refusal says what to run next, because a delegation is a request eitri cannot
fulfil for itself, and an unexplained one resurfaces three tool calls later as
an unexplained SSH failure.

vm_create's boot wait reports what it is waiting for as it waits, so a
ten-minute call is a VM coming up rather than silence — and bytes keep flowing
through a proxy that would cut a silent origin.

The deploy boot-gate grows two legs: an unauthenticated POST /mcp must be
refused before any guest exists, and a whole VM life is driven through the
endpoint with a bearer PAT — register a CA, delegate, have a certificate from an
unregistered CA refused, create, exec, expose, dial the published port, destroy.
That VM is the leg's own, created after the CA is registered, because a guest
trusts the CA set it was created with; the delegation itself may fall either
side of the create, which is the point.

deploy/server/README.md
Old New
@@ -48,6 +48,7 @@ node — with state on a local-path PVC and config from a Secret.
48 5. **Network** (operator): 48 5. **Network** (operator):
49 - Oracle security list AND vnic-1 host firewall: open 8443/udp, 2222/tcp. 49 - Oracle security list AND vnic-1 host firewall: open 8443/udp, 2222/tcp.
50 - Cloudflare DNS: `console.eitri.sh` proxied to the eitri.sh origin (repoint off the old tailnet record — confirm nothing dev-side still resolves it); `sync.eitri.sh` and `gate.eitri.sh` grey-cloud A records to vnic-1's public IP. 50 - Cloudflare DNS: `console.eitri.sh` proxied to the eitri.sh origin (repoint off the old tailnet record — confirm nothing dev-side still resolves it); `sync.eitri.sh` and `gate.eitri.sh` grey-cloud A records to vnic-1's public IP.
51 - `api.eitri.sh` (the MCP endpoint): A record to the same origin as console. Proxied works only when the MCP client sends progress tokens — a `vm_create` that stays silent past Cloudflare's ~100s origin timeout gets a 524; if the client under test doesn't, use a grey-cloud record.
51 6. **Backups**: nightly CronJob writes dated sqlite backups on the PVC; run `backup-pull.sh` from cron on an off-cluster machine — local-path storage does not survive the node, the off-node copy is the DR story. 52 6. **Backups**: nightly CronJob writes dated sqlite backups on the PVC; run `backup-pull.sh` from cron on an off-cluster machine — local-path storage does not survive the node, the off-node copy is the DR story.
52 53
53 ## Rollout 54 ## Rollout
deploy/server/certificate.yaml
Old New
@@ -5,7 +5,7 @@ metadata:
5 namespace: eitri 5 namespace: eitri
6 spec: 6 spec:
7 secretName: console-tls 7 secretName: console-tls
8 dnsNames: [console.eitri.sh] 8 dnsNames: [console.eitri.sh, api.eitri.sh]
9 issuerRef: 9 issuerRef:
10 name: letsencrypt-prod 10 name: letsencrypt-prod
11 kind: ClusterIssuer 11 kind: ClusterIssuer
deploy/server/ingressroute.yaml
Old New
@@ -10,6 +10,12 @@ spec:
10 kind: Rule 10 kind: Rule
11 services: 11 services:
12 - {name: eitri-server, port: 8080} 12 - {name: eitri-server, port: 8080}
13 # api.eitri.sh carries ONLY /mcp: routing the whole host would make it a
14 # second console origin, and OIDC sign-in works at exactly oidc.public_url.
15 - match: Host(`api.eitri.sh`) && PathPrefix(`/mcp`)
16 kind: Rule
17 services:
18 - {name: eitri-server, port: 8080}
13 tls: 19 tls:
14 secretName: console-tls 20 secretName: console-tls
15 --- 21 ---
@@ -27,3 +33,9 @@ spec:
27 - name: redirect-https 33 - name: redirect-https
28 services: 34 services:
29 - {name: eitri-server, port: 8080} 35 - {name: eitri-server, port: 8080}
36 - match: Host(`api.eitri.sh`) && PathPrefix(`/mcp`)
37 kind: Rule
38 middlewares:
39 - name: redirect-https
40 services:
41 - {name: eitri-server, port: 8080}
docs/assumptions.md
Old New
@@ -364,3 +364,69 @@ Underpins there being one exposure rather than one per network.
364 **Proven** in code; the posture is deliberate—see the auth answer in 364 **Proven** in code; the posture is deliberate—see the auth answer in
365 [faq.md](faq.md). A host whose networks are not equally trusted gets more 365 [faq.md](faq.md). A host whose networks are not equally trusted gets more
366 reach than it asked for. 366 reach than it asked for.
367
368 ### eitri holds no signing key for anyone
369
370 A caller lends eitri a credential instead: eitri offers an ephemeral public key,
371 the caller's own CA signs it, and eitri authenticates with the certificate until
372 it expires. Underpins a caller with nothing but a token reaching its own VMs,
373 which is what makes the hosted MCP endpoint usable — without a signing key
374 existing on the server to lose.
375 **Proven** in code: the keyring is a leaf package with no persistence, every
376 validation rule is unit-tested, and the deploy gate refuses a certificate signed
377 by a CA the tenant never registered.
378
379 ### A delegation lives for the process, and nowhere else
380
381 Delegations are held in memory. A control-plane restart or redeploy drops every
382 one of them, and callers delegate again — one `ssh-keygen` and one call, because
383 the offered public key is stable per process. Underpins not persisting a
384 credential that would otherwise outlive the session that granted it: a restart
385 is a revocation, which is the property we want rather than a cost we pay.
386 **Proven** in code: nothing writes a delegation to the store, and the refusal a
387 caller gets afterwards carries the whole three-step recipe.
388
389 ### A delegated certificate's principal is the guest login user
390
391 A guest trusts its tenant's CA set through a bare `TrustedUserCAKeys` line with
392 no `AuthorizedPrincipalsFile`, so sshd matches the certificate's principals
393 against the account being logged into — `ubuntu`, not the tenant. Underpins
394 validating the principal at delegation time rather than letting the mistake
395 surface as an opaque authentication failure at the guest.
396 **Proven** in code: the refusal names the principal that is missing and the
397 `-n` flag that adds it.
398
399 ### A guest's SSH host key is generated on its host and never leaves it
400
401 The host generates the key, reports the public half, and the control plane signs
402 a certificate for the principal it derives from the VM's row. Underpins the
403 claim that eitri holds no guest key: there is nothing to escrow, because nothing
404 is ever sent. A host that has reported a key waits for its certificate rather
405 than booting a guest clients would refuse.
406 **Proven** in code: an integration test drives the round trip over a real sync
407 connection, and both the snapshot and the report are marshalled and searched for
408 private-key bytes.
409
410 ### What eitri signs with is encrypted where it rests
411
412 Every piece of key material the server holds—the fleet's own host CA and gate
413 host key, in the data directory—is sealed under a key-encryption key that lives
414 in the plane's config and nowhere near the data. A copied database, a nightly
415 backup, or a lifted volume therefore yields ciphertext and no signing power.
416 Underpins DR: a restore needs the config as well as the data, and the config
417 Secret is the key's only home.
418 **Proven** in code: sealed on the way out, opened on the way in, and a key file
419 that will not open stops the server rather than being regenerated—a fresh host
420 CA would invalidate every client's pin and every VM's host certificate at once.
421
422 ### A proxied MCP origin carries long calls
423
424 The public-MCP story rests on Cloudflare not cutting a `vm_create` that runs
425 for minutes. Underpins serving `/mcp` through an orange-cloud hostname.
426 **Proven** 2026-08-07 at stg: progress notifications traversed the proxy at a
427 2-second cadence (8 events; create returned in 18.5s), and a deliberately
428 silent call survived 120 seconds intact — the believed ~100s silent-origin
429 cliff did not appear. What the proxy did block was a client signature:
430 python-urllib drew Cloudflare error 1010 (browser integrity check) while Go's
431 client and any self-naming User-Agent passed. A proxied `/mcp` must exempt
432 API user agents — or clients must send their own.
docs/decisions.md
Old New
@@ -27,6 +27,28 @@ Instead of the server minting user certs from an eitri-held CA. A compromised
27 eitri server cannot mint access to any tenant's guests—the user-auth trust 27 eitri server cannot mint access to any tenant's guests—the user-auth trust
28 root lives with the tenant. Details in [ssh-access.md](ssh-access.md). 28 root lives with the tenant. Details in [ssh-access.md](ssh-access.md).
29 29
30 ### Delegated credentials, not a CA eitri holds
31
32 A tenant signs an ephemeral key eitri generates, and eitri uses the resulting
33 certificate until it expires. Instead of eitri holding a signing key on the
34 tenant's behalf, which was the obvious way to let a token-only caller reach a
35 guest: it would make the control-plane database worth stealing and give eitri a
36 power that never lapses. A certificate does lapse, and a restart drops it.
37 Instead of persisting delegations, too — a credential that survives the process
38 that was granted it is one nobody remembers granting. The cost is re-delegating
39 after a redeploy, which is one `ssh-keygen` against a stable public key.
40 Details in [ssh-access.md](ssh-access.md).
41
42 ### A guest's host key is generated by its host, and certified by the fleet
43
44 The host generates the keypair and sends up the public half; the control plane
45 signs a certificate for the name on the VM's row. Instead of the control plane
46 generating the key and shipping the private half down in desired state, which
47 put every guest's host key in one database. The host supplies a key and never a
48 name, so it cannot obtain a certificate for a VM it does not run. The cost is
49 one extra tick before a guest boots, against a create that already waits
50 minutes for cloud-init.
51
30 ### SSH-CA jump gate, not a mesh 52 ### SSH-CA jump gate, not a mesh
31 53
32 Guest access is plain OpenSSH through a certificate-verified bastion. Instead 54 Guest access is plain OpenSSH through a certificate-verified bastion. Instead
docs/mcp.md
Old New
@@ -1,9 +1,16 @@
1 # eitri-mcp: Claude ↔ eitri VMs 1 # eitri-mcp: Claude ↔ eitri VMs
2 2
3 `eitri-mcp` (`cmd/eitri-mcp`) is a stdio MCP server that gives Claude ten 3 eitri gives Claude explicit tools for creating and controlling VMs on a fleet,
4 explicit tools for creating and controlling VMs on an eitri fleet. It is an 4 over two transports:
5 API client of the eitri control plane plus SSH—it embeds no control-plane 5
6 or agent code. 6 - **Local stdio** — `eitri-mcp` (`cmd/eitri-mcp`), a binary you run yourself. It
7 is an API client of the control plane plus SSH; it embeds no control-plane or
8 agent code, and it holds its own SSH user CA.
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.
7 14
8 ## Tools 15 ## Tools
9 16
@@ -19,13 +26,17 @@ or agent code.
19 | `vm_exposures` | List a VM's published ports, same shape. | 26 | `vm_exposures` | List a VM's published ports, same shape. |
20 | `vm_unexpose` | Stop publishing a guest port (`host_port` disambiguates when one guest port is published twice). | 27 | `vm_unexpose` | Stop publishing a guest port (`host_port` disambiguates when one guest port is published twice). |
21 | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. | 28 | `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. |
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. |
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. |
32 | `delegate_complete` | *(remote only)* Hand back the signed certificate. eitri can then reach your VMs until it expires. |
22 33
23 Deliberately absent: any host or fleet-level operation (enroll, decommission, 34 Deliberately absent: any host or fleet-level operation (enroll, decommission,
24 power management, image/firmware knobs). The worst case from a confused model 35 power management, image/firmware knobs). The worst case from a confused model
25 is VM churn, never fleet damage—and Claude Code's per-tool permission 36 is VM churn, never fleet damage—and Claude Code's per-tool permission
26 prompts gate every call regardless. 37 prompts gate every call regardless.
27 38
28 ## Setup 39 ## Setup: local stdio
29 40
30 1. Build the binary: 41 1. Build the binary:
31 42
@@ -74,27 +85,84 @@ prompts gate every call regardless.
74 claude mcp add eitri -- /path/to/repo/bin/eitri-mcp 85 claude mcp add eitri -- /path/to/repo/bin/eitri-mcp
75 ``` 86 ```
76 87
88 ## Setup: remote endpoint
89
90 Mint a personal access token in the console Settings page and point an MCP
91 client at the endpoint with that token as a bearer credential:
92
93 ```
94 claude mcp add --transport http eitri https://api.eitri.sh/mcp \
95 --header "Authorization: Bearer $EITRI_TOKEN"
96 ```
97
98 Then delegate access, which is what lets `vm_exec`, `vm_write_file`,
99 `vm_read_file` and `vm_create`'s boot wait reach a guest. It is two calls and
100 one `ssh-keygen`:
101
102 1. `delegate_begin` returns a public key, the principal (`ubuntu`) and the
103 exact command to run.
104 2. Run it. It signs eitri's public key with **your** CA, on your TTL:
105
106 ```
107 ssh-keygen -s ~/.ssh/my-ca -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub
108 ```
109
110 3. `delegate_complete` with the contents of `eitri-delegation-cert.pub`.
111
112 eitri holds no signing key at any point. Its half is an ephemeral keypair held
113 in memory, useless without a certificate it cannot produce for itself. The
114 certificate chains to a CA your tenant has already registered, so **VMs created
115 before you delegate accept it too**.
116
117 The delegation lives in memory only. A control-plane restart drops it and you
118 delegate again — the public key is stable, so that is one `ssh-keygen` and one
119 `delegate_complete`. `GET /api/v1/delegations` reports the expiry; `DELETE`
120 ends it now.
121
122 Delegation requires the tenant to have at least one registered SSH user CA
123 (`eitri ca upload`), because a certificate signed by a CA your guests do not
124 trust would be refused by them anyway.
125
126 The endpoint answers `POST` only; a `GET` is `405`. There are no sessions—each
127 call carries its own credential. `vm_create` with the default `wait: true`
128 blocks for as long as the guest takes to boot, and emits MCP progress
129 notifications while it waits (for clients that ask for progress).
130
77 ## Access model 131 ## Access model
78 132
79 eitri-mcp reaches VMs by name through eitri's SSH-CA jump gate. There is no 133 Both transports reach VMs by name over SSH, with certificates in both
80 injected key and no TOFU. It holds its own user CA (`ca_key_path`, 134 directions and no TOFU anywhere. In both, the user signing key stays with you:
81 load-or-create) and self-registers that CA's public key with its tenant on 135 they differ only in who does the signing and which path the connection takes.
82 first use; eitri never sees the private half. Per connection it signs a 136
83 short-lived user certificate locally (principal `ubuntu`) with that CA. It 137 **Local stdio** holds its own user CA (`ca_key_path`, load-or-create) and
84 fetches the eitri host CA's public key once via `GET /api/v1/ssh-ca` 138 self-registers that CA's public key with its tenant on first use; eitri never
85 and caches it. To reach a VM, it derives its tenant from the credential 139 sees the private half. Per connection it signs a short-lived user certificate
86 (`/me`, unless `tenant` pins one), dials the gate (the configured `gate` 140 locally (principal `ubuntu`) with that CA, fetches the eitri host CA's public
87 address), authenticates with the user certificate, and opens a tunnel to 141 key once via `GET /api/v1/ssh-ca`, dials the configured gate, and opens a tunnel
88 `<tenant>.<vm>:22`. VMs are addressed by their namespaced connect name, 142 to `<tenant>.<vm>:22`. Host identity is verified on both hops with
89 not IP, so recycled IPs and host-key churn are not a concern. Host identity 143 `ssh.CertChecker` against the host CA: the gate's certificate must carry its
90 is verified on both hops using `ssh.CertChecker` against the eitri host CA: 144 configured domain as principal, and each VM's must carry the VM's connect name.
91 the gate's host certificate must carry its configured domain as principal, 145
92 and each VM's host certificate must carry the VM's connect name. The guest 146 **The remote endpoint** is delegated a credential instead. eitri generates an
93 trusts the tenant's registered user CAs via `TrustedUserCAKeys` 147 ephemeral ed25519 keypair per tenant, in memory only, and hands you the public
94 (provisioned through vendor-data), so no per-VM `authorized_key` injection 148 half; you sign it with your own CA, on your own TTL and principals, and post the
95 is needed. The PAT authenticates API calls only; SSH traffic uses 149 certificate back. eitri then authenticates as that key-plus-certificate: it
96 the certificate, and the token itself is never surfaced in a tool result or 150 reaches the guest's sshd over the VM host's own sync tunnel and verifies the
97 error. 151 guest's host certificate under `<tenant>.<vm>:22` against the host CA. No
152 signing key ever exists on the server, the certificate expires, and a restart
153 drops it.
154
155 Because the certificate chains to a CA the tenant already registered, it is
156 accepted by every guest that trusts that CA — including ones created long
157 before the delegation. Remote exec without a live delegation is refused with the
158 three-step recipe rather than a generic authentication failure.
159
160 VMs are addressed by their namespaced connect name, not IP, so recycled IPs and
161 host-key churn are not a concern. The guest trusts the tenant's registered user
162 CAs via `TrustedUserCAKeys` (provisioned through vendor-data), so no per-VM
163 `authorized_key` injection is needed. The PAT authenticates API calls only; SSH
164 traffic uses the certificate, and the token itself is never surfaced in a tool
165 result or error.
98 166
99 ## Semantics 167 ## Semantics
100 168
@@ -109,11 +177,8 @@ error.
109 service. The tool descriptions say so, so the model treats publishing as a 177 service. The tool descriptions say so, so the model treats publishing as a
110 deliberate act. DNS, TLS certs and routing remain out of scope—the tools 178 deliberate act. DNS, TLS certs and routing remain out of scope—the tools
111 hand back a host address and a port. 179 hand back a host address and a port.
112 - The claude.ai connector (streamable HTTP transport + auth + ingress) is 180 - The remote endpoint authenticates with a bearer PAT. Browser connectors
113 phase 2 and not built—today's transport is stdio, for Claude Code only. 181 (claude.ai) need OAuth, which the endpoint does not speak.
114 Phase 2's VM access is expected to reuse the same short-lived-certificate
115 flow through the gate, just with gate ingress reachable from claude.ai
116 instead of only from the MCP's host.
117 182
118 > **IMPORTANT—"ready" is not "booted."** `vm_create`'s `lifecycle=ready` 183 > **IMPORTANT—"ready" is not "booted."** `vm_create`'s `lifecycle=ready`
119 > means cloud-hypervisor is up and the VM has an allocated IP; it does **not** 184 > means cloud-hypervisor is up and the VM has an allocated IP; it does **not**
@@ -128,6 +193,9 @@ error.
128 193
129 ## Testing this yourself 194 ## Testing this yourself
130 195
131 Unit tests (`internal/mcpserver/*_test.go`) cover the tools against fake API 196 Unit tests (`internal/mcpserver/*_test.go`, `internal/server/mcphttp/*_test.go`)
132 and SSH seams. End-to-end behaviour against a real VM is exercised by driving 197 cover the tools and the HTTP transport against fake API and SSH seams. The
133 the tools through a VM created on the fleet via `make deploy`. 198 deploy boot-gate (`make deploy`) drives a whole VM life through the remote
199 endpoint with a bearer PAT: register a CA, `delegate_begin`, sign, refuse a
200 certificate from an unregistered CA, `delegate_complete`, `vm_create`,
201 `vm_exec`, `vm_expose`, dial the published port, `vm_destroy`.
docs/openapi.json
Old New
@@ -167,6 +167,67 @@
167 ], 167 ],
168 "type": "object" 168 "type": "object"
169 }, 169 },
170 "Delegation": {
171 "properties": {
172 "ca_fingerprint": {
173 "type": "string"
174 },
175 "expires_at": {
176 "type": "string"
177 },
178 "key_id": {
179 "type": "string"
180 },
181 "principals": {
182 "items": {
183 "type": "string"
184 },
185 "type": "array"
186 },
187 "public_key": {
188 "type": "string"
189 },
190 "serial": {
191 "type": "string"
192 }
193 },
194 "required": [
195 "ca_fingerprint",
196 "expires_at",
197 "key_id",
198 "principals",
199 "public_key",
200 "serial"
201 ],
202 "type": "object"
203 },
204 "DelegationChallenge": {
205 "properties": {
206 "instructions": {
207 "type": "string"
208 },
209 "principal": {
210 "type": "string"
211 },
212 "public_key": {
213 "type": "string"
214 }
215 },
216 "required": [
217 "instructions",
218 "principal",
219 "public_key"
220 ],
221 "type": "object"
222 },
223 "DelegationRequest": {
224 "properties": {
225 "certificate": {
226 "type": "string"
227 }
228 },
229 "type": "object"
230 },
170 "EnrollRequest": { 231 "EnrollRequest": {
171 "properties": { 232 "properties": {
172 "arch": { 233 "arch": {
@@ -762,6 +823,131 @@
762 "summary": "Newest audit log rows." 823 "summary": "Newest audit log rows."
763 } 824 }
764 }, 825 },
826 "/api/v1/delegations": {
827 "delete": {
828 "responses": {
829 "204": {
830 "description": "success"
831 },
832 "default": {
833 "content": {
834 "text/plain": {
835 "schema": {
836 "type": "string"
837 }
838 }
839 },
840 "description": "error (plain text)"
841 }
842 },
843 "security": [
844 {
845 "patToken": []
846 }
847 ],
848 "summary": "End the caller tenant's delegation now. eitri drops the certificate and can no longer reach that tenant's VMs."
849 },
850 "get": {
851 "responses": {
852 "200": {
853 "content": {
854 "application/json": {
855 "schema": {
856 "$ref": "#/components/schemas/Delegation"
857 }
858 }
859 },
860 "description": "success"
861 },
862 "default": {
863 "content": {
864 "text/plain": {
865 "schema": {
866 "type": "string"
867 }
868 }
869 },
870 "description": "error (plain text)"
871 }
872 },
873 "security": [
874 {
875 "patToken": []
876 }
877 ],
878 "summary": "Describe the caller tenant's live delegation, including when it expires. 404 when there is none."
879 },
880 "post": {
881 "responses": {
882 "200": {
883 "content": {
884 "application/json": {
885 "schema": {
886 "$ref": "#/components/schemas/DelegationChallenge"
887 }
888 }
889 },
890 "description": "success"
891 },
892 "default": {
893 "content": {
894 "text/plain": {
895 "schema": {
896 "type": "string"
897 }
898 }
899 },
900 "description": "error (plain text)"
901 }
902 },
903 "security": [
904 {
905 "patToken": []
906 }
907 ],
908 "summary": "Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process."
909 },
910 "put": {
911 "requestBody": {
912 "content": {
913 "application/json": {
914 "schema": {
915 "$ref": "#/components/schemas/DelegationRequest"
916 }
917 }
918 },
919 "required": true
920 },
921 "responses": {
922 "200": {
923 "content": {
924 "application/json": {
925 "schema": {
926 "$ref": "#/components/schemas/Delegation"
927 }
928 }
929 },
930 "description": "success"
931 },
932 "default": {
933 "content": {
934 "text/plain": {
935 "schema": {
936 "type": "string"
937 }
938 }
939 },
940 "description": "error (plain text)"
941 }
942 },
943 "security": [
944 {
945 "patToken": []
946 }
947 ],
948 "summary": "Complete a delegation with the certificate your CA signed. The certificate must be a user certificate over the key this delegation issued, signed by a CA registered to your tenant, naming the guest login user as a principal."
949 }
950 },
765 "/api/v1/enroll": { 951 "/api/v1/enroll": {
766 "post": { 952 "post": {
767 "requestBody": { 953 "requestBody": {
docs/releases.md
Old New
@@ -5,6 +5,40 @@ Tarballs and checksums for every release live at
5 newest. The [quickstart](quickstart.md) takes a release from download to a 5 newest. The [quickstart](quickstart.md) takes a release from download to a
6 running VM. 6 running VM.
7 7
8 ## v0.0.4
9
10 A PAT is enough. Point an LLM client anywhere on the internet at
11 `https://api.eitri.sh/mcp` with a personal access token and it gets the whole
12 eitri toolset: create a VM, run commands in it, publish a port, hand back the
13 address. No local install, no config file, no CA of your own.
14
15 **The MCP endpoint.** eitri-server serves MCP at `/mcp` over streamable HTTP,
16 stateless, authenticated with the same bearer token the API takes. One binary,
17 one deploy: the tools call the API in-process, so tenant filtering and
18 authorization are the API's own, and a caller sees exactly the fleet its token
19 can see. The stdio binary is unchanged and still there for local use—both
20 transports serve the same tools, from the same code.
21
22 **Delegated access, not a held key.** A token holder has no key to sign with,
23 and eitri has none to lend: it holds no signing key for anyone. So the direction
24 reverses. eitri generates an ephemeral keypair, in memory only, and hands you
25 the public half; you sign it with your own CA on your own TTL (`ssh-keygen -s`)
26 and hand the certificate back. eitri authenticates as that key plus that
27 certificate until it expires, and holds nothing else. A restart drops the
28 delegation and you delegate again. Because the certificate chains to a CA you
29 already registered, VMs created before you delegated accept it too.
30
31 **Your guests' host keys stay on your hosts.** A guest's SSH host key is
32 generated by the machine that runs it and never leaves it. The host reports the
33 public half, the control plane signs a certificate for the name on the VM's
34 row, and the guest boots with a key nothing above its host has ever held.
35 Opening the database clears every host key escrowed by an earlier release.
36
37 **Progress on the long call.** `vm_create` waits for the guest to boot and
38 cloud-init to settle, which can take minutes. It now reports what it is waiting
39 for as it waits, so a model watching a ten-minute call sees a VM coming up
40 rather than silence.
41
8 ## v0.0.3 42 ## v0.0.3
9 43
10 macOS hosts. An Apple-silicon Mac joins the fleet as a host and runs Linux 44 macOS hosts. An Apple-silicon Mac joins the fleet as a host and runs Linux
docs/shape.html
Old New
@@ -392,6 +392,7 @@
392 "internal/names", 392 "internal/names",
393 "internal/random", 393 "internal/random",
394 "internal/server/api/types", 394 "internal/server/api/types",
395 "internal/server/delegation",
395 "internal/server/hosttoken", 396 "internal/server/hosttoken",
396 "internal/server/hub", 397 "internal/server/hub",
397 "internal/server/registry", 398 "internal/server/registry",
@@ -433,14 +434,17 @@
433 "internal/joinblob", 434 "internal/joinblob",
434 "internal/server/api", 435 "internal/server/api",
435 "internal/server/config", 436 "internal/server/config",
437 "internal/server/delegation",
436 "internal/server/health", 438 "internal/server/health",
437 "internal/server/hub", 439 "internal/server/hub",
440 "internal/server/mcphttp",
438 "internal/server/registry", 441 "internal/server/registry",
439 "internal/server/release", 442 "internal/server/release",
440 "internal/server/sshca", 443 "internal/server/sshca",
441 "internal/server/sshgate", 444 "internal/server/sshgate",
442 "internal/server/store", 445 "internal/server/store",
443 "internal/server/syncsvc", 446 "internal/server/syncsvc",
447 "internal/server/vmssh",
444 "internal/server/web", 448 "internal/server/web",
445 "internal/transport" 449 "internal/transport"
446 ] 450 ]
@@ -452,6 +456,12 @@
452 "imports": [] 456 "imports": []
453 }, 457 },
454 { 458 {
459 "importPath": "internal/server/delegation",
460 "plane": "control",
461 "synopsis": "Package delegation holds the credentials a tenant has lent eitri.",
462 "imports": []
463 },
464 {
455 "importPath": "internal/server/health", 465 "importPath": "internal/server/health",
456 "plane": "control", 466 "plane": "control",
457 "synopsis": "Package health serves the eitri-server liveness and readiness probes.", 467 "synopsis": "Package health serves the eitri-server liveness and readiness probes.",
@@ -470,6 +480,17 @@
470 "imports": [] 480 "imports": []
471 }, 481 },
472 { 482 {
483 "importPath": "internal/server/mcphttp",
484 "plane": "control",
485 "synopsis": "Package mcphttp serves the eitri MCP toolset over HTTP at /mcp.",
486 "imports": [
487 "internal/mcpserver",
488 "internal/server/api",
489 "internal/server/api/client",
490 "internal/server/vmssh"
491 ]
492 },
493 {
473 "importPath": "internal/server/registry", 494 "importPath": "internal/server/registry",
474 "plane": "control", 495 "plane": "control",
475 "synopsis": "Package registry holds volatile actual state in memory.", 496 "synopsis": "Package registry holds volatile actual state in memory.",
@@ -526,6 +547,12 @@
526 ] 547 ]
527 }, 548 },
528 { 549 {
550 "importPath": "internal/server/vmssh",
551 "plane": "control",
552 "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.",
553 "imports": []
554 },
555 {
529 "importPath": "internal/server/web", 556 "importPath": "internal/server/web",
530 "plane": "control", 557 "plane": "control",
531 "synopsis": "Package web embeds the built SvelteKit single-page app and serves it with SPA-style fallback (unknown paths resolve to index.html for client routing).", 558 "synopsis": "Package web embeds the built SvelteKit single-page app and serves it with SPA-style fallback (unknown paths resolve to index.html for client routing).",
docs/shape.json
Old New
@@ -341,6 +341,7 @@
341 "internal/names", 341 "internal/names",
342 "internal/random", 342 "internal/random",
343 "internal/server/api/types", 343 "internal/server/api/types",
344 "internal/server/delegation",
344 "internal/server/hosttoken", 345 "internal/server/hosttoken",
345 "internal/server/hub", 346 "internal/server/hub",
346 "internal/server/registry", 347 "internal/server/registry",
@@ -382,14 +383,17 @@
382 "internal/joinblob", 383 "internal/joinblob",
383 "internal/server/api", 384 "internal/server/api",
384 "internal/server/config", 385 "internal/server/config",
386 "internal/server/delegation",
385 "internal/server/health", 387 "internal/server/health",
386 "internal/server/hub", 388 "internal/server/hub",
389 "internal/server/mcphttp",
387 "internal/server/registry", 390 "internal/server/registry",
388 "internal/server/release", 391 "internal/server/release",
389 "internal/server/sshca", 392 "internal/server/sshca",
390 "internal/server/sshgate", 393 "internal/server/sshgate",
391 "internal/server/store", 394 "internal/server/store",
392 "internal/server/syncsvc", 395 "internal/server/syncsvc",
396 "internal/server/vmssh",
393 "internal/server/web", 397 "internal/server/web",
394 "internal/transport" 398 "internal/transport"
395 ] 399 ]
@@ -401,6 +405,12 @@
401 "imports": [] 405 "imports": []
402 }, 406 },
403 { 407 {
408 "importPath": "internal/server/delegation",
409 "plane": "control",
410 "synopsis": "Package delegation holds the credentials a tenant has lent eitri.",
411 "imports": []
412 },
413 {
404 "importPath": "internal/server/health", 414 "importPath": "internal/server/health",
405 "plane": "control", 415 "plane": "control",
406 "synopsis": "Package health serves the eitri-server liveness and readiness probes.", 416 "synopsis": "Package health serves the eitri-server liveness and readiness probes.",
@@ -419,6 +429,17 @@
419 "imports": [] 429 "imports": []
420 }, 430 },
421 { 431 {
432 "importPath": "internal/server/mcphttp",
433 "plane": "control",
434 "synopsis": "Package mcphttp serves the eitri MCP toolset over HTTP at /mcp.",
435 "imports": [
436 "internal/mcpserver",
437 "internal/server/api",
438 "internal/server/api/client",
439 "internal/server/vmssh"
440 ]
441 },
442 {
422 "importPath": "internal/server/registry", 443 "importPath": "internal/server/registry",
423 "plane": "control", 444 "plane": "control",
424 "synopsis": "Package registry holds volatile actual state in memory.", 445 "synopsis": "Package registry holds volatile actual state in memory.",
@@ -475,6 +496,12 @@
475 ] 496 ]
476 }, 497 },
477 { 498 {
499 "importPath": "internal/server/vmssh",
500 "plane": "control",
501 "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.",
502 "imports": []
503 },
504 {
478 "importPath": "internal/server/web", 505 "importPath": "internal/server/web",
479 "plane": "control", 506 "plane": "control",
480 "synopsis": "Package web embeds the built SvelteKit single-page app and serves it with SPA-style fallback (unknown paths resolve to index.html for client routing).", 507 "synopsis": "Package web embeds the built SvelteKit single-page app and serves it with SPA-style fallback (unknown paths resolve to index.html for client routing).",
docs/ssh-access.md
Old New
@@ -38,6 +38,52 @@ public key. Upload the CA **before creating VMs**—a VM trusts the tenant user
38 CAs present at its creation. The gate authorizes each connection against the 38 CAs present at its creation. The gate authorizes each connection against the
39 tenant the signing CA was uploaded to. 39 tenant the signing CA was uploaded to.
40 40
41 ## Delegating access to eitri
42
43 A caller holding only a token has no CA and no private key, so it cannot sign
44 anything — and eitri holds no signing key for anyone, so it cannot sign on their
45 behalf either. Instead, you lend eitri a credential.
46
47 eitri generates an ephemeral keypair for your tenant, in memory only, and hands
48 you the public half:
49
50 ```sh
51 curl -X POST -H "Authorization: Bearer $EITRI_TOKEN" \
52 https://eitri.example.com/api/v1/delegations
53 ```
54
55 Sign it with your own CA, on your own terms:
56
57 ```sh
58 printf '%s\n' "<public_key from the response>" > eitri-delegation.pub
59 ssh-keygen -s ~/.ssh/eitri-user-ca -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub
60 ```
61
62 `-n ubuntu` is not optional. A guest matches the certificate's principals
63 against the login user, so a certificate naming anything else is refused by
64 every guest. Post the result back:
65
66 ```sh
67 curl -X PUT -H "Authorization: Bearer $EITRI_TOKEN" \
68 -H 'Content-Type: application/json' \
69 -d "{\"certificate\": \"$(cat eitri-delegation-cert.pub)\"}" \
70 https://eitri.example.com/api/v1/delegations
71 ```
72
73 eitri now authenticates to your guests as that key plus that certificate, until
74 the certificate expires. It holds nothing else. `GET /api/v1/delegations`
75 reports the expiry; `DELETE` ends it immediately. So does restarting the
76 control plane — a delegation is in memory and nowhere else, which is the point.
77
78 Because the certificate chains to a CA you have already registered, guests
79 created **before** you delegated accept it. That is the difference from
80 registering a new CA: there is no ordering constraint, because nothing about the
81 guest's trust changes.
82
83 This is what makes [the remote MCP endpoint](mcp.md) work with nothing but a
84 PAT, where the same two steps are the `delegate_begin` and `delegate_complete`
85 tools.
86
41 ## One-liner 87 ## One-liner
42 88
43 ```sh 89 ```sh
internal/mcpserver/cli.go
Old New
@@ -1,9 +1,9 @@
1 // cli.go is the eitri-mcp command line: it loads the config, loads (or creates) 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 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 3 // CA self-registers with the caller's tenant on first gate use (see
4 // Runner.ensure), so a fresh install needs only a PAT. It lives here rather than 4 // gateDialer.ensure), so a fresh install needs only a PAT. It lives here rather
5 // in cmd/eitri-mcp so it is testable and coverage-gated (arch R14: main packages 5 // than in cmd/eitri-mcp so it is testable and coverage-gated (arch R14: main
6 // are wiring only). 6 // packages are wiring only).
7 7
8 package mcpserver 8 package mcpserver
9 9
@@ -60,34 +60,26 @@ func run(cfgPath string) error {
60 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token, UserCALabel: caLabel()} 60 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token, UserCALabel: caLabel()}
61 tools := &Tools{ 61 tools := &Tools{
62 API: API{Client: api}, 62 API: API{Client: api},
63 Runner: NewRunner(RunnerConfig{ 63 Runner: NewRunner(newGateDialer(gateDialerConfig{
64 Gate: cfg.Gate, 64 Gate: cfg.Gate,
65 VMUser: cfg.VMUser, 65 VMUser: cfg.VMUser,
66 API: api, 66 API: api,
67 UserCA: userCA, 67 UserCA: userCA,
68 Tenant: cfg.Tenant, 68 Tenant: cfg.Tenant,
69 }), 69 })),
70 Gate: cfg.Gate, 70 Gate: cfg.Gate,
71 VMUser: cfg.VMUser, 71 VMUser: cfg.VMUser,
72 } 72 }
73 73
74 server := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"}, nil) 74 // No Registrar: this install signs with its own CA, so it has nothing to
75 register(server, "vm_create", "Create an eitri VM (persistent). Waits for ready+cloud-init by default.", tools.VMCreate) 75 // ask eitri to hold.
76 register(server, "vm_list", "List all VMs on the eitri fleet.", tools.VMList) 76 server := NewServer(tools, Options{})
77 register(server, "vm_info", "Show one VM's state and how to reach it.", tools.VMInfo)
78 register(server, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", tools.VMExec)
79 register(server, "vm_write_file", "Write content to a file in a VM (parents created).", tools.VMWriteFile)
80 register(server, "vm_read_file", "Read a file from a VM (capped at 1 MiB).", tools.VMReadFile)
81 register(server, "vm_expose", "Publish a VM's guest TCP port on its host and return the address to dial. Omit host_port to allocate one from 30000-32767. WARNING: a published port has NO AUTHENTICATION in front of it — whoever can reach the host on that port reaches the service. Publish only what is meant to be reachable.", tools.VMExpose)
82 register(server, "vm_exposures", "List a VM's published ports, with the address to dial and each listener's state. These ports are unauthenticated.", tools.VMExposures)
83 register(server, "vm_unexpose", "Stop publishing a VM's guest port; the host closes the listener.", tools.VMUnexpose)
84 register(server, "vm_destroy", "Destroy a VM by id or EXACT name. Explicit-only; never called automatically.", tools.VMDestroy)
85 77
86 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 78 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
87 defer stop() 79 defer stop()
88 80
89 // The user CA registers with the caller's tenant lazily, on the first gate 81 // The user CA registers with the caller's tenant lazily, on the first gate
90 // connection (Runner.ensure), so a control-plane blip at startup can't stop the 82 // connection (gateDialer.ensure), so a control-plane blip at startup can't stop the
91 // server from coming up and serving the non-SSH tools. 83 // server from coming up and serving the non-SSH tools.
92 return server.Run(ctx, &mcp.StdioTransport{}) 84 return server.Run(ctx, &mcp.StdioTransport{})
93 } 85 }
@@ -146,19 +138,3 @@ func loadOrCreateCA(path string) (ssh.Signer, error) {
146 } 138 }
147 return signer, nil 139 return signer, nil
148 } 140 }
149
150 // register adapts a Tools method to the SDK. This is the ONLY place that
151 // touches SDK generics; if the SDK's handler signature changes, change it here.
152 //
153 // Note on the hand-off contract: the SDK drops the Out value when the handler
154 // returns a non-nil error — StructuredContent is left unset and only err.Error()
155 // reaches the model (as IsError text content). VMCreate's degraded-path errors
156 // are self-sufficient (they name the VM id+name), so the model can still find
157 // and destroy the VM from the error text.
158 func register[In, Out any](s *mcp.Server, name, desc string, fn func(context.Context, In) (Out, error)) {
159 mcp.AddTool(s, &mcp.Tool{Name: name, Description: desc},
160 func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
161 out, err := fn(ctx, in)
162 return nil, out, err
163 })
164 }
internal/mcpserver/gatedial.go
Old New
@@ -0,0 +1,139 @@
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
@@ -0,0 +1,159 @@
1 package mcpserver
2
3 import (
4 "context"
5
6 "github.com/modelcontextprotocol/go-sdk/mcp"
7 )
8
9 // Options selects the tools a transport exposes. The VM tools and ca_upload are
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 {
13 Delegator Delegator // non-nil ⇒ expose the delegate tools
14 SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection
15 }
16
17 // Delegator is the two-step exchange by which a caller lends eitri access:
18 // eitri offers a public key, the caller's own CA signs it, eitri gets the
19 // certificate back.
20 type Delegator interface {
21 Begin(ctx context.Context) (BeginResult, error)
22 Complete(ctx context.Context, certificate string) (DelegationResult, error)
23 }
24
25 // BeginResult is what the model shows its human. Note is worded for a model
26 // that would otherwise try to complete the exchange by itself.
27 type BeginResult struct {
28 PublicKey string `json:"public_key"`
29 Principal string `json:"principal"`
30 Instructions string `json:"instructions"`
31 Note string `json:"note"`
32 }
33
34 // DelegationResult reports what eitri may now do, and until when.
35 type DelegationResult struct {
36 ExpiresAt string `json:"expires_at"`
37 CAFingerprint string `json:"ca_fingerprint"`
38 Principals []string `json:"principals"`
39 Note string `json:"note"`
40 }
41
42 // DelegateBeginIn takes no arguments: asking is the whole operation.
43 type DelegateBeginIn struct{}
44
45 // DelegateCompleteIn carries the signed certificate. A certificate is public
46 // material — see the tool description, which says so, because a model may
47 // otherwise refuse to paste something that looks like key material.
48 type DelegateCompleteIn struct {
49 Certificate string `json:"certificate" jsonschema:"the contents of the *-cert.pub file your CA produced"`
50 }
51
52 // NewServer builds the MCP server for one identity. Both transports go through
53 // here, so the tool list cannot drift between them.
54 func NewServer(t *Tools, opts Options) *mcp.Server {
55 s := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"},
56 &mcp.ServerOptions{SchemaCache: opts.SchemaCache})
57 register(s, "vm_create", "Create an eitri VM (persistent). Waits for ready+cloud-init by default.", t.VMCreate)
58 register(s, "vm_list", "List all VMs on the eitri fleet.", t.VMList)
59 register(s, "vm_info", "Show one VM's state and how to reach it.", t.VMInfo)
60 register(s, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", t.VMExec)
61 register(s, "vm_write_file", "Write content to a file in a VM (parents created).", t.VMWriteFile)
62 register(s, "vm_read_file", "Read a file from a VM (capped at 1 MiB).", t.VMReadFile)
63 register(s, "vm_expose", "Publish a VM's guest TCP port on its host and return the address to dial. Omit host_port to allocate one from 30000-32767. WARNING: a published port has NO AUTHENTICATION in front of it — whoever can reach the host on that port reaches the service. Publish only what is meant to be reachable.", t.VMExpose)
64 register(s, "vm_exposures", "List a VM's published ports, with the address to dial and each listener's state. These ports are unauthenticated.", t.VMExposures)
65 register(s, "vm_unexpose", "Stop publishing a VM's guest port; the host closes the listener.", t.VMUnexpose)
66 register(s, "vm_destroy", "Destroy a VM by id or EXACT name. Explicit-only; never called automatically.", t.VMDestroy)
67 register(s, "ca_upload",
68 "Register your SSH user CA's PUBLIC key with your tenant, so your guests trust certificates it signs. "+
69 "Only the public half is sent — eitri never holds a signing key. "+
70 "IMPORTANT: a guest bakes its trusted CA set when it is created, so VMs that already exist will NOT "+
71 "trust a CA uploaded now; create new VMs after uploading. Do this before vm_create, and before delegating.",
72 t.CAUpload)
73 register(s, "tenant_info",
74 "Show how this tenant is set up: which SSH CAs are registered (fingerprint and label), whether eitri "+
75 "currently holds a delegation and when it expires, and the gate address. Read-only. Call it first "+
76 "when an SSH operation fails, instead of guessing which step was skipped.",
77 t.TenantInfo)
78 if opts.Delegator != nil {
79 dg := opts.Delegator
80 register(s, "delegate_begin",
81 "Ask for the public key your CA is to sign, so eitri can reach your VMs. eitri holds no signing key and "+
82 "cannot sign this itself — that is the point. Show the human the public key and the command, and wait "+
83 "for them to hand back the certificate; do not try to produce it yourself. The key stays the same "+
84 "until the control plane restarts — a delegation is held in memory, so after a restart call this "+
85 "tool again for the new key rather than reusing an old certificate.",
86 func(ctx context.Context, _ DelegateBeginIn) (BeginResult, error) { return dg.Begin(ctx) })
87 register(s, "delegate_complete",
88 "Hand back the certificate your CA signed. Certificates are public material, so passing one as an "+
89 "argument is safe — it is not a key and grants nothing without the key eitri holds in memory. "+
90 "Afterwards eitri can reach your VMs until the certificate expires. Because it chains to a CA your "+
91 "tenant already registered, VMs created before this call accept it too.",
92 func(ctx context.Context, in DelegateCompleteIn) (DelegationResult, error) {
93 return dg.Complete(ctx, in.Certificate)
94 })
95 }
96 return s
97 }
98
99 // progressKey carries the per-request progress reporter.
100 type progressKey struct{}
101
102 // withProgress returns a context whose long waits report to report. A transport
103 // that cannot deliver notifications simply does not install one.
104 func withProgress(ctx context.Context, report func(message string)) context.Context {
105 return context.WithValue(ctx, progressKey{}, report)
106 }
107
108 // reportProgress tells the caller a long-running tool is still working. It is a
109 // no-op when nothing is listening, which is every case but a remote tool call
110 // whose client asked for progress.
111 //
112 // It is not only courtesy: vm_create blocks for up to ten minutes and emits
113 // nothing until it finishes, and a proxy in front of the control plane will cut
114 // an origin connection that stays silent for a hundred seconds.
115 func reportProgress(ctx context.Context, message string) {
116 if report, ok := ctx.Value(progressKey{}).(func(string)); ok && report != nil {
117 report(message)
118 }
119 }
120
121 // register adapts a Tools method to the SDK. This is the ONLY place that
122 // touches SDK generics; if the SDK's handler signature changes, change it here.
123 //
124 // Note on the hand-off contract: the SDK drops the Out value when the handler
125 // returns a non-nil error — StructuredContent is left unset and only err.Error()
126 // reaches the model (as IsError text content). VMCreate's degraded-path errors
127 // are self-sufficient (they name the VM id+name), so the model can still find
128 // and destroy the VM from the error text.
129 func register[In, Out any](s *mcp.Server, name, desc string, fn func(context.Context, In) (Out, error)) {
130 mcp.AddTool(s, &mcp.Tool{Name: name, Description: desc},
131 func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
132 out, err := fn(withProgress(ctx, progressReporter(ctx, req)), in)
133 return nil, out, err
134 })
135 }
136
137 // progressReporter builds the notifier for one tool call, or nil when the
138 // client did not ask for progress. The MCP contract is that progress is
139 // reported only against a token the client supplied, so a client that sent none
140 // gets none.
141 func progressReporter(ctx context.Context, req *mcp.CallToolRequest) func(string) {
142 if req == nil || req.Session == nil || req.Params == nil {
143 return nil
144 }
145 token := req.Params.GetProgressToken()
146 if token == nil {
147 return nil
148 }
149 session := req.Session
150 step := 0.0
151 return func(message string) {
152 step++
153 // Best-effort: a client that has gone away must not fail the operation
154 // it asked for.
155 _ = session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{
156 ProgressToken: token, Message: message, Progress: step,
157 })
158 }
159 }
internal/mcpserver/server_test.go
Old New
@@ -0,0 +1,197 @@
1 package mcpserver
2
3 import (
4 "context"
5 "errors"
6 "testing"
7
8 "github.com/modelcontextprotocol/go-sdk/mcp"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11 )
12
13 // commonTools is the toolset both transports expose, in the order NewServer
14 // registers it. A change here is a change to the published contract.
15 var commonTools = []string{
16 "vm_create", "vm_list", "vm_info", "vm_exec", "vm_write_file",
17 "vm_read_file", "vm_expose", "vm_exposures", "vm_unexpose", "vm_destroy",
18 "ca_upload", "tenant_info",
19 }
20
21 // toolNames connects an in-memory client to s and lists what it advertises.
22 func toolNames(t *testing.T, s *mcp.Server) []string {
23 t.Helper()
24 ctx := t.Context()
25 serverTr, clientTr := mcp.NewInMemoryTransports()
26 ss, err := s.Connect(ctx, serverTr, nil)
27 require.NoError(t, err)
28 defer ss.Close()
29
30 cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, clientTr, nil)
31 require.NoError(t, err)
32 defer cs.Close()
33
34 res, err := cs.ListTools(ctx, nil)
35 require.NoError(t, err)
36 names := make([]string, 0, len(res.Tools))
37 for _, tool := range res.Tools {
38 names = append(names, tool.Name)
39 }
40 return names
41 }
42
43 // fakeDelegator stands in for the control plane's delegation endpoints.
44 type fakeDelegator struct {
45 begin BeginResult
46 complete DelegationResult
47 err error
48 beganN int
49 gotCert string
50 }
51
52 func (f *fakeDelegator) Begin(context.Context) (BeginResult, error) {
53 f.beganN++
54 return f.begin, f.err
55 }
56
57 func (f *fakeDelegator) Complete(_ context.Context, certificate string) (DelegationResult, error) {
58 f.gotCert = certificate
59 return f.complete, f.err
60 }
61
62 // connect wires an in-memory client to s.
63 func connect(t *testing.T, s *mcp.Server) *mcp.ClientSession {
64 t.Helper()
65 ctx := t.Context()
66 serverTr, clientTr := mcp.NewInMemoryTransports()
67 ss, err := s.Connect(ctx, serverTr, nil)
68 require.NoError(t, err)
69 t.Cleanup(func() { ss.Close() })
70 cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, clientTr, nil)
71 require.NoError(t, err)
72 t.Cleanup(func() { cs.Close() })
73 return cs
74 }
75
76 // describe returns one tool's advertised description.
77 func describe(t *testing.T, cs *mcp.ClientSession, name string) string {
78 t.Helper()
79 res, err := cs.ListTools(t.Context(), nil)
80 require.NoError(t, err)
81 for _, tool := range res.Tools {
82 if tool.Name == name {
83 return tool.Description
84 }
85 }
86 return ""
87 }
88
89 // TestNewServerExposesTheCommonTools: a local stdio install gets the VM tools
90 // and ca_upload, and nothing else — it holds its own CA and signs locally, so
91 // it has nothing to delegate.
92 func TestNewServerExposesTheCommonTools(t *testing.T) {
93 names := toolNames(t, NewServer(&Tools{}, Options{}))
94 assert.ElementsMatch(t, commonTools, names)
95 }
96
97 // TestNewServerAddsTheDelegateToolsForARemoteTransport: the two extra tools
98 // appear only when a transport can carry the exchange.
99 func TestNewServerAddsTheDelegateToolsForARemoteTransport(t *testing.T) {
100 names := toolNames(t, NewServer(&Tools{}, Options{Delegator: &fakeDelegator{}}))
101 assert.ElementsMatch(t, append(append([]string{}, commonTools...), "delegate_begin", "delegate_complete"), names)
102 }
103
104 // TestDelegateBeginDescriptionStopsAModelSigningItItself: the description is
105 // the only thing standing between a model and an hour of trying to produce a
106 // certificate eitri deliberately cannot produce.
107 func TestDelegateBeginDescriptionStopsAModelSigningItItself(t *testing.T) {
108 desc := describe(t, connect(t, NewServer(&Tools{}, Options{Delegator: &fakeDelegator{}})), "delegate_begin")
109 require.NotEmpty(t, desc)
110 assert.Contains(t, desc, "cannot sign this itself")
111 assert.Contains(t, desc, "Show the human")
112 assert.Contains(t, desc, "wait")
113 }
114
115 // TestDelegateCompleteDescriptionSaysACertificateIsPublic: a model may
116 // otherwise refuse to paste something that looks like key material.
117 func TestDelegateCompleteDescriptionSaysACertificateIsPublic(t *testing.T) {
118 desc := describe(t, connect(t, NewServer(&Tools{}, Options{Delegator: &fakeDelegator{}})), "delegate_complete")
119 require.NotEmpty(t, desc)
120 assert.Contains(t, desc, "public material")
121 assert.Contains(t, desc, "until the certificate expires")
122 assert.Contains(t, desc, "VMs created before this call accept it too")
123 }
124
125 // TestDelegateToolsAreWiredToTheDelegator checks both directions: the tools
126 // call the thing that does the work, and the certificate passes through
127 // unchanged.
128 func TestDelegateToolsAreWiredToTheDelegator(t *testing.T) {
129 dg := &fakeDelegator{
130 begin: BeginResult{PublicKey: "ssh-ed25519 AAAA eitri", Principal: "ubuntu"},
131 complete: DelegationResult{ExpiresAt: "2026-08-08T00:00:00Z", CAFingerprint: "SHA256:abc"},
132 }
133 cs := connect(t, NewServer(&Tools{}, Options{Delegator: dg}))
134
135 res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{Name: "delegate_begin"})
136 require.NoError(t, err)
137 require.False(t, res.IsError)
138 assert.Equal(t, 1, dg.beganN)
139 assert.Equal(t, "ssh-ed25519 AAAA eitri", res.StructuredContent.(map[string]any)["public_key"])
140
141 const cert = "ssh-ed25519-cert-v01@openssh.com AAAAdelegation alex@laptop"
142 res, err = cs.CallTool(t.Context(), &mcp.CallToolParams{
143 Name: "delegate_complete", Arguments: map[string]any{"certificate": cert}})
144 require.NoError(t, err)
145 require.False(t, res.IsError)
146 assert.Equal(t, cert, dg.gotCert, "the certificate must reach the control plane byte for byte")
147 assert.Equal(t, "SHA256:abc", res.StructuredContent.(map[string]any)["ca_fingerprint"])
148 }
149
150 // TestDelegateFailuresSurfaceAsToolErrors: a refusal reaches the model as an
151 // MCP tool error it can read, not a transport failure it cannot.
152 func TestDelegateFailuresSurfaceAsToolErrors(t *testing.T) {
153 cs := connect(t, NewServer(&Tools{}, Options{Delegator: &fakeDelegator{err: errors.New("principals are [alex]")}}))
154
155 res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{Name: "delegate_begin"})
156 require.NoError(t, err)
157 assert.True(t, res.IsError)
158
159 res, err = cs.CallTool(t.Context(), &mcp.CallToolParams{
160 Name: "delegate_complete", Arguments: map[string]any{"certificate": "x"}})
161 require.NoError(t, err)
162 assert.True(t, res.IsError)
163 }
164
165 // TestReportProgressIsANoOpWithoutAListener: the tool layer calls it
166 // unconditionally, so the stdio path must not depend on one being installed.
167 func TestReportProgressIsANoOpWithoutAListener(t *testing.T) {
168 assert.NotPanics(t, func() { reportProgress(t.Context(), "still working") })
169 }
170
171 // TestReportProgressReachesTheListener pins the seam the long waits use.
172 func TestReportProgressReachesTheListener(t *testing.T) {
173 var got []string
174 ctx := withProgress(t.Context(), func(m string) { got = append(got, m) })
175 reportProgress(ctx, "creating vm web-1: creating")
176 reportProgress(ctx, "creating vm web-1: ready")
177 assert.Equal(t, []string{"creating vm web-1: creating", "creating vm web-1: ready"}, got)
178 }
179
180 // TestProgressReporterIsAbsentWithoutAToken: MCP reports progress only against
181 // a token the client supplied, so a client that sent none gets nothing.
182 func TestProgressReporterIsAbsentWithoutAToken(t *testing.T) {
183 assert.Nil(t, progressReporter(t.Context(), nil))
184 assert.Nil(t, progressReporter(t.Context(), &mcp.CallToolRequest{}))
185 }
186
187 // TestCAUploadDescriptionNamesTheFrozenCASet: a guest bakes its trusted CA set
188 // at create, so a model that uploads a CA after creating a VM has done nothing
189 // for that VM. The description is where it learns that, before it acts.
190 func TestCAUploadDescriptionNamesTheFrozenCASet(t *testing.T) {
191 desc := describe(t, connect(t, NewServer(&Tools{}, Options{})), "ca_upload")
192 require.NotEmpty(t, desc)
193 assert.Contains(t, desc, "PUBLIC")
194 assert.Contains(t, desc, "will NOT")
195 assert.Contains(t, desc, "create new VMs after uploading")
196 assert.Contains(t, desc, "before vm_create")
197 }
internal/mcpserver/sshrun.go
Old New
@@ -9,12 +9,8 @@ import (
9 "io/fs" 9 "io/fs"
10 "os" 10 "os"
11 "path" 11 "path"
12 "strings"
13 "sync"
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/pkg/sftp" 14 "github.com/pkg/sftp"
19 "golang.org/x/crypto/ssh" 15 "golang.org/x/crypto/ssh"
20 ) 16 )
@@ -22,28 +18,17 @@ import (
22 // outputCap bounds captured exec/file bytes returned to the model. 18 // outputCap bounds captured exec/file bytes returned to the model.
23 const outputCap = 1 << 20 // 1 MiB 19 const outputCap = 1 << 20 // 1 MiB
24 20
25 // gateAPI is what the Runner needs from the control-plane client to prepare gate 21 // VMDialer reaches a VM by name and names it the way the caller's world spells
26 // credentials before the first connection: derive the caller's tenant (Me), and 22 // it. It is the one thing that differs between transports: the local stdio
27 // verify/register this client's user CA (ListUserCAs + the embedded 23 // install goes through the eitri SSH-CA jump gate with a certificate it
28 // CertAuthority's UploadUserCA). One *client.Client value satisfies it. 24 // self-signs, while the control plane tunnels over the VM host's own sync
29 type gateAPI interface { 25 // connection with the credential that tenant has delegated to it. Everything
30 gateclient.CertAuthority 26 // above this line — exec, SFTP, output capping — is shared.
31 Me() (client.Me, error) 27 type VMDialer interface {
32 ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error) 28 Dial(ctx context.Context, vmName string) (*ssh.Client, error)
33 } 29 // ConnectName maps a bare VM name to the <tenant>.<name> form the gate
34 30 // resolves and the VM's host certificate names.
35 var _ gateAPI = (*client.Client)(nil) 31 ConnectName(ctx context.Context, vmName string) (string, error)
36
37 // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate.
38 // Gate credentials are prepared lazily on first use from API + UserCA + Tenant
39 // (see Runner.ensure); the Gate/VMUser fields shape the dial itself.
40 type RunnerConfig struct {
41 Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host)
42 VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal)
43 API gateAPI // control-plane client backing tenant derivation, CA registration, host-CA fetch
44 UserCA ssh.Signer // this client's persistent user CA; signs user certs locally
45 Tenant string // configured tenant; "" ⇒ derive from the credential via Me()
46 Now func() time.Time // test seam; nil ⇒ time.Now
47 } 32 }
48 33
49 // ExecResult is a completed remote command. 34 // ExecResult is a completed remote command.
@@ -55,99 +40,27 @@ type ExecResult struct {
55 } 40 }
56 41
57 // Runner executes commands and transfers files on VMs over SSH, reaching each 42 // Runner executes commands and transfers files on VMs over SSH, reaching each
58 // VM by NAME through eitri's SSH-CA jump gate. There is no TOFU/known_hosts: 43 // VM by NAME through its dialer. There is no TOFU/known_hosts anywhere on
59 // both hops are verified against the eitri CA — the gate presents a CA-signed 44 // either path: every host key is a certificate verified against the eitri host
60 // host cert for its own domain, the VM a CA-signed host cert for its 45 // CA, and the client authenticates with a short-lived CA-signed user cert.
61 // <tenant>.<name> connect name — and the client authenticates with a
62 // short-lived CA-signed user cert.
63 type Runner struct { 46 type Runner struct {
64 cfg RunnerConfig 47 dial VMDialer
65
66 mu sync.Mutex
67 auth gateclient.Credentials // gate credentials, built once ensure() succeeds; tests may inject
68 } 48 }
69 49
70 func NewRunner(cfg RunnerConfig) *Runner { return &Runner{cfg: cfg} } 50 func NewRunner(d VMDialer) *Runner { return &Runner{dial: d} }
71
72 // ensure prepares gate credentials on first use and caches them in r.auth. It
73 // resolves the caller's tenant (the configured one, else derived from the
74 // credential via Me()) and makes sure this client's user CA is registered with
75 // that tenant so VMs trust the certs it signs. Idempotent across restarts —
76 // registration lists the tenant's user CAs and uploads only when its own
77 // fingerprint is absent. A failed ensure caches nothing, so the next call
78 // retries; its error names the manual fallback (`eitri ca upload`). Never logs or
79 // returns key material.
80 func (r *Runner) ensure(ctx context.Context) error {
81 r.mu.Lock()
82 defer r.mu.Unlock()
83 if r.auth != nil {
84 return nil
85 }
86 tenant := r.cfg.Tenant
87 if tenant == "" {
88 // The credential names the tenant; derive it for the connect name, whose
89 // <tenant>.<vmName> form the VM's host cert principal must match.
90 me, err := r.cfg.API.Me()
91 if err != nil {
92 return fmt.Errorf("resolving tenant from credential: %w", err)
93 }
94 if me.Tenant == "" {
95 return errors.New("credential resolves to no tenant")
96 }
97 tenant = me.Tenant
98 }
99 if err := r.registerUserCA(ctx); err != nil {
100 return err
101 }
102 r.auth = gateclient.NewGateAuth(r.cfg.API, r.cfg.UserCA, tenant, r.cfg.Now)
103 return nil
104 }
105 51
106 // registerUserCA idempotently registers this client's user-CA public key with 52 // ConnectName returns the connect name for vmName, so the Tools layer can build
107 // the caller's tenant: it lists the registered CAs and uploads the local pubkey 53 // a correct `ssh -J` hint without dialing.
108 // only if its fingerprint is absent. Routing follows the configured tenant — an
109 // empty tenant hits the tenant-less endpoints, which operate on the caller's own
110 // tenant (the credential names it). Errors name the manual fallback and never
111 // carry key material.
112 func (r *Runner) registerUserCA(ctx context.Context) error {
113 pub := r.cfg.UserCA.PublicKey()
114 fp := ssh.FingerprintSHA256(pub)
115 cas, err := r.cfg.API.ListUserCAs(ctx, r.cfg.Tenant)
116 if err != nil {
117 return fmt.Errorf("checking registered user CAs (run `eitri ca upload` to register manually): %w", err)
118 }
119 for _, ca := range cas {
120 if ca.Fingerprint == fp {
121 return nil
122 }
123 }
124 line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
125 if err := r.cfg.API.UploadUserCA(ctx, r.cfg.Tenant, line); err != nil {
126 return fmt.Errorf("registering user CA (run `eitri ca upload` to register manually): %w", err)
127 }
128 return nil
129 }
130
131 // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>",
132 // the form the gate resolves and the VM's host-cert principal matches. It
133 // prepares gate credentials on first use so the Tools layer can build a correct
134 // `ssh -J` hint without dialing.
135 func (r *Runner) ConnectName(ctx context.Context, vmName string) (string, error) { 54 func (r *Runner) ConnectName(ctx context.Context, vmName string) (string, error) {
136 if err := r.ensure(ctx); err != nil { 55 return r.dial.ConnectName(ctx, vmName)
137 return "", err
138 }
139 r.mu.Lock()
140 auth := r.auth
141 r.mu.Unlock()
142 return auth.ConnectName(ctx, vmName)
143 } 56 }
144 57
145 // Exec runs cmd on the VM named vmName (reached through the gate). A non-zero 58 // Exec runs cmd on the VM named vmName. A non-zero remote exit is NOT an error —
146 // remote exit is NOT an error — it's in ExitCode. 59 // it's in ExitCode.
147 func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) { 60 func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) {
148 ctx, cancel := context.WithTimeout(ctx, timeout) 61 ctx, cancel := context.WithTimeout(ctx, timeout)
149 defer cancel() 62 defer cancel()
150 client, err := r.dial(ctx, vmName) 63 client, err := r.dial.Dial(ctx, vmName)
151 if err != nil { 64 if err != nil {
152 return ExecResult{}, err 65 return ExecResult{}, err
153 } 66 }
@@ -181,18 +94,6 @@ func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Dura
181 return res, nil 94 return res, nil
182 } 95 }
183 96
184 // dial reaches the VM named vmName through the eitri SSH-CA gate. See
185 // gateclient.Dial for the two-hop dial logic this delegates to.
186 func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) {
187 if err := r.ensure(ctx); err != nil {
188 return nil, err
189 }
190 r.mu.Lock()
191 auth := r.auth
192 r.mu.Unlock()
193 return gateclient.Dial(ctx, gateclient.DialConfig{Gate: r.cfg.Gate, VMUser: r.cfg.VMUser, Auth: auth}, vmName)
194 }
195
196 // cappedBuf captures at most outputCap bytes and records truncation. 97 // cappedBuf captures at most outputCap bytes and records truncation.
197 type cappedBuf struct { 98 type cappedBuf struct {
198 buf bytes.Buffer 99 buf bytes.Buffer
@@ -272,11 +173,10 @@ func (r *Runner) ReadFile(ctx context.Context, vmName, remotePath string) (data
272 return buf[:n], false, nil 173 return buf[:n], false, nil
273 } 174 }
274 175
275 // sftp dials the VM named vmName through the gate and wraps the connection in 176 // sftp dials the VM named vmName and wraps the connection in an sftp.Client.
276 // an sftp.Client. The caller must Close both the returned *ssh.Client and 177 // The caller must Close both the returned *ssh.Client and *sftp.Client.
277 // *sftp.Client.
278 func (r *Runner) sftp(ctx context.Context, vmName string) (*ssh.Client, *sftp.Client, error) { 178 func (r *Runner) sftp(ctx context.Context, vmName string) (*ssh.Client, *sftp.Client, error) {
279 client, err := r.dial(ctx, vmName) 179 client, err := r.dial.Dial(ctx, vmName)
280 if err != nil { 180 if err != nil {
281 return nil, nil, err 181 return nil, nil, err
282 } 182 }
internal/mcpserver/sshrun_test.go
Old New
@@ -247,7 +247,7 @@ func TestExecThroughGate(t *testing.T) {
247 // verifies it under that host. 247 // verifies it under that host.
248 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) 248 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
249 249
250 r := &Runner{cfg: RunnerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga} 250 r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga})
251 res, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) 251 res, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
252 require.NoError(t, err) 252 require.NoError(t, err)
253 assert.Equal(t, "hi\n", res.Stdout) 253 assert.Equal(t, "hi\n", res.Stdout)
@@ -265,7 +265,7 @@ func TestExecVMForeignCAHostCertRejected(t *testing.T) {
265 vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "default.testvm"), ca.PublicKey(), "hi\n", 0) 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) 266 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
267 267
268 r := &Runner{cfg: RunnerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga} 268 r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga})
269 _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) 269 _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
270 require.Error(t, err) 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") 271 assert.Contains(t, err.Error(), "vm testvm ssh handshake", "expected the VM hop to reject the foreign-CA host cert")
@@ -282,7 +282,7 @@ func TestExecGateRejectsNonCAUserKey(t *testing.T) {
282 // so its handshake must fail auth. Host verification still uses the real CA, 282 // so its handshake must fail auth. Host verification still uses the real CA,
283 // isolating the client-auth rejection at the gate hop. 283 // isolating the client-auth rejection at the gate hop.
284 creds := fakeGateCreds{signer: newSigner(t), hostCB: ga.HostKeyCallback()} 284 creds := fakeGateCreds{signer: newSigner(t), hostCB: ga.HostKeyCallback()}
285 r := &Runner{cfg: RunnerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: creds} 285 r := NewRunner(&gateDialer{cfg: gateDialerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: creds})
286 _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) 286 _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second)
287 require.Error(t, err) 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") 288 assert.Contains(t, err.Error(), "gate "+gateAddr+" ssh handshake", "expected the gate hop to reject the non-CA user key")
@@ -350,7 +350,7 @@ func (f *fakeGateAPI) UploadUserCA(_ context.Context, tenant, line string) error
350 func TestRunnerEnsureRegistersUserCAWhenAbsent(t *testing.T) { 350 func TestRunnerEnsureRegistersUserCAWhenAbsent(t *testing.T) {
351 userCA := newSigner(t) 351 userCA := newSigner(t)
352 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme"} // listCAs empty ⇒ absent 352 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme"} // listCAs empty ⇒ absent
353 r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA}) 353 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA})
354 354
355 name, err := r.ConnectName(t.Context(), "web-1") 355 name, err := r.ConnectName(t.Context(), "web-1")
356 require.NoError(t, err) 356 require.NoError(t, err)
@@ -367,7 +367,7 @@ func TestRunnerEnsureSkipsUploadWhenFingerprintPresent(t *testing.T) {
367 userCA := newSigner(t) 367 userCA := newSigner(t)
368 fp := ssh.FingerprintSHA256(userCA.PublicKey()) 368 fp := ssh.FingerprintSHA256(userCA.PublicKey())
369 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listCAs: []client.UserCA{{Fingerprint: fp, Label: "mcp"}}} 369 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listCAs: []client.UserCA{{Fingerprint: fp, Label: "mcp"}}}
370 r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA}) 370 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA})
371 371
372 _, err := r.ConnectName(t.Context(), "web-1") 372 _, err := r.ConnectName(t.Context(), "web-1")
373 require.NoError(t, err) 373 require.NoError(t, err)
@@ -379,7 +379,7 @@ func TestRunnerEnsureSkipsUploadWhenFingerprintPresent(t *testing.T) {
379 379
380 func TestRunnerEnsureExplicitTenantSkipsMe(t *testing.T) { 380 func TestRunnerEnsureExplicitTenantSkipsMe(t *testing.T) {
381 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "should-not-be-used"} 381 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "should-not-be-used"}
382 r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t), Tenant: "team"}) 382 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t), Tenant: "team"})
383 383
384 name, err := r.ConnectName(t.Context(), "web-1") 384 name, err := r.ConnectName(t.Context(), "web-1")
385 require.NoError(t, err) 385 require.NoError(t, err)
@@ -393,7 +393,7 @@ func TestRunnerEnsureExplicitTenantSkipsMe(t *testing.T) {
393 393
394 func TestRunnerEnsureRegistrationFailureNamesFallback(t *testing.T) { 394 func TestRunnerEnsureRegistrationFailureNamesFallback(t *testing.T) {
395 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", uploadErr: errors.New("boom")} 395 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", uploadErr: errors.New("boom")}
396 r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)}) 396 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)})
397 397
398 _, err := r.ConnectName(t.Context(), "web-1") 398 _, err := r.ConnectName(t.Context(), "web-1")
399 require.Error(t, err) 399 require.Error(t, err)
@@ -402,7 +402,7 @@ func TestRunnerEnsureRegistrationFailureNamesFallback(t *testing.T) {
402 402
403 func TestRunnerEnsureRetriesAfterFailureThenCaches(t *testing.T) { 403 func TestRunnerEnsureRetriesAfterFailureThenCaches(t *testing.T) {
404 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listErrs: 1} // first list fails 404 api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listErrs: 1} // first list fails
405 r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)}) 405 r := newGateDialer(gateDialerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)})
406 406
407 _, err := r.ConnectName(t.Context(), "web-1") 407 _, err := r.ConnectName(t.Context(), "web-1")
408 require.Error(t, err, "a failed ensure surfaces the error") 408 require.Error(t, err, "a failed ensure surfaces the error")
internal/mcpserver/tools.go
Old New
@@ -13,10 +13,11 @@ import (
13 "github.com/a73x/eitri/internal/gateclient" 13 "github.com/a73x/eitri/internal/gateclient"
14 "github.com/a73x/eitri/internal/random" 14 "github.com/a73x/eitri/internal/random"
15 "github.com/a73x/eitri/internal/server/api/client" 15 "github.com/a73x/eitri/internal/server/api/client"
16 "golang.org/x/crypto/ssh"
16 ) 17 )
17 18
18 // api and runner are the two seams Tools composes; API and Runner satisfy 19 // api and Exec are the two seams Tools composes; API and Runner satisfy them,
19 // them, fakes replace them in tests. 20 // fakes replace them in tests.
20 type api interface { 21 type api interface {
21 ListVMs(ctx context.Context) ([]client.VM, error) 22 ListVMs(ctx context.Context) ([]client.VM, error)
22 CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) 23 CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error)
@@ -26,9 +27,16 @@ type api interface {
26 CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64) (client.Exposure, error) 27 CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64) (client.Exposure, error)
27 ListExposures(ctx context.Context, vmID string) ([]client.Exposure, error) 28 ListExposures(ctx context.Context, vmID string) ([]client.Exposure, error)
28 DeleteExposure(ctx context.Context, id string) error 29 DeleteExposure(ctx context.Context, id string) error
30 RegisterUserCA(ctx context.Context, caLine, label string) error
31 ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error)
32 Delegation(ctx context.Context) (client.Delegation, error)
29 } 33 }
30 34
31 type runner interface { 35 // Exec is everything the tools do on a VM once something has reached it. It is
36 // exported because the two transports supply it differently: the stdio binary
37 // builds a Runner over the jump gate, the control plane one over the sync
38 // tunnel.
39 type Exec interface {
32 Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) 40 Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error)
33 WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error 41 WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error
34 ReadFile(ctx context.Context, vmName, path string) ([]byte, bool, error) 42 ReadFile(ctx context.Context, vmName, path string) ([]byte, bool, error)
@@ -43,6 +51,19 @@ type API struct {
43 *client.Client 51 *client.Client
44 } 52 }
45 53
54 // RegisterUserCA registers a CA public key with the caller's own tenant. The
55 // label rides a COPY of the client because it is a per-call choice and the
56 // client is shared with every other tool in this request; the request itself
57 // still goes through that same client, so there is one path to the API and one
58 // place authorization is decided.
59 func (a API) RegisterUserCA(ctx context.Context, caLine, label string) error {
60 c := *a.Client
61 if label != "" {
62 c.UserCALabel = label
63 }
64 return c.UploadUserCA(ctx, "", caLine)
65 }
66
46 // FirstOnlineHost returns the first online host — the default placement 67 // FirstOnlineHost returns the first online host — the default placement
47 // target when the caller doesn't name one. Ordering is server-defined; 68 // target when the caller doesn't name one. Ordering is server-defined;
48 // callers must not assume stability across calls. 69 // callers must not assume stability across calls.
@@ -63,14 +84,14 @@ func (a API) FirstOnlineHost(ctx context.Context) (client.Host, error) {
63 // API client must keep satisfying the gate's cert-authority seam. 84 // API client must keep satisfying the gate's cert-authority seam.
64 var ( 85 var (
65 _ api = API{} 86 _ api = API{}
66 _ runner = (*Runner)(nil) 87 _ Exec = (*Runner)(nil)
67 _ gateclient.CertAuthority = (*client.Client)(nil) 88 _ gateclient.CertAuthority = (*client.Client)(nil)
68 ) 89 )
69 90
70 // Tools implements the ten eitri-mcp tools over the API and SSH seams. 91 // Tools implements the ten eitri VM tools over the API and SSH seams.
71 type Tools struct { 92 type Tools struct {
72 API api 93 API api
73 Runner runner 94 Runner Exec
74 Gate string // gate address, for the ssh command hint only 95 Gate string // gate address, for the ssh command hint only
75 VMUser string 96 VMUser string
76 97
@@ -211,6 +232,7 @@ func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error
211 } 232 }
212 } 233 }
213 lastErr = execErr 234 lastErr = execErr
235 reportProgress(ctx, fmt.Sprintf("vm %s is up at %s; waiting for cloud-init to finish", created.Name, ip))
214 // Stop once we are past the deadline, or the next sleep would carry us 236 // Stop once we are past the deadline, or the next sleep would carry us
215 // past it — no point sleeping only to give up. 237 // past it — no point sleeping only to give up.
216 if !time.Now().Add(t.pollEvery()).Before(deadline) { 238 if !time.Now().Add(t.pollEvery()).Before(deadline) {
@@ -249,6 +271,7 @@ func (t *Tools) waitReady(ctx context.Context, id, name string, deadline time.Ti
249 } 271 }
250 } 272 }
251 } 273 }
274 reportProgress(ctx, fmt.Sprintf("creating vm %s: %s", name, lifecycleOrUnknown(last)))
252 select { 275 select {
253 case <-ctx.Done(): 276 case <-ctx.Done():
254 return "", ctx.Err() 277 return "", ctx.Err()
@@ -261,6 +284,15 @@ func (t *Tools) waitReady(ctx context.Context, id, name string, deadline time.Ti
261 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) 284 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)
262 } 285 }
263 286
287 // lifecycleOrUnknown words a lifecycle for a progress message, covering the
288 // first poll or two where the control plane has not listed the VM yet.
289 func lifecycleOrUnknown(lifecycle string) string {
290 if lifecycle == "" {
291 return "not listed yet"
292 }
293 return lifecycle
294 }
295
264 // resolveHost turns a caller-named host into the id to place on: an exact match 296 // resolveHost turns a caller-named host into the id to place on: an exact match
265 // on host id or host name, anywhere in the fleet. An offline match is refused 297 // on host id or host name, anywhere in the fleet. An offline match is refused
266 // rather than accepted — nothing would converge the create until that host came 298 // rather than accepted — nothing would converge the create until that host came
@@ -317,6 +349,104 @@ func (t *Tools) sshCommand(ctx context.Context, name string) string {
317 return fmt.Sprintf("ssh -J %s %s@%s", t.Gate, t.VMUser, target) 349 return fmt.Sprintf("ssh -J %s %s@%s", t.Gate, t.VMUser, target)
318 } 350 }
319 351
352 // ── ca_upload ────────────────────────────────────────────────────────────────
353
354 type CAUploadIn struct {
355 PublicKey string `json:"public_key" jsonschema:"the CA's PUBLIC key as one authorized_keys line, e.g. 'ssh-ed25519 AAAA... alex@laptop'"`
356 Label string `json:"label,omitempty" jsonschema:"optional label for this CA in the console's list"`
357 }
358
359 type CAUploadOut struct {
360 Fingerprint string `json:"fingerprint"`
361 Label string `json:"label,omitempty"`
362 Note string `json:"note"`
363 }
364
365 // CAUpload registers a tenant's own SSH user CA. Only the public half travels —
366 // there is nowhere in this call to put a private key, which is the point.
367 //
368 // The line is parsed here before it is sent so a malformed key is a tool error
369 // naming the problem, rather than a 400 the model has to interpret. The
370 // fingerprint returned is computed from the same bytes, so the caller can see
371 // which CA it just registered without a second round trip.
372 func (t *Tools) CAUpload(ctx context.Context, in CAUploadIn) (CAUploadOut, error) {
373 line := strings.TrimSpace(in.PublicKey)
374 if line == "" {
375 return CAUploadOut{}, errors.New("public_key is required: the CA's public key as an authorized_keys line")
376 }
377 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
378 if err != nil {
379 return CAUploadOut{}, fmt.Errorf("that is not an SSH public key line: %w", err)
380 }
381 if _, isCert := pub.(*ssh.Certificate); isCert {
382 return CAUploadOut{}, errors.New("that is a certificate, not a CA public key — upload the CA's own public key (the .pub beside your CA private key)")
383 }
384 if err := t.API.RegisterUserCA(ctx, line, in.Label); err != nil {
385 return CAUploadOut{}, fmt.Errorf("registering the CA with your tenant: %w", err)
386 }
387 return CAUploadOut{
388 Fingerprint: ssh.FingerprintSHA256(pub),
389 Label: in.Label,
390 Note: "A guest trusts the CA set it was created with, so VMs created BEFORE this upload will not " +
391 "accept certificates from this CA — create new ones. VMs created from now on will.",
392 }, nil
393 }
394
395 // ── tenant_info ──────────────────────────────────────────────────────────────
396
397 type TenantInfoIn struct{}
398
399 // TenantInfoCA is one registered CA, described rather than reproduced: a
400 // fingerprint identifies it, and the key itself is not what a caller is asking
401 // for here.
402 type TenantInfoCA struct {
403 Fingerprint string `json:"fingerprint"`
404 Label string `json:"label,omitempty"`
405 }
406
407 // TenantInfoDelegation is the live delegation, absent when there is none.
408 type TenantInfoDelegation struct {
409 KeyID string `json:"key_id,omitempty"`
410 Principals []string `json:"principals,omitempty"`
411 ExpiresAt string `json:"expires_at,omitempty"`
412 }
413
414 type TenantInfoOut struct {
415 CAs []TenantInfoCA `json:"registered_cas"`
416 // Delegated says whether eitri can currently reach this tenant's VMs. It is
417 // a field of its own because "no delegation" is the answer a caller most
418 // often needs, and an absent object is easy to misread as an error.
419 Delegated bool `json:"delegated"`
420 Delegation *TenantInfoDelegation `json:"delegation,omitempty"`
421 Gate string `json:"gate,omitempty"`
422 }
423
424 // TenantInfo answers "what is set up for me right now" in one call: which CAs
425 // this tenant has registered, whether eitri holds a live delegation and until
426 // when, and the gate address. Without it a caller learns each of these by
427 // trying something and reading the failure, which is a slow way to be told
428 // that a step was skipped.
429 //
430 // No delegation is not an error — it is the common state, and it is reported
431 // as data so a caller can act on it rather than parse a refusal.
432 func (t *Tools) TenantInfo(ctx context.Context, _ TenantInfoIn) (TenantInfoOut, error) {
433 cas, err := t.API.ListUserCAs(ctx, "")
434 if err != nil {
435 return TenantInfoOut{}, fmt.Errorf("reading this tenant's registered CAs: %w", err)
436 }
437 out := TenantInfoOut{CAs: make([]TenantInfoCA, 0, len(cas)), Gate: t.Gate}
438 for _, ca := range cas {
439 out.CAs = append(out.CAs, TenantInfoCA{Fingerprint: ca.Fingerprint, Label: ca.Label})
440 }
441 // A plane with no delegation, or no gate at all, answers this with a
442 // non-2xx. Neither is a failure of the question being asked.
443 if d, derr := t.API.Delegation(ctx); derr == nil {
444 out.Delegated = true
445 out.Delegation = &TenantInfoDelegation{KeyID: d.KeyID, Principals: d.Principals, ExpiresAt: d.ExpiresAt}
446 }
447 return out, nil
448 }
449
320 // ── vm_list / vm_info ──────────────────────────────────────────────────────── 450 // ── vm_list / vm_info ────────────────────────────────────────────────────────
321 451
322 type VMListIn struct{} 452 type VMListIn struct{}
internal/mcpserver/tools_test.go
Old New
@@ -2,13 +2,19 @@ package mcpserver
2 2
3 import ( 3 import (
4 "context" 4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "encoding/json"
8 "errors"
5 "fmt" 9 "fmt"
6 "io/fs" 10 "io/fs"
11 "strings"
7 "testing" 12 "testing"
8 "time" 13 "time"
9 14
10 "github.com/stretchr/testify/assert" 15 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require" 16 "github.com/stretchr/testify/require"
17 "golang.org/x/crypto/ssh"
12 18
13 "github.com/a73x/eitri/internal/server/api/client" 19 "github.com/a73x/eitri/internal/server/api/client"
14 ) 20 )
@@ -29,6 +35,14 @@ type fakeToolsAPI struct {
29 revoked []string 35 revoked []string
30 // hosts the fake's fleet reports; nil means the one-host fleet. 36 // hosts the fake's fleet reports; nil means the one-host fleet.
31 hosts []client.Host 37 hosts []client.Host
38 // CAs registered through ca_upload, as (line, label) pairs, plus an error
39 // the registration can be made to fail with.
40 cas [][2]string
41 caErr error
42 // what tenant_info reads back
43 userCAs []client.UserCA
44 listCAErr error
45 delegation *client.Delegation
32 } 46 }
33 47
34 // fleet is the fake's host list, defaulted so tests that don't care about 48 // fleet is the fake's host list, defaulted so tests that don't care about
@@ -103,6 +117,22 @@ func (f *fakeToolsAPI) DeleteExposure(ctx context.Context, id string) error {
103 f.revoked = append(f.revoked, id) 117 f.revoked = append(f.revoked, id)
104 return nil 118 return nil
105 } 119 }
120 func (f *fakeToolsAPI) ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error) {
121 return f.userCAs, f.listCAErr
122 }
123 func (f *fakeToolsAPI) Delegation(ctx context.Context) (client.Delegation, error) {
124 if f.delegation == nil {
125 return client.Delegation{}, errors.New("no live delegation for this tenant")
126 }
127 return *f.delegation, nil
128 }
129 func (f *fakeToolsAPI) RegisterUserCA(ctx context.Context, caLine, label string) error {
130 if f.caErr != nil {
131 return f.caErr
132 }
133 f.cas = append(f.cas, [2]string{caLine, label})
134 return nil
135 }
106 136
107 type fakeRunner struct { 137 type fakeRunner struct {
108 execs []string 138 execs []string
@@ -589,3 +619,133 @@ func TestWriteAndReadFileTools(t *testing.T) {
589 require.NoError(t, err) 619 require.NoError(t, err)
590 assert.Equal(t, "data", rd.Content) 620 assert.Equal(t, "data", rd.Content)
591 } 621 }
622
623 // ── ca_upload ────────────────────────────────────────────────────────────────
624
625 // caLine is a real ed25519 public key in authorized_keys form.
626 func caLine(t *testing.T) string {
627 t.Helper()
628 pub, _, err := ed25519.GenerateKey(rand.Reader)
629 require.NoError(t, err)
630 sp, err := ssh.NewPublicKey(pub)
631 require.NoError(t, err)
632 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " alex@laptop"
633 }
634
635 // TestCAUploadRegistersThePublicHalf: the line reaches the API unchanged, the
636 // label rides with it, and the answer names the CA that was registered and the
637 // consequence of having registered it now rather than earlier.
638 func TestCAUploadRegistersThePublicHalf(t *testing.T) {
639 api := &fakeToolsAPI{}
640 tools := &Tools{API: api}
641 line := caLine(t)
642
643 out, err := tools.CAUpload(t.Context(), CAUploadIn{PublicKey: line, Label: "laptop"})
644 require.NoError(t, err)
645
646 require.Len(t, api.cas, 1)
647 assert.Equal(t, line, api.cas[0][0], "the key must reach the API byte for byte")
648 assert.Equal(t, "laptop", api.cas[0][1])
649 assert.True(t, strings.HasPrefix(out.Fingerprint, "SHA256:"), "got %q", out.Fingerprint)
650 assert.Equal(t, "laptop", out.Label)
651 assert.Contains(t, out.Note, "created BEFORE this upload will not")
652 }
653
654 // TestCAUploadRefusesWhatIsNotACAPublicKey: a bad line is a tool error naming
655 // the problem, not a 400 the model has to interpret — and a certificate is the
656 // mistake worth naming, since it looks like a key and is not one.
657 func TestCAUploadRefusesWhatIsNotACAPublicKey(t *testing.T) {
658 api := &fakeToolsAPI{}
659 tools := &Tools{API: api}
660
661 _, err := tools.CAUpload(t.Context(), CAUploadIn{})
662 require.Error(t, err)
663 assert.Contains(t, err.Error(), "public_key is required")
664
665 _, err = tools.CAUpload(t.Context(), CAUploadIn{PublicKey: "not a key"})
666 require.Error(t, err)
667 assert.Contains(t, err.Error(), "not an SSH public key line")
668
669 _, err = tools.CAUpload(t.Context(), CAUploadIn{PublicKey: certLine(t)})
670 require.Error(t, err)
671 assert.Contains(t, err.Error(), "certificate, not a CA public key")
672
673 assert.Empty(t, api.cas, "nothing reaches the API until the line parses")
674 }
675
676 // certLine builds a user certificate, the thing most easily confused for a key.
677 func certLine(t *testing.T) string {
678 t.Helper()
679 _, priv, err := ed25519.GenerateKey(rand.Reader)
680 require.NoError(t, err)
681 signer, err := ssh.NewSignerFromSigner(priv)
682 require.NoError(t, err)
683 cert := &ssh.Certificate{Key: signer.PublicKey(), CertType: ssh.UserCert,
684 ValidPrincipals: []string{"ubuntu"}, ValidBefore: ssh.CertTimeInfinity}
685 require.NoError(t, cert.SignCert(rand.Reader, signer))
686 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
687 }
688
689 // TestCAUploadSurfacesARefusal: the API's no reaches the model as a tool error.
690 func TestCAUploadSurfacesARefusal(t *testing.T) {
691 api := &fakeToolsAPI{caErr: errors.New("forbidden")}
692 tools := &Tools{API: api}
693
694 _, err := tools.CAUpload(t.Context(), CAUploadIn{PublicKey: caLine(t)})
695 require.Error(t, err)
696 assert.Contains(t, err.Error(), "registering the CA with your tenant")
697 }
698
699 // ── tenant_info ──────────────────────────────────────────────────────────────
700
701 // TestTenantInfoReportsSetupWithoutKeyMaterial: the answer identifies each CA
702 // and says whether eitri can currently reach anything — and carries no key.
703 func TestTenantInfoReportsSetupWithoutKeyMaterial(t *testing.T) {
704 api := &fakeToolsAPI{
705 userCAs: []client.UserCA{
706 {Fingerprint: "SHA256:aaa", Label: "laptop", PubKey: "ssh-ed25519 AAAASECRETLOOKING alex"},
707 },
708 delegation: &client.Delegation{
709 KeyID: "eitri-delegation", Principals: []string{"ubuntu"}, ExpiresAt: "2026-08-09T00:00:00Z",
710 },
711 }
712 tools := &Tools{API: api, Gate: "gate.eitri.sh:2222"}
713
714 out, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
715 require.NoError(t, err)
716
717 require.Len(t, out.CAs, 1)
718 assert.Equal(t, "SHA256:aaa", out.CAs[0].Fingerprint)
719 assert.Equal(t, "laptop", out.CAs[0].Label)
720 assert.True(t, out.Delegated)
721 require.NotNil(t, out.Delegation)
722 assert.Equal(t, []string{"ubuntu"}, out.Delegation.Principals)
723 assert.Equal(t, "2026-08-09T00:00:00Z", out.Delegation.ExpiresAt)
724 assert.Equal(t, "gate.eitri.sh:2222", out.Gate)
725
726 raw, err := json.Marshal(out)
727 require.NoError(t, err)
728 assert.NotContains(t, string(raw), "AAAASECRETLOOKING", "tenant_info describes CAs, it does not reproduce them")
729 }
730
731 // TestTenantInfoReportsNoDelegationAsData: not having delegated is the common
732 // state and the thing a caller most needs told, so it is a field rather than an
733 // error to parse.
734 func TestTenantInfoReportsNoDelegationAsData(t *testing.T) {
735 tools := &Tools{API: &fakeToolsAPI{}}
736
737 out, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
738 require.NoError(t, err)
739 assert.False(t, out.Delegated)
740 assert.Nil(t, out.Delegation)
741 assert.Empty(t, out.CAs)
742 }
743
744 // TestTenantInfoSurfacesAReadFailure: the CA list is the part that must work.
745 func TestTenantInfoSurfacesAReadFailure(t *testing.T) {
746 tools := &Tools{API: &fakeToolsAPI{listCAErr: errors.New("unauthorized")}}
747
748 _, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
749 require.Error(t, err)
750 assert.Contains(t, err.Error(), "reading this tenant's registered CAs")
751 }
internal/server/api/api.go
Old New
@@ -20,6 +20,7 @@ import (
20 "github.com/a73x/eitri/internal/names" 20 "github.com/a73x/eitri/internal/names"
21 "github.com/a73x/eitri/internal/random" 21 "github.com/a73x/eitri/internal/random"
22 "github.com/a73x/eitri/internal/server/api/types" 22 "github.com/a73x/eitri/internal/server/api/types"
23 "github.com/a73x/eitri/internal/server/delegation"
23 "github.com/a73x/eitri/internal/server/hosttoken" 24 "github.com/a73x/eitri/internal/server/hosttoken"
24 "github.com/a73x/eitri/internal/server/hub" 25 "github.com/a73x/eitri/internal/server/hub"
25 "github.com/a73x/eitri/internal/server/registry" 26 "github.com/a73x/eitri/internal/server/registry"
@@ -83,6 +84,26 @@ type API struct {
83 release ReleaseSource // nil ⇒ release discovery disabled 84 release ReleaseSource // nil ⇒ release discovery disabled
84 upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader) 85 upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader)
85 auth *authFlow // OIDC sign-in relying party (mounted via AuthHandler, outside /api/) 86 auth *authFlow // OIDC sign-in relying party (mounted via AuthHandler, outside /api/)
87 // delegations holds the credentials tenants have lent eitri. Nil until main
88 // wires it, which happens only when the jump gate is on; nil ⇒ the
89 // delegation routes answer 503.
90 delegations *delegation.Keyring
91 }
92
93 // URL renders an API path as a full URL a caller can actually dial, using the
94 // plane's configured public_url. Endpoints named in errors and recipes go
95 // through here: the REST API and /mcp can live on different hostnames, so a
96 // bare path sends a caller to guess which one — a guess that costs real time
97 // when the answer is "not the host you are talking to".
98 //
99 // An unconfigured public_url falls back to the bare path, which is still true,
100 // just less useful.
101 func (a *API) URL(path string) string {
102 base := strings.TrimRight(a.cfg.OIDC.PublicURL, "/")
103 if base == "" {
104 return path
105 }
106 return base + path
86 } 107 }
87 108
88 // SetReleaseSource wires release discovery (nil leaves it disabled). 109 // SetReleaseSource wires release discovery (nil leaves it disabled).
@@ -154,6 +175,12 @@ func (a *API) AuthHandler() http.Handler {
154 return mux 175 return mux
155 } 176 }
156 177
178 // UserAuth wraps h in the same PAT/session resolution the /api/v1 subtree uses,
179 // for handlers mounted outside it. /mcp speaks JSON-RPC rather than the REST
180 // contract, so it lives outside the route table (like /auth/*) — but living
181 // outside the contract must not mean living outside its authentication.
182 func (a *API) UserAuth(h http.Handler) http.Handler { return a.userAuth(h) }
183
157 // StartBackground launches the periodic server-side sweeps: finalizing drained 184 // StartBackground launches the periodic server-side sweeps: finalizing drained
158 // decommissioning hosts, and reaping tombstoned VMs whose host's agent never 185 // decommissioning hosts, and reaping tombstoned VMs whose host's agent never
159 // acked the destroy (abandoned on an offline host). It returns when ctx is 186 // acked the destroy (abandoned on an offline host). It returns when ctx is
internal/server/api/client/client.go
Old New
@@ -37,6 +37,9 @@ type (
37 UserCA = types.UserCA 37 UserCA = types.UserCA
38 Exposure = types.Exposure 38 Exposure = types.Exposure
39 CreateExposureRequest = types.CreateExposureRequest 39 CreateExposureRequest = types.CreateExposureRequest
40 DelegationChallenge = types.DelegationChallenge
41 DelegationRequest = types.DelegationRequest
42 Delegation = types.Delegation
40 ) 43 )
41 44
42 // Client calls the eitri API at BaseURL, authenticating with Token (sent as a 45 // Client calls the eitri API at BaseURL, authenticating with Token (sent as a
@@ -249,3 +252,30 @@ func (c *Client) ListUserCAs(ctx context.Context, tenant string) ([]UserCA, erro
249 var out []UserCA 252 var out []UserCA
250 return out, c.do(ctx, http.MethodGet, path, nil, &out) 253 return out, c.do(ctx, http.MethodGet, path, nil, &out)
251 } 254 }
255
256 // BeginDelegation asks for the public key the caller is to sign, along with the
257 // principal it must carry and the command that signs it. The key is eitri's
258 // ephemeral half, held in memory only.
259 func (c *Client) BeginDelegation(ctx context.Context) (DelegationChallenge, error) {
260 var out DelegationChallenge
261 return out, c.do(ctx, http.MethodPost, "/api/v1/delegations", nil, &out)
262 }
263
264 // CompleteDelegation hands back the signed certificate. eitri can then reach
265 // the caller's VMs until it expires, and holds nothing else.
266 func (c *Client) CompleteDelegation(ctx context.Context, certificate string) (Delegation, error) {
267 var out Delegation
268 return out, c.do(ctx, http.MethodPut, "/api/v1/delegations", DelegationRequest{Certificate: certificate}, &out)
269 }
270
271 // Delegation describes the caller tenant's live delegation, so its expiry is
272 // never a surprise.
273 func (c *Client) Delegation(ctx context.Context) (Delegation, error) {
274 var out Delegation
275 return out, c.do(ctx, http.MethodGet, "/api/v1/delegations", nil, &out)
276 }
277
278 // RevokeDelegation ends the delegation now.
279 func (c *Client) RevokeDelegation(ctx context.Context) error {
280 return c.do(ctx, http.MethodDelete, "/api/v1/delegations", nil, nil)
281 }
internal/server/api/client/client_test.go
Old New
@@ -527,3 +527,87 @@ func TestDeleteExposure(t *testing.T) {
527 t.Errorf("request = %s %s, want DELETE /api/v1/exposures/x-1", cap.method, cap.path) 527 t.Errorf("request = %s %s, want DELETE /api/v1/exposures/x-1", cap.method, cap.path)
528 } 528 }
529 } 529 }
530
531 // The four delegation calls are one endpoint distinguished by method, so the
532 // method is the thing worth pinning: getting it wrong would silently start or
533 // end a delegation instead of reading one.
534 func TestDelegationCalls(t *testing.T) {
535 const delegationJSON = `{"public_key":"ssh-ed25519 AAAA eitri","ca_fingerprint":"SHA256:abc",` +
536 `"key_id":"eitri-delegation","serial":"7","principals":["ubuntu"],"expires_at":"2026-08-08T12:00:00Z"}`
537
538 t.Run("begin", func(t *testing.T) {
539 var cap capture
540 srv := serve(t, &cap, http.StatusOK,
541 `{"public_key":"ssh-ed25519 AAAA eitri","principal":"ubuntu","instructions":"ssh-keygen -s ..."}`)
542 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
543
544 got, err := c.BeginDelegation(context.Background())
545 if err != nil {
546 t.Fatalf("BeginDelegation: %v", err)
547 }
548 if cap.method != http.MethodPost || cap.path != "/api/v1/delegations" {
549 t.Errorf("request = %s %s, want POST /api/v1/delegations", cap.method, cap.path)
550 }
551 if got.PublicKey != "ssh-ed25519 AAAA eitri" || got.Principal != "ubuntu" {
552 t.Errorf("challenge = %+v", got)
553 }
554 if got.Instructions == "" {
555 t.Error("the caller needs the command, not just the key")
556 }
557 })
558
559 t.Run("complete", func(t *testing.T) {
560 var cap capture
561 srv := serve(t, &cap, http.StatusOK, delegationJSON)
562 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
563
564 const cert = "ssh-ed25519-cert-v01@openssh.com AAAAcert alex@laptop"
565 got, err := c.CompleteDelegation(context.Background(), cert)
566 if err != nil {
567 t.Fatalf("CompleteDelegation: %v", err)
568 }
569 if cap.method != http.MethodPut || cap.path != "/api/v1/delegations" {
570 t.Errorf("request = %s %s, want PUT /api/v1/delegations", cap.method, cap.path)
571 }
572 var req types.DelegationRequest
573 if err := json.Unmarshal(cap.body, &req); err != nil {
574 t.Fatalf("request body did not decode as DelegationRequest: %v", err)
575 }
576 if req.Certificate != cert {
577 t.Errorf("certificate = %q, want it passed through unchanged", req.Certificate)
578 }
579 if got.CAFingerprint != "SHA256:abc" || got.ExpiresAt != "2026-08-08T12:00:00Z" {
580 t.Errorf("delegation = %+v", got)
581 }
582 })
583
584 t.Run("read", func(t *testing.T) {
585 var cap capture
586 srv := serve(t, &cap, http.StatusOK, delegationJSON)
587 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
588
589 got, err := c.Delegation(context.Background())
590 if err != nil {
591 t.Fatalf("Delegation: %v", err)
592 }
593 if cap.method != http.MethodGet || cap.path != "/api/v1/delegations" {
594 t.Errorf("request = %s %s, want GET /api/v1/delegations", cap.method, cap.path)
595 }
596 if got.Serial != "7" {
597 t.Errorf("serial = %q, want the string form", got.Serial)
598 }
599 })
600
601 t.Run("revoke", func(t *testing.T) {
602 var cap capture
603 srv := serve(t, &cap, http.StatusNoContent, "")
604 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
605
606 if err := c.RevokeDelegation(context.Background()); err != nil {
607 t.Fatalf("RevokeDelegation: %v", err)
608 }
609 if cap.method != http.MethodDelete || cap.path != "/api/v1/delegations" {
610 t.Errorf("request = %s %s, want DELETE /api/v1/delegations", cap.method, cap.path)
611 }
612 })
613 }
internal/server/api/delegations.go
Old New
@@ -0,0 +1,152 @@
1 package api
2
3 import (
4 "fmt"
5 "net/http"
6 "strconv"
7 "time"
8
9 "github.com/a73x/eitri/internal/server/api/types"
10 "github.com/a73x/eitri/internal/server/delegation"
11 "github.com/a73x/eitri/internal/server/sshca"
12 "golang.org/x/crypto/ssh"
13 )
14
15 // SetDelegations wires the delegation keyring. Called once at startup when the
16 // jump gate is enabled; leaving it nil makes the four routes answer 503, which
17 // is the same condition that makes remote exec refuse.
18 //
19 // The keyring is taken concretely rather than behind an interface: it is a pure
20 // leaf package with no I/O, so a test builds a real one, and every rule about
21 // what a delegation may be lives inside it rather than being restated here.
22 func (a *API) SetDelegations(k *delegation.Keyring) { a.delegations = k }
23
24 // delegationTenant resolves the caller's tenant and refuses when this control
25 // plane has no CA to verify anything against.
26 func (a *API) delegationTenant(w http.ResponseWriter, r *http.Request) (string, bool) {
27 if a.delegations == nil {
28 http.Error(w, "this control plane has no SSH CA configured", http.StatusServiceUnavailable)
29 return "", false
30 }
31 return a.userCATenant(w, r)
32 }
33
34 // handleBeginDelegation returns the public key the caller is to sign, and the
35 // command that signs it. eitri holds the private half in memory and nowhere
36 // else; a restart drops it and the caller delegates again.
37 func (a *API) handleBeginDelegation(w http.ResponseWriter, r *http.Request) {
38 tenant, ok := a.delegationTenant(w, r)
39 if !ok {
40 return
41 }
42 pubLine, err := a.delegations.Begin(tenant)
43 if err != nil {
44 http.Error(w, "internal error", http.StatusInternalServerError)
45 return
46 }
47 principal := a.delegations.Principal
48 a.audit(tenant, "delegation.begin", map[string]string{
49 "tenant": tenant, "fingerprint": fingerprintOf(pubLine),
50 })
51 writeJSON(w, http.StatusOK, types.DelegationChallenge{
52 PublicKey: pubLine,
53 Principal: principal,
54 Instructions: fmt.Sprintf(
55 "printf '%%s\\n' '%s' > eitri-delegation.pub && "+
56 "ssh-keygen -s <your-ca-key> -I eitri-delegation -n %s -V +8h eitri-delegation.pub && "+
57 "curl -X PUT -H \"Authorization: Bearer $EITRI_TOKEN\" -H 'Content-Type: application/json' "+
58 "--data \"{\\\"certificate\\\": \\\"$(cat eitri-delegation-cert.pub)\\\"}\" %s",
59 pubLine, principal, a.URL("/api/v1/delegations")),
60 })
61 }
62
63 // handleCompleteDelegation accepts the signed certificate. Every rule about
64 // what makes a certificate acceptable lives in the delegation package; this
65 // hands its refusal straight back, because each one names the fix.
66 func (a *API) handleCompleteDelegation(w http.ResponseWriter, r *http.Request) {
67 tenant, ok := a.delegationTenant(w, r)
68 if !ok {
69 return
70 }
71 var req types.DelegationRequest
72 if !decodeJSON(w, r, &req) {
73 return
74 }
75 if req.Certificate == "" {
76 http.Error(w, "certificate is required", http.StatusBadRequest)
77 return
78 }
79 d, err := a.delegations.Complete(tenant, req.Certificate, a.tenantTrusts(tenant))
80 if err != nil {
81 http.Error(w, err.Error(), http.StatusBadRequest)
82 return
83 }
84 a.audit(tenant, "delegation.complete", map[string]string{
85 "tenant": tenant, "ca_fingerprint": d.CAFingerprint, "key_id": d.KeyID,
86 "serial": strconv.FormatUint(d.Serial, 10), "expires_at": d.ExpiresAt.Format(time.RFC3339),
87 })
88 writeJSON(w, http.StatusOK, delegationResponse(d))
89 }
90
91 // handleGetDelegation reports the caller's live delegation, so its expiry is
92 // never a surprise. 404 when there is none — including one that has run out,
93 // which is the same situation and calls for the same next step.
94 func (a *API) handleGetDelegation(w http.ResponseWriter, r *http.Request) {
95 tenant, ok := a.delegationTenant(w, r)
96 if !ok {
97 return
98 }
99 d, live := a.delegations.Status(tenant)
100 if !live {
101 http.Error(w, "no live delegation for this tenant", http.StatusNotFound)
102 return
103 }
104 writeJSON(w, http.StatusOK, delegationResponse(d))
105 }
106
107 // handleRevokeDelegation ends the delegation now. Idempotent.
108 func (a *API) handleRevokeDelegation(w http.ResponseWriter, r *http.Request) {
109 tenant, ok := a.delegationTenant(w, r)
110 if !ok {
111 return
112 }
113 a.delegations.Revoke(tenant)
114 a.audit(tenant, "delegation.revoke", map[string]string{"tenant": tenant})
115 w.WriteHeader(http.StatusNoContent)
116 }
117
118 // tenantTrusts answers "is this a CA this tenant registered?" against the
119 // store. A lookup that fails is reported as an error rather than as "not
120 // trusted", so a database hiccup never reads as a rejected certificate.
121 func (a *API) tenantTrusts(tenant string) func(ssh.PublicKey) (bool, error) {
122 return func(pub ssh.PublicKey) (bool, error) {
123 owner, ok, err := a.st.TenantForUserCA(sshca.AuthorizedKeyLine(pub))
124 if err != nil {
125 return false, err
126 }
127 return ok && owner == tenant, nil
128 }
129 }
130
131 func delegationResponse(d delegation.Delegation) types.Delegation {
132 return types.Delegation{
133 PublicKey: d.PublicKey,
134 CAFingerprint: d.CAFingerprint,
135 KeyID: d.KeyID,
136 // A string, like every other serial on this API: a uint64 exceeds what
137 // a JSON number survives in a JavaScript client.
138 Serial: strconv.FormatUint(d.Serial, 10),
139 Principals: d.Principals,
140 ExpiresAt: d.ExpiresAt.Format(time.RFC3339),
141 }
142 }
143
144 // fingerprintOf is best-effort: it names a key in an audit row, and a key that
145 // will not parse is not worth failing a request over.
146 func fingerprintOf(line string) string {
147 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
148 if err != nil {
149 return ""
150 }
151 return ssh.FingerprintSHA256(pub)
152 }
internal/server/api/delegations_test.go
Old New
@@ -0,0 +1,231 @@
1 package api
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "encoding/json"
7 "io"
8 "net/http"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/a73x/eitri/internal/server/api/types"
14 "github.com/a73x/eitri/internal/server/delegation"
15 "github.com/a73x/eitri/internal/server/store"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18 "golang.org/x/crypto/ssh"
19 )
20
21 // newDelegationCA stands in for a tenant's own SSH user CA.
22 func newDelegationCA(t *testing.T) ssh.Signer {
23 t.Helper()
24 _, priv, err := ed25519.GenerateKey(rand.Reader)
25 require.NoError(t, err)
26 s, err := ssh.NewSignerFromSigner(priv)
27 require.NoError(t, err)
28 return s
29 }
30
31 // registerTenantCA registers ca to tenant, the precondition for delegating with
32 // it: eitri only accepts a certificate from a CA the tenant's guests trust.
33 func registerTenantCA(t *testing.T, st *store.Store, tenant string, ca ssh.Signer) {
34 t.Helper()
35 line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(ca.PublicKey())))
36 require.NoError(t, st.AddTenantUserCA(tenant, line, "tenant", "mine", "test"))
37 }
38
39 // signFor is `ssh-keygen -s`, in code.
40 func signFor(t *testing.T, ca ssh.Signer, pubLine, principal string, expiry time.Time) string {
41 t.Helper()
42 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine))
43 require.NoError(t, err)
44 cert := &ssh.Certificate{
45 Key: pub,
46 Serial: 99,
47 CertType: ssh.UserCert,
48 KeyId: "eitri-delegation",
49 ValidPrincipals: []string{principal},
50 ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()),
51 ValidBefore: uint64(expiry.Unix()),
52 }
53 require.NoError(t, cert.SignCert(rand.Reader, ca))
54 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
55 }
56
57 // withDelegations wires a real keyring onto a test server.
58 func withDelegations(a *API) *delegation.Keyring {
59 k := delegation.New(time.Now, "ubuntu")
60 a.SetDelegations(k)
61 return k
62 }
63
64 // bodyText reads an error response body.
65 func bodyText(t *testing.T, resp *http.Response) string {
66 t.Helper()
67 defer resp.Body.Close()
68 b, err := io.ReadAll(resp.Body)
69 require.NoError(t, err)
70 return string(b)
71 }
72
73 func decodeInto(t *testing.T, resp *http.Response, v any) {
74 t.Helper()
75 defer resp.Body.Close()
76 require.NoError(t, json.NewDecoder(resp.Body).Decode(v))
77 }
78
79 // TestDelegationRoundTrip is the whole exchange over HTTP: a challenge, a
80 // certificate signed elsewhere, and a live delegation eitri could not have
81 // created for itself.
82 func TestDelegationRoundTrip(t *testing.T) {
83 ts, st, _, _, a := newServer(t)
84 k := withDelegations(a)
85 ca := newDelegationCA(t)
86 registerTenantCA(t, st, testTenant, ca)
87
88 resp := do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil)
89 require.Equal(t, http.StatusOK, resp.StatusCode)
90 var challenge types.DelegationChallenge
91 decodeInto(t, resp, &challenge)
92 assert.True(t, strings.HasPrefix(challenge.PublicKey, "ssh-ed25519 "))
93 assert.Equal(t, "ubuntu", challenge.Principal)
94 assert.Contains(t, challenge.Instructions, "ssh-keygen -s <your-ca-key>")
95 assert.Contains(t, challenge.Instructions, "-n ubuntu")
96 assert.Contains(t, challenge.Instructions, challenge.PublicKey,
97 "the instructions must carry the key, so a caller need not assemble it")
98
99 expiry := time.Now().Add(8 * time.Hour).Truncate(time.Second)
100 resp = do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT,
101 map[string]any{"certificate": signFor(t, ca, challenge.PublicKey, "ubuntu", expiry)})
102 require.Equal(t, http.StatusOK, resp.StatusCode)
103 var d types.Delegation
104 decodeInto(t, resp, &d)
105 assert.Equal(t, ssh.FingerprintSHA256(ca.PublicKey()), d.CAFingerprint)
106 assert.Equal(t, "99", d.Serial, "a serial is a string on this API, so a JS client keeps every digit")
107 assert.Equal(t, []string{"ubuntu"}, d.Principals)
108 assert.Equal(t, expiry.UTC().Format(time.RFC3339), d.ExpiresAt)
109
110 _, ok := k.Signer(testTenant)
111 assert.True(t, ok)
112
113 // GET reports it, so its expiry is never a surprise.
114 resp = do(t, "GET", ts.URL+"/api/v1/delegations", testPAT, nil)
115 require.Equal(t, http.StatusOK, resp.StatusCode)
116 var got types.Delegation
117 decodeInto(t, resp, &got)
118 assert.Equal(t, d, got)
119
120 // DELETE ends it now.
121 resp = do(t, "DELETE", ts.URL+"/api/v1/delegations", testPAT, nil)
122 require.Equal(t, http.StatusNoContent, resp.StatusCode)
123 _, ok = k.Signer(testTenant)
124 assert.False(t, ok)
125 resp = do(t, "GET", ts.URL+"/api/v1/delegations", testPAT, nil)
126 assert.Equal(t, http.StatusNotFound, resp.StatusCode)
127 }
128
129 // TestBeginIsStableAcrossCalls: re-delegating after an expiry is one signing
130 // step, not a new round trip for a key that has not changed.
131 func TestBeginIsStableAcrossCalls(t *testing.T) {
132 ts, _, _, _, a := newServer(t)
133 withDelegations(a)
134
135 var first, second types.DelegationChallenge
136 decodeInto(t, do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil), &first)
137 decodeInto(t, do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil), &second)
138 assert.Equal(t, first.PublicKey, second.PublicKey)
139 }
140
141 // TestCompleteBeforeBeginIsACleanRefusal: there is no key to have signed yet,
142 // so whatever was posted is for something else.
143 func TestCompleteBeforeBeginIsACleanRefusal(t *testing.T) {
144 ts, st, _, _, a := newServer(t)
145 withDelegations(a)
146 ca := newDelegationCA(t)
147 registerTenantCA(t, st, testTenant, ca)
148
149 // A certificate over some other key, posted first.
150 other := newDelegationCA(t)
151 otherLine := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(other.PublicKey())))
152 resp := do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT,
153 map[string]any{"certificate": signFor(t, ca, otherLine, "ubuntu", time.Now().Add(time.Hour))})
154 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
155 assert.Contains(t, bodyText(t, resp), "current delegation key is")
156 }
157
158 // TestCompleteRefusesAnotherTenantsCA: the trust check is scoped to the CAs the
159 // CALLER's tenant registered, not to every CA the fleet knows.
160 func TestCompleteRefusesAnotherTenantsCA(t *testing.T) {
161 ts, st, _, _, a := newServer(t)
162 withDelegations(a)
163
164 other, err := st.CreateTenantForIdentity("https://idp", "someone-else", "them@example.com")
165 require.NoError(t, err)
166 theirCA := newDelegationCA(t)
167 registerTenantCA(t, st, other.ID, theirCA)
168
169 var challenge types.DelegationChallenge
170 decodeInto(t, do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil), &challenge)
171
172 resp := do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT,
173 map[string]any{"certificate": signFor(t, theirCA, challenge.PublicKey, "ubuntu", time.Now().Add(time.Hour))})
174 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
175 assert.Contains(t, bodyText(t, resp), "not a CA registered to this tenant")
176 }
177
178 // TestCompleteRequiresACertificate: an empty body is a caller error, not a 500.
179 func TestCompleteRequiresACertificate(t *testing.T) {
180 ts, _, _, _, a := newServer(t)
181 withDelegations(a)
182 resp := do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT, map[string]any{})
183 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
184 }
185
186 // TestDelegationRoutesNeedACredential: every one of them acts on the caller's
187 // own tenant, so every one of them needs to know who the caller is.
188 func TestDelegationRoutesNeedACredential(t *testing.T) {
189 ts, _, _, _, a := newServer(t)
190 withDelegations(a)
191 for _, m := range []string{"POST", "PUT", "GET", "DELETE"} {
192 resp := do(t, m, ts.URL+"/api/v1/delegations", "", nil)
193 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, m)
194 }
195 }
196
197 // TestDelegationRoutesWithoutAGate: with no SSH CA there is nothing to verify a
198 // certificate against, and the refusal says which condition it is.
199 func TestDelegationRoutesWithoutAGate(t *testing.T) {
200 ts, _, _, _, _ := newServer(t) // no SetDelegations
201 for _, m := range []string{"POST", "PUT", "GET", "DELETE"} {
202 resp := do(t, m, ts.URL+"/api/v1/delegations", testPAT, map[string]any{"certificate": "x"})
203 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode, m)
204 assert.Contains(t, bodyText(t, resp), "no SSH CA configured")
205 }
206 }
207
208 // TestDelegationIsAudited: the fingerprint, key id, serial and expiry are the
209 // record of what eitri was lent. The key itself never appears anywhere.
210 func TestDelegationIsAudited(t *testing.T) {
211 ts, st, _, _, a := newServer(t)
212 withDelegations(a)
213 ca := newDelegationCA(t)
214 registerTenantCA(t, st, testTenant, ca)
215
216 var challenge types.DelegationChallenge
217 decodeInto(t, do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil), &challenge)
218 do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT,
219 map[string]any{"certificate": signFor(t, ca, challenge.PublicKey, "ubuntu", time.Now().Add(time.Hour))})
220 do(t, "DELETE", ts.URL+"/api/v1/delegations", testPAT, nil)
221
222 rows, err := st.ListAudit(testTenant, 50)
223 require.NoError(t, err)
224 var actions []string
225 for _, r := range rows {
226 actions = append(actions, r.Action)
227 }
228 assert.Contains(t, actions, "delegation.begin")
229 assert.Contains(t, actions, "delegation.complete")
230 assert.Contains(t, actions, "delegation.revoke")
231 }
internal/server/api/principal.go
Old New
@@ -33,6 +33,15 @@ func principalFromContext(r *http.Request) Principal {
33 return p 33 return p
34 } 34 }
35 35
36 // TenantFromContext returns the tenant of the request's authenticated
37 // principal, or "" if the request never passed UserAuth. It is how a handler
38 // mounted outside the /api/v1 subtree — /mcp — reads the identity the auth
39 // middleware resolved.
40 func TenantFromContext(ctx context.Context) string {
41 p, _ := ctx.Value(principalKey{}).(Principal)
42 return p.Tenant
43 }
44
36 // mayActAs reports whether p may act on a resource owned by tenant. Strict 45 // mayActAs reports whether p may act on a resource owned by tenant. Strict
37 // scope-equality; an empty tenant on either side never matches (resources always 46 // scope-equality; an empty tenant on either side never matches (resources always
38 // have a tenant; a zero principal must not pair with a malformed resource via 47 // have a tenant; a zero principal must not pair with a malformed resource via
internal/server/api/routes.go
Old New
@@ -322,6 +322,51 @@ var routeTable = []Route{
322 Doc: "List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint).", 322 Doc: "List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint).",
323 handler: (*API).handleListUserCAs, 323 handler: (*API).handleListUserCAs,
324 }, 324 },
325 // Delegation: eitri holds an ephemeral keypair per tenant, in memory only.
326 // The caller signs its public half with a CA they have already registered
327 // and posts the certificate back, and eitri authenticates to that tenant's
328 // guests with it until it expires. eitri never holds a signing key, and a
329 // restart drops every delegation. Always the caller's own tenant.
330 {
331 Method: "POST",
332 Path: "/api/v1/delegations",
333 Auth: AuthUser,
334 Kind: KindJSON,
335 Response: (*types.DelegationChallenge)(nil),
336 Success: http.StatusOK,
337 Doc: "Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process.",
338 handler: (*API).handleBeginDelegation,
339 },
340 {
341 Method: "PUT",
342 Path: "/api/v1/delegations",
343 Auth: AuthUser,
344 Kind: KindJSON,
345 Request: (*types.DelegationRequest)(nil),
346 Response: (*types.Delegation)(nil),
347 Success: http.StatusOK,
348 Doc: "Complete a delegation with the certificate your CA signed. The certificate must be a user certificate over the key this delegation issued, signed by a CA registered to your tenant, naming the guest login user as a principal.",
349 handler: (*API).handleCompleteDelegation,
350 },
351 {
352 Method: "GET",
353 Path: "/api/v1/delegations",
354 Auth: AuthUser,
355 Kind: KindJSON,
356 Response: (*types.Delegation)(nil),
357 Success: http.StatusOK,
358 Doc: "Describe the caller tenant's live delegation, including when it expires. 404 when there is none.",
359 handler: (*API).handleGetDelegation,
360 },
361 {
362 Method: "DELETE",
363 Path: "/api/v1/delegations",
364 Auth: AuthUser,
365 Kind: KindJSON,
366 Success: http.StatusNoContent,
367 Doc: "End the caller tenant's delegation now. eitri drops the certificate and can no longer reach that tenant's VMs.",
368 handler: (*API).handleRevokeDelegation,
369 },
325 // Revoke a minted user cert (by serial or cert line) and list revocations — 370 // Revoke a minted user cert (by serial or cert line) and list revocations —
326 // enforced at the gate before a cert's short TTL expires. Pure store ops, 371 // enforced at the gate before a cert's short TTL expires. Pure store ops,
327 // available regardless of whether the minter is wired. 372 // available regardless of whether the minter is wired.
internal/server/api/routes_test.go
Old New
@@ -34,7 +34,7 @@ func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type {
34 // `required` array for request schemas, so a type serving both roles would 34 // `required` array for request schemas, so a type serving both roles would
35 // get the wrong treatment on one of them). 35 // get the wrong treatment on one of them).
36 func TestRouteTable(t *testing.T) { 36 func TestRouteTable(t *testing.T) {
37 const wantRoutes = 31 37 const wantRoutes = 35
38 if len(routeTable) != wantRoutes { 38 if len(routeTable) != wantRoutes {
39 t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) 39 t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes)
40 } 40 }
internal/server/api/testdata/delegation-challenge.golden.json
Old New
@@ -0,0 +1,5 @@
1 {
2 "public_key": "ssh-ed25519 AAAAC3Nza eitri-delegation",
3 "principal": "ubuntu",
4 "instructions": "ssh-keygen -s \u003cyour-ca-key\u003e -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub"
5 }
internal/server/api/testdata/delegation.golden.json
Old New
@@ -0,0 +1,10 @@
1 {
2 "public_key": "ssh-ed25519 AAAAC3Nza eitri-delegation",
3 "ca_fingerprint": "SHA256:abcdefghijk",
4 "key_id": "eitri-delegation",
5 "serial": "12345678901234567890",
6 "principals": [
7 "ubuntu"
8 ],
9 "expires_at": "2026-08-08T12:00:00Z"
10 }
internal/server/api/types/types.go
Old New
@@ -297,6 +297,33 @@ type UserCAUploadResponse struct {
297 Fingerprint string `json:"fingerprint"` 297 Fingerprint string `json:"fingerprint"`
298 } 298 }
299 299
300 // DelegationChallenge answers POST /api/v1/delegations: the public half of the
301 // keypair eitri will authenticate with, and the exact command that authorizes
302 // it. eitri cannot sign this itself — that is the point.
303 type DelegationChallenge struct {
304 PublicKey string `json:"public_key"` // authorized_keys line, to be signed
305 Principal string `json:"principal"` // the principal the certificate MUST carry
306 Instructions string `json:"instructions"` // the ssh-keygen line, ready to run
307 }
308
309 // DelegationRequest posts the signed certificate back. A certificate is public
310 // material, so there is nothing sensitive about carrying one in a request body.
311 type DelegationRequest struct {
312 Certificate string `json:"certificate"`
313 }
314
315 // Delegation describes a live delegation. It is entirely public: eitri's half
316 // is an ephemeral key it holds only in memory, and a certificate is not a
317 // secret. ExpiresAt is when eitri stops being able to reach anything.
318 type Delegation struct {
319 PublicKey string `json:"public_key"`
320 CAFingerprint string `json:"ca_fingerprint"`
321 KeyID string `json:"key_id"`
322 Serial string `json:"serial"`
323 Principals []string `json:"principals"`
324 ExpiresAt string `json:"expires_at"` // RFC3339
325 }
326
300 // UserCA is one entry in GET /api/v1/tenants/{tenant}/user-cas. 327 // UserCA is one entry in GET /api/v1/tenants/{tenant}/user-cas.
301 type UserCA struct { 328 type UserCA struct {
302 Fingerprint string `json:"fingerprint"` 329 Fingerprint string `json:"fingerprint"`
internal/server/api/wire_golden_test.go
Old New
@@ -210,6 +210,21 @@ func TestWireGolden(t *testing.T) {
210 Fingerprint: "SHA256:abcdefghijk", 210 Fingerprint: "SHA256:abcdefghijk",
211 }) 211 })
212 212
213 goldenCheck(t, "delegation-challenge", types.DelegationChallenge{
214 PublicKey: "ssh-ed25519 AAAAC3Nza eitri-delegation",
215 Principal: "ubuntu",
216 Instructions: "ssh-keygen -s <your-ca-key> -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub",
217 })
218
219 goldenCheck(t, "delegation", types.Delegation{
220 PublicKey: "ssh-ed25519 AAAAC3Nza eitri-delegation",
221 CAFingerprint: "SHA256:abcdefghijk",
222 KeyID: "eitri-delegation",
223 Serial: "12345678901234567890",
224 Principals: []string{"ubuntu"},
225 ExpiresAt: "2026-08-08T12:00:00Z",
226 })
227
213 goldenCheck(t, "user-ca-list", []types.UserCA{{ 228 goldenCheck(t, "user-ca-list", []types.UserCA{{
214 Fingerprint: "SHA256:abcdefghijk", 229 Fingerprint: "SHA256:abcdefghijk",
215 Label: "team-alpha-ca", 230 Label: "team-alpha-ca",
internal/server/boot/boot.go
Old New
@@ -23,8 +23,10 @@ import (
23 "github.com/a73x/eitri/internal/joinblob" 23 "github.com/a73x/eitri/internal/joinblob"
24 "github.com/a73x/eitri/internal/server/api" 24 "github.com/a73x/eitri/internal/server/api"
25 serverconfig "github.com/a73x/eitri/internal/server/config" 25 serverconfig "github.com/a73x/eitri/internal/server/config"
26 "github.com/a73x/eitri/internal/server/delegation"
26 "github.com/a73x/eitri/internal/server/health" 27 "github.com/a73x/eitri/internal/server/health"
27 "github.com/a73x/eitri/internal/server/hub" 28 "github.com/a73x/eitri/internal/server/hub"
29 "github.com/a73x/eitri/internal/server/mcphttp"
28 "github.com/a73x/eitri/internal/server/registry" 30 "github.com/a73x/eitri/internal/server/registry"
29 "github.com/a73x/eitri/internal/server/release" 31 "github.com/a73x/eitri/internal/server/release"
30 "github.com/a73x/eitri/internal/server/store" 32 "github.com/a73x/eitri/internal/server/store"
@@ -32,6 +34,7 @@ import (
32 "github.com/a73x/eitri/internal/server/web" 34 "github.com/a73x/eitri/internal/server/web"
33 "github.com/a73x/eitri/internal/transport" 35 "github.com/a73x/eitri/internal/transport"
34 "github.com/quic-go/quic-go" 36 "github.com/quic-go/quic-go"
37 "golang.org/x/crypto/ssh"
35 ) 38 )
36 39
37 // RunCLI dispatches the eitri-server command line (everything after the binary 40 // RunCLI dispatches the eitri-server command line (everything after the binary
@@ -60,7 +63,8 @@ func run(cfgPath string) error {
60 63
61 // The key that seals every piece of key material this server holds, decoded 64 // The key that seals every piece of key material this server holds, decoded
62 // once and handed to each place that seals or opens: the gate's key files 65 // once and handed to each place that seals or opens: the gate's key files
63 // (setupSSHGate). Load has already enforced it. 66 // (setupSSHGate), the API on the way into the store, and vmssh.SealedCAs on
67 // the way back out to the certificate signer. Load has already enforced it.
64 kek, err := cfg.KEKBytes() 68 kek, err := cfg.KEKBytes()
65 if err != nil { 69 if err != nil {
66 return fmt.Errorf("config: %w", err) 70 return fmt.Errorf("config: %w", err)
@@ -184,6 +188,17 @@ func run(cfgPath string) error {
184 // Certify the host keys guests generate for themselves. No-op when the gate 188 // Certify the host keys guests generate for themselves. No-op when the gate
185 // is off, and then no guest waits for a certificate. 189 // is off, and then no guest waits for a certificate.
186 sshGate.wireSync(svc) 190 sshGate.wireSync(svc)
191
192 // The credentials tenants have lent eitri. In memory only, so a restart is a
193 // revocation; the sweeper keeps the keyring bounded by tenants that are
194 // actually using it. Wired to the API only when there is a CA to verify
195 // certificates against — with the gate off the routes answer 503, which is
196 // the same condition that makes remote exec refuse.
197 keyring := delegation.New(time.Now, guestLoginUser)
198 if sshGate != nil {
199 a.SetDelegations(keyring)
200 }
201 go sweepDelegations(context.Background(), keyring)
187 // Console broker: the API bridges browser WebSockets to agent console 202 // Console broker: the API bridges browser WebSockets to agent console
188 // streams over the live sync connections the service tracks. 203 // streams over the live sync connections the service tracks.
189 a.SetConsoleDialer(svc) 204 a.SetConsoleDialer(svc)
@@ -217,6 +232,26 @@ func run(cfgPath string) error {
217 // 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
218 // a browser establishes a session in the first place (spec §2). 233 // a browser establishes a session in the first place (spec §2).
219 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
236 // anywhere on the internet holding nothing but a PAT. It speaks JSON-RPC
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
239 // it is the same authenticated principal /api/v1 would see. Both patterns are
240 // registered because a client may address it with or without a trailing path.
241 mcpHandler := a.UserAuth(mcphttp.New(mcphttp.Deps{
242 Handler: a.Handler(),
243 Creds: delegatedCreds{keyring: keyring, st: st},
244 TCP: svc,
245 Lookup: vmLookup(st),
246 HostCA: sshGate.hostCAPublicKey(),
247 Gate: cfg.SSHGateDomain,
248 VMUser: guestLoginUser,
249 // The API's own origin, so a refusal names something dialable rather
250 // than a path on whichever host the caller happens to be talking to.
251 DelegationsURL: a.URL("/api/v1/delegations"),
252 }))
253 root.Handle("/mcp", mcpHandler)
254 root.Handle("/mcp/", mcpHandler)
220 // Unauthenticated probes (outside /api/, so a load balancer or the deploy 255 // Unauthenticated probes (outside /api/, so a load balancer or the deploy
221 // script needs no token). /livez is process-up; /readyz gates on the 256 // script needs no token). /livez is process-up; /readyz gates on the
222 // dependencies the server needs to actually serve — the DB. The QUIC 257 // dependencies the server needs to actually serve — the DB. The QUIC
@@ -278,6 +313,49 @@ func run(cfgPath string) error {
278 return nil 313 return nil
279 } 314 }
280 315
316 // delegatedCreds pairs the in-memory keyring with the store, which is the one
317 // place those two facts meet. It lives here rather than in vmssh so that
318 // package stays free of the store (R1).
319 type delegatedCreds struct {
320 keyring *delegation.Keyring
321 st *store.Store
322 }
323
324 func (d delegatedCreds) Delegated(tenant string) (ssh.Signer, bool) {
325 return d.keyring.Signer(tenant)
326 }
327
328 func (d delegatedCreds) TenantHasUserCA(tenant string) (bool, error) {
329 return d.st.TenantHasUserCA(tenant)
330 }
331
332 // delegationSweepInterval is how often the keyring drops entries nothing is
333 // using. Nothing depends on it being prompt — an expired delegation stops
334 // working the moment it expires, whether or not it has been swept — so it is
335 // slow on purpose.
336 const delegationSweepInterval = 10 * time.Minute
337
338 // sweepDelegations keeps the keyring bounded by ACTIVE tenants rather than by
339 // every tenant that ever started a delegation.
340 func sweepDelegations(ctx context.Context, k *delegation.Keyring) {
341 t := time.NewTicker(delegationSweepInterval)
342 defer t.Stop()
343 for {
344 select {
345 case <-ctx.Done():
346 return
347 case <-t.C:
348 k.Sweep()
349 }
350 }
351 }
352
353 // guestLoginUser is the account a guest is logged into, and therefore the
354 // principal every user certificate carries: a guest trusts its tenant's CA set
355 // through a bare TrustedUserCAKeys line, so sshd matches the certificate
356 // principal against the login user. Stock cloud images name it "ubuntu".
357 const guestLoginUser = "ubuntu"
358
281 // defaultImages translates the config's per-architecture guest images into the 359 // defaultImages translates the config's per-architecture guest images into the
282 // API's own type, so the API package does not import the config schema (R1: the 360 // API's own type, so the API package does not import the config schema (R1: the
283 // wiring converts, the leaves stay independent). 361 // wiring converts, the leaves stay independent).
internal/server/boot/sshgate.go
Old New
@@ -13,6 +13,7 @@ import (
13 "github.com/a73x/eitri/internal/server/sshgate" 13 "github.com/a73x/eitri/internal/server/sshgate"
14 "github.com/a73x/eitri/internal/server/store" 14 "github.com/a73x/eitri/internal/server/store"
15 "github.com/a73x/eitri/internal/server/syncsvc" 15 "github.com/a73x/eitri/internal/server/syncsvc"
16 "github.com/a73x/eitri/internal/server/vmssh"
16 "golang.org/x/crypto/ssh" 17 "golang.org/x/crypto/ssh"
17 ) 18 )
18 19
@@ -88,6 +89,16 @@ func (s guestHostCertSigner) SignHostCert(pub ssh.PublicKey, principal string) (
88 return string(ssh.MarshalAuthorizedKey(cert)), nil 89 return string(ssh.MarshalAuthorizedKey(cert)), nil
89 } 90 }
90 91
92 // hostCAPublicKey returns the HOST CA public key that certifies every VM's host
93 // key, for callers that verify a guest themselves rather than through the gate.
94 // Nil when the gate is off — there is then no CA, and no VM carries a host cert.
95 func (g *sshGateSetup) hostCAPublicKey() ssh.PublicKey {
96 if g == nil {
97 return nil
98 }
99 return g.ca.HostCA().PublicKey()
100 }
101
91 // startListener starts the SSH jump gate listener: when enabled, front 102 // startListener starts the SSH jump gate listener: when enabled, front
92 // `ssh -J gate ubuntu@<vm>` with the hardened bastion. It resolves VM names 103 // `ssh -J gate ubuntu@<vm>` with the hardened bastion. It resolves VM names
93 // against the store, tunnels port 22 through the sync connection (svc.OpenTCP), 104 // against the store, tunnels port 22 through the sync connection (svc.OpenTCP),
@@ -161,6 +172,28 @@ func authorizeVM(st *store.Store) sshgate.Authorizer {
161 } 172 }
162 } 173 }
163 174
175 // vmLookup pairs resolveVM with authorizeVM for callers that reach a VM without
176 // the gate in front of them — the /mcp handler's server-side SSH. It is the same
177 // two checks in the same order: resolve the name within the tenant only, then
178 // re-read the row and require it to still be that tenant's and still be alive.
179 func vmLookup(st *store.Store) vmssh.VMLookup {
180 resolve, authorize := resolveVM(st), authorizeVM(st)
181 return func(tenant, name string) (vmssh.VM, bool) {
182 hostID, vmID, ok := resolve(tenant, name)
183 if !ok || !authorize(tenant, vmID) {
184 return vmssh.VM{}, false
185 }
186 // The row is read again for the certificate rather than carried out of
187 // resolve: authorize has just re-read it, and a VM that lost its row
188 // between the two is one we must not claim to have verified.
189 vm, err := st.GetVM(vmID)
190 if err != nil {
191 return vmssh.VM{}, false
192 }
193 return vmssh.VM{HostID: hostID, VMID: vmID, HostCertified: vm.SSHHostCert != ""}, true
194 }
195 }
196
164 // revokedCert gates every cert auth against the revocation list. Fail-CLOSED 197 // revokedCert gates every cert auth against the revocation list. Fail-CLOSED
165 // for the single connection on a DB error: a store hiccup rejects THAT login 198 // for the single connection on a DB error: a store hiccup rejects THAT login
166 // (returns revoked=true) rather than fail-open (which would let a possibly- 199 // (returns revoked=true) rather than fail-open (which would let a possibly-
internal/server/boot/sshgate_test.go
Old New
@@ -1,9 +1,11 @@
1 package boot 1 package boot
2 2
3 import ( 3 import (
4 "bytes"
4 "crypto/ed25519" 5 "crypto/ed25519"
5 "testing" 6 "testing"
6 7
8 "github.com/a73x/eitri/internal/server/seal"
7 "github.com/a73x/eitri/internal/server/sshca" 9 "github.com/a73x/eitri/internal/server/sshca"
8 "github.com/a73x/eitri/internal/server/store" 10 "github.com/a73x/eitri/internal/server/store"
9 "github.com/stretchr/testify/assert" 11 "github.com/stretchr/testify/assert"
@@ -11,6 +13,10 @@ import (
11 "golang.org/x/crypto/ssh" 13 "golang.org/x/crypto/ssh"
12 ) 14 )
13 15
16 // testKEK stands in for the config's key_encryption_key, which the gate's key
17 // files rest sealed under.
18 var testKEK = bytes.Repeat([]byte{0x2b}, seal.KEKSize)
19
14 // newStore opens a fresh store on a temp DB. The gate closures resolve VMs and 20 // newStore opens a fresh store on a temp DB. The gate closures resolve VMs and
15 // tenants against a REAL store — the same one the live gate uses — so these 21 // tenants against a REAL store — the same one the live gate uses — so these
16 // tests exercise the actual SQL, not a mock. 22 // tests exercise the actual SQL, not a mock.
@@ -107,6 +113,58 @@ func TestAuthorizeVM(t *testing.T) {
107 assert.False(t, authorize(tenantA, vm.ID), "a tombstoned VM must be rejected") 113 assert.False(t, authorize(tenantA, vm.ID), "a tombstoned VM must be rejected")
108 } 114 }
109 115
116 // TestVMLookupAppliesBothChecks pins that the server-side SSH path resolves a
117 // VM under exactly the gate's rules: within the tenant only, and only while the
118 // VM is live and still that tenant's.
119 func TestVMLookupAppliesBothChecks(t *testing.T) {
120 s := newStore(t)
121 tenantA, hostA := makeTenantHost(t, s, "sub-a", "alpha@x.com")
122 tenantB, _ := makeTenantHost(t, s, "sub-b", "beta@x.com")
123 vm := makeVM(t, s, hostA, "web")
124
125 lookup := vmLookup(s)
126
127 got, ok := lookup(tenantA, "web")
128 require.True(t, ok)
129 assert.Equal(t, hostA.ID, got.HostID)
130 assert.Equal(t, vm.ID, got.VMID)
131 assert.False(t, got.HostCertified, "a VM with no host cert reports itself unverifiable")
132
133 // Once the control plane has signed the guest's key, it is verifiable.
134 require.NoError(t, s.RecordVMHostKey(vm.ID, hostA.ID, "ssh-ed25519 AAAApub g", "cert-line"))
135 got, ok = lookup(tenantA, "web")
136 require.True(t, ok)
137 assert.True(t, got.HostCertified)
138
139 _, ok = lookup(tenantB, "web")
140 assert.False(t, ok, "a name must not resolve across tenants")
141
142 _, ok = lookup(tenantA, "nope")
143 assert.False(t, ok, "an unknown name does not resolve")
144
145 require.NoError(t, s.TombstoneVM(vm.ID))
146 _, ok = lookup(tenantA, "web")
147 assert.False(t, ok, "a tombstoned VM must not resolve")
148 }
149
150 // TestHostCAPublicKeyIsNilWhenTheGateIsOff: with no gate there is no CA, so no
151 // guest carries a host certificate and there is nothing to verify against.
152 func TestHostCAPublicKeyIsNilWhenTheGateIsOff(t *testing.T) {
153 var off *sshGateSetup
154 assert.Nil(t, off.hostCAPublicKey())
155 }
156
157 // TestHostCAPublicKeyIsTheHostCA pins that the key handed to the server-side
158 // dialer is the same one clients pin via @cert-authority.
159 func TestHostCAPublicKeyIsTheHostCA(t *testing.T) {
160 dir := t.TempDir()
161 ca, err := sshca.New(dir+"/ca", dir+"/hostkey", testKEK)
162 require.NoError(t, err)
163 g := &sshGateSetup{ca: ca}
164
165 assert.Equal(t, sshca.AuthorizedKeyLine(g.hostCAPublicKey())+"\n", string(ca.HostCAAuthorizedKey()))
166 }
167
110 // TestRevokedCertFailsClosed pins the revocation gate: a known-revoked serial 168 // TestRevokedCertFailsClosed pins the revocation gate: a known-revoked serial
111 // reads revoked, an unknown serial does not, and a store error fails CLOSED 169 // reads revoked, an unknown serial does not, and a store error fails CLOSED
112 // (reports revoked=true) so a DB hiccup rejects THAT login rather than letting a 170 // (reports revoked=true) so a DB hiccup rejects THAT login rather than letting a
internal/server/config/config.go
Old New
@@ -13,9 +13,8 @@ type Config struct {
13 AdminToken string `json:"admin_token"` 13 AdminToken string `json:"admin_token"`
14 HostSecret string `json:"host_secret"` 14 HostSecret string `json:"host_secret"`
15 // KeyEncryptionKey seals every piece of key material this server holds: the 15 // KeyEncryptionKey seals every piece of key material this server holds: the
16 // host CA and gate host key on disk (ssh_ca_key, ssh_host_key) and each 16 // host CA and gate host key on disk (ssh_ca_key, ssh_host_key). 64 hex
17 // tenant's opt-in managed CA in the database. 64 hex characters (32 bytes, 17 // characters (32 bytes, minted with `openssl rand -hex 32`).
18 // minted with `openssl rand -hex 32`).
19 // 18 //
20 // It lives HERE, in the config, and nowhere near the data it protects — so a 19 // It lives HERE, in the config, and nowhere near the data it protects — so a
21 // copied database, a nightly backup, or a lifted volume carries ciphertext 20 // copied database, a nightly backup, or a lifted volume carries ciphertext
internal/server/config/load.go
Old New
@@ -63,9 +63,9 @@ func validate(cfg Config) error {
63 return fmt.Errorf("host_secret is required") 63 return fmt.Errorf("host_secret is required")
64 } 64 }
65 // The key that seals this server's key material. Checked at boot rather than 65 // The key that seals this server's key material. Checked at boot rather than
66 // at first use: a plane whose KEK is missing or mistyped can open neither 66 // at first use: a plane whose KEK is missing or mistyped cannot open its own
67 // its own host CA nor the managed CAs it holds, and that is a refusal to 67 // host CA, and that is a refusal to start, not a surprise on some later
68 // start, not a surprise on some later request. 68 // request.
69 if _, err := cfg.KEKBytes(); err != nil { 69 if _, err := cfg.KEKBytes(); err != nil {
70 return err 70 return err
71 } 71 }
internal/server/config/load_test.go
Old New
@@ -59,9 +59,9 @@ func TestLoadRequiresHostSecret(t *testing.T) {
59 } 59 }
60 } 60 }
61 61
62 // TestLoadValidatesTheKEK: the key that seals every tenant's managed-CA 62 // TestLoadValidatesTheKEK: the key that seals the fleet's own SSH CA is checked
63 // signing key is checked at boot, so a plane that would write keys nothing can 63 // at boot, so a plane that could not open the key material it holds refuses to
64 // read (or fail to read the ones it holds) refuses to start. 64 // start rather than discovering it on some later request.
65 func TestLoadValidatesTheKEK(t *testing.T) { 65 func TestLoadValidatesTheKEK(t *testing.T) {
66 for _, tc := range []struct { 66 for _, tc := range []struct {
67 name, kek string 67 name, kek string
internal/server/delegation/delegation.go
Old New
@@ -0,0 +1,268 @@
1 // Package delegation holds the credentials a tenant has lent eitri.
2 //
3 // eitri generates an ephemeral keypair per tenant and keeps it in memory. The
4 // tenant signs its public half with their own CA, on their own terms — their
5 // TTL, their principals — and posts the certificate back. eitri then
6 // authenticates to that tenant's guests as key-plus-certificate until the
7 // certificate expires, and holds nothing else. There is no signing key here for
8 // anyone: the most privileged thing eitri can possess is a certificate that runs
9 // out.
10 //
11 // Nothing is persisted. A restart is a revocation, which is the point, and the
12 // certificate's own expiry is the second bound. Because the delegated
13 // certificate chains to a CA the tenant has already registered, guests created
14 // long before the delegation accept it — a delegation is a credential, not a
15 // change to what a guest trusts.
16 //
17 // The package is a leaf on purpose: every validation rule lives here, where it
18 // can be tested exhaustively and cheaply, and the HTTP and MCP layers above stay
19 // dumb.
20 package delegation
21
22 import (
23 "bytes"
24 "crypto/ed25519"
25 "crypto/rand"
26 "errors"
27 "fmt"
28 "slices"
29 "strings"
30 "sync"
31 "time"
32
33 "golang.org/x/crypto/ssh"
34 )
35
36 // Keyring holds one ephemeral keypair per tenant and, once delegated, the
37 // certificate that makes it usable.
38 type Keyring struct {
39 // Now is the clock every validity decision is made against. Injected so the
40 // expiry rules are testable without sleeping.
41 Now func() time.Time
42 // Principal is the guest login user every delegated certificate must name.
43 // A guest trusts its tenant's CA set through a bare TrustedUserCAKeys line
44 // with no AuthorizedPrincipalsFile, so sshd matches the certificate's
45 // principals against the user being logged in as — not against the tenant.
46 Principal string
47
48 mu sync.Mutex
49 tenants map[string]*entry
50 }
51
52 type entry struct {
53 key ssh.Signer // ephemeral, generated once per tenant per process
54 cert *ssh.Certificate
55 certSigner ssh.Signer
56 }
57
58 // Delegation is the public description of a live delegation. Every field is
59 // public material: eitri's half is a key it holds only in memory, and a
60 // certificate is not a secret.
61 type Delegation struct {
62 PublicKey string // the ephemeral public key, authorized_keys form
63 CAFingerprint string // SHA256 fingerprint of the CA that signed
64 KeyID string // the certificate's key id, as the signer set it
65 Serial uint64 // the certificate's serial, for revocation
66 Principals []string // the certificate's principals
67 ExpiresAt time.Time // when eitri stops being able to reach anything
68 }
69
70 // New builds an empty keyring. principal is the guest login user delegated
71 // certificates must name.
72 func New(now func() time.Time, principal string) *Keyring {
73 if now == nil {
74 now = time.Now
75 }
76 return &Keyring{Now: now, Principal: principal, tenants: map[string]*entry{}}
77 }
78
79 // Begin returns the public key this tenant is to sign, generating the keypair
80 // on first call and returning the SAME key every time afterwards.
81 //
82 // Stable-per-process is deliberate. Re-delegating after an expiry is then one
83 // ssh-keygen and one Complete, with no round trip to re-fetch a key that has
84 // not changed — but only within one process: a restart generates a new key,
85 // which is what Complete's wrong-key refusal names. The public key is not a
86 // secret in any sense that matters: it is useless without a certificate, and
87 // eitri cannot make itself one.
88 func (k *Keyring) Begin(tenant string) (string, error) {
89 k.mu.Lock()
90 defer k.mu.Unlock()
91 e, err := k.entryLocked(tenant)
92 if err != nil {
93 return "", err
94 }
95 return authorizedLine(e.key.PublicKey()), nil
96 }
97
98 // Complete accepts the certificate a tenant signed over this tenant's ephemeral
99 // key, and puts it to work.
100 //
101 // trusted reports whether a public key is a CA THIS TENANT has registered. It
102 // is a callback so the package stays free of the store; boot supplies the real
103 // lookup. Every refusal below says what to do next, because every one of them
104 // is a mistake a caller can fix — and an unexplained refusal here resurfaces
105 // three tool calls later as an unexplained SSH failure.
106 func (k *Keyring) Complete(tenant, certLine string, trusted func(ssh.PublicKey) (bool, error)) (Delegation, error) {
107 k.mu.Lock()
108 defer k.mu.Unlock()
109 e, err := k.entryLocked(tenant)
110 if err != nil {
111 return Delegation{}, err
112 }
113
114 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certLine))
115 if err != nil {
116 return Delegation{}, errors.New("that is not an SSH certificate — paste the whole contents of the " +
117 "*-cert.pub file ssh-keygen wrote, on one line")
118 }
119 cert, ok := pub.(*ssh.Certificate)
120 if !ok {
121 return Delegation{}, errors.New("that is a public key, not a certificate — sign it with your own CA " +
122 "(`ssh-keygen -s <your-ca> -I eitri-delegation -n " + k.Principal + " -V +8h <file>.pub`) and send the " +
123 "*-cert.pub it writes")
124 }
125 if cert.CertType != ssh.UserCert {
126 return Delegation{}, errors.New("that is a HOST certificate; eitri authenticates as a user, so it needs a " +
127 "user certificate — sign without `-h`")
128 }
129 if !bytes.Equal(cert.Key.Marshal(), e.key.PublicKey().Marshal()) {
130 // Name the key that IS current. The usual cause is a certificate signed
131 // over a key from before a restart, and without the fingerprint to
132 // compare against there is nothing in the refusal to act on.
133 return Delegation{}, fmt.Errorf("that certificate is over %s, but this tenant's current delegation key is "+
134 "%s — the key changes when the control plane restarts. Call delegate_begin again and sign the key it "+
135 "returns", ssh.FingerprintSHA256(cert.Key), ssh.FingerprintSHA256(e.key.PublicKey()))
136 }
137
138 ok, err = trusted(cert.SignatureKey)
139 if err != nil {
140 return Delegation{}, errors.New("checking which CA signed that certificate failed")
141 }
142 if !ok {
143 return Delegation{}, fmt.Errorf("that certificate was signed by %s, which is not a CA registered to this "+
144 "tenant — your guests would refuse it too. Register that CA (`eitri ca upload`) or sign with one you "+
145 "have already registered", ssh.FingerprintSHA256(cert.SignatureKey))
146 }
147
148 // Principals are checked ahead of the full verification so the error can
149 // name the fix. It is the single most likely mistake, and as a bare
150 // "certificate rejected" it is close to undiagnosable.
151 if !slices.Contains(cert.ValidPrincipals, k.Principal) {
152 return Delegation{}, fmt.Errorf("that certificate's principals are [%s]; a guest matches the principal "+
153 "against the login user, so it must include %q — re-sign with `-n %s`",
154 strings.Join(cert.ValidPrincipals, " "), k.Principal, k.Principal)
155 }
156
157 // One call does the signature, the principal and the validity window, all
158 // against this keyring's clock.
159 checker := &ssh.CertChecker{
160 IsUserAuthority: func(auth ssh.PublicKey) bool {
161 return bytes.Equal(auth.Marshal(), cert.SignatureKey.Marshal())
162 },
163 Clock: k.Now,
164 }
165 if err := checker.CheckCert(k.Principal, cert); err != nil {
166 return Delegation{}, fmt.Errorf("that certificate is not usable: %w", err)
167 }
168
169 signer, err := ssh.NewCertSigner(cert, e.key)
170 if err != nil {
171 return Delegation{}, fmt.Errorf("that certificate does not pair with the key: %w", err)
172 }
173 e.cert = cert
174 e.certSigner = signer
175 return describe(e), nil
176 }
177
178 // Signer returns the credential eitri may authenticate to this tenant's guests
179 // with, or false when there is none. An expired delegation is dropped here
180 // rather than reported, so it is indistinguishable from never having existed —
181 // which is what the caller needs to be told to do about it either way.
182 func (k *Keyring) Signer(tenant string) (ssh.Signer, bool) {
183 k.mu.Lock()
184 defer k.mu.Unlock()
185 e, ok := k.tenants[tenant]
186 if !ok || e.cert == nil {
187 return nil, false
188 }
189 if k.expiredLocked(e) {
190 e.cert, e.certSigner = nil, nil
191 return nil, false
192 }
193 return e.certSigner, true
194 }
195
196 // Status describes this tenant's live delegation, if any.
197 func (k *Keyring) Status(tenant string) (Delegation, bool) {
198 k.mu.Lock()
199 defer k.mu.Unlock()
200 e, ok := k.tenants[tenant]
201 if !ok || e.cert == nil || k.expiredLocked(e) {
202 return Delegation{}, false
203 }
204 return describe(e), true
205 }
206
207 // Revoke drops this tenant's delegation immediately. The ephemeral key stays,
208 // so a later Begin returns the same public key and re-delegating is one signing
209 // step.
210 func (k *Keyring) Revoke(tenant string) {
211 k.mu.Lock()
212 defer k.mu.Unlock()
213 if e, ok := k.tenants[tenant]; ok {
214 e.cert, e.certSigner = nil, nil
215 }
216 }
217
218 // Sweep drops every entry that is not doing anything: no certificate, or an
219 // expired one. It bounds the keyring by ACTIVE tenants rather than by every
220 // tenant that ever called Begin. An entry with no certificate survives until
221 // the next sweep — it is one keypair, around a hundred bytes — so a caller who
222 // takes a few minutes between Begin and Complete is not raced.
223 func (k *Keyring) Sweep() {
224 k.mu.Lock()
225 defer k.mu.Unlock()
226 for tenant, e := range k.tenants {
227 if e.cert == nil || k.expiredLocked(e) {
228 delete(k.tenants, tenant)
229 }
230 }
231 }
232
233 // entryLocked returns this tenant's entry, generating its keypair on first use.
234 func (k *Keyring) entryLocked(tenant string) (*entry, error) {
235 if e, ok := k.tenants[tenant]; ok {
236 return e, nil
237 }
238 _, priv, err := ed25519.GenerateKey(rand.Reader)
239 if err != nil {
240 return nil, fmt.Errorf("generate delegation key: %w", err)
241 }
242 signer, err := ssh.NewSignerFromSigner(priv)
243 if err != nil {
244 return nil, fmt.Errorf("delegation signer: %w", err)
245 }
246 e := &entry{key: signer}
247 k.tenants[tenant] = e
248 return e, nil
249 }
250
251 func (k *Keyring) expiredLocked(e *entry) bool {
252 return !k.Now().Before(time.Unix(int64(e.cert.ValidBefore), 0)) //nolint:gosec // ValidBefore is a unix time
253 }
254
255 func describe(e *entry) Delegation {
256 return Delegation{
257 PublicKey: authorizedLine(e.key.PublicKey()),
258 CAFingerprint: ssh.FingerprintSHA256(e.cert.SignatureKey),
259 KeyID: e.cert.KeyId,
260 Serial: e.cert.Serial,
261 Principals: slices.Clone(e.cert.ValidPrincipals),
262 ExpiresAt: time.Unix(int64(e.cert.ValidBefore), 0).UTC(), //nolint:gosec // ValidBefore is a unix time
263 }
264 }
265
266 func authorizedLine(pub ssh.PublicKey) string {
267 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
268 }
internal/server/delegation/delegation_test.go
Old New
@@ -0,0 +1,369 @@
1 package delegation
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13 "golang.org/x/crypto/ssh"
14 )
15
16 var now = time.Unix(1_800_000_000, 0).UTC()
17
18 func fixedNow() time.Time { return now }
19
20 // newCA returns a signer standing in for a tenant's own SSH user CA.
21 func newCA(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 // trusts builds the trust callback for a fixed set of registered CAs.
31 func trusts(cas ...ssh.Signer) func(ssh.PublicKey) (bool, error) {
32 return func(k ssh.PublicKey) (bool, error) {
33 for _, ca := range cas {
34 if string(ca.PublicKey().Marshal()) == string(k.Marshal()) {
35 return true, nil
36 }
37 }
38 return false, nil
39 }
40 }
41
42 type certOpts struct {
43 certType uint32
44 principals []string
45 validAfter time.Time
46 validBefore time.Time
47 key ssh.PublicKey // defaults to the pubLine argument
48 }
49
50 // sign builds the certificate a tenant would produce with `ssh-keygen -s`.
51 func sign(t *testing.T, ca ssh.Signer, pubLine string, o certOpts) string {
52 t.Helper()
53 key := o.key
54 if key == nil {
55 parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine))
56 require.NoError(t, err)
57 key = parsed
58 }
59 if o.certType == 0 {
60 o.certType = ssh.UserCert
61 }
62 if o.principals == nil {
63 o.principals = []string{"ubuntu"}
64 }
65 if o.validAfter.IsZero() {
66 o.validAfter = now.Add(-time.Minute)
67 }
68 if o.validBefore.IsZero() {
69 o.validBefore = now.Add(8 * time.Hour)
70 }
71 cert := &ssh.Certificate{
72 Key: key,
73 Serial: 42,
74 CertType: o.certType,
75 KeyId: "eitri-delegation",
76 ValidPrincipals: o.principals,
77 ValidAfter: uint64(o.validAfter.Unix()),
78 ValidBefore: uint64(o.validBefore.Unix()),
79 Permissions: ssh.Permissions{Extensions: map[string]string{
80 "permit-pty": "", "permit-port-forwarding": "",
81 }},
82 }
83 require.NoError(t, cert.SignCert(rand.Reader, ca))
84 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
85 }
86
87 // delegate runs the whole happy path and returns the keyring, the CA and the
88 // resulting description.
89 func delegate(t *testing.T) (*Keyring, ssh.Signer, Delegation) {
90 t.Helper()
91 k := New(fixedNow, "ubuntu")
92 ca := newCA(t)
93 pub, err := k.Begin("acme")
94 require.NoError(t, err)
95 d, err := k.Complete("acme", sign(t, ca, pub, certOpts{}), trusts(ca))
96 require.NoError(t, err)
97 return k, ca, d
98 }
99
100 func TestBeginIsStableAndPerTenant(t *testing.T) {
101 k := New(fixedNow, "ubuntu")
102 a1, err := k.Begin("acme")
103 require.NoError(t, err)
104 a2, err := k.Begin("acme")
105 require.NoError(t, err)
106 assert.Equal(t, a1, a2, "re-delegating must not need a new key")
107
108 b, err := k.Begin("other")
109 require.NoError(t, err)
110 assert.NotEqual(t, a1, b, "one tenant's delegation must never be usable as another's")
111 assert.True(t, strings.HasPrefix(a1, "ssh-ed25519 "), "got %q", a1)
112 }
113
114 func TestCompleteAcceptsAWellFormedCertificate(t *testing.T) {
115 k, ca, d := delegate(t)
116
117 assert.Equal(t, ssh.FingerprintSHA256(ca.PublicKey()), d.CAFingerprint)
118 assert.Equal(t, "eitri-delegation", d.KeyID)
119 assert.Equal(t, uint64(42), d.Serial)
120 assert.Equal(t, []string{"ubuntu"}, d.Principals)
121 assert.Equal(t, now.Add(8*time.Hour), d.ExpiresAt)
122
123 signer, ok := k.Signer("acme")
124 require.True(t, ok)
125 cert, ok := signer.PublicKey().(*ssh.Certificate)
126 require.True(t, ok, "eitri must authenticate as key-plus-certificate")
127 assert.Equal(t, d.PublicKey, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert.Key))))
128 }
129
130 func TestCompleteRefusals(t *testing.T) {
131 ca := newCA(t)
132 stranger := newCA(t)
133 otherTenantCA := newCA(t)
134
135 cases := []struct {
136 name string
137 // cert builds the line to post, given the tenant's own public key.
138 cert func(t *testing.T, pub string) string
139 // trust is the tenant's registered CA set.
140 trust func(ssh.PublicKey) (bool, error)
141 contains string
142 }{
143 {
144 name: "a bare public key",
145 cert: func(t *testing.T, pub string) string { return pub },
146 trust: trusts(ca),
147 contains: "public key, not a certificate",
148 },
149 {
150 name: "garbage bytes",
151 cert: func(t *testing.T, _ string) string { return "not a key at all" },
152 trust: trusts(ca),
153 contains: "not an SSH certificate",
154 },
155 {
156 name: "a host certificate",
157 cert: func(t *testing.T, pub string) string {
158 return sign(t, ca, pub, certOpts{certType: ssh.HostCert})
159 },
160 trust: trusts(ca),
161 contains: "HOST certificate",
162 },
163 {
164 name: "a certificate for somebody else's key",
165 cert: func(t *testing.T, _ string) string {
166 return sign(t, ca, "", certOpts{key: newCA(t).PublicKey()})
167 },
168 trust: trusts(ca),
169 contains: "current delegation key is",
170 },
171 {
172 name: "a certificate over a key from before a restart",
173 cert: func(t *testing.T, _ string) string {
174 return sign(t, ca, "", certOpts{key: newCA(t).PublicKey()})
175 },
176 trust: trusts(ca),
177 // The remedy, not just the diagnosis: the key changed underneath
178 // the caller and only delegate_begin hands out the new one.
179 contains: "Call delegate_begin again",
180 },
181 {
182 name: "a certificate from an unregistered CA",
183 cert: func(t *testing.T, pub string) string { return sign(t, stranger, pub, certOpts{}) },
184 trust: trusts(ca),
185 contains: "not a CA registered to this tenant",
186 },
187 {
188 name: "a certificate from ANOTHER tenant's registered CA",
189 cert: func(t *testing.T, pub string) string { return sign(t, otherTenantCA, pub, certOpts{}) },
190 trust: trusts(ca), // this tenant's set; the other tenant's CA is not in it
191 contains: "not a CA registered to this tenant",
192 },
193 {
194 name: "principals that omit the login user",
195 cert: func(t *testing.T, pub string) string {
196 return sign(t, ca, pub, certOpts{principals: []string{"alex"}})
197 },
198 trust: trusts(ca),
199 contains: "must include \"ubuntu\"",
200 },
201 {
202 name: "an already-expired certificate",
203 cert: func(t *testing.T, pub string) string {
204 return sign(t, ca, pub, certOpts{
205 validAfter: now.Add(-2 * time.Hour),
206 validBefore: now.Add(-time.Hour),
207 })
208 },
209 trust: trusts(ca),
210 contains: "not usable",
211 },
212 {
213 name: "a not-yet-valid certificate",
214 cert: func(t *testing.T, pub string) string {
215 return sign(t, ca, pub, certOpts{
216 validAfter: now.Add(time.Hour),
217 validBefore: now.Add(2 * time.Hour),
218 })
219 },
220 trust: trusts(ca),
221 contains: "not usable",
222 },
223 }
224
225 for _, tc := range cases {
226 t.Run(tc.name, func(t *testing.T) {
227 k := New(fixedNow, "ubuntu")
228 pub, err := k.Begin("acme")
229 require.NoError(t, err)
230
231 _, err = k.Complete("acme", tc.cert(t, pub), tc.trust)
232 require.Error(t, err)
233 assert.Contains(t, err.Error(), tc.contains)
234
235 _, ok := k.Signer("acme")
236 assert.False(t, ok, "a refused certificate must leave eitri with nothing")
237 })
238 }
239 }
240
241 func TestCompleteReportsATrustLookupFailure(t *testing.T) {
242 k := New(fixedNow, "ubuntu")
243 ca := newCA(t)
244 pub, err := k.Begin("acme")
245 require.NoError(t, err)
246
247 _, err = k.Complete("acme", sign(t, ca, pub, certOpts{}), func(ssh.PublicKey) (bool, error) {
248 return false, assert.AnError
249 })
250 require.Error(t, err)
251 assert.Contains(t, err.Error(), "checking which CA signed")
252 }
253
254 func TestARefusalNamesTheCommandThatFixesIt(t *testing.T) {
255 k := New(fixedNow, "ubuntu")
256 pub, err := k.Begin("acme")
257 require.NoError(t, err)
258 _, err = k.Complete("acme", pub, trusts())
259 require.Error(t, err)
260 assert.Contains(t, err.Error(), "ssh-keygen -s", "a refusal must say what to run next")
261 assert.Contains(t, err.Error(), "-n ubuntu")
262 }
263
264 func TestSignerStopsAtExpiry(t *testing.T) {
265 k := New(fixedNow, "ubuntu")
266 ca := newCA(t)
267 pub, err := k.Begin("acme")
268 require.NoError(t, err)
269 _, err = k.Complete("acme", sign(t, ca, pub, certOpts{validBefore: now.Add(time.Hour)}), trusts(ca))
270 require.NoError(t, err)
271
272 _, ok := k.Signer("acme")
273 require.True(t, ok)
274
275 clock := now
276 k.Now = func() time.Time { return clock }
277 clock = now.Add(59 * time.Minute)
278 _, ok = k.Signer("acme")
279 assert.True(t, ok, "still inside the window")
280
281 clock = now.Add(time.Hour)
282 _, ok = k.Signer("acme")
283 assert.False(t, ok, "eitri must lose access the moment the certificate does")
284 _, ok = k.Status("acme")
285 assert.False(t, ok)
286 }
287
288 func TestSignerAndStatusAreAbsentBeforeAnyDelegation(t *testing.T) {
289 k := New(fixedNow, "ubuntu")
290 _, ok := k.Signer("acme")
291 assert.False(t, ok)
292 _, ok = k.Status("acme")
293 assert.False(t, ok)
294
295 // Even after Begin: a key with no certificate is not access.
296 _, err := k.Begin("acme")
297 require.NoError(t, err)
298 _, ok = k.Signer("acme")
299 assert.False(t, ok)
300 }
301
302 func TestRevokeIsImmediateAndKeepsTheKey(t *testing.T) {
303 k, ca, d := delegate(t)
304 k.Revoke("acme")
305
306 _, ok := k.Signer("acme")
307 assert.False(t, ok)
308
309 // The same key comes back, so re-delegating is one signing step.
310 pub, err := k.Begin("acme")
311 require.NoError(t, err)
312 assert.Equal(t, d.PublicKey, pub)
313 _, err = k.Complete("acme", sign(t, ca, pub, certOpts{}), trusts(ca))
314 require.NoError(t, err)
315 _, ok = k.Signer("acme")
316 assert.True(t, ok)
317 }
318
319 func TestSweepDropsWhatIsNotBeingUsed(t *testing.T) {
320 clock := now
321 k := New(func() time.Time { return clock }, "ubuntu")
322 ca := newCA(t)
323
324 live, err := k.Begin("live")
325 require.NoError(t, err)
326 _, err = k.Complete("live", sign(t, ca, live, certOpts{validBefore: now.Add(8 * time.Hour)}), trusts(ca))
327 require.NoError(t, err)
328
329 dead, err := k.Begin("dead")
330 require.NoError(t, err)
331 _, err = k.Complete("dead", sign(t, ca, dead, certOpts{validBefore: now.Add(time.Hour)}), trusts(ca))
332 require.NoError(t, err)
333
334 _, err = k.Begin("never-finished")
335 require.NoError(t, err)
336
337 clock = now.Add(2 * time.Hour)
338 k.Sweep()
339
340 k.mu.Lock()
341 _, hasLive := k.tenants["live"]
342 _, hasDead := k.tenants["dead"]
343 _, hasPending := k.tenants["never-finished"]
344 k.mu.Unlock()
345
346 assert.True(t, hasLive)
347 assert.False(t, hasDead, "an expired delegation is dead weight")
348 assert.False(t, hasPending, "a keyring is bounded by active tenants")
349 }
350
351 func TestConcurrentUseIsSafe(t *testing.T) {
352 k := New(fixedNow, "ubuntu")
353 ca := newCA(t)
354 var wg sync.WaitGroup
355 for i := range 8 {
356 wg.Add(1)
357 go func() {
358 defer wg.Done()
359 tenant := []string{"a", "b"}[i%2]
360 pub, err := k.Begin(tenant)
361 assert.NoError(t, err)
362 _, _ = k.Complete(tenant, sign(t, ca, pub, certOpts{}), trusts(ca))
363 k.Signer(tenant)
364 k.Status(tenant)
365 k.Sweep()
366 }()
367 }
368 wg.Wait()
369 }
internal/server/mcphttp/inproc.go
Old New
@@ -0,0 +1,58 @@
1 package mcphttp
2
3 import (
4 "bytes"
5 "io"
6 "net/http"
7 )
8
9 // inproc dispatches a client request into the server's own handler. The caller's
10 // PAT rides the Authorization header exactly as a remote client's would, so the
11 // request re-authenticates through the same middleware and lands on the same
12 // Principal — there is no second authorization path to keep in step with the
13 // first, and no socket in between.
14 type inproc struct{ h http.Handler }
15
16 func (t inproc) RoundTrip(r *http.Request) (*http.Response, error) {
17 rec := &recorder{header: http.Header{}, status: http.StatusOK}
18 t.h.ServeHTTP(rec, r)
19 resp := &http.Response{
20 StatusCode: rec.status,
21 Status: http.StatusText(rec.status),
22 Header: rec.header,
23 Body: io.NopCloser(bytes.NewReader(rec.body.Bytes())),
24 ContentLength: int64(rec.body.Len()),
25 Request: r,
26 Proto: "HTTP/1.1",
27 ProtoMajor: 1,
28 ProtoMinor: 1,
29 }
30 return resp, nil
31 }
32
33 // recorder is the minimal http.ResponseWriter the in-process round trip needs.
34 // It is written out by hand rather than importing net/http/httptest, which is a
35 // testing package and has no business in a serving path.
36 type recorder struct {
37 header http.Header
38 body bytes.Buffer
39 status int
40 wroteHeader bool
41 }
42
43 func (r *recorder) Header() http.Header { return r.header }
44
45 func (r *recorder) Write(p []byte) (int, error) {
46 if !r.wroteHeader {
47 r.WriteHeader(http.StatusOK)
48 }
49 return r.body.Write(p)
50 }
51
52 func (r *recorder) WriteHeader(status int) {
53 if r.wroteHeader {
54 return
55 }
56 r.wroteHeader = true
57 r.status = status
58 }
internal/server/mcphttp/mcphttp.go
Old New
@@ -0,0 +1,124 @@
1 // Package mcphttp serves the eitri MCP toolset over HTTP at /mcp. The transport
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
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
6 // are the same ones the stdio binary exposes.
7 //
8 // 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
10 // are the API's, unduplicated; SSH reaches VMs over the host's sync tunnel with
11 // the credential that tenant has delegated to eitri.
12 package mcphttp
13
14 import (
15 "context"
16 "fmt"
17 "net/http"
18 "strings"
19
20 "github.com/a73x/eitri/internal/mcpserver"
21 "github.com/a73x/eitri/internal/server/api"
22 "github.com/a73x/eitri/internal/server/api/client"
23 "github.com/a73x/eitri/internal/server/vmssh"
24 "github.com/modelcontextprotocol/go-sdk/mcp"
25 "golang.org/x/crypto/ssh"
26 )
27
28 // inprocBaseURL is the host the in-process client addresses. Nothing resolves
29 // it: the request never leaves the process, and the API's own mux answers.
30 const inprocBaseURL = "http://eitri.internal"
31
32 // Deps is everything /mcp needs from the rest of the control plane.
33 type Deps struct {
34 Handler http.Handler // the API's own mux, for the in-process client
35 Creds vmssh.Credentials // what a tenant has delegated, and whether it has any CA at all
36 TCP vmssh.TCPDialer
37 Lookup vmssh.VMLookup
38 HostCA ssh.PublicKey // nil ⇒ no jump gate ⇒ remote exec refused with a clear reason
39 Gate string // gate address, for the ssh_command hint only
40 VMUser string // guest login user, and the certificate principal
41 // DelegationsURL is the full URL a caller POSTs to start a delegation. The
42 // REST API and /mcp can be served on different hostnames, so the refusal
43 // that carries this must not leave the caller to guess which.
44 DelegationsURL string
45 }
46
47 // New returns the /mcp handler. The API mux and the schema cache are built once
48 // and shared by every request; everything identity-bearing is built per request.
49 func New(d Deps) http.Handler {
50 transport := &http.Client{Transport: inproc{h: d.Handler}}
51 cache := mcp.NewSchemaCache()
52 return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
53 return serverFor(d, transport, cache, r)
54 }, &mcp.StreamableHTTPOptions{Stateless: true})
55 }
56
57 // serverFor builds the MCP server for one authenticated caller. The tenant comes
58 // from the principal the auth middleware resolved; the PAT is passed straight
59 // back down so the in-process API calls re-authenticate as that same caller.
60 func serverFor(d Deps, transport *http.Client, cache *mcp.SchemaCache, r *http.Request) *mcp.Server {
61 tenant := api.TenantFromContext(r.Context())
62 pat := bearer(r)
63 c := &client.Client{BaseURL: inprocBaseURL, Token: pat, HTTP: transport}
64 tools := &mcpserver.Tools{
65 API: mcpserver.API{Client: c},
66 Runner: mcpserver.NewRunner(&vmssh.Dialer{
67 Tenant: tenant,
68 VMUser: d.VMUser,
69 TCP: d.TCP,
70 Lookup: d.Lookup,
71 Creds: d.Creds,
72 HostCA: d.HostCA,
73 DelegationsURL: d.DelegationsURL,
74 }),
75 Gate: d.Gate,
76 VMUser: d.VMUser,
77 }
78 return mcpserver.NewServer(tools, mcpserver.Options{
79 Delegator: delegator{c: c},
80 SchemaCache: cache,
81 })
82 }
83
84 // bearer returns the request's bearer token. A request that reached here passed
85 // the auth middleware, so a non-empty Authorization header is the PAT; a console
86 // session cookie instead leaves this empty, and the in-process API calls then
87 // fail as unauthenticated rather than silently borrowing someone's identity.
88 func bearer(r *http.Request) string {
89 tok, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
90 return tok
91 }
92
93 // delegator backs the two delegate tools with the delegation endpoints, and
94 // words what the model should do next at each step. Authorization stays the
95 // API's: these go through the same in-process client every other tool uses.
96 type delegator struct{ c *client.Client }
97
98 func (dg delegator) Begin(ctx context.Context) (mcpserver.BeginResult, error) {
99 ch, err := dg.c.BeginDelegation(ctx)
100 if err != nil {
101 return mcpserver.BeginResult{}, fmt.Errorf("starting a delegation: %w", err)
102 }
103 return mcpserver.BeginResult{
104 PublicKey: ch.PublicKey,
105 Principal: ch.Principal,
106 Instructions: ch.Instructions,
107 Note: "eitri cannot sign this itself — it holds no CA. Show the human the public key and the command, " +
108 "wait for them to run it, and pass the resulting *-cert.pub to delegate_complete.",
109 }, nil
110 }
111
112 func (dg delegator) Complete(ctx context.Context, certificate string) (mcpserver.DelegationResult, error) {
113 d, err := dg.c.CompleteDelegation(ctx, certificate)
114 if err != nil {
115 return mcpserver.DelegationResult{}, fmt.Errorf("completing the delegation: %w", err)
116 }
117 return mcpserver.DelegationResult{
118 ExpiresAt: d.ExpiresAt,
119 CAFingerprint: d.CAFingerprint,
120 Principals: d.Principals,
121 Note: "eitri can now reach your VMs until " + d.ExpiresAt + ". It holds no signing key — only this " +
122 "certificate, in memory. If the control plane restarts, delegate again.",
123 }, nil
124 }
internal/server/mcphttp/mcphttp_test.go
Old New
@@ -0,0 +1,559 @@
1 package mcphttp
2
3 import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "crypto/rand"
8 "encoding/json"
9 "net/http"
10 "net/http/httptest"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/a73x/eitri/internal/server/api"
16 "github.com/a73x/eitri/internal/server/api/types"
17 "github.com/a73x/eitri/internal/server/delegation"
18 "github.com/a73x/eitri/internal/server/hub"
19 "github.com/a73x/eitri/internal/server/registry"
20 "github.com/a73x/eitri/internal/server/store"
21 "github.com/a73x/eitri/internal/server/vmssh"
22 "github.com/modelcontextprotocol/go-sdk/mcp"
23 "github.com/stretchr/testify/assert"
24 "github.com/stretchr/testify/require"
25 "golang.org/x/crypto/ssh"
26 )
27
28 // fixture is a control plane with two tenants, each holding a PAT, fronted by
29 // the same root mux the server binary builds: /api/ and /mcp behind one
30 // authentication.
31 type fixture struct {
32 ts *httptest.Server
33 st *store.Store
34 tenantA string
35 patA string
36 tenantB string
37 patB string
38 keyring *delegation.Keyring
39 }
40
41 // testCreds is the pairing boot makes: the in-memory keyring plus the store.
42 type testCreds struct {
43 k *delegation.Keyring
44 st *store.Store
45 }
46
47 func (c testCreds) Delegated(tenant string) (ssh.Signer, bool) { return c.k.Signer(tenant) }
48 func (c testCreds) TenantHasUserCA(tenant string) (bool, error) { return c.st.TenantHasUserCA(tenant) }
49
50 func newFixture(t *testing.T) fixture {
51 t.Helper()
52 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
53 require.NoError(t, err)
54 t.Cleanup(func() { st.Close() })
55
56 a := api.New(api.Config{
57 HostSecret: []byte("hostsecret"),
58 AdvertiseHTTP: "http://127.0.0.1:8080",
59 AdvertiseQUIC: "127.0.0.1:8443",
60 ServerCertSHA256: strings.Repeat("c", 64),
61 }, st, registry.New(time.Now), hub.New())
62 t.Cleanup(a.Close)
63
64 f := fixture{st: st}
65 f.tenantA, f.patA = newTenant(t, st, "alpha")
66 f.tenantB, f.patB = newTenant(t, st, "beta")
67
68 // A host CA stands in for a configured jump gate, so the exec path gets past
69 // "no CA configured" and reaches the tenant's own CA situation.
70 _, hostCAPriv, err := ed25519.GenerateKey(rand.Reader)
71 require.NoError(t, err)
72 hostCA, err := ssh.NewSignerFromSigner(hostCAPriv)
73 require.NoError(t, err)
74
75 // The delegation keyring the control plane wires: shared between the API
76 // (which fills it) and the exec path (which reads it), exactly as in boot.
77 f.keyring = delegation.New(time.Now, "ubuntu")
78 a.SetDelegations(f.keyring)
79
80 root := http.NewServeMux()
81 root.Handle("/api/", a.Handler())
82 mcpHandler := a.UserAuth(New(Deps{
83 Handler: a.Handler(), Creds: testCreds{k: f.keyring, st: st}, VMUser: "ubuntu", HostCA: hostCA.PublicKey(),
84 Lookup: func(string, string) (vmssh.VM, bool) { return vmssh.VM{}, false },
85 }))
86 root.Handle("/mcp", mcpHandler)
87 root.Handle("/mcp/", mcpHandler)
88
89 f.ts = httptest.NewServer(root)
90 t.Cleanup(f.ts.Close)
91 return f
92 }
93
94 func newTenant(t *testing.T, st *store.Store, handle string) (string, string) {
95 t.Helper()
96 tn, err := st.CreateTenantForIdentity("https://issuer.example", "sub-"+handle, handle+"@example.com")
97 require.NoError(t, err)
98 pat, _, err := st.CreateAPIToken(tn.ID, handle, 0)
99 require.NoError(t, err)
100 return tn.ID, pat
101 }
102
103 // seedVM gives a tenant one host and one ready VM, so vm_list has something to
104 // filter and the exec tools get as far as trying to reach a guest.
105 func seedVM(t *testing.T, st *store.Store, tenant, hostName, vmName string) {
106 t.Helper()
107 tok, err := st.CreateEnrollmentToken(tenant)
108 require.NoError(t, err)
109 h, err := st.RedeemEnrollmentToken(tok, store.EnrollFacts{
110 Name: hostName, OS: "linux", Arch: "amd64", Provisioner: "cloudhv"})
111 require.NoError(t, err)
112 require.NoError(t, st.CreateVM(store.VM{
113 ID: vmName + "-id", HostID: h.ID, Name: vmName,
114 ImageURL: "https://images.example/x.img", ImageSHA256: strings.Repeat("a", 64),
115 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
116 _, err = st.RecordVMStatus(vmName+"-id", "ready", "", "10.77.0.5")
117 require.NoError(t, err)
118 }
119
120 // connect opens a real MCP client against /mcp with pat as the bearer token.
121 func connect(t *testing.T, f fixture, pat string) *mcp.ClientSession {
122 t.Helper()
123 tr := &mcp.StreamableClientTransport{
124 Endpoint: f.ts.URL + "/mcp",
125 HTTPClient: &http.Client{Transport: bearerTransport{pat: pat}},
126 }
127 cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(t.Context(), tr, nil)
128 require.NoError(t, err)
129 t.Cleanup(func() { cs.Close() })
130 return cs
131 }
132
133 // bearerTransport attaches the PAT the way a remote MCP client would.
134 type bearerTransport struct{ pat string }
135
136 func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
137 if b.pat != "" {
138 r.Header.Set("Authorization", "Bearer "+b.pat)
139 }
140 return http.DefaultTransport.RoundTrip(r)
141 }
142
143 // post sends a raw JSON-RPC body, for the cases an MCP client cannot express
144 // (no credential, a bad one, the wrong method).
145 func post(t *testing.T, f fixture, method, pat, body string) *http.Response {
146 t.Helper()
147 req, err := http.NewRequest(method, f.ts.URL+"/mcp", strings.NewReader(body))
148 require.NoError(t, err)
149 req.Header.Set("Content-Type", "application/json")
150 req.Header.Set("Accept", "application/json, text/event-stream")
151 if pat != "" {
152 req.Header.Set("Authorization", "Bearer "+pat)
153 }
154 resp, err := http.DefaultClient.Do(req)
155 require.NoError(t, err)
156 t.Cleanup(func() { resp.Body.Close() })
157 return resp
158 }
159
160 const initBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}`
161
162 // ── tests ────────────────────────────────────────────────────────────────────
163
164 // TestMCPRequiresACredential: the endpoint lives outside the REST route table
165 // but not outside its authentication.
166 func TestMCPRequiresACredential(t *testing.T) {
167 f := newFixture(t)
168 assert.Equal(t, http.StatusUnauthorized, post(t, f, "POST", "", initBody).StatusCode)
169 }
170
171 // TestMCPRejectsABadPAT: an invalid token is indistinguishable from none.
172 func TestMCPRejectsABadPAT(t *testing.T) {
173 f := newFixture(t)
174 assert.Equal(t, http.StatusUnauthorized, post(t, f, "POST", "eitri_pat_notreal", initBody).StatusCode)
175 }
176
177 // TestMCPGetIsNotAllowed: stateless streamable HTTP has no server-initiated
178 // stream to open, so a GET is answered 405 rather than hanging.
179 func TestMCPGetIsNotAllowed(t *testing.T) {
180 f := newFixture(t)
181 resp := post(t, f, "GET", f.patA, "")
182 assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
183 }
184
185 // TestMCPExposesTheFullToolset: a remote caller gets the ten VM tools plus the
186 // two delegation tools, which is what makes a bare PAT enough.
187 func TestMCPExposesTheFullToolset(t *testing.T) {
188 f := newFixture(t)
189 cs := connect(t, f, f.patA)
190
191 res, err := cs.ListTools(t.Context(), nil)
192 require.NoError(t, err)
193 names := make([]string, 0, len(res.Tools))
194 for _, tool := range res.Tools {
195 names = append(names, tool.Name)
196 }
197 assert.Len(t, names, 14)
198 assert.Contains(t, names, "delegate_begin")
199 assert.Contains(t, names, "delegate_complete")
200 assert.Contains(t, names, "ca_upload")
201 assert.Contains(t, names, "tenant_info")
202 assert.Contains(t, names, "vm_exec")
203 }
204
205 // TestVMListIsTenantIsolated is the property the whole per-request-identity
206 // design exists for: two PATs, two tenants, and neither sees the other's VMs.
207 func TestVMListIsTenantIsolated(t *testing.T) {
208 f := newFixture(t)
209 seedVM(t, f.st, f.tenantA, "host-alpha", "alpha-vm")
210 seedVM(t, f.st, f.tenantB, "host-beta", "beta-vm")
211
212 assert.Equal(t, []string{"alpha-vm"}, listVMNames(t, connect(t, f, f.patA)))
213 assert.Equal(t, []string{"beta-vm"}, listVMNames(t, connect(t, f, f.patB)))
214 }
215
216 func listVMNames(t *testing.T, cs *mcp.ClientSession) []string {
217 t.Helper()
218 res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{Name: "vm_list"})
219 require.NoError(t, err)
220 require.False(t, res.IsError, "vm_list failed: %+v", res.Content)
221
222 var out struct {
223 VMs []types.VM `json:"vms"`
224 }
225 raw, err := json.Marshal(res.StructuredContent)
226 require.NoError(t, err)
227 require.NoError(t, json.Unmarshal(raw, &out))
228 names := make([]string, 0, len(out.VMs))
229 for _, vm := range out.VMs {
230 names = append(names, vm.Name)
231 }
232 return names
233 }
234
235 // TestDelegationRoundTripThroughTheTransport drives the whole exchange the way
236 // a model would: ask for a key, sign it out of band with the tenant's own CA,
237 // hand the certificate back, and find eitri holding a credential it could not
238 // have made for itself.
239 func TestDelegationRoundTripThroughTheTransport(t *testing.T) {
240 f := newFixture(t)
241 ca := registerCA(t, f, f.patA)
242 cs := connect(t, f, f.patA)
243
244 begin := callBegin(t, cs)
245 require.NotEmpty(t, begin.PublicKey)
246 assert.Equal(t, "ubuntu", begin.Principal)
247 assert.Contains(t, begin.Instructions, "ssh-keygen -s")
248 assert.Contains(t, begin.Note, "cannot sign this itself")
249
250 done := callComplete(t, cs, signDelegation(t, ca, begin.PublicKey, "ubuntu", time.Now().Add(time.Hour)))
251 assert.Equal(t, ssh.FingerprintSHA256(ca.PublicKey()), done.CAFingerprint)
252 assert.Equal(t, []string{"ubuntu"}, done.Principals)
253 assert.Contains(t, done.Note, "It holds no signing key")
254
255 // eitri can now authenticate as this tenant — and only as this tenant.
256 _, ok := f.keyring.Signer(f.tenantA)
257 assert.True(t, ok)
258 _, ok = f.keyring.Signer(f.tenantB)
259 assert.False(t, ok, "one tenant's delegation is nobody else's")
260 }
261
262 // TestDelegateCompleteRefusesAnUnregisteredCA is the live proof that the trust
263 // check is wired to the tenant's real CA set, and that its refusal says so.
264 func TestDelegateCompleteRefusesAnUnregisteredCA(t *testing.T) {
265 f := newFixture(t)
266 registerCA(t, f, f.patA)
267 cs := connect(t, f, f.patA)
268
269 begin := callBegin(t, cs)
270 stranger := newCA(t)
271 res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
272 Name: "delegate_complete",
273 Arguments: map[string]any{
274 "certificate": signDelegation(t, stranger, begin.PublicKey, "ubuntu", time.Now().Add(time.Hour)),
275 },
276 })
277 require.NoError(t, err)
278 require.True(t, res.IsError)
279 assert.Contains(t, toolErrorText(res), "not a CA registered to this tenant")
280
281 _, ok := f.keyring.Signer(f.tenantA)
282 assert.False(t, ok, "a refused certificate must leave eitri with nothing")
283 }
284
285 // TestDelegateCompleteRefusesTheWrongPrincipal: the most likely user mistake
286 // has to be caught here, or it resurfaces as an opaque SSH failure later.
287 func TestDelegateCompleteRefusesTheWrongPrincipal(t *testing.T) {
288 f := newFixture(t)
289 ca := registerCA(t, f, f.patA)
290 cs := connect(t, f, f.patA)
291
292 begin := callBegin(t, cs)
293 res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
294 Name: "delegate_complete",
295 Arguments: map[string]any{
296 "certificate": signDelegation(t, ca, begin.PublicKey, "alex", time.Now().Add(time.Hour)),
297 },
298 })
299 require.NoError(t, err)
300 require.True(t, res.IsError)
301 assert.Contains(t, toolErrorText(res), `must include "ubuntu"`)
302 }
303
304 // newCA returns a signer standing in for someone's own SSH user CA.
305 func newCA(t *testing.T) ssh.Signer {
306 t.Helper()
307 _, priv, err := ed25519.GenerateKey(rand.Reader)
308 require.NoError(t, err)
309 s, err := ssh.NewSignerFromSigner(priv)
310 require.NoError(t, err)
311 return s
312 }
313
314 // registerCA uploads a CA to the caller's tenant through the real endpoint,
315 // which is what makes a delegation signed by it acceptable.
316 func registerCA(t *testing.T, f fixture, pat string) ssh.Signer {
317 t.Helper()
318 ca := newCA(t)
319 body, err := json.Marshal(types.UserCARequest{
320 PublicKey: string(ssh.MarshalAuthorizedKey(ca.PublicKey())), Label: "mine"})
321 require.NoError(t, err)
322 req, err := http.NewRequest(http.MethodPost, f.ts.URL+"/api/v1/user-cas", bytes.NewReader(body))
323 require.NoError(t, err)
324 req.Header.Set("Authorization", "Bearer "+pat)
325 req.Header.Set("Content-Type", "application/json")
326 resp, err := http.DefaultClient.Do(req)
327 require.NoError(t, err)
328 defer resp.Body.Close()
329 require.Equal(t, http.StatusCreated, resp.StatusCode)
330 return ca
331 }
332
333 // signDelegation is what the human runs: `ssh-keygen -s`, in code.
334 func signDelegation(t *testing.T, ca ssh.Signer, pubLine, principal string, expiry time.Time) string {
335 t.Helper()
336 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine))
337 require.NoError(t, err)
338 cert := &ssh.Certificate{
339 Key: pub,
340 Serial: 7,
341 CertType: ssh.UserCert,
342 KeyId: "eitri-delegation",
343 ValidPrincipals: []string{principal},
344 ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()),
345 ValidBefore: uint64(expiry.Unix()),
346 }
347 require.NoError(t, cert.SignCert(rand.Reader, ca))
348 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
349 }
350
351 type beginOut struct {
352 PublicKey string `json:"public_key"`
353 Principal string `json:"principal"`
354 Instructions string `json:"instructions"`
355 Note string `json:"note"`
356 }
357
358 type delegationOut struct {
359 ExpiresAt string `json:"expires_at"`
360 CAFingerprint string `json:"ca_fingerprint"`
361 Principals []string `json:"principals"`
362 Note string `json:"note"`
363 }
364
365 func callBegin(t *testing.T, cs *mcp.ClientSession) beginOut {
366 t.Helper()
367 var out beginOut
368 callToolInto(t, cs, &mcp.CallToolParams{Name: "delegate_begin"}, &out)
369 return out
370 }
371
372 func callComplete(t *testing.T, cs *mcp.ClientSession, cert string) delegationOut {
373 t.Helper()
374 var out delegationOut
375 callToolInto(t, cs, &mcp.CallToolParams{
376 Name: "delegate_complete", Arguments: map[string]any{"certificate": cert}}, &out)
377 return out
378 }
379
380 func callToolInto(t *testing.T, cs *mcp.ClientSession, params *mcp.CallToolParams, into any) {
381 t.Helper()
382 res, err := cs.CallTool(t.Context(), params)
383 require.NoError(t, err)
384 require.False(t, res.IsError, "%s failed: %+v", params.Name, res.Content)
385 raw, err := json.Marshal(res.StructuredContent)
386 require.NoError(t, err)
387 require.NoError(t, json.Unmarshal(raw, into))
388 }
389
390 // TestExecWithoutADelegationRefusesInWords: a tool failure comes back as an MCP
391 // error the model can read and act on, not a 500 — and eitri cannot get access
392 // by itself, so the message has to be a request with a recipe in it.
393 func TestExecWithoutADelegationRefusesInWords(t *testing.T) {
394 f := newFixture(t)
395 seedVM(t, f.st, f.tenantA, "host-alpha", "alpha-vm")
396 cs := connect(t, f, f.patA)
397
398 res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
399 Name: "vm_exec",
400 Arguments: map[string]any{"vm": "alpha-vm", "command": "true"},
401 })
402 require.NoError(t, err, "a refused tool must not fail the transport")
403 require.True(t, res.IsError)
404 assert.Contains(t, toolErrorText(res), "no live SSH delegation")
405 assert.Contains(t, toolErrorText(res), "delegate_begin")
406 }
407
408 // TestExecWithoutAJumpGateSaysSo: on a control plane with no SSH CA at all,
409 // guests carry no host certificate and remote exec cannot be made safe. The
410 // refusal names that, rather than sending the caller off to delegate.
411 func TestExecWithoutAJumpGateSaysSo(t *testing.T) {
412 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
413 require.NoError(t, err)
414 t.Cleanup(func() { st.Close() })
415 a := api.New(api.Config{HostSecret: []byte("hostsecret"), ServerCertSHA256: strings.Repeat("c", 64)},
416 st, registry.New(time.Now), hub.New())
417 t.Cleanup(a.Close)
418 tenant, pat := newTenant(t, st, "alpha")
419 seedVM(t, st, tenant, "host-alpha", "alpha-vm")
420
421 root := http.NewServeMux()
422 root.Handle("/api/", a.Handler())
423 root.Handle("/mcp", a.UserAuth(New(Deps{
424 Handler: a.Handler(), Creds: testCreds{k: delegation.New(time.Now, "ubuntu"), st: st}, VMUser: "ubuntu"})))
425 ts := httptest.NewServer(root)
426 t.Cleanup(ts.Close)
427
428 cs := connect(t, fixture{ts: ts}, pat)
429 res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
430 Name: "vm_exec",
431 Arguments: map[string]any{"vm": "alpha-vm", "command": "true"},
432 })
433 require.NoError(t, err)
434 require.True(t, res.IsError)
435 assert.Contains(t, toolErrorText(res), "no SSH CA configured")
436 }
437
438 // TestToolErrorsNeverEchoTheCredential: the PAT rides every request, so it must
439 // never come back out in a message a model will repeat.
440 func TestToolErrorsNeverEchoTheCredential(t *testing.T) {
441 f := newFixture(t)
442 cs := connect(t, f, f.patA)
443
444 res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
445 Name: "vm_exec",
446 Arguments: map[string]any{"vm": "nonexistent", "command": "true"},
447 })
448 require.NoError(t, err)
449 require.True(t, res.IsError)
450 assert.NotContains(t, toolErrorText(res), f.patA)
451 }
452
453 func toolErrorText(res *mcp.CallToolResult) string {
454 var b strings.Builder
455 for _, c := range res.Content {
456 if tc, ok := c.(*mcp.TextContent); ok {
457 b.WriteString(tc.Text)
458 }
459 }
460 return b.String()
461 }
462
463 // TestInprocPassesTheCallersIdentity pins the in-process transport's contract:
464 // it re-enters the API's own handler carrying the request unchanged, so the
465 // answer is the one a remote client with that PAT would get.
466 func TestInprocPassesTheCallersIdentity(t *testing.T) {
467 var gotAuth string
468 tr := inproc{h: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
469 gotAuth = r.Header.Get("Authorization")
470 w.Header().Set("Content-Type", "application/json")
471 w.WriteHeader(http.StatusTeapot)
472 w.Write([]byte(`{"ok":true}`))
473 })}
474
475 req, err := http.NewRequestWithContext(context.Background(), "GET", "http://eitri.internal/api/v1/me", nil)
476 require.NoError(t, err)
477 req.Header.Set("Authorization", "Bearer eitri_pat_x")
478
479 resp, err := tr.RoundTrip(req)
480 require.NoError(t, err)
481 defer resp.Body.Close()
482 assert.Equal(t, "Bearer eitri_pat_x", gotAuth)
483 assert.Equal(t, http.StatusTeapot, resp.StatusCode)
484 assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
485 assert.Equal(t, int64(len(`{"ok":true}`)), resp.ContentLength)
486 }
487
488 // TestInprocDefaultsToOK covers the handler that writes a body without ever
489 // naming a status.
490 func TestInprocDefaultsToOK(t *testing.T) {
491 tr := inproc{h: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
492 w.Write([]byte("hi"))
493 })}
494 req, err := http.NewRequest("GET", "http://eitri.internal/x", nil)
495 require.NoError(t, err)
496 resp, err := tr.RoundTrip(req)
497 require.NoError(t, err)
498 defer resp.Body.Close()
499 assert.Equal(t, http.StatusOK, resp.StatusCode)
500 }
501
502 // TestCAUploadRegistersWithTheCallersOwnTenant drives the tool end to end
503 // through the transport and out into the store: the CA lands on the tenant the
504 // PAT names, with its label, and on nobody else's. Registering a CA is the
505 // first step of the journey a bare token has to make, so it has to work with
506 // nothing but that token.
507 func TestCAUploadRegistersWithTheCallersOwnTenant(t *testing.T) {
508 f := newFixture(t)
509 ca := newCA(t)
510 line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(ca.PublicKey())))
511
512 var out struct {
513 Fingerprint string `json:"fingerprint"`
514 Label string `json:"label"`
515 Note string `json:"note"`
516 }
517 callToolInto(t, connect(t, f, f.patA), &mcp.CallToolParams{
518 Name: "ca_upload",
519 Arguments: map[string]any{"public_key": line, "label": "from-mcp"},
520 }, &out)
521
522 assert.Equal(t, ssh.FingerprintSHA256(ca.PublicKey()), out.Fingerprint)
523 assert.Equal(t, "from-mcp", out.Label)
524
525 cas, err := f.st.ListTenantUserCAs(f.tenantA)
526 require.NoError(t, err)
527 require.Len(t, cas, 1)
528 assert.Equal(t, line, cas[0].Pubkey)
529 assert.Equal(t, "from-mcp", cas[0].Label)
530
531 other, err := f.st.ListTenantUserCAs(f.tenantB)
532 require.NoError(t, err)
533 assert.Empty(t, other, "a CA belongs to the tenant whose token uploaded it")
534 }
535
536 // TestCAUploadThenDelegateIsTheWholeJourney: upload a CA with a bare PAT, then
537 // delegate against it. The second step only works because the first registered
538 // the CA the delegation is signed by — which is the point of having both tools.
539 func TestCAUploadThenDelegateIsTheWholeJourney(t *testing.T) {
540 f := newFixture(t)
541 ca := newCA(t)
542 cs := connect(t, f, f.patA)
543
544 var uploaded struct {
545 Fingerprint string `json:"fingerprint"`
546 }
547 callToolInto(t, cs, &mcp.CallToolParams{
548 Name: "ca_upload",
549 Arguments: map[string]any{"public_key": strings.TrimSpace(string(ssh.MarshalAuthorizedKey(ca.PublicKey())))},
550 }, &uploaded)
551
552 begin := callBegin(t, cs)
553 done := callComplete(t, cs, signDelegation(t, ca, begin.PublicKey, "ubuntu", time.Now().Add(time.Hour)))
554 assert.Equal(t, uploaded.Fingerprint, done.CAFingerprint,
555 "the delegation must chain to the CA the same session just uploaded")
556
557 _, ok := f.keyring.Signer(f.tenantA)
558 assert.True(t, ok)
559 }
internal/server/seal/seal.go
Old New
@@ -1,9 +1,8 @@
1 // Package seal encrypts the key material eitri holds, so that what rests on 1 // Package seal encrypts the key material eitri holds, so that what rests on
2 // disk is not what signs. One key-encryption key — the server's 2 // disk is not what signs. One key-encryption key — the server's
3 // key_encryption_key, which lives in its config and nowhere else — protects 3 // key_encryption_key, which lives in its config and nowhere else — protects
4 // every piece: the host CA and gate host key in the data directory, and each 4 // every piece: the fleet's host CA and gate host key, in the data directory. A
5 // tenant's opt-in managed CA in the database. A stolen database, a copied 5 // copied backup or a lifted PVC yields ciphertext and nothing signable.
6 // backup, or a lifted PVC yields ciphertext and nothing signable.
7 // 6 //
8 // The package is pure: bytes in, bytes out, no files and no store. Where each 7 // The package is pure: bytes in, bytes out, no files and no store. Where each
9 // sealed thing lives is its own package's business. 8 // sealed thing lives is its own package's business.
internal/server/vmssh/vmssh.go
Old New
@@ -0,0 +1,220 @@
1 // Package vmssh reaches a tenant's VM from inside the control plane: it tunnels
2 // to the guest's sshd over the host's live sync connection and authenticates
3 // with the credential that tenant has delegated to eitri. It is the server-side
4 // half of the MCP exec seam; the client-side half goes through the jump gate.
5 //
6 // Taking the tunnel rather than the gate is one hop fewer and the same crypto:
7 // the guest's host certificate is verified under its <tenant>.<name> principal
8 // against the host CA, exactly as a client dialing through the gate verifies it.
9 // It also avoids the server having to resolve and dial its own public gate
10 // domain, which is unreachable from inside a pod whenever that name points at an
11 // external address, and absent entirely when the gate is off.
12 //
13 // Gate-side certificate revocation is deliberately not consulted here. It does
14 // not need to be: these certificates live minutes, are used by the process that
15 // minted them, and are never handed to anyone.
16 package vmssh
17
18 import (
19 "context"
20 "errors"
21 "fmt"
22 "io"
23 "net"
24 "strings"
25 "time"
26
27 "golang.org/x/crypto/ssh"
28 )
29
30 // TCPDialer opens a byte pipe to vmID:port on hostID. *syncsvc.Service satisfies
31 // it — the same seam the jump gate itself is built on.
32 type TCPDialer interface {
33 OpenTCP(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error)
34 }
35
36 // VM is what a name resolves to: where the guest runs, and whether anything
37 // could verify it on arrival.
38 type VM struct {
39 HostID, VMID string
40 // HostCertified reports that the control plane has signed this guest's host
41 // key. A guest created by an agent that predates the key exchange has no
42 // certificate and can never acquire one without being recreated, so this is
43 // a permanent property of that VM rather than a race to wait out.
44 HostCertified bool
45 }
46
47 // VMLookup resolves a bare VM name WITHIN tenant and re-checks ownership. It
48 // mirrors the gate's resolve+authorize pair: a name never resolves across
49 // tenants, and a tombstoned or foreign VM is indistinguishable from a missing
50 // one.
51 type VMLookup func(tenant, name string) (VM, bool)
52
53 // Credentials answers what eitri may authenticate as, for one tenant, right
54 // now. The two questions are separate because the answers call for different
55 // words: a tenant with a live delegation and one that has never registered a CA
56 // at all are told different things to do next.
57 type Credentials interface {
58 // Delegated returns this tenant's live delegated credential, or false when
59 // there is none — including one that has expired, which is the same
60 // situation and calls for the same next step.
61 Delegated(tenant string) (ssh.Signer, bool)
62 // TenantHasUserCA reports whether the tenant has registered any CA at all.
63 // Without one there is nothing a delegated certificate could chain to.
64 TenantHasUserCA(tenant string) (bool, error)
65 }
66
67 // Dialer reaches one tenant's VMs. It implements the MCP exec seam's VMDialer.
68 type Dialer struct {
69 Tenant string
70 VMUser string // guest login user, and therefore the certificate principal
71 TCP TCPDialer
72 Lookup VMLookup
73 Creds Credentials
74 // HostCA verifies each guest's host certificate. Nil means this control
75 // plane has no SSH CA at all (the jump gate is off), so VMs carry no host
76 // certificate and there is nothing to verify against.
77 HostCA ssh.PublicKey
78 // DelegationsURL is where a caller POSTs to start a delegation, as a full
79 // URL. The refusal below is the only instruction most callers get, and the
80 // REST API does not necessarily live on the host they are talking to.
81 DelegationsURL string
82 }
83
84 // ConnectName returns the VM's <tenant>.<name> connect name — the form the gate
85 // resolves and the VM's host certificate names.
86 func (d *Dialer) ConnectName(_ context.Context, vmName string) (string, error) {
87 return d.Tenant + "." + vmName, nil
88 }
89
90 // Dial opens an authenticated SSH connection to the VM named vmName. The caller
91 // must Close the returned client.
92 func (d *Dialer) Dial(ctx context.Context, vmName string) (*ssh.Client, error) {
93 if d.HostCA == nil {
94 return nil, errors.New("remote exec is unavailable: this control plane has no SSH CA configured")
95 }
96 signer, err := d.signer()
97 if err != nil {
98 return nil, err
99 }
100 vm, ok := d.Lookup(d.Tenant, vmName)
101 if !ok {
102 return nil, fmt.Errorf("no VM named %q", vmName)
103 }
104 // Refuse before dialing. This guest has no host certificate and cannot be
105 // issued one — its key was never reported — so the handshake would fail on
106 // an unverifiable host key, which reads as a crypto problem rather than the
107 // lifecycle one it is.
108 if !vm.HostCertified {
109 return nil, fmt.Errorf("vm %s predates certified host keys: it was created by an agent older than the "+
110 "host-key exchange, so nothing can verify it. Upgrade that host's agent, then recreate the VM", vmName)
111 }
112
113 pipe, err := d.TCP.OpenTCP(ctx, vm.HostID, vm.VMID, 22)
114 if err != nil {
115 return nil, fmt.Errorf("vm %s unreachable: %w", vmName, err)
116 }
117 // The address is both the tunnel's far end and the name the guest's host
118 // certificate is checked under — its principal is exactly this.
119 addr := d.Tenant + "." + vmName + ":22"
120 checker := &ssh.CertChecker{
121 IsHostAuthority: func(auth ssh.PublicKey, _ string) bool {
122 return keyEquals(auth, d.HostCA)
123 },
124 }
125 conf := &ssh.ClientConfig{
126 User: d.VMUser,
127 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
128 HostKeyCallback: checker.CheckHostKey,
129 Timeout: 15 * time.Second,
130 }
131 nc, chans, reqs, err := ssh.NewClientConn(pipeConn{pipe}, addr, conf)
132 if err != nil {
133 pipe.Close()
134 return nil, handshakeError(vmName, err)
135 }
136 return ssh.NewClient(nc, chans, reqs), nil
137 }
138
139 // signer returns the credential this tenant has delegated, or explains how to
140 // delegate one. The wording is the feature: eitri cannot obtain access on its
141 // own, so a refusal here is a request, and it has to be one a caller can act on
142 // without reading the docs.
143 func (d *Dialer) signer() (ssh.Signer, error) {
144 if signer, ok := d.Creds.Delegated(d.Tenant); ok {
145 return signer, nil
146 }
147 var prefix string
148 has, err := d.Creds.TenantHasUserCA(d.Tenant)
149 if err != nil {
150 return nil, errors.New("looking up this tenant's SSH CAs failed")
151 }
152 if !has {
153 prefix = "this tenant has no registered SSH user CA at all — upload one first (`eitri ca upload`), " +
154 "or your guests will trust nothing you sign.\n\n"
155 }
156 // ST1005 wants a one-line lowercase fragment. This message is the feature:
157 // eitri cannot obtain access on its own, so the refusal IS the request, and
158 // it has to carry the whole recipe or the caller is left guessing.
159 //nolint:staticcheck // deliberately multi-line, caller-facing prose
160 return nil, fmt.Errorf(`%sno live SSH delegation for tenant %q.
161
162 eitri holds no signing key — you delegate access to it, and it expires.
163
164 1. call delegate_begin (or POST %s) for a public key
165 2. sign it with your own CA:
166 ssh-keygen -s <your-ca> -I eitri-delegation -n %s -V +8h eitri-delegation.pub
167 3. return eitri-delegation-cert.pub via delegate_complete
168
169 A delegation lives in memory only: a control-plane restart drops it, and you
170 delegate again.`, prefix, d.Tenant, d.delegationsURL(), d.VMUser)
171 }
172
173 // delegationsURL is the configured endpoint, or the bare path when a plane has
174 // not been told its own public URL. Never empty, so the recipe always reads.
175 func (d *Dialer) delegationsURL() string {
176 if d.DelegationsURL == "" {
177 return "/api/v1/delegations"
178 }
179 return d.DelegationsURL
180 }
181
182 // handshakeError names the one failure a caller can do something about. A guest
183 // bakes its CA set at create, so a delegation signed by a CA registered after
184 // that VM was made is refused by it — which reaches us as an ordinary
185 // authentication failure and would otherwise read as a mystery.
186 func handshakeError(vmName string, err error) error {
187 if strings.Contains(err.Error(), "unable to authenticate") {
188 return fmt.Errorf("vm %s refused eitri's certificate: it trusts the CA set it was created with, and that "+
189 "set does not include the CA you delegated with. Delegate with a CA this VM trusts, or create a new VM", vmName)
190 }
191 return fmt.Errorf("vm %s ssh handshake: %w", vmName, err)
192 }
193
194 // keyEquals compares two SSH public keys by their wire encodings.
195 func keyEquals(a, b ssh.PublicKey) bool {
196 return a != nil && b != nil && string(a.Marshal()) == string(b.Marshal())
197 }
198
199 // pipeConn adapts the tunnel's byte pipe to the net.Conn that ssh.NewClientConn
200 // requires. The addresses are stubs — the SSH client only ever reports them —
201 // and the deadline methods refuse rather than lying: the client sets none of
202 // them, and a silent no-op deadline would be a trap for the next caller.
203 type pipeConn struct{ rwc io.ReadWriteCloser }
204
205 func (c pipeConn) Read(p []byte) (int, error) { return c.rwc.Read(p) }
206 func (c pipeConn) Write(p []byte) (int, error) { return c.rwc.Write(p) }
207 func (c pipeConn) Close() error { return c.rwc.Close() }
208 func (c pipeConn) LocalAddr() net.Addr { return tunnelAddr{} }
209 func (c pipeConn) RemoteAddr() net.Addr { return tunnelAddr{} }
210
211 func (c pipeConn) SetDeadline(time.Time) error { return errors.ErrUnsupported }
212 func (c pipeConn) SetReadDeadline(time.Time) error { return errors.ErrUnsupported }
213 func (c pipeConn) SetWriteDeadline(time.Time) error { return errors.ErrUnsupported }
214
215 // tunnelAddr names the sync tunnel in the two places net.Conn insists on an
216 // address. There is no socket underneath, so there is no address to report.
217 type tunnelAddr struct{}
218
219 func (tunnelAddr) Network() string { return "eitri-sync" }
220 func (tunnelAddr) String() string { return "tunnel" }
internal/server/vmssh/vmssh_test.go
Old New
@@ -0,0 +1,420 @@
1 package vmssh
2
3 import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "crypto/rand"
8 "errors"
9 "io"
10 "net"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16 "golang.org/x/crypto/ssh"
17 )
18
19 // ── scaffolding ──────────────────────────────────────────────────────────────
20
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 // what a VM presents once eitri has minted its host certificate.
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 mirrors a guest's TrustedUserCAKeys policy: only certificates
48 // signed by the tenant's CA, and the principal must match the login user.
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 {
52 return auth != nil && ca != nil && bytes.Equal(auth.Marshal(), ca.Marshal())
53 },
54 }
55 return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
56 cert, ok := key.(*ssh.Certificate)
57 if !ok || cert.CertType != ssh.UserCert {
58 return nil, errors.New("only user certificates are accepted")
59 }
60 if !checker.IsUserAuthority(cert.SignatureKey) {
61 return nil, errors.New("certificate not signed by a trusted CA")
62 }
63 if err := checker.CheckCert(meta.User(), cert); err != nil {
64 return nil, err
65 }
66 return &ssh.Permissions{}, nil
67 }
68 }
69
70 // fakeTunnel is a TCPDialer whose pipe is served by an in-memory sshd. It
71 // records the (hostID, vmID, port) it was asked for, which is how the tests see
72 // what the dialer resolved.
73 type fakeTunnel struct {
74 hostSigner ssh.Signer
75 userCA ssh.PublicKey
76 execOut string
77
78 openErr error
79 hostID string
80 vmID string
81 port uint32
82 }
83
84 // OpenTCP hands back a byte pipe to an sshd standing in for the guest. It is
85 // backed by a loopback socket rather than net.Pipe: an SSH handshake opens with
86 // both ends writing their version string, which an unbuffered pipe deadlocks on.
87 func (f *fakeTunnel) OpenTCP(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) {
88 f.hostID, f.vmID, f.port = hostID, vmID, port
89 if f.openErr != nil {
90 return nil, f.openErr
91 }
92 ln, err := net.Listen("tcp", "127.0.0.1:0")
93 if err != nil {
94 return nil, err
95 }
96 conf := &ssh.ServerConfig{PublicKeyCallback: caUserAuth(f.userCA)}
97 conf.AddHostKey(f.hostSigner)
98 go func() {
99 defer ln.Close()
100 nc, err := ln.Accept()
101 if err != nil {
102 return
103 }
104 serveGuest(nc, conf, f.execOut)
105 }()
106 return net.Dial("tcp", ln.Addr().String())
107 }
108
109 func serveGuest(nc net.Conn, conf *ssh.ServerConfig, out string) {
110 sc, chans, reqs, err := ssh.NewServerConn(nc, conf)
111 if err != nil {
112 nc.Close()
113 return
114 }
115 defer sc.Close()
116 go ssh.DiscardRequests(reqs)
117 for newCh := range chans {
118 if newCh.ChannelType() != "session" {
119 newCh.Reject(ssh.UnknownChannelType, "only session")
120 continue
121 }
122 ch, chReqs, err := newCh.Accept()
123 if err != nil {
124 continue
125 }
126 go func() {
127 defer ch.Close()
128 for req := range chReqs {
129 if req.Type == "exec" {
130 req.Reply(true, nil)
131 io.WriteString(ch, out)
132 ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{0}))
133 return
134 }
135 if req.WantReply {
136 req.Reply(false, nil)
137 }
138 }
139 }()
140 }
141 }
142
143 // fakeCreds answers what the tenant has lent eitri, from fixed values.
144 type fakeCreds struct {
145 signer ssh.Signer
146 hasCA bool
147 err error
148 }
149
150 func (f fakeCreds) Delegated(string) (ssh.Signer, bool) { return f.signer, f.signer != nil }
151 func (f fakeCreds) TenantHasUserCA(string) (bool, error) {
152 if f.err != nil {
153 return false, f.err
154 }
155 return f.hasCA, nil
156 }
157
158 // delegatedSigner builds the credential a completed delegation leaves eitri
159 // with: an ephemeral key plus a user certificate its tenant's CA signed.
160 func delegatedSigner(t *testing.T, ca ssh.Signer, principal string) ssh.Signer {
161 t.Helper()
162 key := newSigner(t)
163 cert := &ssh.Certificate{
164 Key: key.PublicKey(),
165 CertType: ssh.UserCert,
166 KeyId: "eitri-delegation",
167 ValidPrincipals: []string{principal},
168 ValidBefore: ssh.CertTimeInfinity,
169 }
170 require.NoError(t, cert.SignCert(rand.Reader, ca))
171 cs, err := ssh.NewCertSigner(cert, key)
172 require.NoError(t, err)
173 return cs
174 }
175
176 // lookupOne resolves exactly one name, for one tenant, to a certified VM.
177 func lookupOne(tenant, name, hostID, vmID string) VMLookup {
178 return func(gotTenant, gotName string) (VM, bool) {
179 if gotTenant != tenant || gotName != name {
180 return VM{}, false
181 }
182 return VM{HostID: hostID, VMID: vmID, HostCertified: true}, true
183 }
184 }
185
186 // newDialer wires a dialer whose tenant has delegated a live credential,
187 // against a guest that trusts the CA behind it.
188 func newDialer(t *testing.T) (*Dialer, *fakeTunnel) {
189 t.Helper()
190 hostCA := newSigner(t)
191 tenantCA := newSigner(t)
192
193 tun := &fakeTunnel{
194 hostSigner: hostCertSigner(t, hostCA, "acme.web-1"),
195 userCA: tenantCA.PublicKey(),
196 execOut: "hi\n",
197 }
198 return &Dialer{
199 Tenant: "acme",
200 VMUser: "ubuntu",
201 TCP: tun,
202 Lookup: lookupOne("acme", "web-1", "h-1", "v-1"),
203 Creds: fakeCreds{signer: delegatedSigner(t, tenantCA, "ubuntu"), hasCA: true},
204 HostCA: hostCA.PublicKey(),
205 }, tun
206 }
207
208 // ── tests ────────────────────────────────────────────────────────────────────
209
210 // TestDialReachesTheGuest is the whole path: resolve, tunnel to port 22,
211 // authenticate with the delegated credential, verify the guest's host
212 // certificate under its namespaced name, run a command.
213 func TestDialReachesTheGuest(t *testing.T) {
214 d, tun := newDialer(t)
215
216 client, err := d.Dial(t.Context(), "web-1")
217 require.NoError(t, err)
218 defer client.Close()
219
220 assert.Equal(t, "h-1", tun.hostID)
221 assert.Equal(t, "v-1", tun.vmID)
222 assert.Equal(t, uint32(22), tun.port)
223
224 sess, err := client.NewSession()
225 require.NoError(t, err)
226 defer sess.Close()
227 out, err := sess.Output("echo hi")
228 require.NoError(t, err)
229 assert.Equal(t, "hi\n", string(out))
230 }
231
232 // TestConnectNameIsNamespaced pins the name a guest's host certificate carries.
233 func TestConnectNameIsNamespaced(t *testing.T) {
234 d, _ := newDialer(t)
235 name, err := d.ConnectName(t.Context(), "web-1")
236 require.NoError(t, err)
237 assert.Equal(t, "acme.web-1", name)
238 }
239
240 // TestDialRejectsAForeignHostCA: a guest whose host certificate was signed by
241 // some other CA is not this fleet's guest.
242 func TestDialRejectsAForeignHostCA(t *testing.T) {
243 d, tun := newDialer(t)
244 tun.hostSigner = hostCertSigner(t, newSigner(t), "acme.web-1")
245
246 _, err := d.Dial(t.Context(), "web-1")
247 require.Error(t, err)
248 assert.Contains(t, err.Error(), "vm web-1 ssh handshake")
249 }
250
251 // TestDialRejectsAHostCertForAnotherVM: the certificate must name THIS VM, or a
252 // tunnel pointed at the wrong guest would go unnoticed.
253 func TestDialRejectsAHostCertForAnotherVM(t *testing.T) {
254 d, tun := newDialer(t)
255 hostCA := newSigner(t)
256 d.HostCA = hostCA.PublicKey()
257 tun.hostSigner = hostCertSigner(t, hostCA, "acme.other-vm")
258
259 _, err := d.Dial(t.Context(), "web-1")
260 require.Error(t, err)
261 assert.Contains(t, err.Error(), "vm web-1 ssh handshake")
262 }
263
264 // TestDialOnAVMThatDoesNotTrustTheDelegatedCA: the guest baked its CA set at
265 // create, so a delegation signed by a CA registered later is refused by it. The
266 // refusal has to say why, because "permission denied" reads as a key problem
267 // when it is an ordering one.
268 func TestDialOnAVMThatDoesNotTrustTheDelegatedCA(t *testing.T) {
269 d, tun := newDialer(t)
270 tun.userCA = newSigner(t).PublicKey() // the guest trusts some other CA
271
272 _, err := d.Dial(t.Context(), "web-1")
273 require.Error(t, err)
274 assert.Contains(t, err.Error(), "refused eitri's certificate")
275 assert.Contains(t, err.Error(), "the CA set it was created with")
276 }
277
278 // TestDialRefusesAForeignName: a name in another tenant answers exactly like a
279 // missing one — existence is never leaked across tenants.
280 func TestDialRefusesAForeignName(t *testing.T) {
281 d, _ := newDialer(t)
282 d.Lookup = lookupOne("other-tenant", "web-1", "h-1", "v-1")
283
284 _, err := d.Dial(t.Context(), "web-1")
285 require.Error(t, err)
286 assert.Contains(t, err.Error(), `no VM named "web-1"`)
287
288 // A name that does not exist at all produces the identical message.
289 d.Lookup = func(string, string) (VM, bool) { return VM{}, false }
290 _, missing := d.Dial(t.Context(), "web-1")
291 require.Error(t, missing)
292 assert.Equal(t, err.Error(), missing.Error())
293 }
294
295 // TestDialWithNoDelegation is the refusal that matters most: eitri cannot get
296 // access on its own, so this message is a request, and it has to carry the
297 // whole recipe.
298 func TestDialWithNoDelegation(t *testing.T) {
299 d, _ := newDialer(t)
300 d.Creds = fakeCreds{hasCA: true}
301
302 _, err := d.Dial(t.Context(), "web-1")
303 require.Error(t, err)
304 msg := err.Error()
305 assert.Contains(t, msg, `no live SSH delegation for tenant "acme"`)
306 assert.Contains(t, msg, "eitri holds no signing key")
307 assert.Contains(t, msg, "delegate_begin")
308 assert.Contains(t, msg, "ssh-keygen -s <your-ca> -I eitri-delegation -n ubuntu")
309 assert.Contains(t, msg, "delegate_complete")
310 assert.Contains(t, msg, "a control-plane restart drops it")
311 }
312
313 // TestDialWithNoRegisteredCAAtAll adds the step before the recipe: there is
314 // nothing for a delegated certificate to chain to yet.
315 func TestDialWithNoRegisteredCAAtAll(t *testing.T) {
316 d, _ := newDialer(t)
317 d.Creds = fakeCreds{}
318
319 _, err := d.Dial(t.Context(), "web-1")
320 require.Error(t, err)
321 assert.Contains(t, err.Error(), "no registered SSH user CA at all")
322 assert.Contains(t, err.Error(), "eitri ca upload")
323 // And still the recipe, because that is the step after it.
324 assert.Contains(t, err.Error(), "delegate_begin")
325 }
326
327 // TestAnExpiredDelegationReadsAsNone: Delegated already dropped it, and the
328 // same message is the correct one — it says how to renew.
329 func TestAnExpiredDelegationReadsAsNone(t *testing.T) {
330 d, _ := newDialer(t)
331 d.Creds = fakeCreds{hasCA: true} // an expired entry reports itself absent
332
333 _, err := d.Dial(t.Context(), "web-1")
334 require.Error(t, err)
335 assert.Contains(t, err.Error(), "no live SSH delegation")
336 }
337
338 // TestDialWithoutAJumpGate: no host CA means guests carry no host certificate,
339 // so there is nothing to verify and no safe connection to make.
340 func TestDialWithoutAJumpGate(t *testing.T) {
341 d, _ := newDialer(t)
342 d.HostCA = nil
343
344 _, err := d.Dial(t.Context(), "web-1")
345 require.Error(t, err)
346 assert.Contains(t, err.Error(), "no SSH CA configured")
347 }
348
349 // TestDialWhenTheTunnelRefuses surfaces an offline host as an unreachable VM
350 // rather than a CA problem.
351 func TestDialWhenTheTunnelRefuses(t *testing.T) {
352 d, tun := newDialer(t)
353 tun.openErr = errors.New("host offline")
354
355 _, err := d.Dial(t.Context(), "web-1")
356 require.Error(t, err)
357 assert.Contains(t, err.Error(), "vm web-1 unreachable")
358 }
359
360 // TestDialSurfacesACAStoreFailureWithoutDetail: a lookup failure must not turn
361 // into a message that describes the database.
362 func TestDialSurfacesACAStoreFailureWithoutDetail(t *testing.T) {
363 d, _ := newDialer(t)
364 d.Creds = fakeCreds{err: errors.New("disk on fire")}
365
366 _, err := d.Dial(t.Context(), "web-1")
367 require.Error(t, err)
368 assert.NotContains(t, err.Error(), "disk on fire")
369 }
370
371 // TestPipeConnDeadlinesRefuse pins the adapter's honesty: a caller that sets a
372 // deadline learns it did nothing, instead of trusting a silent no-op.
373 func TestPipeConnDeadlinesRefuse(t *testing.T) {
374 client, server := net.Pipe()
375 defer client.Close()
376 defer server.Close()
377 c := pipeConn{client}
378
379 assert.ErrorIs(t, c.SetDeadline(time.Now()), errors.ErrUnsupported)
380 assert.ErrorIs(t, c.SetReadDeadline(time.Now()), errors.ErrUnsupported)
381 assert.ErrorIs(t, c.SetWriteDeadline(time.Now()), errors.ErrUnsupported)
382 assert.Equal(t, "eitri-sync", c.LocalAddr().Network())
383 assert.Equal(t, "tunnel", c.RemoteAddr().String())
384 }
385
386 // TestDialRefusesAVMThatPredatesCertifiedHostKeys: a guest with no host
387 // certificate can never acquire one without being recreated, so the refusal is
388 // a lifecycle instruction and it happens BEFORE a dial. The raw handshake
389 // failure ("non-certificate host key") reads as a crypto problem and is a dead
390 // end for anyone trying to fix it.
391 func TestDialRefusesAVMThatPredatesCertifiedHostKeys(t *testing.T) {
392 d, tun := newDialer(t)
393 d.Lookup = func(string, string) (VM, bool) {
394 return VM{HostID: "h-1", VMID: "v-1", HostCertified: false}, true
395 }
396
397 _, err := d.Dial(t.Context(), "web-1")
398 require.Error(t, err)
399 assert.Contains(t, err.Error(), "vm web-1 predates certified host keys")
400 assert.Contains(t, err.Error(), "Upgrade that host's agent, then recreate the VM")
401 assert.Empty(t, tun.hostID, "nothing may be dialed for a VM that could never be verified")
402 }
403
404 // TestRefusalNamesADialableEndpoint: the REST API is not necessarily on the
405 // host the caller is talking to, so the recipe carries a URL, not a path.
406 func TestRefusalNamesADialableEndpoint(t *testing.T) {
407 d, _ := newDialer(t)
408 d.Creds = fakeCreds{hasCA: true}
409 d.DelegationsURL = "https://console.eitri.sh/api/v1/delegations"
410
411 _, err := d.Dial(t.Context(), "web-1")
412 require.Error(t, err)
413 assert.Contains(t, err.Error(), "POST https://console.eitri.sh/api/v1/delegations")
414
415 // Unconfigured, the recipe still reads — just less usefully.
416 d.DelegationsURL = ""
417 _, err = d.Dial(t.Context(), "web-1")
418 require.Error(t, err)
419 assert.Contains(t, err.Error(), "POST /api/v1/delegations")
420 }
internal/smoke/mcp.go
Old New
@@ -0,0 +1,363 @@
1 package smoke
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/binary"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "net/http"
12 "slices"
13 "strings"
14 "time"
15
16 "github.com/modelcontextprotocol/go-sdk/mcp"
17 "golang.org/x/crypto/ssh"
18 )
19
20 // mcpTools is what the remote-MCP leg needs from an MCP session: the advertised
21 // tool list, and calling one. Declaring it lets the leg's sequencing be tested
22 // without a control plane.
23 type mcpTools interface {
24 List(ctx context.Context) ([]string, error)
25 Call(ctx context.Context, name string, args map[string]any) (map[string]any, error)
26 }
27
28 // remoteToolCount is the toolset a bearer PAT gets over HTTP: the ten VM tools,
29 // ca_upload, tenant_info, and the two delegation tools.
30 const remoteToolCount = 14
31
32 // dialMCP opens a real MCP client against the control plane's /mcp endpoint,
33 // authenticating with a bearer PAT exactly as a remote LLM client does. The
34 // returned close function tears the session down.
35 func dialMCP(ctx context.Context, serverURL, pat string) (sdkTools, func(), error) {
36 tr := &mcp.StreamableClientTransport{
37 Endpoint: strings.TrimRight(serverURL, "/") + "/mcp",
38 HTTPClient: &http.Client{Transport: patTransport{pat: pat}, Timeout: 15 * time.Minute},
39 }
40 session, err := mcp.NewClient(&mcp.Implementation{Name: "eitri-smoke", Version: "1"}, nil).Connect(ctx, tr, nil)
41 if err != nil {
42 return sdkTools{}, nil, fmt.Errorf("connect to %s/mcp with a bearer PAT: %w", serverURL, err)
43 }
44 return sdkTools{session: session}, func() { session.Close() }, nil
45 }
46
47 // patTransport attaches the PAT the way a remote MCP client configuration does.
48 type patTransport struct{ pat string }
49
50 func (p patTransport) RoundTrip(r *http.Request) (*http.Response, error) {
51 r.Header.Set("Authorization", "Bearer "+p.pat)
52 return http.DefaultTransport.RoundTrip(r)
53 }
54
55 // sdkTools adapts an MCP session to mcpTools, folding a tool-level error into a
56 // Go error so the leg reads one way for both failure kinds.
57 type sdkTools struct{ session *mcp.ClientSession }
58
59 func (s sdkTools) List(ctx context.Context) ([]string, error) {
60 res, err := s.session.ListTools(ctx, nil)
61 if err != nil {
62 return nil, err
63 }
64 names := make([]string, 0, len(res.Tools))
65 for _, tool := range res.Tools {
66 names = append(names, tool.Name)
67 }
68 return names, nil
69 }
70
71 func (s sdkTools) Call(ctx context.Context, name string, args map[string]any) (map[string]any, error) {
72 res, err := s.session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
73 if err != nil {
74 return nil, fmt.Errorf("%s: %w", name, err)
75 }
76 if res.IsError {
77 var b strings.Builder
78 for _, c := range res.Content {
79 if tc, ok := c.(*mcp.TextContent); ok {
80 b.WriteString(tc.Text)
81 }
82 }
83 return nil, fmt.Errorf("%s: %s", name, b.String())
84 }
85 raw, err := json.Marshal(res.StructuredContent)
86 if err != nil {
87 return nil, fmt.Errorf("%s: %w", name, err)
88 }
89 var out map[string]any
90 if err := json.Unmarshal(raw, &out); err != nil {
91 return nil, fmt.Errorf("%s: %w", name, err)
92 }
93 return out, nil
94 }
95
96 // proveRemoteMCPNeedsACredential is the cheap, fleet-independent half: the /mcp
97 // endpoint must refuse an unauthenticated caller. It runs before any VM exists,
98 // so a misrouted or unauthenticated endpoint fails the gate in a second rather
99 // than after a boot.
100 func proveRemoteMCPNeedsACredential(serverURL string) error {
101 body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18",` +
102 `"capabilities":{},"clientInfo":{"name":"eitri-smoke","version":"1"}}}`
103 req, err := http.NewRequest(http.MethodPost, strings.TrimRight(serverURL, "/")+"/mcp", strings.NewReader(body))
104 if err != nil {
105 return err
106 }
107 req.Header.Set("Content-Type", "application/json")
108 req.Header.Set("Accept", "application/json, text/event-stream")
109 resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
110 if err != nil {
111 return fmt.Errorf("POST %s/mcp: %w", serverURL, err)
112 }
113 defer resp.Body.Close()
114 if resp.StatusCode != http.StatusUnauthorized {
115 return fmt.Errorf("FAIL: unauthenticated POST /mcp answered %d, want 401", resp.StatusCode)
116 }
117 return nil
118 }
119
120 // delegator is the out-of-band half of the exchange, as the smoke performs it:
121 // register a CA with the tenant, and sign eitri's ephemeral key with it. In a
122 // real session a human runs ssh-keygen; here the gate does it in-process,
123 // which is the same act.
124 type delegator struct {
125 ca ssh.Signer
126 // stranger is a CA nobody registered, for the negative leg.
127 stranger ssh.Signer
128 principal string
129 now func() time.Time
130 }
131
132 // proveMCP drives one full cycle through the remote MCP endpoint and nothing
133 // else: delegate access to eitri, create a VM, run a command in it, publish a
134 // port, read the guest's banner back through that port, and destroy it.
135 //
136 // The CA is registered BEFORE the VM is created, because a guest bakes its CA
137 // set at create and a CA registered afterwards is one it will never trust. The
138 // DELEGATION itself may happen on either side of the create — that is the whole
139 // improvement over holding a signing key, and it is worth stating plainly here
140 // so nobody reintroduces an ordering constraint that no longer exists.
141 func proveMCP(ctx context.Context, c mcpTools, vmName string, d delegator, now func() time.Time, sleep func(time.Duration), dial bannerFunc) error {
142 names, err := c.List(ctx)
143 if err != nil {
144 return fmt.Errorf("list remote MCP tools: %w", err)
145 }
146 if len(names) != remoteToolCount {
147 return fmt.Errorf("FAIL: remote MCP advertises %d tools (%s), want %d", len(names), strings.Join(names, ", "), remoteToolCount)
148 }
149 for _, want := range []string{"ca_upload", "tenant_info", "delegate_begin", "delegate_complete", "vm_create", "vm_exec", "vm_expose", "vm_destroy"} {
150 if !slices.Contains(names, want) {
151 return fmt.Errorf("FAIL: remote MCP does not advertise %s (has: %s)", want, strings.Join(names, ", "))
152 }
153 }
154
155 // Through the tool, not the endpoint behind it: registering a CA is the
156 // first step a bare token has to take, so the gate drives the same call an
157 // LLM client would.
158 uploaded, err := c.Call(ctx, "ca_upload", map[string]any{
159 "public_key": authorizedLine(d.ca.PublicKey()),
160 "label": "eitri-smoke",
161 })
162 if err != nil {
163 return fmt.Errorf("ca_upload: %w", err)
164 }
165 if got, _ := uploaded["fingerprint"].(string); got != ssh.FingerprintSHA256(d.ca.PublicKey()) {
166 return fmt.Errorf("FAIL: ca_upload registered %s, want %s", got, ssh.FingerprintSHA256(d.ca.PublicKey()))
167 }
168
169 begin, err := c.Call(ctx, "delegate_begin", nil)
170 if err != nil {
171 return fmt.Errorf("delegate_begin: %w", err)
172 }
173 pubLine, _ := begin["public_key"].(string)
174 if pubLine == "" {
175 return errors.New("FAIL: delegate_begin returned no public key to sign")
176 }
177 principal, _ := begin["principal"].(string)
178 if principal != d.principal {
179 return fmt.Errorf("FAIL: delegate_begin asked for principal %q, want %q", principal, d.principal)
180 }
181
182 // The negative leg: a certificate from a CA nobody registered must be
183 // refused. It costs one call and no VM, and it is the only live proof that
184 // the trust check is wired to the tenant's real CA set rather than to
185 // whatever signature happens to verify.
186 stranger, err := signDelegation(d.stranger, pubLine, principal, d.now())
187 if err != nil {
188 return err
189 }
190 if _, err := c.Call(ctx, "delegate_complete", map[string]any{"certificate": stranger}); err == nil {
191 return errors.New("FAIL: delegate_complete accepted a certificate from an unregistered CA")
192 }
193
194 cert, err := signDelegation(d.ca, pubLine, principal, d.now())
195 if err != nil {
196 return err
197 }
198 done, err := c.Call(ctx, "delegate_complete", map[string]any{"certificate": cert})
199 if err != nil {
200 return fmt.Errorf("delegate_complete: %w", err)
201 }
202 if got, _ := done["ca_fingerprint"].(string); got != ssh.FingerprintSHA256(d.ca.PublicKey()) {
203 return fmt.Errorf("FAIL: delegation reports CA %s, want %s", got, ssh.FingerprintSHA256(d.ca.PublicKey()))
204 }
205 expiresAt, _ := done["expires_at"].(string)
206 if expiresAt == "" {
207 return errors.New("FAIL: delegation reports no expiry; a delegation that never ends is not one")
208 }
209
210 // What a caller would read back before doing anything else: the CA it just
211 // registered is listed, and the delegation it just made is live.
212 info, err := c.Call(ctx, "tenant_info", nil)
213 if err != nil {
214 return fmt.Errorf("tenant_info: %w", err)
215 }
216 if delegated, _ := info["delegated"].(bool); !delegated {
217 return errors.New("FAIL: tenant_info reports no delegation immediately after delegate_complete")
218 }
219 if err := infoListsCA(info, ssh.FingerprintSHA256(d.ca.PublicKey())); err != nil {
220 return err
221 }
222
223 if _, err := c.Call(ctx, "vm_create", map[string]any{"name": vmName}); err != nil {
224 return fmt.Errorf("vm_create over MCP: %w", err)
225 }
226 defer func() {
227 // Destroy on the way out even when the leg failed: the smoke leaves no
228 // VM behind on the live fleet.
229 if _, err := c.Call(context.WithoutCancel(ctx), "vm_destroy", map[string]any{"vm": vmName}); err != nil {
230 fmt.Printf("eitri-smoke: MCP vm_destroy failed; vm %s left behind: %v\n", vmName, err)
231 }
232 }()
233
234 nonce, err := randNonce()
235 if err != nil {
236 return err
237 }
238 exec, err := c.Call(ctx, "vm_exec", map[string]any{"vm": vmName, "command": "echo " + nonce})
239 if err != nil {
240 return fmt.Errorf("vm_exec over MCP: %w", err)
241 }
242 if stdout, _ := exec["stdout"].(string); !strings.Contains(stdout, nonce) {
243 return fmt.Errorf("FAIL: vm_exec over MCP echoed %q, want it to contain %q", stdout, nonce)
244 }
245
246 exposed, err := c.Call(ctx, "vm_expose", map[string]any{"vm": vmName, "guest_port": 22})
247 if err != nil {
248 return fmt.Errorf("vm_expose over MCP: %w", err)
249 }
250 address, err := exposureAddress(exposed)
251 if err != nil {
252 return err
253 }
254 if err := proveBanner(ctx, address, now, sleep, dial); err != nil {
255 return err
256 }
257 if _, err := c.Call(ctx, "vm_unexpose", map[string]any{"vm": vmName, "guest_port": 22}); err != nil {
258 return fmt.Errorf("vm_unexpose over MCP: %w", err)
259 }
260 return nil
261 }
262
263 // delegationTTL is how long the smoke's delegation lives. Long enough for one
264 // gate run, short enough that a run which dies mid-way leaves nothing usable.
265 const delegationTTL = 30 * time.Minute
266
267 // signDelegation is `ssh-keygen -s`, performed in process: the caller's CA
268 // signs eitri's ephemeral public key for the guest login user.
269 func signDelegation(ca ssh.Signer, pubLine, principal string, now time.Time) (string, error) {
270 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine))
271 if err != nil {
272 return "", fmt.Errorf("parse the key eitri asked us to sign: %w", err)
273 }
274 var serial uint64
275 if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil {
276 return "", fmt.Errorf("certificate serial: %w", err)
277 }
278 cert := &ssh.Certificate{
279 Key: pub,
280 Serial: serial,
281 CertType: ssh.UserCert,
282 KeyId: "eitri-smoke-delegation",
283 ValidPrincipals: []string{principal},
284 ValidAfter: uint64(now.Add(-time.Minute).Unix()),
285 ValidBefore: uint64(now.Add(delegationTTL).Unix()),
286 Permissions: ssh.Permissions{Extensions: map[string]string{
287 "permit-pty": "", "permit-port-forwarding": "",
288 }},
289 }
290 if err := cert.SignCert(rand.Reader, ca); err != nil {
291 return "", fmt.Errorf("sign the delegation certificate: %w", err)
292 }
293 return authorizedLine(cert), nil
294 }
295
296 // authorizedLine renders a key or certificate as the one-line form ssh-keygen
297 // writes and every endpoint here accepts.
298 func authorizedLine(k ssh.PublicKey) string {
299 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(k)))
300 }
301
302 // infoListsCA checks tenant_info named the CA the leg registered, and that it
303 // described it rather than reproducing it.
304 func infoListsCA(info map[string]any, want string) error {
305 cas, _ := info["registered_cas"].([]any)
306 for _, raw := range cas {
307 ca, _ := raw.(map[string]any)
308 if fp, _ := ca["fingerprint"].(string); fp == want {
309 if _, leaked := ca["pubkey"]; leaked {
310 return errors.New("FAIL: tenant_info returned CA key material; it must describe, not reproduce")
311 }
312 return nil
313 }
314 }
315 return fmt.Errorf("FAIL: tenant_info does not list the CA this run registered (%s)", want)
316 }
317
318 // exposureAddress pulls the dialable address out of a vm_expose result. The
319 // address is empty until the VM's host has reported its uplink, so an empty one
320 // is a real failure rather than something to retry.
321 func exposureAddress(out map[string]any) (string, error) {
322 exposure, ok := out["exposure"].(map[string]any)
323 if !ok {
324 return "", errors.New("FAIL: vm_expose returned no exposure")
325 }
326 address, _ := exposure["address"].(string)
327 if address == "" {
328 return "", errors.New("FAIL: vm_expose returned no address to dial")
329 }
330 return address, nil
331 }
332
333 // proveBanner dials a published address until the guest's sshd answers, the
334 // same proof proveExposure makes of the API path.
335 func proveBanner(ctx context.Context, address string, now func() time.Time, sleep func(time.Duration), dial bannerFunc) error {
336 var lastErr error
337 err := pollLoop(ctx, now, sleep, 60*time.Second, 3*time.Second, func() (bool, error) {
338 banner, derr := dial(ctx, address)
339 if derr != nil {
340 // The listener binds on the host's next converge, a tick away.
341 lastErr = derr
342 return false, nil
343 }
344 if !strings.HasPrefix(banner, sshBannerPrefix) {
345 return false, fmt.Errorf("FAIL: port published over MCP at %s answered %q, want an %s banner",
346 address, truncateBanner(banner), sshBannerPrefix)
347 }
348 return true, nil
349 })
350 if errors.Is(err, errPollTimeout) {
351 return fmt.Errorf("FAIL: no %s banner from the MCP-published port %s within 60s: %v", sshBannerPrefix, address, lastErr)
352 }
353 return err
354 }
355
356 // randNonce returns a value the guest cannot have echoed by accident.
357 func randNonce() (string, error) {
358 var b [6]byte
359 if _, err := rand.Read(b[:]); err != nil {
360 return "", fmt.Errorf("generate exec nonce: %w", err)
361 }
362 return "mcp-" + hex.EncodeToString(b[:]), nil
363 }
internal/smoke/mcp_test.go
Old New
@@ -0,0 +1,363 @@
1 package smoke
2
3 import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "errors"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16 "golang.org/x/crypto/ssh"
17 )
18
19 // fakeMCP scripts an MCP endpoint: it records the calls the leg makes, answers
20 // each from results, and can be made to fail one of them. delegate_complete is
21 // answered like the real one — the certificate is parsed and its signing CA
22 // reported back — so the leg's own checks are exercised rather than mocked past.
23 type fakeMCP struct {
24 tools []string
25 results map[string]map[string]any
26 failOn string
27 // trusted is the tenant's registered CA set, as the control plane would
28 // hold it: a certificate signed by anything else is refused.
29 trusted []ssh.PublicKey
30 pubLine string
31 // uploaded records the CA lines ca_upload received.
32 uploaded []string
33 // delegated is what tenant_info reports; set once a certificate lands.
34 delegated bool
35
36 calls []string
37 }
38
39 func newFakeMCP(t *testing.T, trusted ...ssh.Signer) *fakeMCP {
40 t.Helper()
41 _, priv, err := ed25519.GenerateKey(rand.Reader)
42 require.NoError(t, err)
43 eitriKey, err := ssh.NewSignerFromSigner(priv)
44 require.NoError(t, err)
45
46 f := &fakeMCP{
47 tools: []string{
48 "vm_create", "vm_list", "vm_info", "vm_exec", "vm_write_file", "vm_read_file",
49 "vm_expose", "vm_exposures", "vm_unexpose", "vm_destroy",
50 "ca_upload", "tenant_info", "delegate_begin", "delegate_complete",
51 },
52 pubLine: authorizedLine(eitriKey.PublicKey()),
53 results: map[string]map[string]any{
54 "vm_create": {"id": "v-1", "name": "smoke-mcp"},
55 "vm_exec": {"stdout": "", "exit_code": 0.0},
56 "vm_expose": {"exposure": map[string]any{"id": "x-1", "address": "10.0.0.1:30001"}},
57 "vm_unexpose": {"id": "x-1"},
58 "vm_destroy": {"id": "v-1"},
59 },
60 }
61 for _, ca := range trusted {
62 f.trusted = append(f.trusted, ca.PublicKey())
63 }
64 f.results["delegate_begin"] = map[string]any{"public_key": f.pubLine, "principal": "ubuntu"}
65 return f
66 }
67
68 func (f *fakeMCP) List(context.Context) ([]string, error) { return f.tools, nil }
69
70 func (f *fakeMCP) Call(_ context.Context, name string, args map[string]any) (map[string]any, error) {
71 f.calls = append(f.calls, name)
72 if name == f.failOn {
73 return nil, errors.New(name + ": refused")
74 }
75 if name == "ca_upload" {
76 line, _ := args["public_key"].(string)
77 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
78 if err != nil {
79 return nil, errors.New("ca_upload: not a public key")
80 }
81 f.uploaded = append(f.uploaded, line)
82 return map[string]any{"fingerprint": ssh.FingerprintSHA256(pub)}, nil
83 }
84 if name == "tenant_info" {
85 cas := []any{}
86 for _, line := range f.uploaded {
87 if pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)); err == nil {
88 cas = append(cas, map[string]any{"fingerprint": ssh.FingerprintSHA256(pub), "label": "eitri-smoke"})
89 }
90 }
91 return map[string]any{"registered_cas": cas, "delegated": f.delegated}, nil
92 }
93 if name == "delegate_complete" {
94 return f.complete(args)
95 }
96 out := map[string]any{}
97 for k, v := range f.results[name] {
98 out[k] = v
99 }
100 return out, nil
101 }
102
103 // complete stands in for the control plane's own validation: the certificate
104 // must be over the key this fake handed out, and signed by a registered CA.
105 func (f *fakeMCP) complete(args map[string]any) (map[string]any, error) {
106 line, _ := args["certificate"].(string)
107 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
108 if err != nil {
109 return nil, errors.New("delegate_complete: not a certificate")
110 }
111 cert, ok := pub.(*ssh.Certificate)
112 if !ok {
113 return nil, errors.New("delegate_complete: not a certificate")
114 }
115 if authorizedLine(cert.Key) != f.pubLine {
116 return nil, errors.New("delegate_complete: that certificate is for a different key")
117 }
118 for _, ca := range f.trusted {
119 if string(ca.Marshal()) == string(cert.SignatureKey.Marshal()) {
120 f.delegated = true
121 return map[string]any{
122 "ca_fingerprint": ssh.FingerprintSHA256(cert.SignatureKey),
123 "principals": []any{"ubuntu"},
124 "expires_at": time.Unix(int64(cert.ValidBefore), 0).UTC().Format(time.RFC3339),
125 }, nil
126 }
127 }
128 return nil, errors.New("delegate_complete: not a CA registered to this tenant")
129 }
130
131 // echoingMCP makes vm_exec behave like a guest: it echoes the command's
132 // argument, which is the nonce the leg generated.
133 type echoingMCP struct{ *fakeMCP }
134
135 func (e echoingMCP) Call(ctx context.Context, name string, args map[string]any) (map[string]any, error) {
136 out, err := e.fakeMCP.Call(ctx, name, args)
137 if err != nil || name != "vm_exec" {
138 return out, err
139 }
140 cmd, _ := args["command"].(string)
141 out["stdout"] = strings.TrimPrefix(cmd, "echo ") + "\n"
142 return out, nil
143 }
144
145 func okBannerDial(context.Context, string) (string, error) { return "SSH-2.0-OpenSSH_9.6\r\n", nil }
146
147 // newCA returns a signer standing in for somebody's own SSH user CA.
148 func newCA(t *testing.T) ssh.Signer {
149 t.Helper()
150 _, priv, err := ed25519.GenerateKey(rand.Reader)
151 require.NoError(t, err)
152 s, err := ssh.NewSignerFromSigner(priv)
153 require.NoError(t, err)
154 return s
155 }
156
157 // testDelegator builds the out-of-band half: ca is the one the leg registers
158 // and signs with, stranger is the one nobody registered.
159 func testDelegator(ca, stranger ssh.Signer) delegator {
160 return delegator{
161 ca: ca,
162 stranger: stranger,
163 principal: "ubuntu",
164 now: func() time.Time { return time.Unix(1_800_000_000, 0) },
165 }
166 }
167
168 // TestProveMCPDrivesTheWholeCycle pins the order the leg must run in: the CA is
169 // registered BEFORE the VM is created, because a guest bakes its trusted CA set
170 // at create and would otherwise refuse every certificate. The delegation itself
171 // may fall on either side of the create, which is the improvement over holding
172 // a signing key.
173 func TestProveMCPDrivesTheWholeCycle(t *testing.T) {
174 ca, stranger := newCA(t), newCA(t)
175 f := echoingMCP{newFakeMCP(t, ca)}
176 clock := &fakeClock{}
177
178 require.NoError(t, proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial))
179
180 assert.Equal(t, []string{authorizedLine(ca.PublicKey())}, f.uploaded,
181 "the CA is registered through the tool, before anything is created")
182 assert.Equal(t,
183 []string{"ca_upload", "delegate_begin", "delegate_complete", "delegate_complete",
184 "tenant_info", "vm_create", "vm_exec", "vm_expose", "vm_unexpose", "vm_destroy"},
185 f.calls)
186 }
187
188 // TestProveMCPRequiresTheUnregisteredCAToBeRefused is the negative leg itself:
189 // if the control plane ever accepted a certificate from a CA nobody registered,
190 // the gate must fail.
191 func TestProveMCPRequiresTheUnregisteredCAToBeRefused(t *testing.T) {
192 ca, stranger := newCA(t), newCA(t)
193 f := echoingMCP{newFakeMCP(t, ca, stranger)} // a plane that trusts everyone
194 clock := &fakeClock{}
195
196 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial)
197 require.Error(t, err)
198 assert.Contains(t, err.Error(), "accepted a certificate from an unregistered CA")
199 }
200
201 // TestProveMCPRejectsAWrongPrincipal: the challenge names the principal the
202 // certificate must carry, and a plane asking for a different one is a plane the
203 // gate does not understand.
204 func TestProveMCPRejectsAWrongPrincipal(t *testing.T) {
205 ca, stranger := newCA(t), newCA(t)
206 f := newFakeMCP(t, ca)
207 f.results["delegate_begin"] = map[string]any{"public_key": f.pubLine, "principal": "root"}
208 clock := &fakeClock{}
209
210 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial)
211 require.Error(t, err)
212 assert.Contains(t, err.Error(), `asked for principal "root"`)
213 }
214
215 // TestProveMCPRejectsADelegationWithNoExpiry: a delegation that never ends is
216 // not one.
217 func TestProveMCPRejectsADelegationWithNoExpiry(t *testing.T) {
218 ca, stranger := newCA(t), newCA(t)
219 f := noExpiryMCP{newFakeMCP(t, ca)}
220 clock := &fakeClock{}
221
222 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial)
223 require.Error(t, err)
224 assert.Contains(t, err.Error(), "reports no expiry")
225 }
226
227 type noExpiryMCP struct{ *fakeMCP }
228
229 func (n noExpiryMCP) Call(ctx context.Context, name string, args map[string]any) (map[string]any, error) {
230 out, err := n.fakeMCP.Call(ctx, name, args)
231 if err == nil && name == "delegate_complete" {
232 delete(out, "expires_at")
233 }
234 return out, err
235 }
236
237 // TestProveMCPDestroysItsVMOnFailure: a failed leg must not leave a VM running
238 // on the live fleet.
239 func TestProveMCPDestroysItsVMOnFailure(t *testing.T) {
240 ca, stranger := newCA(t), newCA(t)
241 f := newFakeMCP(t, ca)
242 f.failOn = "vm_exec"
243 clock := &fakeClock{}
244
245 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial)
246 require.Error(t, err)
247 assert.Contains(t, err.Error(), "vm_exec over MCP")
248 assert.Contains(t, f.calls, "vm_destroy", "the leg must reap its own VM even when it fails")
249 }
250
251 // TestProveMCPRejectsAShortToolset: a transport that advertises fewer tools than
252 // the stdio binary means the two have drifted apart.
253 func TestProveMCPRejectsAShortToolset(t *testing.T) {
254 ca, stranger := newCA(t), newCA(t)
255 f := newFakeMCP(t, ca)
256 f.tools = f.tools[:5]
257 clock := &fakeClock{}
258
259 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial)
260 require.Error(t, err)
261 assert.Contains(t, err.Error(), "advertises 5 tools")
262 }
263
264 // TestProveMCPRejectsAMissingDelegateTool guards the tools a remote caller
265 // cannot work without.
266 func TestProveMCPRejectsAMissingDelegateTool(t *testing.T) {
267 ca, stranger := newCA(t), newCA(t)
268 f := newFakeMCP(t, ca)
269 for i, name := range f.tools {
270 if name == "delegate_begin" {
271 f.tools[i] = "vm_something_else"
272 }
273 }
274 clock := &fakeClock{}
275
276 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial)
277 require.Error(t, err)
278 assert.Contains(t, err.Error(), "does not advertise delegate_begin")
279 }
280
281 // TestProveMCPRejectsAnExposureWithNoAddress: a grant with nowhere to dial is a
282 // failure, not something to retry.
283 func TestProveMCPRejectsAnExposureWithNoAddress(t *testing.T) {
284 ca, stranger := newCA(t), newCA(t)
285 f := echoingMCP{newFakeMCP(t, ca)}
286 f.results["vm_expose"] = map[string]any{"exposure": map[string]any{"id": "x-1"}}
287 clock := &fakeClock{}
288
289 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial)
290 require.Error(t, err)
291 assert.Contains(t, err.Error(), "no address to dial")
292 }
293
294 // TestProveMCPRejectsAWrongBanner: something is listening on the published port,
295 // but it is not the guest's sshd.
296 func TestProveMCPRejectsAWrongBanner(t *testing.T) {
297 ca, stranger := newCA(t), newCA(t)
298 f := echoingMCP{newFakeMCP(t, ca)}
299 clock := &fakeClock{}
300
301 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep,
302 func(context.Context, string) (string, error) { return "HTTP/1.1 200 OK", nil })
303 require.Error(t, err)
304 assert.Contains(t, err.Error(), "want an SSH-2.0 banner")
305 }
306
307 // TestProveMCPRejectsAnEchoThatDoesNotComeBack: the nonce proves the command
308 // actually ran in the guest rather than being answered by the control plane.
309 func TestProveMCPRejectsAnEchoThatDoesNotComeBack(t *testing.T) {
310 ca, stranger := newCA(t), newCA(t)
311 f := newFakeMCP(t, ca) // plain fake: vm_exec returns an empty stdout
312 clock := &fakeClock{}
313
314 err := proveMCP(t.Context(), f, "smoke-mcp", testDelegator(ca, stranger), clock.now, clock.sleep, okBannerDial)
315 require.Error(t, err)
316 assert.Contains(t, err.Error(), "want it to contain")
317 }
318
319 // TestSignDelegationProducesAUsableCertificate pins the shape the control plane
320 // checks: a user certificate over the key it handed out, naming the login user,
321 // with a real validity window.
322 func TestSignDelegationProducesAUsableCertificate(t *testing.T) {
323 ca := newCA(t)
324 f := newFakeMCP(t, ca)
325 now := time.Unix(1_800_000_000, 0)
326
327 line, err := signDelegation(ca, f.pubLine, "ubuntu", now)
328 require.NoError(t, err)
329 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
330 require.NoError(t, err)
331 cert, ok := pub.(*ssh.Certificate)
332 require.True(t, ok)
333 assert.Equal(t, uint32(ssh.UserCert), cert.CertType)
334 assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals)
335 assert.Equal(t, f.pubLine, authorizedLine(cert.Key))
336 assert.Equal(t, uint64(now.Add(delegationTTL).Unix()), cert.ValidBefore)
337
338 checker := &ssh.CertChecker{
339 IsUserAuthority: func(auth ssh.PublicKey) bool {
340 return string(auth.Marshal()) == string(ca.PublicKey().Marshal())
341 },
342 Clock: func() time.Time { return now },
343 }
344 require.NoError(t, checker.CheckCert("ubuntu", cert))
345 }
346
347 // TestProveRemoteMCPNeedsACredential passes only on a 401 — an endpoint that
348 // answers anything else is either unprotected or not the MCP endpoint.
349 func TestProveRemoteMCPNeedsACredential(t *testing.T) {
350 unauthorized := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
351 http.Error(w, "sign in required", http.StatusUnauthorized)
352 }))
353 defer unauthorized.Close()
354 assert.NoError(t, proveRemoteMCPNeedsACredential(unauthorized.URL))
355
356 open := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
357 w.WriteHeader(http.StatusOK)
358 }))
359 defer open.Close()
360 err := proveRemoteMCPNeedsACredential(open.URL)
361 require.Error(t, err)
362 assert.Contains(t, err.Error(), "answered 200, want 401")
363 }
internal/smoke/run.go
Old New
@@ -54,6 +54,14 @@ func Run() error {
54 } 54 }
55 fmt.Printf("credential chain OK (tenant %s)\n", ciTenant) 55 fmt.Printf("credential chain OK (tenant %s)\n", ciTenant)
56 56
57 // The remote MCP endpoint's cheap half: it must exist and must refuse an
58 // unauthenticated caller. It needs no guest, so a misrouted or unprotected
59 // /mcp fails here in a second rather than after a VM has booted.
60 if err := proveRemoteMCPNeedsACredential(cfg.ServerURL); err != nil {
61 return err
62 }
63 fmt.Println("remote MCP endpoint refuses an unauthenticated caller")
64
57 // Phase 2 — VM lifecycle. Authenticate with an operator-minted PAT read from 65 // Phase 2 — VM lifecycle. Authenticate with an operator-minted PAT read from
58 // CI_PAT_FILE (a normal, tenant-scoped console PAT — spec §3's automation 66 // CI_PAT_FILE (a normal, tenant-scoped console PAT — spec §3's automation
59 // story). The scenario's tenant is DERIVED via Me(), never assumed, and 67 // story). The scenario's tenant is DERIVED via Me(), never assumed, and
@@ -85,19 +93,47 @@ func Run() error {
85 return fmt.Errorf("generate vm name: %w", err) 93 return fmt.Errorf("generate vm name: %w", err)
86 } 94 }
87 95
96 // The smoke's own user CA. It is the gate check's CA when one is configured,
97 // and it is what the MCP leg delegates with either way — so an in-memory CA
98 // stands in when SMOKE_USER_CA_FILE is unset, and the delegation leg never
99 // depends on how the gate happens to be configured.
100 userCA, err := smokeUserCA(cfg.SmokeUserCAFile)
101 if err != nil {
102 return err
103 }
104
88 var gate *gateHooks 105 var gate *gateHooks
89 if cfg.SmokeGate != "" && cfg.SmokeUserCAFile != "" { 106 if cfg.SmokeGate != "" && cfg.SmokeUserCAFile != "" {
90 userCA, err := loadOrCreateUserCA(cfg.SmokeUserCAFile)
91 if err != nil {
92 return fmt.Errorf("load smoke user CA: %w", err)
93 }
94 gate = realGateHooks(cfg, tenant, api, userCA, time.Now, time.Sleep) 107 gate = realGateHooks(cfg, tenant, api, userCA, time.Now, time.Sleep)
95 } else { 108 } else {
96 fmt.Fprintln(os.Stderr, "eitri-smoke: gate SSH check skipped (SMOKE_GATE/SMOKE_USER_CA_FILE not set)") 109 fmt.Fprintln(os.Stderr, "eitri-smoke: gate SSH check skipped (SMOKE_GATE/SMOKE_USER_CA_FILE not set)")
97 } 110 }
98 111
112 // A second CA, registered with nobody, for the negative delegation leg.
113 stranger, err := generateUserCA()
114 if err != nil {
115 return err
116 }
117
99 ctx := context.Background() 118 ctx := context.Background()
100 msg, err := runScenario(ctx, cfg, vmName, api, realRunSSH(cfg.AgentUserHost, cfg.AgentPort), gate, time.Now, time.Sleep, realReadPubKey, readBanner) 119
120 // Remote MCP, over the same PAT: no local install, no uploaded CA, nothing
121 // but a token and an HTTP endpoint. The session lives for the whole run.
122 tools, closeMCP, err := dialMCP(ctx, cfg.ServerURL, pat)
123 if err != nil {
124 return err
125 }
126 defer closeMCP()
127 mcp := func(ctx context.Context, name string) error {
128 return proveMCP(ctx, tools, name, delegator{
129 ca: userCA,
130 stranger: stranger,
131 principal: cfg.SmokeVMUser,
132 now: time.Now,
133 }, time.Now, time.Sleep, readBanner)
134 }
135
136 msg, err := runScenario(ctx, cfg, vmName, api, realRunSSH(cfg.AgentUserHost, cfg.AgentPort), gate, mcp, time.Now, time.Sleep, realReadPubKey, readBanner)
101 if err != nil { 137 if err != nil {
102 return err 138 return err
103 } 139 }
internal/smoke/scenario.go
Old New
@@ -116,6 +116,10 @@ type gateHooks struct {
116 exec func(ctx context.Context, vmName string) error // reach the guest through the gate (after boot) 116 exec func(ctx context.Context, vmName string) error // reach the guest through the gate (after boot)
117 } 117 }
118 118
119 // mcpLeg is the remote-MCP proof: one full cycle driven entirely through the
120 // control plane's /mcp endpoint with a bearer PAT. nil means "skip it".
121 type mcpLeg func(ctx context.Context, vmName string) error
122
119 // pollLoop calls attempt repeatedly (with sleep between calls) until attempt 123 // pollLoop calls attempt repeatedly (with sleep between calls) until attempt
120 // reports done, returns a non-nil error, or the deadline (now()+timeout) is 124 // reports done, returns a non-nil error, or the deadline (now()+timeout) is
121 // reached, in which case it returns errPollTimeout. now and sleep are 125 // reached, in which case it returns errPollTimeout. now and sleep are
@@ -148,10 +152,13 @@ func pollLoop(ctx context.Context, now func() time.Time, sleep func(time.Duratio
148 // smoke's user CA with the tenant before create (the guest bakes its trusted 152 // smoke's user CA with the tenant before create (the guest bakes its trusted
149 // CAs at boot, so registration MUST happen first) and proves gate SSH access 153 // CAs at boot, so registration MUST happen first) and proves gate SSH access
150 // after the boot-proof — a hard gate, so a failure there fails the scenario. 154 // after the boot-proof — a hard gate, so a failure there fails the scenario.
155 // mcp, when non-nil, drives a second VM's whole life through the remote MCP
156 // endpoint — that leg owns its own VM, created after it registers its own CA
157 // with the tenant, so the guest trusts the certificates eitri presents.
151 // now/sleep are the injected clock so the poll deadlines are unit-testable 158 // now/sleep are the injected clock so the poll deadlines are unit-testable
152 // without real waiting. On success it returns the human-readable COMPLETE 159 // without real waiting. On success it returns the human-readable COMPLETE
153 // line; on any failure it returns a descriptive error. 160 // line; on any failure it returns a descriptive error.
154 func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH sshFunc, gate *gateHooks, now func() time.Time, sleep func(time.Duration), readPubKey func() string, dialBanner bannerFunc) (string, error) { 161 func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH sshFunc, gate *gateHooks, mcp mcpLeg, now func() time.Time, sleep func(time.Duration), readPubKey func() string, dialBanner bannerFunc) (string, error) {
155 if gate != nil { 162 if gate != nil {
156 if err := gate.register(ctx); err != nil { 163 if err := gate.register(ctx); err != nil {
157 return "", fmt.Errorf("register smoke user CA: %w", err) 164 return "", fmt.Errorf("register smoke user CA: %w", err)
@@ -219,6 +226,16 @@ func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH
219 } 226 }
220 exposureOK = true 227 exposureOK = true
221 228
229 // Remote MCP: the same fleet, driven by a bearer PAT over HTTP with no
230 // local install and no uploaded CA. It creates and destroys its own VM.
231 mcpOK := false
232 if mcp != nil {
233 if err := mcp(ctx, vmName+"-mcp"); err != nil {
234 return "", err
235 }
236 mcpOK = true
237 }
238
222 // Power cycle: prove the guest comes BACK. A first boot runs on the 239 // Power cycle: prove the guest comes BACK. A first boot runs on the
223 // kernel's in-memory partition table; only a stop→start proves the 240 // kernel's in-memory partition table; only a stop→start proves the
224 // on-disk GPT survived growpart. The sector-0 regression hid exactly 241 // on-disk GPT survived growpart. The sector-0 regression hid exactly
@@ -292,5 +309,8 @@ func runScenario(ctx context.Context, cfg Config, vmName string, c vmAPI, runSSH
292 if exposureOK { 309 if exposureOK {
293 msg += ", exposed port: ok" 310 msg += ", exposed port: ok"
294 } 311 }
312 if mcpOK {
313 msg += ", remote MCP: ok"
314 }
295 return msg, nil 315 return msg, nil
296 } 316 }
internal/smoke/scenario_test.go
Old New
@@ -169,7 +169,7 @@ func TestRunScenarioSuccess(t *testing.T) {
169 } 169 }
170 170
171 clock := &fakeClock{t: time.Unix(0, 0)} 171 clock := &fakeClock{t: time.Unix(0, 0)}
172 msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) 172 msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
173 if err != nil { 173 if err != nil {
174 t.Fatalf("runScenario: %v", err) 174 t.Fatalf("runScenario: %v", err)
175 } 175 }
@@ -230,7 +230,7 @@ func TestRunScenarioRebootDeathFails(t *testing.T) {
230 } 230 }
231 231
232 clock := &fakeClock{t: time.Unix(0, 0)} 232 clock := &fakeClock{t: time.Unix(0, 0)}
233 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) 233 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
234 if err == nil { 234 if err == nil {
235 t.Fatal("runScenario: want error for a VM that never came back, got nil") 235 t.Fatal("runScenario: want error for a VM that never came back, got nil")
236 } 236 }
@@ -256,7 +256,7 @@ func TestRunScenarioExposureFailureFails(t *testing.T) {
256 badBanner := func(ctx context.Context, addr string) (string, error) { return "HTTP/1.1 400\r\n", nil } 256 badBanner := func(ctx context.Context, addr string) (string, error) { return "HTTP/1.1 400\r\n", nil }
257 257
258 clock := &fakeClock{t: time.Unix(0, 0)} 258 clock := &fakeClock{t: time.Unix(0, 0)}
259 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), nil, clock.now, clock.sleep, noopReadPubKey, badBanner) 259 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), nil, nil, clock.now, clock.sleep, noopReadPubKey, badBanner)
260 if err == nil { 260 if err == nil {
261 t.Fatal("runScenario: want error, got nil") 261 t.Fatal("runScenario: want error, got nil")
262 } 262 }
@@ -291,7 +291,7 @@ func TestRunScenarioSerialPanicFails(t *testing.T) {
291 } 291 }
292 292
293 clock := &fakeClock{t: time.Unix(0, 0)} 293 clock := &fakeClock{t: time.Unix(0, 0)}
294 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) 294 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
295 if err == nil { 295 if err == nil {
296 t.Fatal("runScenario: want error, got nil") 296 t.Fatal("runScenario: want error, got nil")
297 } 297 }
@@ -324,7 +324,7 @@ func TestRunScenarioNeverReadyTimesOut(t *testing.T) {
324 } 324 }
325 325
326 clock := &fakeClock{t: time.Unix(0, 0)} 326 clock := &fakeClock{t: time.Unix(0, 0)}
327 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) 327 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
328 if err == nil { 328 if err == nil {
329 t.Fatal("runScenario: want error, got nil") 329 t.Fatal("runScenario: want error, got nil")
330 } 330 }
@@ -362,7 +362,7 @@ func TestRunScenarioNeverReapedTimesOut(t *testing.T) {
362 } 362 }
363 363
364 clock := &fakeClock{t: time.Unix(0, 0)} 364 clock := &fakeClock{t: time.Unix(0, 0)}
365 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) 365 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, runSSH, nil, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
366 if err == nil { 366 if err == nil {
367 t.Fatal("runScenario: want error, got nil") 367 t.Fatal("runScenario: want error, got nil")
368 } 368 }
@@ -390,7 +390,7 @@ func TestRunScenarioNoHosts(t *testing.T) {
390 }, 390 },
391 } 391 }
392 clock := &fakeClock{t: time.Unix(0, 0)} 392 clock := &fakeClock{t: time.Unix(0, 0)}
393 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, nil, clock.now, clock.sleep, noopReadPubKey, okBanner) 393 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, nil, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
394 if err == nil { 394 if err == nil {
395 t.Fatal("runScenario: want error, got nil") 395 t.Fatal("runScenario: want error, got nil")
396 } 396 }
@@ -464,7 +464,7 @@ func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) {
464 } 464 }
465 465
466 clock := &fakeClock{t: time.Unix(0, 0)} 466 clock := &fakeClock{t: time.Unix(0, 0)}
467 msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, clock.now, clock.sleep, noopReadPubKey, okBanner) 467 msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
468 if err != nil { 468 if err != nil {
469 t.Fatalf("runScenario: %v", err) 469 t.Fatalf("runScenario: %v", err)
470 } 470 }
@@ -514,7 +514,7 @@ func TestRunScenarioGateRegisterErrorAbortsBeforeCreate(t *testing.T) {
514 } 514 }
515 515
516 clock := &fakeClock{t: time.Unix(0, 0)} 516 clock := &fakeClock{t: time.Unix(0, 0)}
517 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, gate, clock.now, clock.sleep, noopReadPubKey, okBanner) 517 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, nil, gate, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
518 if err == nil { 518 if err == nil {
519 t.Fatal("runScenario: want error, got nil") 519 t.Fatal("runScenario: want error, got nil")
520 } 520 }
@@ -538,7 +538,7 @@ func TestRunScenarioGateExecErrorFails(t *testing.T) {
538 } 538 }
539 539
540 clock := &fakeClock{t: time.Unix(0, 0)} 540 clock := &fakeClock{t: time.Unix(0, 0)}
541 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, clock.now, clock.sleep, noopReadPubKey, okBanner) 541 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), gate, nil, clock.now, clock.sleep, noopReadPubKey, okBanner)
542 if err == nil { 542 if err == nil {
543 t.Fatal("runScenario: want error, got nil") 543 t.Fatal("runScenario: want error, got nil")
544 } 544 }
@@ -599,3 +599,44 @@ func TestGetVMFiltersById(t *testing.T) {
599 t.Errorf("getVM(vm-3) against a non-empty list = present %v, err %v; want absent", present, err) 599 t.Errorf("getVM(vm-3) against a non-empty list = present %v, err %v; want absent", present, err)
600 } 600 }
601 } 601 }
602
603 // TestRunScenarioRunsTheMCPLegOnItsOwnVM pins where the remote-MCP leg sits and
604 // that it owns a separate VM: it must not reuse the scenario's, whose CA set
605 // was baked before the MCP leg registered the CA it delegates with.
606 func TestRunScenarioRunsTheMCPLegOnItsOwnVM(t *testing.T) {
607 var calls []string
608 api := happyPathAPI(t, &calls)
609
610 mcpVM := ""
611 mcp := func(_ context.Context, vmName string) error {
612 mcpVM = vmName
613 calls = append(calls, "mcp")
614 return nil
615 }
616
617 clock := &fakeClock{t: time.Unix(0, 0)}
618 msg, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), nil, mcp, clock.now, clock.sleep, noopReadPubKey, okBanner)
619 if err != nil {
620 t.Fatalf("runScenario: %v", err)
621 }
622 if !strings.Contains(msg, "remote MCP: ok") {
623 t.Errorf("message = %q, want it to mention remote MCP: ok", msg)
624 }
625 if mcpVM == "smoke-test" || mcpVM == "" {
626 t.Errorf("MCP leg drove vm %q, want a VM of its own", mcpVM)
627 }
628 }
629
630 // TestRunScenarioFailsWhenTheMCPLegFails: the remote endpoint is a hard gate,
631 // not an advisory check.
632 func TestRunScenarioFailsWhenTheMCPLegFails(t *testing.T) {
633 var calls []string
634 api := happyPathAPI(t, &calls)
635 mcp := func(context.Context, string) error { return errors.New("FAIL: register refused") }
636
637 clock := &fakeClock{t: time.Unix(0, 0)}
638 _, err := runScenario(context.Background(), baseCfg(), "smoke-test", api, happyPathRunSSH(), nil, mcp, clock.now, clock.sleep, noopReadPubKey, okBanner)
639 if err == nil || !strings.Contains(err.Error(), "register refused") {
640 t.Fatalf("runScenario err = %v, want the MCP leg's failure to fail the gate", err)
641 }
642 }
internal/smoke/userca.go
Old New
@@ -13,6 +13,30 @@ import (
13 "golang.org/x/crypto/ssh" 13 "golang.org/x/crypto/ssh"
14 ) 14 )
15 15
16 // smokeUserCA returns the CA the smoke signs with: the persistent one at path,
17 // or a throwaway held in memory when no path is configured. The gate check
18 // needs the persistent one (its public half is registered once and reused);
19 // the delegation leg registers whichever it gets, so it works either way.
20 func smokeUserCA(path string) (ssh.Signer, error) {
21 if path == "" {
22 return generateUserCA()
23 }
24 return loadOrCreateUserCA(path)
25 }
26
27 // generateUserCA returns a fresh ed25519 CA that is never written down.
28 func generateUserCA() (ssh.Signer, error) {
29 _, priv, err := ed25519.GenerateKey(rand.Reader)
30 if err != nil {
31 return nil, fmt.Errorf("generate user CA key: %w", err)
32 }
33 signer, err := ssh.NewSignerFromSigner(priv)
34 if err != nil {
35 return nil, fmt.Errorf("build user CA signer: %w", err)
36 }
37 return signer, nil
38 }
39
16 // loadOrCreateUserCA loads the smoke's persistent user CA key from path, 40 // loadOrCreateUserCA loads the smoke's persistent user CA key from path,
17 // generating and persisting a fresh ed25519 key on first run so subsequent 41 // generating and persisting a fresh ed25519 key on first run so subsequent
18 // smoke runs reuse the same CA identity (the gate check registers this key's 42 // smoke runs reuse the same CA identity (the gate check registers this key's
scripts/coverage.sh
Old New
@@ -30,7 +30,10 @@ declare -A FLOOR=(
30 [internal/agent/syncclient]=74 30 [internal/agent/syncclient]=74
31 [internal/agent/exposeproxy]=80 31 [internal/agent/exposeproxy]=80
32 [internal/server/api]=76 32 [internal/server/api]=76
33 [internal/server/boot]=17 33 [internal/server/delegation]=90
34 [internal/server/mcphttp]=88
35 [internal/server/vmssh]=90
36 [internal/server/boot]=20
34 [internal/server/api/client]=89 37 [internal/server/api/client]=89
35 [internal/server/api/spec]=91 38 [internal/server/api/spec]=91
36 [internal/server/store]=76 39 [internal/server/store]=76
web/src/lib/api-types.ts
Old New
@@ -52,6 +52,140 @@ export interface paths {
52 patch?: never; 52 patch?: never;
53 trace?: never; 53 trace?: never;
54 }; 54 };
55 "/api/v1/delegations": {
56 parameters: {
57 query?: never;
58 header?: never;
59 path?: never;
60 cookie?: never;
61 };
62 /** Describe the caller tenant's live delegation, including when it expires. 404 when there is none. */
63 get: {
64 parameters: {
65 query?: never;
66 header?: never;
67 path?: never;
68 cookie?: never;
69 };
70 requestBody?: never;
71 responses: {
72 /** @description success */
73 200: {
74 headers: {
75 [name: string]: unknown;
76 };
77 content: {
78 "application/json": components["schemas"]["Delegation"];
79 };
80 };
81 /** @description error (plain text) */
82 default: {
83 headers: {
84 [name: string]: unknown;
85 };
86 content: {
87 "text/plain": string;
88 };
89 };
90 };
91 };
92 /** Complete a delegation with the certificate your CA signed. The certificate must be a user certificate over the key this delegation issued, signed by a CA registered to your tenant, naming the guest login user as a principal. */
93 put: {
94 parameters: {
95 query?: never;
96 header?: never;
97 path?: never;
98 cookie?: never;
99 };
100 requestBody: {
101 content: {
102 "application/json": components["schemas"]["DelegationRequest"];
103 };
104 };
105 responses: {
106 /** @description success */
107 200: {
108 headers: {
109 [name: string]: unknown;
110 };
111 content: {
112 "application/json": components["schemas"]["Delegation"];
113 };
114 };
115 /** @description error (plain text) */
116 default: {
117 headers: {
118 [name: string]: unknown;
119 };
120 content: {
121 "text/plain": string;
122 };
123 };
124 };
125 };
126 /** Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process. */
127 post: {
128 parameters: {
129 query?: never;
130 header?: never;
131 path?: never;
132 cookie?: never;
133 };
134 requestBody?: never;
135 responses: {
136 /** @description success */
137 200: {
138 headers: {
139 [name: string]: unknown;
140 };
141 content: {
142 "application/json": components["schemas"]["DelegationChallenge"];
143 };
144 };
145 /** @description error (plain text) */
146 default: {
147 headers: {
148 [name: string]: unknown;
149 };
150 content: {
151 "text/plain": string;
152 };
153 };
154 };
155 };
156 /** End the caller tenant's delegation now. eitri drops the certificate and can no longer reach that tenant's VMs. */
157 delete: {
158 parameters: {
159 query?: never;
160 header?: never;
161 path?: never;
162 cookie?: never;
163 };
164 requestBody?: never;
165 responses: {
166 /** @description success */
167 204: {
168 headers: {
169 [name: string]: unknown;
170 };
171 content?: never;
172 };
173 /** @description error (plain text) */
174 default: {
175 headers: {
176 [name: string]: unknown;
177 };
178 content: {
179 "text/plain": string;
180 };
181 };
182 };
183 };
184 options?: never;
185 head?: never;
186 patch?: never;
187 trace?: never;
188 };
55 "/api/v1/enroll": { 189 "/api/v1/enroll": {
56 parameters: { 190 parameters: {
57 query?: never; 191 query?: never;
@@ -1400,6 +1534,22 @@ export interface components {
1400 id: string; 1534 id: string;
1401 name: string; 1535 name: string;
1402 }; 1536 };
1537 Delegation: {
1538 ca_fingerprint: string;
1539 expires_at: string;
1540 key_id: string;
1541 principals: string[];
1542 public_key: string;
1543 serial: string;
1544 };
1545 DelegationChallenge: {
1546 instructions: string;
1547 principal: string;
1548 public_key: string;
1549 };
1550 DelegationRequest: {
1551 certificate?: string;
1552 };
1403 EnrollRequest: { 1553 EnrollRequest: {
1404 arch?: string; 1554 arch?: string;
1405 bridge_cidr?: string | null; 1555 bridge_cidr?: string | null;