a73x

8dd4b711

feat(server,web): a guest names the CAs it trusts

a73x   2026-08-11 08:12

Commit message
feat(server,web): a guest names the CAs it trusts

A guest's sshd trusts the tenant user-CA set it was created with, and
nothing rewrites that trust afterwards. That property is the whole reason
create refuses a tenant with no registered CA: such a guest is not
unreachable until a CA arrives, it is unreachable for good. But nothing
ever showed which set a given guest had, so a guest created before a CA
was registered rejected certificates signed by it and the failure
attributed to nothing.

The property was also not quite true. The snapshot built each VM's CA set
by reading its tenant's current registrations on every push, so what a
guest baked was whatever had been registered by the time its host got as
far as writing the seed — behind an image download, a multi-GB disk copy,
and the wait for a host certificate, which is minutes rather than
instants. The trust was decided at the agent's create pass, not at create.
The set is now copied onto the VM row by the same query that permits the
create, and the snapshot serves that copy, so a CA registered afterwards
does not reach a VM that already exists. That is what the refusal, the
quickstart and ssh-access.md have all been saying.

The VM page carries the set as a first-class fact, by label and
fingerprint — the two things the Settings page shows, so an operator can
match them by eye. The injected key stays where it was but reads as what
it is: the optional extra key, not the way in. The fleet page marks a
guest whose set is missing a CA the tenant now holds, because the
fleet-level question is which guests predate the current set.

A VM created before any of this recorded nothing, and says so. The column
is nullable for that alone: an empty set and an absent record mean
opposite things — "trusts no CA" against "we did not write it down" — and
only the second is ever true, since create refuses the first. Nothing
infers a set for such a row and no stale marker is offered for one. The
snapshot falls back to the live set there, which is the behaviour it had
before and matters only for a VM caught mid-create by the upgrade; serving
an empty set instead would seed a guest that trusts nothing at all.

