212f6ed1
feat(server): a PAT is enough — public MCP at /mcp
a73x 2026-08-08 13:20
Commit message
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; |