401f3162
feat(server): a VM says which key eitri put in it
a73x 2026-08-06 09:12
Commit message
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -329,6 +329,25 @@ | |||
| 329 | ], | 329 | ], |
| 330 | "type": "object" | 330 | "type": "object" |
| 331 | }, | 331 | }, |
| 332 | "InjectedKey": { | ||
| 333 | "properties": { | ||
| 334 | "comment": { | ||
| 335 | "type": "string" | ||
| 336 | }, | ||
| 337 | "fingerprint": { | ||
| 338 | "type": "string" | ||
| 339 | }, | ||
| 340 | "type": { | ||
| 341 | "type": "string" | ||
| 342 | } | ||
| 343 | }, | ||
| 344 | "required": [ | ||
| 345 | "comment", | ||
| 346 | "fingerprint", | ||
| 347 | "type" | ||
| 348 | ], | ||
| 349 | "type": "object" | ||
| 350 | }, | ||
| 332 | "Me": { | 351 | "Me": { |
| 333 | "properties": { | 352 | "properties": { |
| 334 | "email": { | 353 | "email": { |
| @@ -550,6 +569,16 @@ | |||
| 550 | "image_url": { | 569 | "image_url": { |
| 551 | "type": "string" | 570 | "type": "string" |
| 552 | }, | 571 | }, |
| 572 | "injected_key": { | ||
| 573 | "anyOf": [ | ||
| 574 | { | ||
| 575 | "$ref": "#/components/schemas/InjectedKey" | ||
| 576 | }, | ||
| 577 | { | ||
| 578 | "type": "null" | ||
| 579 | } | ||
| 580 | ] | ||
| 581 | }, | ||
| 553 | "last_error": { | 582 | "last_error": { |
| 554 | "type": "string" | 583 | "type": "string" |
| 555 | }, | 584 | }, |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -542,6 +542,7 @@ func toVMResponse(vm store.VM, actualPower, phase string, destroyAt int64) types | |||
| 542 | Phase: phase, | 542 | Phase: phase, |
| 543 | DestroyAt: destroyAt, | 543 | DestroyAt: destroyAt, |
| 544 | Lifecycle: deriveLifecycle(vm, actualPower, phase), | 544 | Lifecycle: deriveLifecycle(vm, actualPower, phase), |
| 545 | InjectedKey: injectedKey(vm), | ||
| 545 | } | 546 | } |
| 546 | } | 547 | } |
| 547 | 548 | ||
| @@ -766,6 +767,11 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 766 | // with a different default user gets the key there. The key is embedded as a | 767 | // with a different default user gets the key there. The key is embedded as a |
| 767 | // YAML scalar node, so it cannot inject structure (validateCreateVM's | 768 | // YAML scalar node, so it cannot inject structure (validateCreateVM's |
| 768 | // single-line check is belt-and-suspenders, not the load-bearing guard). | 769 | // single-line check is belt-and-suspenders, not the load-bearing guard). |
| 770 | // Describe the key BEFORE the merge below clears the field. The record of | ||
| 771 | // what eitri installed must survive that clearing — it is the whole reason | ||
| 772 | // the console can answer "which key did you put in this VM". | ||
| 773 | keyType, keyFP, keyComment := describeKey(req.SSHAuthorizedKey) | ||
| 774 | |||
| 769 | if req.CloudInit != "" && req.SSHAuthorizedKey != "" { | 775 | if req.CloudInit != "" && req.SSHAuthorizedKey != "" { |
| 770 | merged, err := cloudinit.AddSSHKey(req.CloudInit, req.SSHAuthorizedKey) | 776 | merged, err := cloudinit.AddSSHKey(req.CloudInit, req.SSHAuthorizedKey) |
| 771 | if err != nil { | 777 | if err != nil { |
| @@ -787,11 +793,15 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 787 | ImageSHA256: req.ImageSHA256, | 793 | ImageSHA256: req.ImageSHA256, |
| 788 | CloudInit: req.CloudInit, | 794 | CloudInit: req.CloudInit, |
| 789 | SSHAuthorizedKey: req.SSHAuthorizedKey, | 795 | SSHAuthorizedKey: req.SSHAuthorizedKey, |
| 790 | VCPUs: req.VCPUs, | 796 | |
| 791 | MemMB: req.MemMB, | 797 | InjectedKeyType: keyType, |
| 792 | DiskGB: req.DiskGB, | 798 | InjectedKeyFP: keyFP, |
| 793 | Persistent: req.Persistent, | 799 | InjectedKeyComment: keyComment, |
| 794 | PowerState: req.PowerState, | 800 | VCPUs: req.VCPUs, |
| 801 | MemMB: req.MemMB, | ||
| 802 | DiskGB: req.DiskGB, | ||
| 803 | Persistent: req.Persistent, | ||
| 804 | PowerState: req.PowerState, | ||
| 795 | } | 805 | } |
| 796 | 806 | ||
| 797 | // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed | 807 | // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed |
internal/server/api/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1055,3 +1055,80 @@ func TestVMResponseSurfacesTeardownDestroyDeadline(t *testing.T) { | |||
| 1055 | assert.Equal(t, float64(0), byID[healthyID]["destroy_at"], | 1055 | assert.Equal(t, float64(0), byID[healthyID]["destroy_at"], |
| 1056 | "a VM not scheduled for destruction reports destroy_at == 0") | 1056 | "a VM not scheduled for destruction reports destroy_at == 0") |
| 1057 | } | 1057 | } |
| 1058 | |||
| 1059 | // TestVMReportsTheKeyEitriInjected pins that the console can answer "which key | ||
| 1060 | // did you put in this VM". The subtle case is the second one: when a create | ||
| 1061 | // supplies BOTH cloud_init and a key, the key is merged into the cloud-init | ||
| 1062 | // document and ssh_authorized_key is cleared — so the column alone forgets a | ||
| 1063 | // key eitri definitely installed. | ||
| 1064 | // | ||
| 1065 | // It also pins the boundary of the claim. eitri reports what IT injected; a key | ||
| 1066 | // a user buries in their own cloud_init is theirs, and is deliberately invisible | ||
| 1067 | // here rather than half-tracked. | ||
| 1068 | func TestVMReportsTheKeyEitriInjected(t *testing.T) { | ||
| 1069 | const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG alex@laptop" | ||
| 1070 | ts, _, _ := testServer(t) | ||
| 1071 | out := enroll(t, ts) | ||
| 1072 | |||
| 1073 | // vmInjectedKey creates a VM and returns its injected_key from the API. | ||
| 1074 | vmInjectedKey := func(t *testing.T, name string, body map[string]any) map[string]any { | ||
| 1075 | t.Helper() | ||
| 1076 | body["host_id"], body["name"] = out["host_id"], name | ||
| 1077 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, body) | ||
| 1078 | require.Equal(t, 201, resp.StatusCode) | ||
| 1079 | var created map[string]string | ||
| 1080 | json.NewDecoder(resp.Body).Decode(&created) | ||
| 1081 | |||
| 1082 | resp = do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil) | ||
| 1083 | require.Equal(t, 200, resp.StatusCode) | ||
| 1084 | for _, v := range decodeJSONKeys(t, resp) { | ||
| 1085 | if v["id"] == created["id"] { | ||
| 1086 | require.Contains(t, v, "injected_key", "the VM response must carry injected_key") | ||
| 1087 | if v["injected_key"] == nil { | ||
| 1088 | return nil | ||
| 1089 | } | ||
| 1090 | return v["injected_key"].(map[string]any) | ||
| 1091 | } | ||
| 1092 | } | ||
| 1093 | t.Fatal("created VM missing from the list") | ||
| 1094 | return nil | ||
| 1095 | } | ||
| 1096 | |||
| 1097 | t.Run("key alone", func(t *testing.T) { | ||
| 1098 | got := vmInjectedKey(t, "keyed", map[string]any{"ssh_authorized_key": key}) | ||
| 1099 | require.NotNil(t, got) | ||
| 1100 | assert.Equal(t, "ssh-ed25519", got["type"]) | ||
| 1101 | assert.Equal(t, "alex@laptop", got["comment"]) | ||
| 1102 | assert.Contains(t, got["fingerprint"], "SHA256:") | ||
| 1103 | }) | ||
| 1104 | |||
| 1105 | // The merge path: ssh_authorized_key is cleared once folded into cloud-init, | ||
| 1106 | // so this is exactly where a naive read of the column reports nothing. | ||
| 1107 | t.Run("key merged into cloud-init is still reported", func(t *testing.T) { | ||
| 1108 | got := vmInjectedKey(t, "keyed-with-ci", map[string]any{ | ||
| 1109 | "ssh_authorized_key": key, | ||
| 1110 | "cloud_init": "#cloud-config\npackages:\n - htop\n", | ||
| 1111 | }) | ||
| 1112 | require.NotNil(t, got, "a key merged into cloud-init was still injected by eitri") | ||
| 1113 | assert.Equal(t, "alex@laptop", got["comment"]) | ||
| 1114 | }) | ||
| 1115 | |||
| 1116 | t.Run("no key injected reports nothing", func(t *testing.T) { | ||
| 1117 | assert.Nil(t, vmInjectedKey(t, "bare", map[string]any{})) | ||
| 1118 | }) | ||
| 1119 | |||
| 1120 | // A key eitri never installed is not eitri's to report. | ||
| 1121 | t.Run("a key hidden in the user's own cloud-init is not claimed", func(t *testing.T) { | ||
| 1122 | assert.Nil(t, vmInjectedKey(t, "byo", map[string]any{ | ||
| 1123 | "cloud_init": "#cloud-config\nusers:\n - name: me\n ssh_authorized_keys:\n - " + key + "\n", | ||
| 1124 | })) | ||
| 1125 | }) | ||
| 1126 | |||
| 1127 | // The description is derived and readable; the key itself stays write-only. | ||
| 1128 | t.Run("the raw key is still never echoed", func(t *testing.T) { | ||
| 1129 | resp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil) | ||
| 1130 | for _, v := range decodeJSONKeys(t, resp) { | ||
| 1131 | assert.NotContains(t, v, "ssh_authorized_key") | ||
| 1132 | } | ||
| 1133 | }) | ||
| 1134 | } | ||
internal/server/api/injectedkey.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,72 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | |||
| 6 | "github.com/a73x/eitri/internal/server/api/types" | ||
| 7 | "github.com/a73x/eitri/internal/server/store" | ||
| 8 | "golang.org/x/crypto/ssh" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // maxKeyCommentLen bounds the comment kept from a user-supplied key. It is | ||
| 12 | // free text that ends up rendered in the console, and an authorized_keys | ||
| 13 | // comment is conventionally a short "user@host" — anything longer is either a | ||
| 14 | // mistake or an attempt to fill a column. | ||
| 15 | const maxKeyCommentLen = 128 | ||
| 16 | |||
| 17 | // describeKey summarises the authorized key eitri is about to install, for the | ||
| 18 | // record kept on the VM row. It returns the key type, its SHA256 fingerprint in | ||
| 19 | // OpenSSH's own spelling, and the key's comment. | ||
| 20 | // | ||
| 21 | // It DESCRIBES rather than validates. Create deliberately accepts any | ||
| 22 | // single-line key — the guest's sshd is the thing that decides what it will | ||
| 23 | // honour, and refusing a key here on cryptographic grounds would be eitri | ||
| 24 | // second-guessing it. So a key that cannot be parsed still yields whatever can | ||
| 25 | // be said about it: the leading token as its type, and no fingerprint. An empty | ||
| 26 | // fingerprint is the signal that the key was unreadable, not that none exists. | ||
| 27 | // | ||
| 28 | // A key eitri never installed returns three empty strings, which is a different | ||
| 29 | // statement entirely: nothing was injected. | ||
| 30 | func describeKey(line string) (keyType, fingerprint, comment string) { | ||
| 31 | line = strings.TrimSpace(line) | ||
| 32 | if line == "" { | ||
| 33 | return "", "", "" | ||
| 34 | } | ||
| 35 | pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | ||
| 36 | if err != nil { | ||
| 37 | // Unreadable: say what it claims to be and nothing more. The first | ||
| 38 | // field of an authorized_keys line is its type by convention, and a | ||
| 39 | // claim is still worth showing next to "we could not read this". | ||
| 40 | return firstField(line), "", "" | ||
| 41 | } | ||
| 42 | if len(comment) > maxKeyCommentLen { | ||
| 43 | comment = comment[:maxKeyCommentLen] | ||
| 44 | } | ||
| 45 | return pub.Type(), ssh.FingerprintSHA256(pub), comment | ||
| 46 | } | ||
| 47 | |||
| 48 | // firstField returns the first whitespace-separated token of s, bounded so an | ||
| 49 | // unparseable line cannot contribute an unbounded "type". | ||
| 50 | func firstField(s string) string { | ||
| 51 | if i := strings.IndexAny(s, " \t"); i >= 0 { | ||
| 52 | s = s[:i] | ||
| 53 | } | ||
| 54 | if len(s) > 32 { | ||
| 55 | s = s[:32] | ||
| 56 | } | ||
| 57 | return s | ||
| 58 | } | ||
| 59 | |||
| 60 | // injectedKey renders a VM row's recorded key for the wire, or nil when eitri | ||
| 61 | // installed none. It is derived and read-only: the key itself stays write-only, | ||
| 62 | // like every other field a create accepts and no response echoes. | ||
| 63 | func injectedKey(vm store.VM) *types.InjectedKey { | ||
| 64 | if vm.InjectedKeyType == "" && vm.InjectedKeyFP == "" { | ||
| 65 | return nil | ||
| 66 | } | ||
| 67 | return &types.InjectedKey{ | ||
| 68 | Type: vm.InjectedKeyType, | ||
| 69 | Fingerprint: vm.InjectedKeyFP, | ||
| 70 | Comment: vm.InjectedKeyComment, | ||
| 71 | } | ||
| 72 | } | ||
internal/server/api/injectedkey_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,49 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "github.com/stretchr/testify/assert" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // realKey is a genuine ed25519 authorized_keys line, so the fingerprint below | ||
| 11 | // is the one OpenSSH itself computes (`ssh-keygen -lf`). | ||
| 12 | const realKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG alex@laptop" | ||
| 13 | |||
| 14 | func TestDescribeKey(t *testing.T) { | ||
| 15 | t.Run("a real key is identified by fingerprint", func(t *testing.T) { | ||
| 16 | typ, fp, comment := describeKey(realKey) | ||
| 17 | assert.Equal(t, "ssh-ed25519", typ) | ||
| 18 | assert.True(t, strings.HasPrefix(fp, "SHA256:"), "fingerprint must be OpenSSH-spelled, got %q", fp) | ||
| 19 | assert.Equal(t, "alex@laptop", comment) | ||
| 20 | }) | ||
| 21 | |||
| 22 | t.Run("no key injected is three empties", func(t *testing.T) { | ||
| 23 | typ, fp, comment := describeKey("") | ||
| 24 | assert.Empty(t, typ) | ||
| 25 | assert.Empty(t, fp) | ||
| 26 | assert.Empty(t, comment) | ||
| 27 | }) | ||
| 28 | |||
| 29 | // Create deliberately accepts any single-line key — the guest's sshd decides | ||
| 30 | // what it honours — so an unreadable one must still be described, not | ||
| 31 | // dropped. An empty fingerprint is the signal, and it must be | ||
| 32 | // distinguishable from "no key at all". | ||
| 33 | t.Run("an unreadable key still says what it claims to be", func(t *testing.T) { | ||
| 34 | typ, fp, comment := describeKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@host") | ||
| 35 | assert.Equal(t, "ssh-ed25519", typ) | ||
| 36 | assert.Empty(t, fp, "an unreadable key has no fingerprint") | ||
| 37 | assert.Empty(t, comment) | ||
| 38 | }) | ||
| 39 | |||
| 40 | t.Run("bounded against a hostile line", func(t *testing.T) { | ||
| 41 | typ, _, _ := describeKey(strings.Repeat("z", 5000)) | ||
| 42 | assert.LessOrEqual(t, len(typ), 32, "an unparseable type must be bounded") | ||
| 43 | |||
| 44 | long := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG " + | ||
| 45 | strings.Repeat("c", 5000) | ||
| 46 | _, _, comment := describeKey(long) | ||
| 47 | assert.LessOrEqual(t, len(comment), maxKeyCommentLen) | ||
| 48 | }) | ||
| 49 | } | ||
internal/server/api/testdata/snapshot.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -63,7 +63,12 @@ | |||
| 63 | "actual_power": "stopped", | 63 | "actual_power": "stopped", |
| 64 | "phase": "creating", | 64 | "phase": "creating", |
| 65 | "destroy_at": 1785153600, | 65 | "destroy_at": 1785153600, |
| 66 | "lifecycle": "deleting" | 66 | "lifecycle": "deleting", |
| 67 | "injected_key": { | ||
| 68 | "type": "ssh-ed25519", | ||
| 69 | "fingerprint": "SHA256:0000000000000000000000000000000000000000000", | ||
| 70 | "comment": "alex@laptop" | ||
| 71 | } | ||
| 67 | } | 72 | } |
| 68 | ], | 73 | ], |
| 69 | "server_version": "v0.0.1-test", | 74 | "server_version": "v0.0.1-test", |
internal/server/api/testdata/vm.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -16,5 +16,10 @@ | |||
| 16 | "actual_power": "stopped", | 16 | "actual_power": "stopped", |
| 17 | "phase": "creating", | 17 | "phase": "creating", |
| 18 | "destroy_at": 1785153600, | 18 | "destroy_at": 1785153600, |
| 19 | "lifecycle": "deleting" | 19 | "lifecycle": "deleting", |
| 20 | "injected_key": { | ||
| 21 | "type": "ssh-ed25519", | ||
| 22 | "fingerprint": "SHA256:0000000000000000000000000000000000000000000", | ||
| 23 | "comment": "alex@laptop" | ||
| 24 | } | ||
| 20 | } | 25 | } |
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -114,6 +114,24 @@ type VM struct { | |||
| 114 | // loops (reconciler, reaper, agent) read and write the underlying axes, | 114 | // loops (reconciler, reaper, agent) read and write the underlying axes, |
| 115 | // never this field. Values: creating | ready | stopped | failed | deleting. | 115 | // never this field. Values: creating | ready | stopped | failed | deleting. |
| 116 | Lifecycle string `json:"lifecycle"` | 116 | Lifecycle string `json:"lifecycle"` |
| 117 | // InjectedKey describes the authorized key EITRI installed in this guest at | ||
| 118 | // create, or null when it installed none. It is a description, not the key: | ||
| 119 | // ssh_authorized_key stays write-only like everything else a create accepts. | ||
| 120 | // | ||
| 121 | // It covers only what eitri put there. A key hidden inside a user's own | ||
| 122 | // cloud_init is invisible here by design — eitri did not install it and does | ||
| 123 | // not claim to know about it. | ||
| 124 | InjectedKey *InjectedKey `json:"injected_key"` | ||
| 125 | } | ||
| 126 | |||
| 127 | // InjectedKey identifies one authorized key by its OpenSSH fingerprint, the way | ||
| 128 | // `ssh-add -l` does. An empty Fingerprint means the key could not be read — | ||
| 129 | // create accepts any single-line key, since the guest's sshd is what decides | ||
| 130 | // what it honours. | ||
| 131 | type InjectedKey struct { | ||
| 132 | Type string `json:"type"` | ||
| 133 | Fingerprint string `json:"fingerprint"` | ||
| 134 | Comment string `json:"comment"` | ||
| 117 | } | 135 | } |
| 118 | 136 | ||
| 119 | // StateSnapshot is the full fleet state pushed as each `event: state` frame | 137 | // StateSnapshot is the full fleet state pushed as each `event: state` frame |
internal/server/api/wire_golden_test.go
| Old | New | ||
|---|---|---|---|
| @@ -107,6 +107,11 @@ func TestWireGolden(t *testing.T) { | |||
| 107 | Phase: "creating", | 107 | Phase: "creating", |
| 108 | DestroyAt: 1785153600, | 108 | DestroyAt: 1785153600, |
| 109 | Lifecycle: "deleting", | 109 | Lifecycle: "deleting", |
| 110 | InjectedKey: &types.InjectedKey{ | ||
| 111 | Type: "ssh-ed25519", | ||
| 112 | Fingerprint: "SHA256:0000000000000000000000000000000000000000000", | ||
| 113 | Comment: "alex@laptop", | ||
| 114 | }, | ||
| 110 | } | 115 | } |
| 111 | goldenCheck(t, "vm", vm) | 116 | goldenCheck(t, "vm", vm) |
| 112 | 117 | ||
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -77,6 +77,14 @@ type VM struct { | |||
| 77 | // never returned in vmResponse and never logged. SSHHostCert is the matching | 77 | // never returned in vmResponse and never logged. SSHHostCert is the matching |
| 78 | // CA-signed host cert (authorized_keys form); public, but grouped here. | 78 | // CA-signed host cert (authorized_keys form); public, but grouped here. |
| 79 | SSHHostKey, SSHHostCert string | 79 | SSHHostKey, SSHHostCert string |
| 80 | // InjectedKey* describe the authorized key eitri installed at create: its | ||
| 81 | // type, SHA256 fingerprint and comment. They are a RECORD of what eitri | ||
| 82 | // did, not an input to it — SSHAuthorizedKey is cleared when the key is | ||
| 83 | // merged into user-supplied cloud-init, and these are not. Keys a user | ||
| 84 | // hides inside their own cloud_init are their business and are deliberately | ||
| 85 | // not tracked. Empty on rows created before the columns existed, which is | ||
| 86 | // honest: the record was never taken. | ||
| 87 | InjectedKeyType, InjectedKeyFP, InjectedKeyComment string | ||
| 80 | // The VM's reachable address is AssignedIP (the agent-reported bridge IP). | 88 | // The VM's reachable address is AssignedIP (the agent-reported bridge IP). |
| 81 | CreatedAt time.Time | 89 | CreatedAt time.Time |
| 82 | DeletedAt *time.Time | 90 | DeletedAt *time.Time |
| @@ -266,6 +274,15 @@ func Open(path, cidrPool string) (*Store, error) { | |||
| 266 | // never applies to new rows). | 274 | // never applies to new rows). |
| 267 | {"audit_log", "tenant", "TEXT NOT NULL DEFAULT 'default'"}, | 275 | {"audit_log", "tenant", "TEXT NOT NULL DEFAULT 'default'"}, |
| 268 | {"revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"}, | 276 | {"revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"}, |
| 277 | // What eitri put in the guest's authorized_keys, described rather than | ||
| 278 | // copied: type, SHA256 fingerprint and comment. Derived ONCE at create | ||
| 279 | // (see api.describeKey) because the SSE snapshot marshals every VM on a | ||
| 280 | // 1s tick and parsing a key per read would be work repeated forever. | ||
| 281 | // Rows that predate these columns show no key, which is honest — the | ||
| 282 | // record was never taken. | ||
| 283 | {"vms", "injected_key_type", "TEXT NOT NULL DEFAULT ''"}, | ||
| 284 | {"vms", "injected_key_fp", "TEXT NOT NULL DEFAULT ''"}, | ||
| 285 | {"vms", "injected_key_comment", "TEXT NOT NULL DEFAULT ''"}, | ||
| 269 | } { | 286 | } { |
| 270 | if err := ensureColumn(db, c.table, c.column, c.decl); err != nil { | 287 | if err := ensureColumn(db, c.table, c.column, c.decl); err != nil { |
| 271 | db.Close() | 288 | db.Close() |
| @@ -572,11 +589,13 @@ func (s *Store) CreateVM(vm VM) error { | |||
| 572 | _, err = tx.Exec( | 589 | _, err = tx.Exec( |
| 573 | `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, | 590 | `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 574 | ssh_host_key, ssh_host_cert, | 591 | ssh_host_key, ssh_host_cert, |
| 592 | injected_key_type, injected_key_fp, injected_key_comment, | ||
| 575 | vcpus, mem_mb, disk_gb, persistent, power_state, created_at) | 593 | vcpus, mem_mb, disk_gb, persistent, power_state, created_at) |
| 576 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, | 594 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, |
| 577 | vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256, | 595 | vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256, |
| 578 | vm.CloudInit, vm.SSHAuthorizedKey, | 596 | vm.CloudInit, vm.SSHAuthorizedKey, |
| 579 | vm.SSHHostKey, vm.SSHHostCert, | 597 | vm.SSHHostKey, vm.SSHHostCert, |
| 598 | vm.InjectedKeyType, vm.InjectedKeyFP, vm.InjectedKeyComment, | ||
| 580 | vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, | 599 | vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, |
| 581 | now.Format(time.RFC3339), | 600 | now.Format(time.RFC3339), |
| 582 | ) | 601 | ) |
| @@ -1072,6 +1091,7 @@ func (s *Store) RecordVMStatus(id, status, lastErr, ip string) (string, error) { | |||
| 1072 | // of lockstep. | 1091 | // of lockstep. |
| 1073 | const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, | 1092 | const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 1074 | ssh_host_key, ssh_host_cert, | 1093 | ssh_host_key, ssh_host_cert, |
| 1094 | injected_key_type, injected_key_fp, injected_key_comment, | ||
| 1075 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | 1095 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, |
| 1076 | created_at, deleted_at` | 1096 | created_at, deleted_at` |
| 1077 | 1097 | ||
| @@ -1084,6 +1104,7 @@ func scanVM(rows *sql.Rows) (VM, error) { | |||
| 1084 | err := rows.Scan( | 1104 | err := rows.Scan( |
| 1085 | &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256, | 1105 | &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256, |
| 1086 | &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert, | 1106 | &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert, |
| 1107 | &vm.InjectedKeyType, &vm.InjectedKeyFP, &vm.InjectedKeyComment, | ||
| 1087 | &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, | 1108 | &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, |
| 1088 | &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, | 1109 | &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, |
| 1089 | &createdAt, &deletedAt, | 1110 | &createdAt, &deletedAt, |
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -1314,6 +1314,11 @@ export interface components { | |||
| 1314 | status: string; | 1314 | status: string; |
| 1315 | virt: string; | 1315 | virt: string; |
| 1316 | }; | 1316 | }; |
| 1317 | InjectedKey: { | ||
| 1318 | comment: string; | ||
| 1319 | fingerprint: string; | ||
| 1320 | type: string; | ||
| 1321 | }; | ||
| 1317 | Me: { | 1322 | Me: { |
| 1318 | email: string; | 1323 | email: string; |
| 1319 | tenant: string; | 1324 | tenant: string; |
| @@ -1377,6 +1382,7 @@ export interface components { | |||
| 1377 | host_id: string; | 1382 | host_id: string; |
| 1378 | id: string; | 1383 | id: string; |
| 1379 | image_url: string; | 1384 | image_url: string; |
| 1385 | injected_key?: components["schemas"]["InjectedKey"] | null; | ||
| 1380 | last_error: string; | 1386 | last_error: string; |
| 1381 | lifecycle: string; | 1387 | lifecycle: string; |
| 1382 | mem_mb: number; | 1388 | mem_mb: number; |
web/src/routes/vms/[id]/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -122,6 +122,25 @@ | |||
| 122 | <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> | 122 | <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> |
| 123 | <tr><th>Persistent</th><td>{vm.persistent}</td></tr> | 123 | <tr><th>Persistent</th><td>{vm.persistent}</td></tr> |
| 124 | <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> | 124 | <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> |
| 125 | <tr> | ||
| 126 | <th>Injected key</th> | ||
| 127 | <td> | ||
| 128 | {#if !vm.injected_key} | ||
| 129 | <span class="hint">none—eitri installed no key in this guest</span> | ||
| 130 | {:else if vm.injected_key.fingerprint} | ||
| 131 | <code class="wrap">{vm.injected_key.fingerprint}</code> | ||
| 132 | <span class="hint" | ||
| 133 | >{vm.injected_key.type}{#if vm.injected_key.comment}, {vm.injected_key | ||
| 134 | .comment}{/if}</span | ||
| 135 | > | ||
| 136 | {:else} | ||
| 137 | <span class="hint" | ||
| 138 | >{vm.injected_key.type}—unreadable, so no fingerprint. The guest's sshd decides | ||
| 139 | whether it works.</span | ||
| 140 | > | ||
| 141 | {/if} | ||
| 142 | </td> | ||
| 143 | </tr> | ||
| 125 | {#if vm.last_error}<tr><th>Last error</th><td class="err">{vm.last_error}</td></tr>{/if} | 144 | {#if vm.last_error}<tr><th>Last error</th><td class="err">{vm.last_error}</td></tr>{/if} |
| 126 | <tr><th>Created</th><td>{vm.created_at}</td></tr> | 145 | <tr><th>Created</th><td>{vm.created_at}</td></tr> |
| 127 | </tbody> | 146 | </tbody> |