8dd4b711
feat(server,web): a guest names the CAs it trusts
a73x 2026-08-11 08:12
Commit message
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 |