4c1941bf
fix(server): a tenant with no CA is told how to get one
a73x 2026-08-11 04:13
Commit message
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. */ |