a73x

4c1941bf

fix(server): a tenant with no CA is told how to get one

a73x   2026-08-11 04:13

Commit message
fix(server): a tenant with no CA is told how to get one

Create has always refused a tenant that has registered no SSH user CA,
and for the right reason: a guest trusts the CA set baked into it when it
is created, nothing rewrites that trust afterwards, so such a guest is
not unreachable until a CA arrives — it is unreachable for good, and the
only remedy is to delete it and start again.

What the refusal could not do is get itself fixed. It answered 400,
saying the request was malformed when the request was fine and the tenant
was not ready, and it offered one remedy: a REST endpoint, which is the
one thing a person at a console and a model holding a PAT both cannot
use. So it now answers 409 — the same reading the certified-host-key
refusal gives for the host-side half of this story — and names every way
in: the console's Settings page, `eitri ca upload`, the MCP `ca_upload`
tool, and the endpoint all three post to. They write the same tenant CA
set, so any of them satisfies the check, and MCP's vm_create posts the
same route and reads the same message.

The tests are new. The scope was the untested part and the one that
matters most: a CA registered by another tenant says nothing about
whether this tenant could reach the guest it is asking for, and a
fleet-wide reading would let the first tenant to upload one walk every
other tenant into the dead end.

The console says it earlier still: opening Create VM for a tenant with no
registered CA shows what such a guest would be, and links to settings.
The API stays the enforcer.

The quickstart registers a CA before it boots a VM, which is the order
the guest has always required.