docs/openapi.json
Old New
@@ -636,6 +636,21 @@
636 ], 636 ],
637 "type": "object" 637 "type": "object"
638 }, 638 },
639 "TrustedCA": {
640 "properties": {
641 "fingerprint": {
642 "type": "string"
643 },
644 "label": {
645 "type": "string"
646 }
647 },
648 "required": [
649 "fingerprint",
650 "label"
651 ],
652 "type": "object"
653 },
639 "UserCA": { 654 "UserCA": {
640 "properties": { 655 "properties": {
641 "fingerprint": { 656 "fingerprint": {
@@ -741,6 +756,15 @@
741 "status": { 756 "status": {
742 "type": "string" 757 "type": "string"
743 }, 758 },
759 "trusted_cas": {
760 "items": {
761 "$ref": "#/components/schemas/TrustedCA"
762 },
763 "type": [
764 "array",
765 "null"
766 ]
767 },
744 "vcpus": { 768 "vcpus": {
745 "type": "integer" 769 "type": "integer"
746 } 770 }
docs/ssh-access.md
Old New
@@ -38,6 +38,23 @@ 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 Registering a CA later does not reach a VM that already exists. The set is
42 copied onto the VM as it is created and nothing rewrites it, so a certificate
43 signed by a CA registered afterwards is refused by that guest's sshd. Recreating
44 the guest is the only way to change what it trusts.
45
46 ## Which CAs does a guest trust?
47
48 A VM's page lists them under **Trusted CAs**, by the label you gave the CA and
49 its fingerprint. Match those against Settings → SSH Access to tell whether a
50 certificate you are about to sign will open that guest. A VM created before
51 eitri recorded this reads *unrecorded*—it trusts whatever was registered on the
52 day it was made, and there is no record of which CAs those were.
53
54 On the fleet page, a VM missing any CA your tenant now has is marked *stale
55 trust*. It still works with the CAs it was created against; it just cannot be
56 opened by every CA you hold.
57
41 ## Delegating access to eitri 58 ## Delegating access to eitri
42 59
43 A caller holding only a token has no CA and no private key, so it cannot sign 60 A caller holding only a token has no CA and no private key, so it cannot sign
internal/server/api/api.go
Old New
@@ -574,6 +574,7 @@ func toVMResponse(vm store.VM, actualPower, phase string, destroyAt int64) types
574 DestroyAt: destroyAt, 574 DestroyAt: destroyAt,
575 Lifecycle: deriveLifecycle(vm, actualPower, phase), 575 Lifecycle: deriveLifecycle(vm, actualPower, phase),
576 InjectedKey: injectedKey(vm), 576 InjectedKey: injectedKey(vm),
577 TrustedCAs: trustedCAs(vm),
577 } 578 }
578 } 579 }
579 580
@@ -796,10 +797,18 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
796 // this is a 409, like the certified-host-key refusal below that it is the 797 // 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 // 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. 799 // authz gate above has already proven is the caller's own.
799 if has, err := a.st.TenantHasUserCA(host.Tenant); err != nil { 800 //
801 // This READS the set rather than counting it, and the set it read is what
802 // goes onto the row below. That is what makes the sentence above true: the
803 // trust is decided here, at create, by the same query that permits the
804 // create — not later, by whatever the tenant happens to have registered by
805 // the time a host gets around to building the guest's seed.
806 tenantCAs, err := a.st.ListTenantUserCAs(host.Tenant)
807 if err != nil {
800 http.Error(w, "internal error", http.StatusInternalServerError) 808 http.Error(w, "internal error", http.StatusInternalServerError)
801 return 809 return
802 } else if !has { 810 }
811 if len(tenantCAs) == 0 {
803 http.Error(w, noUserCARefusal(host.Tenant, a.URL(userCAPath(host.Tenant))), http.StatusConflict) 812 http.Error(w, noUserCARefusal(host.Tenant, a.URL(userCAPath(host.Tenant))), http.StatusConflict)
804 return 813 return
805 } 814 }
@@ -872,9 +881,14 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
872 InjectedKeyType: keyType, 881 InjectedKeyType: keyType,
873 InjectedKeyFP: keyFP, 882 InjectedKeyFP: keyFP,
874 InjectedKeyComment: keyComment, 883 InjectedKeyComment: keyComment,
875 VCPUs: req.VCPUs, 884
876 MemMB: req.MemMB, 885 // Frozen from the set the refusal above just checked, so the row records
877 DiskGB: req.DiskGB, 886 // exactly what permitted it to exist.
887 TrustedCAs: freezeTrustedCAs(tenantCAs),
888
889 VCPUs: req.VCPUs,
890 MemMB: req.MemMB,
891 DiskGB: req.DiskGB,
878 // A lost guest is booted again, always: the restart policy is not a 892 // A lost guest is booted again, always: the restart policy is not a
879 // choice a create gets to make, and the agent reads this field to decide. 893 // choice a create gets to make, and the agent reads this field to decide.
880 Persistent: true, 894 Persistent: true,
internal/server/api/testdata/snapshot.golden.json
Old New
@@ -69,7 +69,13 @@
69 "type": "ssh-ed25519", 69 "type": "ssh-ed25519",
70 "fingerprint": "SHA256:0000000000000000000000000000000000000000000", 70 "fingerprint": "SHA256:0000000000000000000000000000000000000000000",
71 "comment": "alex@laptop" 71 "comment": "alex@laptop"
72 } 72 },
73 "trusted_cas": [
74 {
75 "label": "laptop-ca",
76 "fingerprint": "SHA256:1111111111111111111111111111111111111111111"
77 }
78 ]
73 } 79 }
74 ], 80 ],
75 "server_version": "v0.0.1-test", 81 "server_version": "v0.0.1-test",
internal/server/api/testdata/vm.golden.json
Old New
@@ -21,5 +21,11 @@
21 "type": "ssh-ed25519", 21 "type": "ssh-ed25519",
22 "fingerprint": "SHA256:0000000000000000000000000000000000000000000", 22 "fingerprint": "SHA256:0000000000000000000000000000000000000000000",
23 "comment": "alex@laptop" 23 "comment": "alex@laptop"
24 } 24 },
25 "trusted_cas": [
26 {
27 "label": "laptop-ca",
28 "fingerprint": "SHA256:1111111111111111111111111111111111111111111"
29 }
30 ]
25 } 31 }
internal/server/api/trustedcas.go
Old New
@@ -0,0 +1,58 @@
1 package api
2
3 import (
4 "github.com/a73x/eitri/internal/server/api/types"
5 "github.com/a73x/eitri/internal/server/store"
6 "golang.org/x/crypto/ssh"
7 )
8
9 // freezeTrustedCAs turns the tenant's registered CA set into the record that
10 // goes onto a VM row at create — the set that guest will trust for the rest of
11 // its life, whatever the tenant registers or removes afterwards.
12 //
13 // It carries the authorized_keys line as well as the fingerprint because the
14 // row has to SERVE this trust to the agent that bakes it, and a fingerprint is
15 // one-way. The fingerprint is computed once, here, for the same reason the
16 // injected key's is (see describeKey): the console re-reads these rows on a
17 // tick and parsing a key per read would be work repeated forever.
18 //
19 // A CA whose stored line will not parse still contributes its line, with an
20 // empty fingerprint. The line is what reaches the guest, so dropping the entry
21 // would quietly narrow the trust the tenant asked for; an empty fingerprint
22 // says "we could not name this one", which is a display problem, not a reason
23 // to change what the guest trusts.
24 func freezeTrustedCAs(cas []store.TenantUserCA) []store.TrustedCA {
25 out := make([]store.TrustedCA, 0, len(cas))
26 for _, c := range cas {
27 fp := ""
28 if pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(c.Pubkey)); err == nil {
29 fp = ssh.FingerprintSHA256(pub)
30 }
31 out = append(out, store.TrustedCA{
32 Label: c.Label,
33 Fingerprint: fp,
34 AuthorizedKey: c.Pubkey,
35 })
36 }
37 return out
38 }
39
40 // trustedCAs renders a VM row's frozen CA set for the wire, or nil when the row
41 // carries no record — a VM created before the set was written down. nil is the
42 // honest answer there and the clients render it as such; it must never be
43 // flattened to an empty list, which would claim the guest trusts nothing.
44 //
45 // The authorized_keys lines stay behind. What a reader of a VM object is asking
46 // is "which CAs does this guest honour", and a label and a fingerprint answer
47 // it — the key lines are the agent's business. They ride a tenant-scoped
48 // object, so this leaks nothing across tenants that GET /user-cas would not.
49 func trustedCAs(vm store.VM) *[]types.TrustedCA {
50 if vm.TrustedCAs == nil {
51 return nil
52 }
53 out := make([]types.TrustedCA, 0, len(vm.TrustedCAs))
54 for _, c := range vm.TrustedCAs {
55 out = append(out, types.TrustedCA{Label: c.Label, Fingerprint: c.Fingerprint})
56 }
57 return &out
58 }
internal/server/api/trustedcas_test.go
Old New
@@ -0,0 +1,135 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8
9 "github.com/a73x/eitri/internal/server/api/types"
10 "github.com/a73x/eitri/internal/server/sshca"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13 )
14
15 // createVMFor creates a VM on hostID as the holder of pat and returns its id.
16 func createVMFor(t *testing.T, ts *httptest.Server, pat, hostID string) string {
17 t.Helper()
18 resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
19 require.Equal(t, http.StatusCreated, resp.StatusCode)
20 var out struct {
21 ID string `json:"id"`
22 }
23 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
24 return out.ID
25 }
26
27 // getVM reads one VM back off the list endpoint as the holder of pat — the
28 // same route the console renders every VM from, so what this sees is what the
29 // page sees.
30 func getVM(t *testing.T, ts *httptest.Server, pat, id string) types.VM {
31 t.Helper()
32 resp := do(t, "GET", ts.URL+"/api/v1/vms", pat, nil)
33 require.Equal(t, http.StatusOK, resp.StatusCode)
34 var vms []types.VM
35 require.NoError(t, json.NewDecoder(resp.Body).Decode(&vms))
36 for _, vm := range vms {
37 if vm.ID == id {
38 return vm
39 }
40 }
41 t.Fatalf("vm %s absent from the list", id)
42 return types.VM{}
43 }
44
45 // uploadCA registers a freshly generated user CA under label and returns its
46 // canonical authorized_keys line.
47 func uploadCA(t *testing.T, ts *httptest.Server, pat, label string) string {
48 t.Helper()
49 _, signer, err := sshca.GenerateHostKey()
50 require.NoError(t, err)
51 line := sshca.AuthorizedKeyLine(signer.PublicKey())
52 resp := do(t, "POST", ts.URL+"/api/v1/user-cas", pat,
53 map[string]any{"public_key": line, "label": label})
54 require.Equal(t, http.StatusCreated, resp.StatusCode)
55 return line
56 }
57
58 // TestCreatedVMNamesTheCAsItTrusts pins the fact the VM page exists to show:
59 // the set is on the object, by the label the tenant gave it, and it is the set
60 // that was registered when the VM was made.
61 func TestCreatedVMNamesTheCAsItTrusts(t *testing.T) {
62 ts, st, _, _, _ := newServer(t)
63 pat, hostID := caLessTenant(t, ts, st, "trusts")
64 uploadCA(t, ts, pat, "laptop")
65
66 vm := getVM(t, ts, pat, createVMFor(t, ts, pat, hostID))
67
68 require.NotNil(t, vm.TrustedCAs, "a VM created now records what it trusts")
69 require.Len(t, *vm.TrustedCAs, 1)
70 assert.Equal(t, "laptop", (*vm.TrustedCAs)[0].Label)
71 assert.NotEmpty(t, (*vm.TrustedCAs)[0].Fingerprint, "the fingerprint is what an operator matches by eye")
72 }
73
74 // TestALaterCADoesNotJoinAnExistingVMsTrust is the create-time freeze as the
75 // API tells it, and the same property the snapshot test pins one layer down.
76 // If this ever fails, the 409 that refuses a CA-less tenant is telling users
77 // something untrue about their own fleet.
78 func TestALaterCADoesNotJoinAnExistingVMsTrust(t *testing.T) {
79 ts, st, _, _, _ := newServer(t)
80 pat, hostID := caLessTenant(t, ts, st, "frozen")
81 uploadCA(t, ts, pat, "laptop")
82
83 id := createVMFor(t, ts, pat, hostID)
84 uploadCA(t, ts, pat, "ci") // registered AFTER the VM exists
85
86 vm := getVM(t, ts, pat, id)
87 require.NotNil(t, vm.TrustedCAs)
88 require.Len(t, *vm.TrustedCAs, 1, "the guest's trust was fixed when it was created")
89 assert.Equal(t, "laptop", (*vm.TrustedCAs)[0].Label)
90
91 // And the fuller set does reach the next VM, so the freeze is per-VM rather
92 // than the tenant's set having simply stopped growing.
93 later := getVM(t, ts, pat, createVMFor(t, ts, pat, hostID))
94 require.NotNil(t, later.TrustedCAs)
95 assert.Len(t, *later.TrustedCAs, 2)
96 }
97
98 // TestTrustedCAsCarryNoKeyMaterial pins that the wire shows the two facts a
99 // reader needs and not the CA lines themselves. They are public keys, so this
100 // is not a secrecy boundary — it is the object answering the question it was
101 // asked instead of shipping the agent's copy to every console.
102 func TestTrustedCAsCarryNoKeyMaterial(t *testing.T) {
103 ts, st, _, _, _ := newServer(t)
104 pat, hostID := caLessTenant(t, ts, st, "nomaterial")
105 line := uploadCA(t, ts, pat, "laptop")
106 createVMFor(t, ts, pat, hostID)
107
108 resp := do(t, "GET", ts.URL+"/api/v1/vms", pat, nil)
109 require.Equal(t, http.StatusOK, resp.StatusCode)
110 assert.NotContains(t, bodyText(t, resp), line,
111 "the VM object names its CAs; it does not carry their key lines")
112 }
113
114 // TestTrustedCAsDoNotCrossTenants pins the isolation. The set rides a
115 // tenant-scoped object, so the leak this guards against would be a server-side
116 // mix-up rather than a missing authz check — one tenant's CA labels and
117 // fingerprints appearing on another tenant's guest.
118 func TestTrustedCAsDoNotCrossTenants(t *testing.T) {
119 ts, st, _, _, _ := newServer(t)
120 patA, hostA := caLessTenant(t, ts, st, "tenant-a")
121 patB, hostB := caLessTenant(t, ts, st, "tenant-b")
122 uploadCA(t, ts, patA, "a-laptop")
123 uploadCA(t, ts, patB, "b-laptop")
124
125 vmA := getVM(t, ts, patA, createVMFor(t, ts, patA, hostA))
126 vmB := getVM(t, ts, patB, createVMFor(t, ts, patB, hostB))
127
128 require.NotNil(t, vmA.TrustedCAs)
129 require.NotNil(t, vmB.TrustedCAs)
130 require.Len(t, *vmA.TrustedCAs, 1)
131 require.Len(t, *vmB.TrustedCAs, 1)
132 assert.Equal(t, "a-laptop", (*vmA.TrustedCAs)[0].Label)
133 assert.Equal(t, "b-laptop", (*vmB.TrustedCAs)[0].Label)
134 assert.NotEqual(t, (*vmA.TrustedCAs)[0].Fingerprint, (*vmB.TrustedCAs)[0].Fingerprint)
135 }
internal/server/api/types/types.go
Old New
@@ -130,6 +130,21 @@ type VM struct {
130 // cloud_init is invisible here by design — eitri did not install it and does 130 // cloud_init is invisible here by design — eitri did not install it and does
131 // not claim to know about it. 131 // not claim to know about it.
132 InjectedKey *InjectedKey `json:"injected_key"` 132 InjectedKey *InjectedKey `json:"injected_key"`
133 // TrustedCAs is the tenant user-CA set this guest's sshd was created to
134 // trust — the CAs whose certificates can open it. It is frozen at create
135 // and nothing rewrites it, so a CA the tenant registers later does NOT
136 // appear here and does NOT reach this guest.
137 //
138 // null means the VM predates this record: it trusts whatever its tenant had
139 // registered on the day it was made, and nobody wrote that down. That is
140 // different from an empty list, which cannot occur — create refuses a
141 // tenant with no CA — so clients must not conflate them.
142 //
143 // A POINTER to the slice, for that distinction alone: a plain nil slice
144 // also marshals to null, but the spec generator reads nullability off the
145 // pointer, so a bare slice would be published as an always-present array
146 // and the contract would promise something the server does not deliver.
147 TrustedCAs *[]TrustedCA `json:"trusted_cas"`
133 } 148 }
134 149
135 // InjectedKey identifies one authorized key by its OpenSSH fingerprint, the way 150 // InjectedKey identifies one authorized key by its OpenSSH fingerprint, the way
@@ -142,6 +157,19 @@ type InjectedKey struct {
142 Comment string `json:"comment"` 157 Comment string `json:"comment"`
143 } 158 }
144 159
160 // TrustedCA names one user CA a guest trusts, by the label its tenant gave it
161 // and its OpenSSH SHA256 fingerprint — the same two facts GET /user-cas shows,
162 // so an operator can match a guest's trust against the tenant's current set by
163 // eye. The CA's public key line is deliberately not here: it is what the agent
164 // bakes, not what a reader of a VM is asking for.
165 //
166 // An empty Fingerprint means the stored key line could not be parsed, matching
167 // InjectedKey's reading of the same situation.
168 type TrustedCA struct {
169 Label string `json:"label"`
170 Fingerprint string `json:"fingerprint"`
171 }
172
145 // StateSnapshot is the full fleet state pushed as each `event: state` frame 173 // StateSnapshot is the full fleet state pushed as each `event: state` frame
146 // over the SSE stream (GET /api/v1/events). 174 // over the SSE stream (GET /api/v1/events).
147 type StateSnapshot struct { 175 type StateSnapshot struct {
internal/server/api/usercas.go
Old New
@@ -21,8 +21,9 @@ func userCAPath(tenant string) string { return "/api/v1/tenants/" + tenant + "/u
21 // 21 //
22 // Every remedy named here lands in the same place: the console's Settings page, 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 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 24 // writes tenant_user_cas — the one set create reads, both to permit the create
25 // them satisfies this check. All four are named because the caller may be a 25 // and to freeze onto the VM. Taking any of them satisfies this check, and the
26 // next create trusts what it wrote. 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 // 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 // with nothing but the API, and each can only act on the one it has.
28 func noUserCARefusal(tenant, uploadURL string) string { 29 func noUserCARefusal(tenant, uploadURL string) string {
internal/server/api/wire_golden_test.go
Old New
@@ -113,6 +113,10 @@ func TestWireGolden(t *testing.T) {
113 Fingerprint: "SHA256:0000000000000000000000000000000000000000000", 113 Fingerprint: "SHA256:0000000000000000000000000000000000000000000",
114 Comment: "alex@laptop", 114 Comment: "alex@laptop",
115 }, 115 },
116 TrustedCAs: &[]types.TrustedCA{{
117 Label: "laptop-ca",
118 Fingerprint: "SHA256:1111111111111111111111111111111111111111111",
119 }},
116 } 120 }
117 goldenCheck(t, "vm", vm) 121 goldenCheck(t, "vm", vm)
118 122
internal/server/store/store.go
Old New
@@ -94,6 +94,18 @@ type VM struct {
94 // not tracked. Empty on rows created before the columns existed, which is 94 // not tracked. Empty on rows created before the columns existed, which is
95 // honest: the record was never taken. 95 // honest: the record was never taken.
96 InjectedKeyType, InjectedKeyFP, InjectedKeyComment string 96 InjectedKeyType, InjectedKeyFP, InjectedKeyComment string
97 // TrustedCAs is the tenant user-CA set frozen onto this VM at create — the
98 // set its guest's sshd will trust, and the answer to "can the certificate I
99 // am holding open this guest". It is the DESIRED state the agent bakes, not
100 // a report from the guest: the agent is handed exactly this set and writes
101 // it into TrustedUserCAKeys, and nothing rewrites it afterwards.
102 //
103 // nil means NO record was taken — the row predates the column. An empty
104 // non-nil set cannot occur: create refuses a tenant that has registered no
105 // CA, so every VM created since this column existed has at least one. That
106 // makes nil unambiguously "unrecorded" rather than "trusts nothing", which
107 // is the distinction every reader of this field depends on.
108 TrustedCAs []TrustedCA
97 // The VM's reachable address is AssignedIP (the agent-reported bridge IP). 109 // The VM's reachable address is AssignedIP (the agent-reported bridge IP).
98 CreatedAt time.Time 110 CreatedAt time.Time
99 DeletedAt *time.Time 111 DeletedAt *time.Time
@@ -101,6 +113,26 @@ type VM struct {
101 Tenant string 113 Tenant string
102 } 114 }
103 115
116 // TrustedCA is one user CA as it was frozen onto a VM at create: the label its
117 // tenant gave it, its SHA256 fingerprint in OpenSSH's spelling, and the
118 // canonical authorized_keys line itself.
119 //
120 // AuthorizedKey is here because the row has to SERVE the trust, not just
121 // describe it — the snapshot the agent bakes from carries key material, and a
122 // fingerprint cannot be turned back into a key. Label and Fingerprint are what
123 // the API shows; AuthorizedKey stays server-side, not because it is secret (a
124 // CA public key is public by construction) but because it is not the fact a
125 // reader of the VM object is asking for.
126 //
127 // The set is stored as JSON in one column rather than a side table: it is
128 // immutable once written, only ever read whole, and belongs to the VM's
129 // lifetime, so a table would add a join and a delete cascade to buy nothing.
130 type TrustedCA struct {
131 Label string `json:"label"`
132 Fingerprint string `json:"fingerprint"`
133 AuthorizedKey string `json:"authorized_key"`
134 }
135
104 const schema = ` 136 const schema = `
105 CREATE TABLE IF NOT EXISTS meta ( 137 CREATE TABLE IF NOT EXISTS meta (
106 key TEXT PRIMARY KEY, 138 key TEXT PRIMARY KEY,
@@ -189,8 +221,10 @@ CREATE TABLE IF NOT EXISTS revoked_ssh_certs (
189 ); 221 );
190 222
191 -- tenant_user_cas: uploaded per-tenant USER CA public keys (BYO). eitri holds 223 -- tenant_user_cas: uploaded per-tenant USER CA public keys (BYO). eitri holds
192 -- NO user signing key; it only registers pubkeys. A VM bakes its tenant's set 224 -- NO user signing key; it only registers pubkeys. A VM COPIES its tenant's set
193 -- into TrustedUserCAKeys at create; the gate trusts this set (mutable, live) 225 -- onto its own row at create (vms.trusted_cas) and the agent bakes that copy
226 -- into TrustedUserCAKeys, so editing this table never reaches an existing
227 -- guest; the gate trusts this set (mutable, live)
194 -- and stamps a connection's tenant from WHICH ca_pubkey verified the cert. 228 -- and stamps a connection's tenant from WHICH ca_pubkey verified the cert.
195 -- ca_pubkey is the canonical authorized_keys line ("type base64", no comment / 229 -- ca_pubkey is the canonical authorized_keys line ("type base64", no comment /
196 -- trailing newline — see sshca.AuthorizedKeyLine). scope is 'tenant' in v1 230 -- trailing newline — see sshca.AuthorizedKeyLine). scope is 'tenant' in v1
@@ -317,6 +351,13 @@ func Open(path, cidrPool string) (*Store, error) {
317 // The private half is the host's and stays there; this is the half the 351 // The private half is the host's and stays there; this is the half the
318 // control plane signs a certificate for. 352 // control plane signs a certificate for.
319 {"vms", "ssh_host_pubkey", "TEXT NOT NULL DEFAULT ''"}, 353 {"vms", "ssh_host_pubkey", "TEXT NOT NULL DEFAULT ''"},
354 // The tenant user-CA set this VM was created against, as JSON. NULLABLE
355 // on purpose, and the only vms column that is: every other late column
356 // backfills to a zero value that reads as "nothing to say", but here
357 // the empty set and the absent record mean opposite things — "this
358 // guest trusts no CA" versus "we did not write down which CAs it
359 // trusts". Rows that predate this column get NULL and say so.
360 {"vms", "trusted_cas", "TEXT"},
320 // The address a host presents on the network it reaches the fleet 361 // The address a host presents on the network it reaches the fleet
321 // over. Reported every tick like the guest subnet, and stored for the 362 // over. Reported every tick like the guest subnet, and stored for the
322 // same reason: the console renders `host:port` for every exposure, 363 // same reason: the console renders `host:port` for every exposure,
@@ -766,14 +807,27 @@ func (s *Store) CreateVM(vm VM) error {
766 } 807 }
767 vm.Tenant = hostTenant 808 vm.Tenant = hostTenant
768 809
810 // The CA set is frozen here, in the same statement that makes the VM exist,
811 // so there is no window in which a row exists without the trust it was
812 // created against. A caller that supplies none writes NULL rather than an
813 // empty array — see VM.TrustedCAs for why those are different facts.
814 var trustedCAs any
815 if vm.TrustedCAs != nil {
816 b, err := json.Marshal(vm.TrustedCAs)
817 if err != nil {
818 return fmt.Errorf("marshal trusted cas: %w", err)
819 }
820 trustedCAs = string(b)
821 }
822
769 _, err = tx.Exec( 823 _, err = tx.Exec(
770 `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, 824 `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key,
771 injected_key_type, injected_key_fp, injected_key_comment, 825 injected_key_type, injected_key_fp, injected_key_comment, trusted_cas,
772 vcpus, mem_mb, disk_gb, persistent, power_state, created_at) 826 vcpus, mem_mb, disk_gb, persistent, power_state, created_at)
773 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, 827 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
774 vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256, 828 vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256,
775 vm.CloudInit, vm.SSHAuthorizedKey, 829 vm.CloudInit, vm.SSHAuthorizedKey,
776 vm.InjectedKeyType, vm.InjectedKeyFP, vm.InjectedKeyComment, 830 vm.InjectedKeyType, vm.InjectedKeyFP, vm.InjectedKeyComment, trustedCAs,
777 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, 831 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState,
778 now.Format(time.RFC3339), 832 now.Format(time.RFC3339),
779 ) 833 )
@@ -1284,7 +1338,7 @@ func (s *Store) RecordVMHostKey(vmID, hostID, pubkey, cert string) error {
1284 // of lockstep. 1338 // of lockstep.
1285 const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, 1339 const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key,
1286 ssh_host_pubkey, ssh_host_cert, 1340 ssh_host_pubkey, ssh_host_cert,
1287 injected_key_type, injected_key_fp, injected_key_comment, 1341 injected_key_type, injected_key_fp, injected_key_comment, trusted_cas,
1288 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, 1342 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip,
1289 created_at, deleted_at` 1343 created_at, deleted_at`
1290 1344
@@ -1294,10 +1348,11 @@ func scanVM(rows *sql.Rows) (VM, error) {
1294 var vm VM 1348 var vm VM
1295 var createdAt string 1349 var createdAt string
1296 var deletedAt sql.NullString 1350 var deletedAt sql.NullString
1351 var trustedCAs sql.NullString
1297 err := rows.Scan( 1352 err := rows.Scan(
1298 &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256, 1353 &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256,
1299 &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostPubKey, &vm.SSHHostCert, 1354 &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostPubKey, &vm.SSHHostCert,
1300 &vm.InjectedKeyType, &vm.InjectedKeyFP, &vm.InjectedKeyComment, 1355 &vm.InjectedKeyType, &vm.InjectedKeyFP, &vm.InjectedKeyComment, &trustedCAs,
1301 &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, 1356 &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent,
1302 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, 1357 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP,
1303 &createdAt, &deletedAt, 1358 &createdAt, &deletedAt,
@@ -1305,6 +1360,13 @@ func scanVM(rows *sql.Rows) (VM, error) {
1305 if err != nil { 1360 if err != nil {
1306 return VM{}, err 1361 return VM{}, err
1307 } 1362 }
1363 // NULL leaves TrustedCAs nil: the row was written before the column, and
1364 // nothing here may invent a set it never recorded. Unparseable JSON is
1365 // treated the same way for the same reason — an unreadable record is not a
1366 // record, and guessing would be worse than admitting it.
1367 if trustedCAs.Valid {
1368 _ = json.Unmarshal([]byte(trustedCAs.String), &vm.TrustedCAs)
1369 }
1308 vm.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) 1370 vm.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
1309 if deletedAt.Valid { 1371 if deletedAt.Valid {
1310 t, _ := time.Parse(time.RFC3339, deletedAt.String) 1372 t, _ := time.Parse(time.RFC3339, deletedAt.String)
internal/server/store/trustedcas_test.go
Old New
@@ -0,0 +1,78 @@
1 package store
2
3 import (
4 "path/filepath"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9 )
10
11 // TestTrustedCAsRoundtrip pins that a VM's frozen CA set survives the store
12 // whole — label, fingerprint and key line, in the order it was given, since
13 // that is the order the guest's TrustedUserCAKeys file ends up in.
14 func TestTrustedCAsRoundtrip(t *testing.T) {
15 s := newStore(t)
16 h := enrollHost(t, s)
17 want := []TrustedCA{
18 {Label: "laptop", Fingerprint: "SHA256:aaa", AuthorizedKey: "ssh-ed25519 AAAALAPTOP"},
19 {Label: "", Fingerprint: "SHA256:bbb", AuthorizedKey: "ssh-ed25519 AAAACI"},
20 }
21 require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
22 ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
23 TrustedCAs: want}))
24
25 got, err := s.GetVM("vm1")
26 require.NoError(t, err)
27 assert.Equal(t, want, got.TrustedCAs)
28 }
29
30 // TestTrustedCAsUnrecordedIsNilNotEmpty pins the distinction the whole feature
31 // turns on. A VM created with no record reads back as nil — "we did not write
32 // this down" — and NOT as an empty set, which would claim the guest trusts no
33 // CA at all. Every reader (the console's empty state, the snapshot's fallback)
34 // branches on exactly this.
35 func TestTrustedCAsUnrecordedIsNilNotEmpty(t *testing.T) {
36 s := newStore(t)
37 h := enrollHost(t, s)
38 require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
39 ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
40
41 got, err := s.GetVM("vm1")
42 require.NoError(t, err)
43 assert.Nil(t, got.TrustedCAs)
44 }
45
46 // TestTrustedCAsMigrationLeavesAPreFeatureRowUnrecorded runs a row through the
47 // actual migration: a database whose vms table has no trusted_cas column at
48 // all, reopened so ensureColumn adds it. The row that was already there must
49 // come back unrecorded rather than as an empty set — it is a VM whose guest was
50 // seeded long ago and whose trust nobody wrote down, and inventing a set for it
51 // would be a fabricated fact on a page whose entire point is not to have any.
52 func TestTrustedCAsMigrationLeavesAPreFeatureRowUnrecorded(t *testing.T) {
53 path := filepath.Join(t.TempDir(), "eitri.db")
54 s, err := Open(path, "10.77.0.0/16")
55 require.NoError(t, err)
56 _, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
57 require.NoError(t, err)
58 h := enrollHost(t, s)
59 require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
60 ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
61 TrustedCAs: []TrustedCA{{Label: "laptop", AuthorizedKey: "ssh-ed25519 AAAALAPTOP"}}}))
62
63 // Rewind the schema to before the column existed, taking the recorded set
64 // with it — which is precisely the state of every row in a database that
65 // predates this feature.
66 _, err = s.db.Exec(`ALTER TABLE vms DROP COLUMN trusted_cas`)
67 require.NoError(t, err)
68 require.NoError(t, s.Close())
69
70 s2, err := Open(path, "10.77.0.0/16")
71 require.NoError(t, err)
72 t.Cleanup(func() { s2.Close() })
73
74 got, err := s2.GetVM("vm1")
75 require.NoError(t, err)
76 assert.Nil(t, got.TrustedCAs, "a row that predates the column has no record, not an empty one")
77 assert.Equal(t, "web-1", got.Name, "the rest of the row survives the migration")
78 }
internal/server/syncsvc/syncsvc.go
Old New
@@ -314,19 +314,42 @@ func (s *Service) buildSnapshot(hostID string) (*pb.DesiredStateSnapshot, error)
314 } 314 }
315 snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))} 315 snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))}
316 snap.AgentUpgrade = s.offerFor(hostID) 316 snap.AgentUpgrade = s.offerFor(hostID)
317 caCache := map[string][]string{} // tenant -> canonical CA lines 317 caCache := map[string][]string{} // tenant -> canonical CA lines, legacy rows only
318 for _, v := range vms { 318 for _, v := range vms {
319 cas, ok := caCache[v.Tenant] 319 // The CA set a guest is built to trust is the one frozen onto its row
320 if !ok { 320 // at create. Serving it from here — rather than re-reading the tenant's
321 list, err := s.st.ListTenantUserCAs(v.Tenant) 321 // CURRENT set on every push, as this once did — is what makes that
322 if err != nil { 322 // trust decided at create in fact and not just in the documentation: a
323 return nil, fmt.Errorf("list tenant user cas: %w", err) 323 // CA registered after a VM exists no longer reaches it, however long
324 } 324 // that VM has been waiting on an image download or a host certificate.
325 cas = make([]string, 0, len(list)) 325 cas := make([]string, 0, len(v.TrustedCAs))
326 for _, c := range list { 326 for _, c := range v.TrustedCAs {
327 cas = append(cas, c.Pubkey) 327 cas = append(cas, c.AuthorizedKey)
328 }
329
330 // A row that predates the column recorded nothing, and there is no set
331 // to serve — so fall back to the tenant's live set, which is exactly
332 // what this VM would have been sent before the freeze existed. Such a
333 // guest has almost certainly been seeded for months and its trust is
334 // long since fixed in its own filesystem, so the fallback is moot for
335 // all but one case: a pre-upgrade VM still mid-create when the server
336 // rolled. For that one the fallback preserves the old behaviour and it
337 // gets a working guest, where an empty set would seed a guest that
338 // trusts no CA at all and is unreachable for good.
339 if v.TrustedCAs == nil {
340 legacy, ok := caCache[v.Tenant]
341 if !ok {
342 list, err := s.st.ListTenantUserCAs(v.Tenant)
343 if err != nil {
344 return nil, fmt.Errorf("list tenant user cas: %w", err)
345 }
346 legacy = make([]string, 0, len(list))
347 for _, c := range list {
348 legacy = append(legacy, c.Pubkey)
349 }
350 caCache[v.Tenant] = legacy
328 } 351 }
329 caCache[v.Tenant] = cas 352 cas = legacy
330 } 353 }
331 snap.Vms = append(snap.Vms, &pb.VMDesired{ 354 snap.Vms = append(snap.Vms, &pb.VMDesired{
332 VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256, 355 VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256,
internal/server/syncsvc/trustedcas_test.go
Old New
@@ -0,0 +1,102 @@
1 package syncsvc
2
3 import (
4 "testing"
5
6 "github.com/a73x/eitri/internal/server/store"
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9 )
10
11 const (
12 caLaptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAALAPTOP"
13 caCI = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAACIAAAA"
14 )
15
16 // trustedVM places a VM on the fixture's host carrying the given frozen CA set.
17 // A nil set writes a row with no record, which is what every VM created before
18 // the column existed looks like.
19 func trustedVM(t *testing.T, f *fixture, id, name string, cas []store.TrustedCA) {
20 t.Helper()
21 require.NoError(t, f.st.CreateVM(store.VM{ID: id, HostID: f.host.ID, Name: name,
22 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5,
23 PowerState: "running", TrustedCAs: cas}))
24 }
25
26 // snapCAs returns the CA lines the snapshot carries for vmID.
27 func snapCAs(t *testing.T, f *fixture, vmID string) []string {
28 t.Helper()
29 snap, err := f.svc.buildSnapshot(f.host.ID)
30 require.NoError(t, err)
31 for _, v := range snap.GetVms() {
32 if v.GetVmId() == vmID {
33 return v.GetSshUserCaAuthorizedKeys()
34 }
35 }
36 t.Fatalf("vm %s absent from snapshot", vmID)
37 return nil
38 }
39
40 // TestSnapshotServesTheFrozenSetNotTheLiveOne is the property the whole feature
41 // rests on, and the one the create-time refusal has always claimed: a CA
42 // registered after a VM exists does not reach that VM. It used to — the
43 // snapshot read the tenant's current set on every push, so a guest still
44 // waiting on an image download or a host certificate would bake whatever had
45 // arrived by then.
46 func TestSnapshotServesTheFrozenSetNotTheLiveOne(t *testing.T) {
47 f := setup(t)
48 trustedVM(t, f, "vm1", "web-1", []store.TrustedCA{{Label: "laptop", AuthorizedKey: caLaptop}})
49
50 // The tenant registers a second CA AFTER the VM was created.
51 require.NoError(t, f.st.AddTenantUserCA(testTenant, caCI, "tenant", "ci", "admin"))
52
53 assert.Equal(t, []string{caLaptop}, snapCAs(t, f, "vm1"),
54 "a CA registered after create must not reach a guest that already exists")
55 }
56
57 // TestSnapshotGivesALaterVMTheLaterSet is the other half: the freeze is per-VM,
58 // not fleet-wide, so a VM created after the upload trusts the fuller set. Both
59 // VMs sit on one host and one snapshot, which is where a shared cache would
60 // have leaked one VM's set into the other.
61 func TestSnapshotGivesALaterVMTheLaterSet(t *testing.T) {
62 f := setup(t)
63 trustedVM(t, f, "vm1", "web-1", []store.TrustedCA{{Label: "laptop", AuthorizedKey: caLaptop}})
64 trustedVM(t, f, "vm2", "web-2", []store.TrustedCA{
65 {Label: "laptop", AuthorizedKey: caLaptop},
66 {Label: "ci", AuthorizedKey: caCI},
67 })
68
69 assert.Equal(t, []string{caLaptop}, snapCAs(t, f, "vm1"))
70 assert.Equal(t, []string{caLaptop, caCI}, snapCAs(t, f, "vm2"))
71 }
72
73 // TestSnapshotFallsBackToTheLiveSetForAnUnrecordedVM pins the migration edge. A
74 // row written before the column has no set to serve, and serving an empty one
75 // would seed a guest that trusts no CA at all — unreachable for good. The
76 // fallback is the pre-freeze behaviour, kept for exactly the VM that was
77 // mid-create when the server rolled.
78 func TestSnapshotFallsBackToTheLiveSetForAnUnrecordedVM(t *testing.T) {
79 f := setup(t)
80 require.NoError(t, f.st.AddTenantUserCA(testTenant, caLaptop, "tenant", "laptop", "admin"))
81 trustedVM(t, f, "vm1", "web-1", nil)
82
83 assert.Equal(t, []string{caLaptop}, snapCAs(t, f, "vm1"))
84 }
85
86 // TestSnapshotDoesNotCrossTenantsOnTheFallback guards the one path that still
87 // reads a live set: it must read the VM's OWN tenant. A fleet-wide read here
88 // would hand one tenant's CA to another tenant's guest, which is the failure
89 // the create-side refusal was tested against for the same reason.
90 func TestSnapshotDoesNotCrossTenantsOnTheFallback(t *testing.T) {
91 f := setup(t)
92 other, err := f.st.CreateTenantForIdentity("https://test-issuer", "other-subject", "other@test.local")
93 require.NoError(t, err)
94 require.NoError(t, f.st.AddTenantUserCA(other.ID, caCI, "tenant", "someone-elses", "admin"))
95 require.NoError(t, f.st.AddTenantUserCA(testTenant, caLaptop, "tenant", "laptop", "admin"))
96 trustedVM(t, f, "vm1", "web-1", nil)
97
98 // Exactly the host tenant's own CA: asserting the set rather than the
99 // absence keeps this from passing if the fallback stopped serving anything.
100 assert.Equal(t, []string{caLaptop}, snapCAs(t, f, "vm1"),
101 "another tenant's CA must not be served to this tenant's guest")
102 }
web/src/lib/api-types.ts
Old New
@@ -1657,6 +1657,10 @@ export interface components {
1657 StreamTicketResponse: { 1657 StreamTicketResponse: {
1658 ticket: string; 1658 ticket: string;
1659 }; 1659 };
1660 TrustedCA: {
1661 fingerprint: string;
1662 label: string;
1663 };
1660 UserCA: { 1664 UserCA: {
1661 fingerprint: string; 1665 fingerprint: string;
1662 label: string; 1666 label: string;
@@ -1689,6 +1693,7 @@ export interface components {
1689 phase: string; 1693 phase: string;
1690 power_state: string; 1694 power_state: string;
1691 status: string; 1695 status: string;
1696 trusted_cas?: components["schemas"]["TrustedCA"][] | null;
1692 vcpus: number; 1697 vcpus: number;
1693 }; 1698 };
1694 }; 1699 };
web/src/lib/fleet.svelte.ts
Old New
@@ -418,6 +418,41 @@ export function vmStatus(vm: VM): string {
418 return vm.lifecycle || (vm.deleted ? 'deleting' : vm.phase || vm.status || 'unknown'); 418 return vm.lifecycle || (vm.deleted ? 'deleting' : vm.phase || vm.status || 'unknown');
419 } 419 }
420 420
421 /** shortFingerprint abbreviates an OpenSSH SHA256 fingerprint for a table cell,
422 * keeping the SHA256: prefix (so it still reads as a fingerprint and not a
423 * hash of some other kind) and enough of the digest to tell two CAs apart by
424 * eye. Settings shows the full string; this is for places listing several.
425 * Anything that is not a recognisable fingerprint is returned untouched. */
426 export function shortFingerprint(fp: string): string {
427 if (!fp.startsWith('SHA256:') || fp.length <= 19) return fp;
428 return fp.slice(0, 19) + '…';
429 }
430
431 /** vmTrustStale reports whether a guest was created before the tenant's current
432 * CA set was complete — the tenant has since registered a CA this guest does
433 * not trust, and a certificate signed by that CA will not open it. A guest's
434 * trust is fixed at create, so this never resolves on its own: the remedy is
435 * to recreate the VM.
436 *
437 * Three cases return false, all of them for the same reason — the comparison
438 * would be asserting more than is known:
439 *
440 * - The CAs have not been fetched yet. Every guest would read as stale against
441 * an empty list for the moment before they land, which is the same trap
442 * userCAsLoaded already exists to avoid.
443 * - The VM has no record (created before the freeze). There is nothing to
444 * compare, and inferring staleness from silence would flag every legacy VM
445 * on the fleet, including the ones that are perfectly current.
446 * - A CA on either side has no readable fingerprint. Fingerprints are how the
447 * two sides are matched; an unreadable one is unmatchable, so counting it as
448 * missing would report staleness that may not exist.
449 */
450 export function vmTrustStale(vm: VM): boolean {
451 if (!fleet.userCAsLoaded || !vm.trusted_cas) return false;
452 const trusted = new Set(vm.trusted_cas.map((ca) => ca.fingerprint).filter(Boolean));
453 return fleet.userCAs.some((ca) => ca.fingerprint && !trusted.has(ca.fingerprint));
454 }
455
421 /** vmPowerAction is the power flip a VM's controls may offer, or null when 456 /** vmPowerAction is the power flip a VM's controls may offer, or null when
422 * offering one would be a lie. 457 * offering one would be a lie.
423 * 458 *
web/src/routes/+page.svelte
Old New
@@ -12,6 +12,7 @@
12 vmPower, 12 vmPower,
13 vmIP, 13 vmIP,
14 vmPowerAction, 14 vmPowerAction,
15 vmTrustStale,
15 deleteConfirm, 16 deleteConfirm,
16 upgradeAgent, 17 upgradeAgent,
17 refreshUserCAs, 18 refreshUserCAs,
@@ -94,12 +95,20 @@
94 // "still loading" never reads as a warning. 95 // "still loading" never reads as a warning.
95 const noCA = $derived(fleet.userCAsLoaded && fleet.userCAs.length === 0); 96 const noCA = $derived(fleet.userCAsLoaded && fleet.userCAs.length === 0);
96 97
98 // The VMs table marks guests whose trust predates the tenant's CURRENT CA
99 // set, so the CAs are needed for the table itself and not only for the
100 // create dialog. They are not in the SSE snapshot, so fetch them on mount;
101 // they change only when someone registers or removes one.
102 $effect(() => {
103 refreshUserCAs();
104 });
105
97 function openCreate() { 106 function openCreate() {
98 form = { host_id: fleet.hosts[0]?.id ?? '' }; 107 form = { host_id: fleet.hosts[0]?.id ?? '' };
99 showCreate = true; 108 showCreate = true;
100 // CAs are not in the SSE snapshot, so this page has never fetched them. 109 // Ask again as the dialog opens: the mount fetch may be minutes old, and
101 // Asking as the dialog opens keeps the answer current for a tenant that 110 // this keeps the answer current for a tenant that registered one in
102 // registered one in another tab. 111 // another tab.
103 refreshUserCAs(); 112 refreshUserCAs();
104 } 113 }
105 114
@@ -309,7 +318,14 @@
309 <tbody> 318 <tbody>
310 {#each shownVMs as v (v.id)} 319 {#each shownVMs as v (v.id)}
311 <tr> 320 <tr>
312 <td><a href="/vms/{v.id}">{v.name}</a></td> 321 <td>
322 <a href="/vms/{v.id}">{v.name}</a>
323 {#if vmTrustStale(v)}<span
324 class="stale"
325 title="stale trust—this guest was created before the tenant's current CA set. A certificate from a CA registered since will not open it; recreate the VM to pick one up."
326 >△ stale trust</span
327 >{/if}
328 </td>
313 <td>{hostName(v.host_id)}</td> 329 <td>{hostName(v.host_id)}</td>
314 <td class="num">{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td> 330 <td class="num">{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td>
315 <td> 331 <td>
@@ -545,6 +561,15 @@
545 .teardown { 561 .teardown {
546 font-weight: bold; 562 font-weight: bold;
547 } 563 }
564 /* Stale trust is a footnote, not an alarm: the guest works, it just cannot
565 be opened by every CA the tenant now holds. Faint and glyph-led, like the
566 status lights—the triangle carries the state, so it survives without
567 colour, and the words carry it for anyone who cannot see the glyph. */
568 .stale {
569 color: var(--faint);
570 white-space: nowrap;
571 cursor: help;
572 }
548 .onboard { 573 .onboard {
549 border: 1px solid var(--hairline); 574 border: 1px solid var(--hairline);
550 padding: 1em; 575 padding: 1em;
web/src/routes/vms/[id]/+page.svelte
Old New
@@ -20,6 +20,7 @@
20 createExposure, 20 createExposure,
21 deleteExposure, 21 deleteExposure,
22 formatHostPort, 22 formatHostPort,
23 shortFingerprint,
23 clock, 24 clock,
24 type VMEvent, 25 type VMEvent,
25 type Exposure 26 type Exposure
@@ -241,15 +242,42 @@
241 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> 242 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr>
242 <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> 243 <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr>
243 <tr> 244 <tr>
245 <th>Trusted CAs</th>
246 <td>
247 {#if !vm.trusted_cas}
248 <span class="hint"
249 >unrecorded—this guest predates the record. It trusts whatever CAs its tenant
250 had registered on the day it was created.</span
251 >
252 {:else}
253 <ul class="cas">
254 {#each vm.trusted_cas as ca (ca.fingerprint + ca.label)}
255 <li>
256 {ca.label || 'unlabelled'}
257 <code>{shortFingerprint(ca.fingerprint) || 'unreadable key'}</code>
258 </li>
259 {/each}
260 </ul>
261 <span class="hint"
262 >fixed when this guest was created—a CA registered since does not reach it</span
263 >
264 {/if}
265 </td>
266 </tr>
267 <tr>
244 <th>Injected key</th> 268 <th>Injected key</th>
245 <td> 269 <td>
246 {#if !vm.injected_key} 270 {#if !vm.injected_key}
247 <span class="hint">none—eitri installed no key in this guest</span> 271 <span class="hint"
272 >none—no extra key was named at create, which is the usual case. Access is by
273 certificate, from the CAs above.</span
274 >
248 {:else if vm.injected_key.fingerprint} 275 {:else if vm.injected_key.fingerprint}
249 <code class="wrap">{vm.injected_key.fingerprint}</code> 276 <code class="wrap">{vm.injected_key.fingerprint}</code>
250 <span class="hint" 277 <span class="hint"
251 >{vm.injected_key.type}{#if vm.injected_key.comment}, {vm.injected_key 278 >{vm.injected_key.type}{#if vm.injected_key.comment}, {vm.injected_key
252 .comment}{/if}</span 279 .comment}{/if}—an extra key installed at create, alongside the CA
280 trust above</span
253 > 281 >
254 {:else} 282 {:else}
255 <span class="hint" 283 <span class="hint"
@@ -377,6 +405,14 @@
377 .err { 405 .err {
378 color: var(--bad); 406 color: var(--bad);
379 } 407 }
408 /* The trusted CAs are a list inside a value cell: unbulleted and flush, so
409 the row still reads as one fact with several parts rather than as a
410 nested table. Usually one or two entries. */
411 .cas {
412 list-style: none;
413 margin: 0;
414 padding: 0;
415 }
380 /* Given the width, the page stops being one column of everything: what this 416 /* Given the width, the page stops being one column of everything: what this
381 VM IS reads down the left, what you can DO with it stands to the right. 417 VM IS reads down the left, what you can DO with it stands to the right.
382 Space alone divides them—a vertical rule beside a ruled table would read 418 Space alone divides them—a vertical rule beside a ruled table would read