docs/mcp.md
Old New
@@ -16,7 +16,7 @@ Both serve the same tools.
16 16
17 | Tool | Purpose | 17 | Tool | Purpose |
18 |---|---| 18 |---|---|
19 | `vm_create` | Create a VM; by default waits for `lifecycle=ready` + IP, then for guest SSH and `cloud-init status --wait` to finish. | 19 | `vm_create` | Create a VM; by default waits for `lifecycle=ready` + IP, then for guest SSH and `cloud-init status --wait` to finish. Refused while the tenant has no registered SSH user CA—call `ca_upload` first, or the guest would trust nothing. |
20 | `vm_list` | List all VMs on the fleet (id, name, lifecycle, IP, size). | 20 | `vm_list` | List all VMs on the fleet (id, name, lifecycle, IP, size). |
21 | `vm_info` | Show one VM's state plus a ready-to-use `ssh` command. | 21 | `vm_info` | Show one VM's state plus a ready-to-use `ssh` command. |
22 | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. | 22 | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. |
docs/quickstart.md
Old New
@@ -104,15 +104,13 @@ a Mac's guests sit on vmnet's subnet. The host reports that subnet to the fleet,
104 so it is what the console shows—no allocation is made for a Mac and none is 104 so it is what the console shows—no allocation is made for a Mac and none is
105 used. Everything above that—`eitri ssh`, the console, reconcile—is the same. 105 used. Everything above that—`eitri ssh`, the console, reconcile—is the same.
106 106
107 ### Boot a VM 107 ### Register your CA
108
109 Console → **+ Create VM**, pick your host, create. Watch it boot in the
110 browser serial console.
111
112 ### SSH in
113 108
114 SSH access uses certificates signed by **your** CA—eitri never holds a 109 SSH access uses certificates signed by **your** CA—eitri never holds a
115 user key that can enter your VMs. On your laptop, download the client CLI. It 110 user key that can enter your VMs. A guest trusts the CA set it is created with,
111 so this comes before your first VM.
112
113 On your laptop, download the client CLI. It
116 targets the hosted service by default, so the only thing to set is a personal 114 targets the hosted service by default, so the only thing to set is a personal
117 access token: 115 access token:
118 116
@@ -135,7 +133,23 @@ eitri ca upload ~/.ssh/eitri_user_ca.pub
135 Your token names the tenant, so neither command needs one. In more than one 133 Your token names the tenant, so neither command needs one. In more than one
136 tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly. 134 tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly.
137 135
138 Then SSH in. The console and gate are hosted defaults, and `eitri ssh` uses 136 The console does the same under **Settings**, and so does the MCP `ca_upload`
137 tool. All three register into the same set.
138
139 ### Boot a VM
140
141 Console → **+ Create VM**, pick your host, create. Watch it boot in the
142 browser serial console.
143
144 A tenant with no registered CA is refused here, rather than handed a guest that
145 nothing can reach: the CA set is baked into the guest at create and registering
146 one afterwards does not reach a VM that already exists. Register a CA first—the
147 console's Settings page, `eitri ca upload <ca.pub>`, or the MCP `ca_upload`
148 tool—then create the VM.
149
150 ### SSH in
151
152 The console and gate are hosted defaults, and `eitri ssh` uses
139 your token to look up your tenant for the connect name—nothing else to set: 153 your token to look up your tenant for the connect name—nothing else to set:
140 154
141 ```sh 155 ```sh
@@ -375,7 +389,8 @@ server's `default_images`.
375 389
376 ### Boot a VM 390 ### Boot a VM
377 391
378 VMs trust your SSH CA from birth, so register one first. eitri gets the 392 VMs trust your SSH CA from birth, so register one first—create refuses a tenant
393 that has none, rather than hand it a guest nothing can reach. eitri gets the
379 public key, never the private one. On your laptop: 394 public key, never the private one. On your laptop:
380 395
381 ```sh 396 ```sh
internal/server/api/api.go
Old New
@@ -789,13 +789,18 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
789 return 789 return
790 } 790 }
791 791
792 // BYO CA precondition: a VM with no trusted user CA baked at create is 792 // BYO CA precondition: a guest bakes its tenant's user-CA set into its sshd
793 // unreachable. Require the tenant to have registered ≥1 user CA first. 793 // trust at create and nothing updates it afterwards, so a tenant with no
794 // registered CA creates guests that nothing can ever reach. The request is
795 // well-formed — the tenant is not ready to make a guest it can reach — so
796 // this is a 409, like the certified-host-key refusal below that it is the
797 // tenant-side half of. The CA is read against the HOST's tenant, which the
798 // authz gate above has already proven is the caller's own.
794 if has, err := a.st.TenantHasUserCA(host.Tenant); err != nil { 799 if has, err := a.st.TenantHasUserCA(host.Tenant); err != nil {
795 http.Error(w, "internal error", http.StatusInternalServerError) 800 http.Error(w, "internal error", http.StatusInternalServerError)
796 return 801 return
797 } else if !has { 802 } else if !has {
798 http.Error(w, "tenant has no registered SSH user CA; upload one via POST /api/v1/tenants/"+host.Tenant+"/user-cas before creating VMs", http.StatusBadRequest) 803 http.Error(w, noUserCARefusal(host.Tenant, a.URL(userCAPath(host.Tenant))), http.StatusConflict)
799 return 804 return
800 } 805 }
801 806
internal/server/api/usercas.go
Old New
@@ -8,6 +8,31 @@ import (
8 "golang.org/x/crypto/ssh" 8 "golang.org/x/crypto/ssh"
9 ) 9 )
10 10
11 // userCAPath is a tenant's user-CA collection — the endpoint every way of
12 // registering a CA ends up posting to.
13 func userCAPath(tenant string) string { return "/api/v1/tenants/" + tenant + "/user-cas" }
14
15 // noUserCARefusal explains why a tenant cannot be given a guest: it has
16 // registered no SSH user CA, and a guest bakes its tenant's CA set into the
17 // sshd trust it is created with. Nothing rewrites that set afterwards, so a VM
18 // created now is not merely unreachable until a CA arrives — it is unreachable
19 // for good, and delete-and-recreate is the only remedy. This is the whole
20 // reason the refusal sits at create, before anything has been spent.
21 //
22 // Every remedy named here lands in the same place: the console's Settings page,
23 // `eitri ca upload`, and the MCP ca_upload tool all POST this endpoint, which
24 // writes tenant_user_cas — the one set TenantHasUserCA reads. Taking any of
25 // them satisfies this check. All four are named because the caller may be a
26 // human at a browser, a human at a shell, a model holding a PAT, or a program
27 // with nothing but the API, and each can only act on the one it has.
28 func noUserCARefusal(tenant, uploadURL string) string {
29 return "tenant " + tenant + " has no registered SSH user CA: a guest trusts the CA set baked into it at " +
30 "create, so a VM created now would trust no certificate at all and nothing could ever reach it — " +
31 "registering a CA afterwards does not reach a guest that already exists. Register one first — the " +
32 "console's Settings page, `eitri ca upload <ca.pub>`, the MCP tool ca_upload, or POST " + uploadURL +
33 " — then create the VM."
34 }
35
11 // userCATenant picks the tenant a user-CA request operates on and authorizes 36 // userCATenant picks the tenant a user-CA request operates on and authorizes
12 // the caller against it. The {tenant} path segment names it on the explicit 37 // the caller against it. The {tenant} path segment names it on the explicit
13 // /tenants/{tenant} routes; on the tenant-less sibling routes there is no 38 // /tenants/{tenant} routes; on the tenant-less sibling routes there is no
internal/server/api/usercas_test.go
Old New
@@ -3,10 +3,12 @@ package api
3 import ( 3 import (
4 "encoding/json" 4 "encoding/json"
5 "net/http" 5 "net/http"
6 "net/http/httptest"
6 "testing" 7 "testing"
7 8
8 "github.com/a73x/eitri/internal/server/api/types" 9 "github.com/a73x/eitri/internal/server/api/types"
9 "github.com/a73x/eitri/internal/server/sshca" 10 "github.com/a73x/eitri/internal/server/sshca"
11 "github.com/a73x/eitri/internal/server/store"
10 "github.com/stretchr/testify/assert" 12 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require" 13 "github.com/stretchr/testify/require"
12 ) 14 )
@@ -89,3 +91,79 @@ func TestUploadUserCAGarbageKeyIs400(t *testing.T) {
89 map[string]any{"public_key": "not-a-key"}) 91 map[string]any{"public_key": "not-a-key"})
90 assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 92 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
91 } 93 }
94
95 // caLessTenant provisions a tenant that has a PAT and an enrolled, reporting
96 // host but has registered no user CA — the state every tenant is in between
97 // signing up and uploading, and the one the create refusal is about. It returns
98 // that tenant's PAT and host id.
99 func caLessTenant(t *testing.T, ts *httptest.Server, st *store.Store, sub string) (pat, hostID string) {
100 t.Helper()
101 tn, err := st.CreateTenantForIdentity("https://issuer.example", sub, sub+"@example.com")
102 require.NoError(t, err)
103 pat, _, err = st.CreateAPIToken(tn.ID, sub, 0)
104 require.NoError(t, err)
105 return pat, enrollWith(t, ts, pat, sub+"-host")
106 }
107
108 // TestCreateVMRefusesTenantWithNoUserCA is the tenant-side half of the
109 // pre-CSR refusal: a guest bakes its tenant's CA set into its sshd trust at
110 // create and nothing updates it afterwards, so a tenant with no CA would be
111 // creating a guest nothing can ever reach — a dead end whose only remedy is to
112 // delete it and start again. The request is well-formed and the host is fine;
113 // it is the tenant that is not ready, which is what makes this a 409.
114 //
115 // The message is asserted, not just the status, because it is the feature: a
116 // caller must be able to fix this from wherever it is standing without reading
117 // the docs, and the caller may be a browser, a shell, a model, or a program.
118 func TestCreateVMRefusesTenantWithNoUserCA(t *testing.T) {
119 ts, st, _, _, _ := newServer(t)
120 pat, hostID := caLessTenant(t, ts, st, "no-ca")
121
122 resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
123 require.Equal(t, http.StatusConflict, resp.StatusCode)
124
125 body := bodyText(t, resp)
126 assert.Contains(t, body, "no registered SSH user CA", "the refusal must name what is missing")
127 assert.Contains(t, body, "Settings", "a human at the console must be told where to go")
128 assert.Contains(t, body, "eitri ca upload", "a human at a shell must be given the command")
129 assert.Contains(t, body, "ca_upload", "a model holding a PAT must be given the tool")
130 assert.Contains(t, body, "/user-cas", "a program with only the API must be given the endpoint")
131 }
132
133 // TestCreateVMProceedsOnceACAIsRegistered walks the remedy the refusal names
134 // and asserts it is sufficient: the same tenant, host and request that were
135 // refused go through once a CA is registered. Without this the refusal could
136 // be standing in front of some other failure and nobody would know.
137 func TestCreateVMProceedsOnceACAIsRegistered(t *testing.T) {
138 ts, st, _, _, _ := newServer(t)
139 pat, hostID := caLessTenant(t, ts, st, "late-ca")
140
141 resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
142 require.Equal(t, http.StatusConflict, resp.StatusCode)
143
144 // Register through the endpoint the refusal names — the same one the
145 // console, the CLI and the MCP ca_upload tool all post to.
146 _, signer, err := sshca.GenerateHostKey()
147 require.NoError(t, err)
148 resp = do(t, "POST", ts.URL+"/api/v1/user-cas", pat,
149 map[string]any{"public_key": sshca.AuthorizedKeyLine(signer.PublicKey())})
150 require.Equal(t, http.StatusCreated, resp.StatusCode)
151
152 resp = do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
153 assert.Equal(t, http.StatusCreated, resp.StatusCode, "a registered CA is the whole precondition")
154 }
155
156 // TestCreateVMNotUnlockedByAnotherTenantsCA pins the scope of the check. A
157 // guest trusts ITS OWN tenant's CA set, so a CA registered anywhere else says
158 // nothing about whether this tenant could reach the guest it is asking for.
159 // A fleet-wide reading of "is there a CA" would let the first tenant to upload
160 // one silently unblock every other tenant into the same dead end.
161 func TestCreateVMNotUnlockedByAnotherTenantsCA(t *testing.T) {
162 ts, st, _, _, _ := newServer(t)
163 // testTenant already has a seeded CA; this one has none.
164 pat, hostID := caLessTenant(t, ts, st, "borrower")
165
166 resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
167 assert.Equal(t, http.StatusConflict, resp.StatusCode,
168 "another tenant's CA must not satisfy this tenant's precondition")
169 }
web/src/lib/fleet.svelte.ts
Old New
@@ -43,6 +43,10 @@ export const fleet = $state({
43 hosts: [] as Host[], 43 hosts: [] as Host[],
44 vms: [] as VM[], 44 vms: [] as VM[],
45 userCAs: [] as UserCA[], 45 userCAs: [] as UserCA[],
46 // userCAsLoaded separates "no CA registered" from "not asked yet". An empty
47 // list means both until the first fetch returns, and a UI that warns on the
48 // empty one would warn at every tenant for the moment before its CAs land.
49 userCAsLoaded: false,
46 connected: false, 50 connected: false,
47 error: '', 51 error: '',
48 server_version: '', 52 server_version: '',
@@ -357,6 +361,7 @@ export async function revokeToken(id: string) {
357 export async function refreshUserCAs() { 361 export async function refreshUserCAs() {
358 try { 362 try {
359 fleet.userCAs = await listUserCAs(); 363 fleet.userCAs = await listUserCAs();
364 fleet.userCAsLoaded = true;
360 } catch (err) { 365 } catch (err) {
361 fleet.error = String(err); 366 fleet.error = String(err);
362 } 367 }
web/src/routes/+page.svelte
Old New
@@ -14,6 +14,7 @@
14 vmPowerAction, 14 vmPowerAction,
15 deleteConfirm, 15 deleteConfirm,
16 upgradeAgent, 16 upgradeAgent,
17 refreshUserCAs,
17 type CreateVMRequest, 18 type CreateVMRequest,
18 type VM 19 type VM
19 } from '$lib/fleet.svelte'; 20 } from '$lib/fleet.svelte';
@@ -87,9 +88,19 @@
87 88
88 let form = $state<CreateVMRequest>({ host_id: '' }); 89 let form = $state<CreateVMRequest>({ host_id: '' });
89 90
91 // noCA is the tenant with nothing registered, which is worth saying before
92 // the form is filled in rather than after it is submitted. Held off until
93 // the CAs have actually been fetched, so an empty list that only means
94 // "still loading" never reads as a warning.
95 const noCA = $derived(fleet.userCAsLoaded && fleet.userCAs.length === 0);
96
90 function openCreate() { 97 function openCreate() {
91 form = { host_id: fleet.hosts[0]?.id ?? '' }; 98 form = { host_id: fleet.hosts[0]?.id ?? '' };
92 showCreate = true; 99 showCreate = true;
100 // CAs are not in the SSE snapshot, so this page has never fetched them.
101 // Asking as the dialog opens keeps the answer current for a tenant that
102 // registered one in another tab.
103 refreshUserCAs();
93 } 104 }
94 105
95 async function submitCreate(e: Event) { 106 async function submitCreate(e: Event) {
@@ -332,6 +343,19 @@
332 <div class="modal" role="dialog"> 343 <div class="modal" role="dialog">
333 <form class="card" onsubmit={submitCreate}> 344 <form class="card" onsubmit={submitCreate}>
334 <h3>Create VM</h3> 345 <h3>Create VM</h3>
346 {#if noCA}
347 <!-- Courtesy, not enforcement: the API refuses this create with a
348 409 and the form renders that message. Saying it here saves a
349 round trip, and says it while the reader can still act. -->
350 <div class="no-ca">
351 <p>
352 This tenant has no registered SSH user CA. A guest trusts the CA set it is created
353 with, so a VM made now would trust no certificate and nothing could ever reach it —
354 registering a CA afterwards does not reach a guest that already exists.
355 </p>
356 <p><a href="/settings">Register a CA in settings</a>, then create the VM.</p>
357 </div>
358 {/if}
335 <label> 359 <label>
336 Host 360 Host
337 <select bind:value={form.host_id} required> 361 <select bind:value={form.host_id} required>
@@ -390,6 +414,19 @@
390 h3 { 414 h3 {
391 font-size: inherit; 415 font-size: inherit;
392 } 416 }
417 /* A tenant with no CA is about to make something it cannot reach, so the
418 warning gets a box of its own inside the dialog. Ink border like the
419 teardown callout: this one is meant to stop the reader. */
420 .no-ca {
421 border: 1px solid var(--ink);
422 padding: 0.6em 0.8em;
423 }
424 .no-ca p {
425 margin: 0 0 0.5em;
426 }
427 .no-ca p:last-child {
428 margin-bottom: 0;
429 }
393 /* Every table keeps its natural width and scrolls sideways inside its own 430 /* Every table keeps its natural width and scrolls sideways inside its own
394 box. Nothing is squeezed and nothing is truncated: a CIDR or a host name 431 box. Nothing is squeezed and nothing is truncated: a CIDR or a host name
395 that does not fit is scrolled to, never elided into a half-truth. */ 432 that does not fit is scrolled to, never elided into a half-truth. */