2874212c
feat(server): a guest's host key never leaves its host
a73x 2026-08-08 12:00
Commit message
internal/agent/reconcile/hostkey_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,145 @@ | |||
| 1 | package reconcile | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "os" | ||
| 6 | "testing" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/agent/seed" | ||
| 10 | "github.com/a73x/eitri/internal/pb" | ||
| 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | "google.golang.org/protobuf/proto" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // needsHostCert marks a desired VM the way a fleet with an SSH CA does. | ||
| 17 | func needsHostCert(v *pb.VMDesired) { v.HostCertRequired = true } | ||
| 18 | |||
| 19 | // withHostCert supplies the certificate the control plane signed. | ||
| 20 | func withHostCert(cert string) func(*pb.VMDesired) { | ||
| 21 | return func(v *pb.VMDesired) { v.HostCertRequired = true; v.SshHostCert = cert } | ||
| 22 | } | ||
| 23 | |||
| 24 | // TestAwaitingHostCertDoesNotSpendRetryBudget is the regression that matters | ||
| 25 | // most in this exchange. Waiting for a certificate is the round trip working; | ||
| 26 | // if it were charged to the create budget, three slow ticks would terminal-fail | ||
| 27 | // every VM on the fleet at once. | ||
| 28 | func TestAwaitingHostCertDoesNotSpendRetryBudget(t *testing.T) { | ||
| 29 | f := setup(t) | ||
| 30 | |||
| 31 | for tick := uint64(1); tick <= 3; tick++ { | ||
| 32 | rep := f.step(snap(tick, vm("vm1", needsHostCert))) | ||
| 33 | row := findVM(rep, "vm1") | ||
| 34 | require.NotNil(t, row) | ||
| 35 | assert.Equal(t, "creating", row.Phase, "tick %d", tick) | ||
| 36 | assert.NotEmpty(t, row.SshHostPubkey, "the public key must ride every report") | ||
| 37 | } | ||
| 38 | |||
| 39 | rec, ok, err := f.st.Get("vm1") | ||
| 40 | require.NoError(t, err) | ||
| 41 | require.True(t, ok) | ||
| 42 | assert.Equal(t, 0, rec.CreateAttempts, "waiting for a certificate is not an attempt") | ||
| 43 | |||
| 44 | // Nothing was built either: no disk, no seed, no boot. | ||
| 45 | assert.Empty(t, f.prov.prepared) | ||
| 46 | assert.Empty(t, f.prov.booted) | ||
| 47 | assert.Equal(t, 0, f.prov.prepCalls) | ||
| 48 | |||
| 49 | // And the budget is intact, so the VM still creates once the cert lands. | ||
| 50 | rep := f.step(snap(4, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host")))) | ||
| 51 | assert.Equal(t, "ready", findVM(rep, "vm1").Phase) | ||
| 52 | } | ||
| 53 | |||
| 54 | // TestHostKeySurvivesAnAgentRestart pins load-or-create: the key the control | ||
| 55 | // plane signs must be the key this host still holds afterwards. | ||
| 56 | func TestHostKeySurvivesAnAgentRestart(t *testing.T) { | ||
| 57 | f := setup(t) | ||
| 58 | first := findVM(f.step(snap(1, vm("vm1", needsHostCert))), "vm1").GetSshHostPubkey() | ||
| 59 | require.NotEmpty(t, first) | ||
| 60 | |||
| 61 | // A restart loses every in-memory worker; the record and the key file are | ||
| 62 | // all that carry over. | ||
| 63 | f.restart(t) | ||
| 64 | second := findVM(f.step(snap(2, vm("vm1", needsHostCert))), "vm1").GetSshHostPubkey() | ||
| 65 | assert.Equal(t, first, second, "a restart must report the same key, not a new one") | ||
| 66 | } | ||
| 67 | |||
| 68 | // TestSeedReceivesTheOnDiskHostKey proves the private half goes from this | ||
| 69 | // host's disk straight into the guest's seed, and that the certificate the | ||
| 70 | // control plane sent is the one installed beside it. | ||
| 71 | func TestSeedReceivesTheOnDiskHostKey(t *testing.T) { | ||
| 72 | f := setup(t) | ||
| 73 | var got seed.Params | ||
| 74 | f.eng.Seed = func(_ string, p seed.Params) error { got = p; return nil } | ||
| 75 | |||
| 76 | f.step(snap(1, vm("vm1", needsHostCert))) | ||
| 77 | onDisk, err := os.ReadFile(f.st.HostKeyPath("vm1")) | ||
| 78 | require.NoError(t, err) | ||
| 79 | |||
| 80 | const cert = "ssh-ed25519-cert-v01@openssh.com AAAAcert host" | ||
| 81 | f.step(snap(2, vm("vm1", withHostCert(cert)))) | ||
| 82 | |||
| 83 | assert.Equal(t, string(onDisk), got.SSHHostKeyPEM) | ||
| 84 | assert.Equal(t, cert, got.SSHHostCert) | ||
| 85 | assert.Contains(t, got.SSHHostKeyPEM, "OPENSSH PRIVATE KEY") | ||
| 86 | } | ||
| 87 | |||
| 88 | // TestReportCarriesNoPrivateKeyMaterial is the blunt assertion: whatever else | ||
| 89 | // changes about the report, the guest's private key must never be in it. The | ||
| 90 | // public half rides every report; the private half rides none. | ||
| 91 | func TestReportCarriesNoPrivateKeyMaterial(t *testing.T) { | ||
| 92 | f := setup(t) | ||
| 93 | f.step(snap(1, vm("vm1", needsHostCert))) | ||
| 94 | priv, err := os.ReadFile(f.st.HostKeyPath("vm1")) | ||
| 95 | require.NoError(t, err) | ||
| 96 | |||
| 97 | rep := f.step(snap(2, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host")))) | ||
| 98 | raw, err := proto.Marshal(rep) | ||
| 99 | require.NoError(t, err) | ||
| 100 | |||
| 101 | assert.False(t, bytes.Contains(raw, priv), "the private key must never reach the wire") | ||
| 102 | assert.False(t, bytes.Contains(raw, []byte("PRIVATE KEY"))) | ||
| 103 | assert.Contains(t, string(raw), findVM(rep, "vm1").GetSshHostPubkey()) | ||
| 104 | } | ||
| 105 | |||
| 106 | // TestHostKeyFileIsPrivate: the file the seed reads is the guest's identity. | ||
| 107 | func TestHostKeyFileIsPrivate(t *testing.T) { | ||
| 108 | f := setup(t) | ||
| 109 | f.step(snap(1, vm("vm1", needsHostCert))) | ||
| 110 | fi, err := os.Stat(f.st.HostKeyPath("vm1")) | ||
| 111 | require.NoError(t, err) | ||
| 112 | assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | ||
| 113 | } | ||
| 114 | |||
| 115 | // TestReapRemovesTheHostKey: a destroyed VM leaves no key behind. | ||
| 116 | func TestReapRemovesTheHostKey(t *testing.T) { | ||
| 117 | f := setup(t) | ||
| 118 | f.step(snap(1, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host")))) | ||
| 119 | require.FileExists(t, f.st.HostKeyPath("vm1")) | ||
| 120 | |||
| 121 | f.step(snap(2, tombstoned(vm("vm1")))) | ||
| 122 | f.now = f.now.Add(10 * time.Minute) | ||
| 123 | f.step(snap(3, tombstoned(vm("vm1")))) | ||
| 124 | |||
| 125 | _, err := os.Stat(f.st.HostKeyPath("vm1")) | ||
| 126 | assert.True(t, os.IsNotExist(err), "destroying a VM must take its host key with it") | ||
| 127 | } | ||
| 128 | |||
| 129 | // TestGateOffGivesTheGuestNoHostKey: with no CA in the fleet there is no | ||
| 130 | // certificate to wait for and nothing that could certify a key, so the agent | ||
| 131 | // generates none and the guest falls back to the one it makes for itself. | ||
| 132 | func TestGateOffGivesTheGuestNoHostKey(t *testing.T) { | ||
| 133 | f := setup(t) | ||
| 134 | var got seed.Params | ||
| 135 | f.eng.Seed = func(_ string, p seed.Params) error { got = p; return nil } | ||
| 136 | |||
| 137 | rep := f.step(snap(1, vm("vm1"))) | ||
| 138 | |||
| 139 | assert.Equal(t, "ready", findVM(rep, "vm1").Phase) | ||
| 140 | assert.Empty(t, findVM(rep, "vm1").SshHostPubkey) | ||
| 141 | assert.Empty(t, got.SSHHostKeyPEM, "there is no other place a host key could come from") | ||
| 142 | assert.Empty(t, got.SSHHostCert) | ||
| 143 | _, err := os.Stat(f.st.HostKeyPath("vm1")) | ||
| 144 | assert.True(t, os.IsNotExist(err), "no CA in the fleet means no key to generate") | ||
| 145 | } | ||
internal/agent/reconcile/reconcile.go
| Old | New | ||
|---|---|---|---|
| @@ -123,6 +123,15 @@ type Engine struct { | |||
| 123 | // Seed builds the cloud-init NoCloud seed ISO at outPath. | 123 | // Seed builds the cloud-init NoCloud seed ISO at outPath. |
| 124 | Seed func(outPath string, p seed.Params) error | 124 | Seed func(outPath string, p seed.Params) error |
| 125 | 125 | ||
| 126 | // HostKey returns the guest host key stored at path, generating and | ||
| 127 | // persisting one when there is none. Both halves come back because both are | ||
| 128 | // needed here and neither travels together: the public line is reported | ||
| 129 | // upward for certification, the private PEM goes straight into the seed. | ||
| 130 | // | ||
| 131 | // It is load-or-create, not create: a VM waiting for its certificate must | ||
| 132 | // present the same key after an agent restart as before one. | ||
| 133 | HostKey func(path string) (state.HostKey, error) | ||
| 134 | |||
| 126 | // BootID returns the current host boot identifier (e.g. /proc/sys/kernel/random/boot_id). | 135 | // BootID returns the current host boot identifier (e.g. /proc/sys/kernel/random/boot_id). |
| 127 | // Changes on reboot, enabling lost-VM detection. | 136 | // Changes on reboot, enabling lost-VM detection. |
| 128 | BootID func() string | 137 | BootID func() string |
| @@ -319,6 +328,7 @@ func (e *Engine) fenceReport(currentEpoch uint64) *pb.ActualStateReport { | |||
| 319 | } | 328 | } |
| 320 | for _, rec := range recs { | 329 | for _, rec := range recs { |
| 321 | var res vmResult | 330 | var res vmResult |
| 331 | res.hostPubKey = rec.HostPubKey | ||
| 322 | // Quarantined VMs belong in Quarantined[], not Vms[]. | 332 | // Quarantined VMs belong in Quarantined[], not Vms[]. |
| 323 | if rec.QuarantinedAt != nil { | 333 | if rec.QuarantinedAt != nil { |
| 324 | res.quarantined = quarantinedEntry(rec, e.graceFor(rec)) | 334 | res.quarantined = quarantinedEntry(rec, e.graceFor(rec)) |
| @@ -374,6 +384,9 @@ func (e *Engine) reconcileOne(ctx context.Context, id string, a assignment) (vmR | |||
| 374 | slog.Warn("reconcile: skipping pass, VM record unreadable", "vm_id", id, "err", err) | 384 | slog.Warn("reconcile: skipping pass, VM record unreadable", "vm_id", id, "err", err) |
| 375 | return res, false | 385 | return res, false |
| 376 | } | 386 | } |
| 387 | // Whatever this pass goes on to do, the VM's public host key rides its row. | ||
| 388 | // create overwrites this the moment it generates one. | ||
| 389 | res.hostPubKey = rec.HostPubKey | ||
| 377 | 390 | ||
| 378 | if a.desired != nil && !a.tombstoned { | 391 | if a.desired != nil && !a.tombstoned { |
| 379 | // Un-delete path: the VM re-appears in desired while still carrying a | 392 | // Un-delete path: the VM re-appears in desired while still carrying a |
| @@ -651,6 +664,40 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 651 | return | 664 | return |
| 652 | } | 665 | } |
| 653 | 666 | ||
| 667 | // The guest's host key is generated here and stays here. The control plane | ||
| 668 | // receives the public half, signs it, and sends the certificate back down; | ||
| 669 | // the private half goes straight into this VM's seed and nothing above this | ||
| 670 | // host ever holds it. | ||
| 671 | // | ||
| 672 | // Ahead of the retry budget deliberately. Waiting for a certificate is the | ||
| 673 | // round trip working, not an attempt failing — charging it would spend a | ||
| 674 | // VM's whole budget in three ticks against a control plane that is merely | ||
| 675 | // slow to sign, and terminal-fail every VM on the fleet at once. | ||
| 676 | var hostKey state.HostKey | ||
| 677 | if d.HostCertRequired { | ||
| 678 | rec.Spec = spec // SaveVM keys off the record's own spec | ||
| 679 | var err error | ||
| 680 | if hostKey, err = e.HostKey(e.St.HostKeyPath(d.VmId)); err != nil { | ||
| 681 | e.failCreate(ctx, rec, err, res) | ||
| 682 | return | ||
| 683 | } | ||
| 684 | if rec.HostPubKey != hostKey.PublicLine { | ||
| 685 | rec.HostPubKey = hostKey.PublicLine | ||
| 686 | if err := e.St.SaveVM(rec); err != nil { | ||
| 687 | e.failCreate(ctx, rec, err, res) | ||
| 688 | return | ||
| 689 | } | ||
| 690 | } | ||
| 691 | res.hostPubKey = rec.HostPubKey | ||
| 692 | if d.SshHostCert == "" { | ||
| 693 | // Reported, not yet certified. Publish the public key and wait: | ||
| 694 | // booting now would hand the guest a host key every client refuses, | ||
| 695 | // and converge never rebuilds a seed, so it would stay that way. | ||
| 696 | res.report(d.VmId, rec.IP, "stopped", "creating", "awaiting host certificate") | ||
| 697 | return | ||
| 698 | } | ||
| 699 | } | ||
| 700 | |||
| 654 | // Serialized admission: compute quota under one lock. A refusal is | 701 | // Serialized admission: compute quota under one lock. A refusal is |
| 655 | // NON-TERMINAL — it returns before touching CreateAttempts, so once room | 702 | // NON-TERMINAL — it returns before touching CreateAttempts, so once room |
| 656 | // frees the next tick retries and boots. | 703 | // frees the next tick retries and boots. |
| @@ -716,8 +763,10 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 716 | SSHAuthorizedKey: d.SshAuthorizedKey, | 763 | SSHAuthorizedKey: d.SshAuthorizedKey, |
| 717 | UserData: d.CloudInit, | 764 | UserData: d.CloudInit, |
| 718 | SSHUserCAAuthorizedKey: joinCALines(d.GetSshUserCaAuthorizedKeys()), | 765 | SSHUserCAAuthorizedKey: joinCALines(d.GetSshUserCaAuthorizedKeys()), |
| 719 | SSHHostKeyPEM: d.SshHostKeyPem, | 766 | // Zero-valued unless this fleet has a CA, in which case it is the key |
| 720 | SSHHostCert: d.SshHostCert, | 767 | // generated above and held on this host. Nothing else can supply one. |
| 768 | SSHHostKeyPEM: hostKey.PrivatePEM, | ||
| 769 | SSHHostCert: d.SshHostCert, | ||
| 721 | }); err != nil { | 770 | }); err != nil { |
| 722 | e.failCreate(ctx, rec, err, res) | 771 | e.failCreate(ctx, rec, err, res) |
| 723 | return | 772 | return |
| @@ -919,6 +968,13 @@ func quarantinedEntry(rec state.Record, grace time.Duration) *pb.QuarantinedVM { | |||
| 919 | type vmResult struct { | 968 | type vmResult struct { |
| 920 | vm *pb.ActualVM | 969 | vm *pb.ActualVM |
| 921 | quarantined *pb.QuarantinedVM | 970 | quarantined *pb.QuarantinedVM |
| 971 | // hostPubKey rides every row this VM contributes rather than being passed | ||
| 972 | // to report at each of its call sites. The public key is level-triggered — | ||
| 973 | // it must be in EVERY report for as long as the VM exists, so that a lost | ||
| 974 | // snapshot or a control-plane restart re-certifies with no operator step — | ||
| 975 | // and stamping it once in merge is what makes that true without asking six | ||
| 976 | // call sites to remember. | ||
| 977 | hostPubKey string | ||
| 922 | } | 978 | } |
| 923 | 979 | ||
| 924 | // report records this VM's actual row. A VM contributes at most one row, so a | 980 | // report records this VM's actual row. A VM contributes at most one row, so a |
| @@ -945,6 +1001,7 @@ func (r vmResult) clone() vmResult { | |||
| 945 | // merge folds this VM's result into the host report. | 1001 | // merge folds this VM's result into the host report. |
| 946 | func (r *vmResult) merge(rep *pb.ActualStateReport) { | 1002 | func (r *vmResult) merge(rep *pb.ActualStateReport) { |
| 947 | if r.vm != nil { | 1003 | if r.vm != nil { |
| 1004 | r.vm.SshHostPubkey = r.hostPubKey | ||
| 948 | rep.Vms = append(rep.Vms, r.vm) | 1005 | rep.Vms = append(rep.Vms, r.vm) |
| 949 | } | 1006 | } |
| 950 | if r.quarantined != nil { | 1007 | if r.quarantined != nil { |
| @@ -952,8 +1009,9 @@ func (r *vmResult) merge(rep *pb.ActualStateReport) { | |||
| 952 | } | 1009 | } |
| 953 | } | 1010 | } |
| 954 | 1011 | ||
| 955 | // newActualVM builds one ActualVM row. pb.ActualVM has exactly these five | 1012 | // newActualVM builds one ActualVM row from what a reconcile pass observed. |
| 956 | // fields; unset values are the proto zero-value "". | 1013 | // The row's remaining field, ssh_host_pubkey, is stamped by merge — see |
| 1014 | // vmResult.hostPubKey. Unset values are the proto zero-value "". | ||
| 957 | func newActualVM(vmID, ip, power, phase, lastError string) *pb.ActualVM { | 1015 | func newActualVM(vmID, ip, power, phase, lastError string) *pb.ActualVM { |
| 958 | return &pb.ActualVM{ | 1016 | return &pb.ActualVM{ |
| 959 | VmId: vmID, | 1017 | VmId: vmID, |
internal/agent/reconcile/reconcile_test.go
| Old | New | ||
|---|---|---|---|
| @@ -186,21 +186,40 @@ func setup(t *testing.T) *fixture { | |||
| 186 | st, err := state.Open(t.TempDir()) | 186 | st, err := state.Open(t.TempDir()) |
| 187 | require.NoError(t, err) | 187 | require.NoError(t, err) |
| 188 | f := &fixture{st: st, prov: newFakeProv(), now: time.Unix(1_700_000_000, 0), boot: "boot-1"} | 188 | f := &fixture{st: st, prov: newFakeProv(), now: time.Unix(1_700_000_000, 0), boot: "boot-1"} |
| 189 | f.eng = &Engine{ | 189 | f.eng = f.newEngine() |
| 190 | St: st, | 190 | t.Cleanup(f.eng.Stop) |
| 191 | return f | ||
| 192 | } | ||
| 193 | |||
| 194 | // newEngine builds an Engine over this fixture's state dir and backend. setup | ||
| 195 | // calls it once; restart calls it again, which is the only way to model an | ||
| 196 | // agent coming back — an Engine is terminal once stopped, exactly like the | ||
| 197 | // process it lives in. | ||
| 198 | func (f *fixture) newEngine() *Engine { | ||
| 199 | return &Engine{ | ||
| 200 | St: f.st, | ||
| 191 | Prov: f.prov, | 201 | Prov: f.prov, |
| 192 | Images: func(ctx context.Context, url, sha string) (string, error) { | 202 | Images: func(ctx context.Context, url, sha string) (string, error) { |
| 193 | return "/cache/" + sha + ".raw", nil | 203 | return "/cache/" + sha + ".raw", nil |
| 194 | }, | 204 | }, |
| 195 | Seed: func(out string, p seed.Params) error { return nil }, | 205 | Seed: func(out string, p seed.Params) error { return nil }, |
| 206 | HostKey: state.LoadOrCreateHostKey, | ||
| 196 | BootID: func() string { return f.boot }, | 207 | BootID: func() string { return f.boot }, |
| 197 | Now: func() time.Time { return f.now }, | 208 | Now: func() time.Time { return f.now }, |
| 198 | TombstoneGrace: 5 * time.Minute, | 209 | TombstoneGrace: 5 * time.Minute, |
| 199 | VanishGrace: time.Hour, | 210 | VanishGrace: time.Hour, |
| 200 | MaxCreateAttempts: 3, | 211 | MaxCreateAttempts: 3, |
| 201 | } | 212 | } |
| 213 | } | ||
| 214 | |||
| 215 | // restart drops the running engine and builds a fresh one over the same state | ||
| 216 | // directory, the way an agent restart does: nothing survives but what is on | ||
| 217 | // disk. | ||
| 218 | func (f *fixture) restart(t *testing.T) { | ||
| 219 | t.Helper() | ||
| 220 | f.eng.Stop() | ||
| 221 | f.eng = f.newEngine() | ||
| 202 | t.Cleanup(f.eng.Stop) | 222 | t.Cleanup(f.eng.Stop) |
| 203 | return f | ||
| 204 | } | 223 | } |
| 205 | 224 | ||
| 206 | // step drives ONE reconcile tick to completion: dispatch, wait for every | 225 | // step drives ONE reconcile tick to completion: dispatch, wait for every |
internal/agent/run/cli.go
| Old | New | ||
|---|---|---|---|
| @@ -261,6 +261,7 @@ func serve(st *state.Store, cfg Config) error { | |||
| 261 | Prov: prov, | 261 | Prov: prov, |
| 262 | Images: cache.Ensure, | 262 | Images: cache.Ensure, |
| 263 | Seed: seed.Build, | 263 | Seed: seed.Build, |
| 264 | HostKey: state.LoadOrCreateHostKey, | ||
| 264 | BootID: hostinfo.BootID, | 265 | BootID: hostinfo.BootID, |
| 265 | Now: time.Now, | 266 | Now: time.Now, |
| 266 | TombstoneGrace: cfg.TombstoneGrace, | 267 | TombstoneGrace: cfg.TombstoneGrace, |
internal/agent/state/hostkey.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,67 @@ | |||
| 1 | package state | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/ed25519" | ||
| 5 | "crypto/rand" | ||
| 6 | "encoding/pem" | ||
| 7 | "fmt" | ||
| 8 | "os" | ||
| 9 | "path/filepath" | ||
| 10 | "strings" | ||
| 11 | |||
| 12 | "golang.org/x/crypto/ssh" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // HostKey is a guest's SSH host key as it lives on its host: the private half | ||
| 16 | // in OpenSSH PEM form, which is written into that guest's seed and goes | ||
| 17 | // nowhere else, and the public half as an authorized_keys line, which is the | ||
| 18 | // only half that ever travels. | ||
| 19 | type HostKey struct { | ||
| 20 | PublicLine string // authorized_keys form, newline-trimmed | ||
| 21 | PrivatePEM string // OpenSSH PEM | ||
| 22 | } | ||
| 23 | |||
| 24 | // LoadOrCreateHostKey returns the ed25519 host key stored at path, generating | ||
| 25 | // and persisting one (0600) when there is none there yet. | ||
| 26 | // | ||
| 27 | // Load-or-create rather than create is load-bearing, not convenience: between | ||
| 28 | // generating a key and receiving the certificate for it, the agent may restart | ||
| 29 | // any number of times. It must come back holding the same key, or the | ||
| 30 | // certificate the control plane signs is for a key nothing on this host has. | ||
| 31 | func LoadOrCreateHostKey(path string) (HostKey, error) { | ||
| 32 | raw, err := os.ReadFile(path) | ||
| 33 | if err == nil { | ||
| 34 | return parseHostKey(raw) | ||
| 35 | } | ||
| 36 | if !os.IsNotExist(err) { | ||
| 37 | return HostKey{}, err | ||
| 38 | } | ||
| 39 | |||
| 40 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 41 | if err != nil { | ||
| 42 | return HostKey{}, err | ||
| 43 | } | ||
| 44 | block, err := ssh.MarshalPrivateKey(priv, "") | ||
| 45 | if err != nil { | ||
| 46 | return HostKey{}, err | ||
| 47 | } | ||
| 48 | encoded := pem.EncodeToMemory(block) | ||
| 49 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | ||
| 50 | return HostKey{}, err | ||
| 51 | } | ||
| 52 | if err := atomicWriteMode(path, encoded, 0o600); err != nil { | ||
| 53 | return HostKey{}, err | ||
| 54 | } | ||
| 55 | return parseHostKey(encoded) | ||
| 56 | } | ||
| 57 | |||
| 58 | // parseHostKey derives both halves from a stored PEM, so a freshly generated | ||
| 59 | // key and a reloaded one are described by exactly the same code. | ||
| 60 | func parseHostKey(pemBytes []byte) (HostKey, error) { | ||
| 61 | signer, err := ssh.ParsePrivateKey(pemBytes) | ||
| 62 | if err != nil { | ||
| 63 | return HostKey{}, fmt.Errorf("parse host key: %w", err) | ||
| 64 | } | ||
| 65 | line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))) | ||
| 66 | return HostKey{PublicLine: line, PrivatePEM: string(pemBytes)}, nil | ||
| 67 | } | ||
internal/agent/state/hostkey_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,78 @@ | |||
| 1 | package state | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestLoadOrCreateHostKeyIsStableAndPrivate(t *testing.T) { | ||
| 13 | s, err := Open(t.TempDir()) | ||
| 14 | require.NoError(t, err) | ||
| 15 | path := s.HostKeyPath("vm1") | ||
| 16 | |||
| 17 | first, err := LoadOrCreateHostKey(path) | ||
| 18 | require.NoError(t, err) | ||
| 19 | assert.Contains(t, first.PrivatePEM, "OPENSSH PRIVATE KEY") | ||
| 20 | assert.True(t, strings.HasPrefix(first.PublicLine, "ssh-ed25519 "), "got %q", first.PublicLine) | ||
| 21 | assert.NotContains(t, first.PublicLine, "\n", "an authorized_keys line is one line") | ||
| 22 | |||
| 23 | fi, err := os.Stat(path) | ||
| 24 | require.NoError(t, err) | ||
| 25 | assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | ||
| 26 | |||
| 27 | // Load-or-create: the second call must return the key the first one wrote, | ||
| 28 | // or a certificate signed for the first key would be for nothing. | ||
| 29 | second, err := LoadOrCreateHostKey(path) | ||
| 30 | require.NoError(t, err) | ||
| 31 | assert.Equal(t, first, second) | ||
| 32 | } | ||
| 33 | |||
| 34 | func TestLoadOrCreateHostKeyIsPerVM(t *testing.T) { | ||
| 35 | s, err := Open(t.TempDir()) | ||
| 36 | require.NoError(t, err) | ||
| 37 | a, err := LoadOrCreateHostKey(s.HostKeyPath("vm1")) | ||
| 38 | require.NoError(t, err) | ||
| 39 | b, err := LoadOrCreateHostKey(s.HostKeyPath("vm2")) | ||
| 40 | require.NoError(t, err) | ||
| 41 | assert.NotEqual(t, a.PublicLine, b.PublicLine) | ||
| 42 | } | ||
| 43 | |||
| 44 | func TestLoadOrCreateHostKeyRefusesGarbage(t *testing.T) { | ||
| 45 | s, err := Open(t.TempDir()) | ||
| 46 | require.NoError(t, err) | ||
| 47 | path := s.HostKeyPath("vm1") | ||
| 48 | require.NoError(t, os.MkdirAll(s.VMDir("vm1"), 0o700)) | ||
| 49 | require.NoError(t, os.WriteFile(path, []byte("not a key"), 0o600)) | ||
| 50 | |||
| 51 | _, err = LoadOrCreateHostKey(path) | ||
| 52 | require.Error(t, err, "an unreadable key must be reported, never silently replaced") | ||
| 53 | } | ||
| 54 | |||
| 55 | func TestDeleteVMTakesTheHostKeyWithIt(t *testing.T) { | ||
| 56 | s, err := Open(t.TempDir()) | ||
| 57 | require.NoError(t, err) | ||
| 58 | require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}})) | ||
| 59 | _, err = LoadOrCreateHostKey(s.HostKeyPath("vm1")) | ||
| 60 | require.NoError(t, err) | ||
| 61 | |||
| 62 | require.NoError(t, s.DeleteVM("vm1")) | ||
| 63 | _, err = os.Stat(s.HostKeyPath("vm1")) | ||
| 64 | assert.True(t, os.IsNotExist(err)) | ||
| 65 | } | ||
| 66 | |||
| 67 | func TestHostPubKeyRoundTripsThroughARecord(t *testing.T) { | ||
| 68 | s, err := Open(t.TempDir()) | ||
| 69 | require.NoError(t, err) | ||
| 70 | hk, err := LoadOrCreateHostKey(s.HostKeyPath("vm1")) | ||
| 71 | require.NoError(t, err) | ||
| 72 | require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}, HostPubKey: hk.PublicLine})) | ||
| 73 | |||
| 74 | rec, ok, err := s.Get("vm1") | ||
| 75 | require.NoError(t, err) | ||
| 76 | require.True(t, ok) | ||
| 77 | assert.Equal(t, hk.PublicLine, rec.HostPubKey) | ||
| 78 | } | ||
internal/agent/state/state.go
| Old | New | ||
|---|---|---|---|
| @@ -38,6 +38,11 @@ type Record struct { | |||
| 38 | CreateAttempts int // bounded retry before terminal failed (spec) | 38 | CreateAttempts int // bounded retry before terminal failed (spec) |
| 39 | LastError string | 39 | LastError string |
| 40 | CreatedAt time.Time | 40 | CreatedAt time.Time |
| 41 | // HostPubKey is the public half of the guest's SSH host key, as an | ||
| 42 | // authorized_keys line. The private half is a file in this VM's directory | ||
| 43 | // and never leaves the host; this is what the agent reports upward so the | ||
| 44 | // control plane can certify it. Empty until the key has been generated. | ||
| 45 | HostPubKey string | ||
| 41 | } | 46 | } |
| 42 | 47 | ||
| 43 | type Identity struct { | 48 | type Identity struct { |
| @@ -71,6 +76,12 @@ func (s *Store) DiskPath(vmID string) string { return filepath.Join(s.VMDir(vmID | |||
| 71 | // SeedPath returns the path of the VM's cloud-init seed ISO. | 76 | // SeedPath returns the path of the VM's cloud-init seed ISO. |
| 72 | func (s *Store) SeedPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "seed.iso") } | 77 | func (s *Store) SeedPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "seed.iso") } |
| 73 | 78 | ||
| 79 | // HostKeyPath returns the path of the VM's SSH host private key. It lives | ||
| 80 | // under the VM directory, so DeleteVM takes it with everything else. | ||
| 81 | func (s *Store) HostKeyPath(vmID string) string { | ||
| 82 | return filepath.Join(s.VMDir(vmID), "ssh_host_ed25519_key") | ||
| 83 | } | ||
| 84 | |||
| 74 | // SocketPath returns the path of the VM's cloud-hypervisor API socket. | 85 | // SocketPath returns the path of the VM's cloud-hypervisor API socket. |
| 75 | func (s *Store) SocketPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "ch.sock") } | 86 | func (s *Store) SocketPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "ch.sock") } |
| 76 | 87 | ||
| @@ -99,12 +110,24 @@ func MAC(vmID string) string { | |||
| 99 | // atomicWrite writes data to path using a tmp file + rename so that readers | 110 | // atomicWrite writes data to path using a tmp file + rename so that readers |
| 100 | // never see a partial write. | 111 | // never see a partial write. |
| 101 | func atomicWrite(path string, data []byte) error { | 112 | func atomicWrite(path string, data []byte) error { |
| 113 | return atomicWriteMode(path, data, 0o600) | ||
| 114 | } | ||
| 115 | |||
| 116 | // atomicWriteMode is atomicWrite with the resulting file's permissions stated | ||
| 117 | // outright. os.CreateTemp already opens 0600, but a file holding a private key | ||
| 118 | // should not owe its permissions to a helper's default. | ||
| 119 | func atomicWriteMode(path string, data []byte, perm os.FileMode) error { | ||
| 102 | dir := filepath.Dir(path) | 120 | dir := filepath.Dir(path) |
| 103 | f, err := os.CreateTemp(dir, ".tmp-") | 121 | f, err := os.CreateTemp(dir, ".tmp-") |
| 104 | if err != nil { | 122 | if err != nil { |
| 105 | return err | 123 | return err |
| 106 | } | 124 | } |
| 107 | tmpName := f.Name() | 125 | tmpName := f.Name() |
| 126 | if err := f.Chmod(perm); err != nil { | ||
| 127 | f.Close() | ||
| 128 | os.Remove(tmpName) | ||
| 129 | return err | ||
| 130 | } | ||
| 108 | if _, err := f.Write(data); err != nil { | 131 | if _, err := f.Write(data); err != nil { |
| 109 | f.Close() | 132 | f.Close() |
| 110 | os.Remove(tmpName) | 133 | os.Remove(tmpName) |
internal/agent/syncclient/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -217,8 +217,9 @@ func newClient(t *testing.T, addr, fp, hostID, cred string) *Client { | |||
| 217 | require.NoError(t, agentSt.SaveIdentity(id)) | 217 | require.NoError(t, agentSt.SaveIdentity(id)) |
| 218 | engine := &reconcile.Engine{ | 218 | engine := &reconcile.Engine{ |
| 219 | St: agentSt, Prov: noopProv{}, | 219 | St: agentSt, Prov: noopProv{}, |
| 220 | Images: func(context.Context, string, string) (string, error) { return "/x.raw", nil }, | 220 | Images: func(context.Context, string, string) (string, error) { return "/x.raw", nil }, |
| 221 | Seed: func(string, seed.Params) error { return nil }, | 221 | Seed: func(string, seed.Params) error { return nil }, |
| 222 | HostKey: state.LoadOrCreateHostKey, | ||
| 222 | // CIDR/grace not exercised by these tests. | 223 | // CIDR/grace not exercised by these tests. |
| 223 | BootID: func() string { return "boot-test" }, Now: time.Now, | 224 | BootID: func() string { return "boot-test" }, Now: time.Now, |
| 224 | TombstoneGrace: time.Hour, VanishGrace: time.Hour, MaxCreateAttempts: 3, | 225 | TombstoneGrace: time.Hour, VanishGrace: time.Hour, MaxCreateAttempts: 3, |
internal/agent/syncclient/hostcert_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,77 @@ | |||
| 1 | package syncclient | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | |||
| 8 | "github.com/a73x/eitri/internal/server/sshca" | ||
| 9 | "github.com/a73x/eitri/internal/server/store" | ||
| 10 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | ||
| 12 | "golang.org/x/crypto/ssh" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // gateSigner is the control plane's half of the exchange: it signs whatever | ||
| 16 | // public key a host reports, for whatever principal the control plane chose. | ||
| 17 | type gateSigner struct{ ca ssh.Signer } | ||
| 18 | |||
| 19 | func (g gateSigner) SignHostCert(pub ssh.PublicKey, principal string) (string, error) { | ||
| 20 | cert, err := sshca.SignHostCert(g.ca, pub, []string{principal}, principal, time.Now(), sshca.HostCertTTL) | ||
| 21 | if err != nil { | ||
| 22 | return "", err | ||
| 23 | } | ||
| 24 | return string(ssh.MarshalAuthorizedKey(cert)), nil | ||
| 25 | } | ||
| 26 | |||
| 27 | // TestGuestHostKeyRoundTrip drives the whole exchange over a real QUIC sync | ||
| 28 | // connection: the host generates a key, reports the public half, the control | ||
| 29 | // plane signs it for the name on the VM's row, and the certificate comes back | ||
| 30 | // down. The private half is never asked for and never sent. | ||
| 31 | func TestGuestHostKeyRoundTrip(t *testing.T) { | ||
| 32 | h := newServerHarness(t) | ||
| 33 | _, caSigner, err := sshca.GenerateHostKey() | ||
| 34 | require.NoError(t, err) | ||
| 35 | h.svc.SetHostCertSigner(gateSigner{ca: caSigner}) | ||
| 36 | |||
| 37 | hostID, cred := h.enroll() | ||
| 38 | c := newClient(t, h.addr, h.fp, hostID, cred) | ||
| 39 | require.NoError(t, h.st.CreateVM(store.VM{ID: "vm1", HostID: hostID, Name: "a", | ||
| 40 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "stopped"})) | ||
| 41 | |||
| 42 | go c.Run(t.Context()) | ||
| 43 | |||
| 44 | var vm store.VM | ||
| 45 | require.Eventually(t, func() bool { | ||
| 46 | vm, err = h.st.GetVM("vm1") | ||
| 47 | return err == nil && vm.SSHHostCert != "" | ||
| 48 | }, 10*time.Second, 100*time.Millisecond, "the control plane should certify the key its host reported") | ||
| 49 | |||
| 50 | // The certificate is for the key that stayed on the host, under the name | ||
| 51 | // the control plane derived from the row. | ||
| 52 | priv, err := os.ReadFile(c.St.HostKeyPath("vm1")) | ||
| 53 | require.NoError(t, err) | ||
| 54 | hostSigner, err := ssh.ParsePrivateKey(priv) | ||
| 55 | require.NoError(t, err) | ||
| 56 | |||
| 57 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(vm.SSHHostCert)) | ||
| 58 | require.NoError(t, err) | ||
| 59 | cert, ok := pk.(*ssh.Certificate) | ||
| 60 | require.True(t, ok) | ||
| 61 | assert.Equal(t, uint32(ssh.HostCert), cert.CertType) | ||
| 62 | assert.Equal(t, []string{testTenant + ".a"}, cert.ValidPrincipals) | ||
| 63 | assert.Equal(t, hostSigner.PublicKey().Marshal(), cert.Key.Marshal(), | ||
| 64 | "the certificate must be for the key this host holds") | ||
| 65 | |||
| 66 | // The stored public key is the reported one, and nothing resembling a | ||
| 67 | // private key was persisted alongside it. | ||
| 68 | assert.Equal(t, string(ssh.MarshalAuthorizedKey(hostSigner.PublicKey())), vm.SSHHostPubKey+"\n") | ||
| 69 | assert.NotContains(t, vm.SSHHostPubKey, "PRIVATE") | ||
| 70 | |||
| 71 | // Once certified, the VM completes its create — it was held at the gate | ||
| 72 | // until the certificate arrived. | ||
| 73 | require.Eventually(t, func() bool { | ||
| 74 | v, err := h.st.GetVM("vm1") | ||
| 75 | return err == nil && v.Status == "ready" | ||
| 76 | }, 10*time.Second, 100*time.Millisecond, "the guest should boot once its certificate lands") | ||
| 77 | } | ||
internal/pb/sync.pb.go
| Old | New | ||
|---|---|---|---|
| @@ -599,12 +599,17 @@ func (x *HostMetrics) GetDiskFreeGb() int64 { | |||
| 599 | } | 599 | } |
| 600 | 600 | ||
| 601 | type ActualVM struct { | 601 | type ActualVM struct { |
| 602 | state protoimpl.MessageState `protogen:"open.v1"` | 602 | state protoimpl.MessageState `protogen:"open.v1"` |
| 603 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | 603 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` |
| 604 | Power string `protobuf:"bytes,2,opt,name=power,proto3" json:"power,omitempty"` // "running"|"stopped" | 604 | Power string `protobuf:"bytes,2,opt,name=power,proto3" json:"power,omitempty"` // "running"|"stopped" |
| 605 | Phase string `protobuf:"bytes,3,opt,name=phase,proto3" json:"phase,omitempty"` // "creating"|"ready"|"failed"|"quarantined" | 605 | Phase string `protobuf:"bytes,3,opt,name=phase,proto3" json:"phase,omitempty"` // "creating"|"ready"|"failed"|"quarantined" |
| 606 | Ip string `protobuf:"bytes,4,opt,name=ip,proto3" json:"ip,omitempty"` // the address this guest has, however its host came by it | 606 | Ip string `protobuf:"bytes,4,opt,name=ip,proto3" json:"ip,omitempty"` // the address this guest has, however its host came by it |
| 607 | LastError string `protobuf:"bytes,5,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` | 607 | LastError string `protobuf:"bytes,5,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` |
| 608 | // The guest's ed25519 HOST public key. It is generated on the host, and the | ||
| 609 | // private half never leaves it — this is the only half that travels. Sent on | ||
| 610 | // every report for as long as the VM exists (level-triggered), so a lost | ||
| 611 | // snapshot or a control-plane restart re-certifies without operator action. | ||
| 612 | SshHostPubkey string `protobuf:"bytes,6,opt,name=ssh_host_pubkey,json=sshHostPubkey,proto3" json:"ssh_host_pubkey,omitempty"` | ||
| 608 | unknownFields protoimpl.UnknownFields | 613 | unknownFields protoimpl.UnknownFields |
| 609 | sizeCache protoimpl.SizeCache | 614 | sizeCache protoimpl.SizeCache |
| 610 | } | 615 | } |
| @@ -674,6 +679,13 @@ func (x *ActualVM) GetLastError() string { | |||
| 674 | return "" | 679 | return "" |
| 675 | } | 680 | } |
| 676 | 681 | ||
| 682 | func (x *ActualVM) GetSshHostPubkey() string { | ||
| 683 | if x != nil { | ||
| 684 | return x.SshHostPubkey | ||
| 685 | } | ||
| 686 | return "" | ||
| 687 | } | ||
| 688 | |||
| 677 | type QuarantinedVM struct { | 689 | type QuarantinedVM struct { |
| 678 | state protoimpl.MessageState `protogen:"open.v1"` | 690 | state protoimpl.MessageState `protogen:"open.v1"` |
| 679 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | 691 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` |
| @@ -875,24 +887,33 @@ func (x *ActualStateReport) GetHostUplinkAddr() string { | |||
| 875 | } | 887 | } |
| 876 | 888 | ||
| 877 | type VMDesired struct { | 889 | type VMDesired struct { |
| 878 | state protoimpl.MessageState `protogen:"open.v1"` | 890 | state protoimpl.MessageState `protogen:"open.v1"` |
| 879 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | 891 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` |
| 880 | Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` | 892 | Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` |
| 881 | ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` | 893 | ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` |
| 882 | ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"` | 894 | ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"` |
| 883 | CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty | 895 | CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty |
| 884 | Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"` | 896 | Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"` |
| 885 | MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"` | 897 | MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"` |
| 886 | DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"` | 898 | DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"` |
| 887 | Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"` | 899 | Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"` |
| 888 | PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped" | 900 | PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped" |
| 889 | Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[]) | 901 | Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[]) |
| 890 | SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"` | 902 | SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"` |
| 891 | SshHostKeyPem string `protobuf:"bytes,16,opt,name=ssh_host_key_pem,json=sshHostKeyPem,proto3" json:"ssh_host_key_pem,omitempty"` // the VM's persistent ed25519 host private key (OpenSSH PEM); seed installs it as /etc/ssh/ssh_host_ed25519_key. WRITE-ONLY key material. Empty when the jump gate is off. | 903 | // The certificate the control plane signed for the public key the host |
| 892 | SshHostCert string `protobuf:"bytes,17,opt,name=ssh_host_cert,json=sshHostCert,proto3" json:"ssh_host_cert,omitempty"` // the VM's CA-signed host cert (authorized_keys form); seed installs it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty when the jump gate is off. | 904 | // reported in ActualVM.ssh_host_pubkey (authorized_keys form); seed installs |
| 893 | SshUserCaAuthorizedKeys []string `protobuf:"bytes,18,rep,name=ssh_user_ca_authorized_keys,json=sshUserCaAuthorizedKeys,proto3" json:"ssh_user_ca_authorized_keys,omitempty"` // the VM's tenant user-CA set (canonical authorized_keys lines); seed writes them all into TrustedUserCAKeys. Empty when the gate is off / tenant has none. | 905 | // it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty until the round trip |
| 894 | unknownFields protoimpl.UnknownFields | 906 | // completes, and forever when the jump gate is off. |
| 895 | sizeCache protoimpl.SizeCache | 907 | SshHostCert string `protobuf:"bytes,17,opt,name=ssh_host_cert,json=sshHostCert,proto3" json:"ssh_host_cert,omitempty"` |
| 908 | SshUserCaAuthorizedKeys []string `protobuf:"bytes,18,rep,name=ssh_user_ca_authorized_keys,json=sshUserCaAuthorizedKeys,proto3" json:"ssh_user_ca_authorized_keys,omitempty"` // the VM's tenant user-CA set (canonical authorized_keys lines); seed writes them all into TrustedUserCAKeys. Empty when the gate is off / tenant has none. | ||
| 909 | // The fleet has an SSH CA, so this guest must present a certified host key. | ||
| 910 | // A host that has reported its public key waits here until the certificate | ||
| 911 | // arrives rather than booting a guest that clients would refuse. Without it | ||
| 912 | // the agent could not tell "the gate is off, boot uncertified" from "your | ||
| 913 | // certificate has not come back yet" — both are an empty ssh_host_cert. | ||
| 914 | HostCertRequired bool `protobuf:"varint,19,opt,name=host_cert_required,json=hostCertRequired,proto3" json:"host_cert_required,omitempty"` | ||
| 915 | unknownFields protoimpl.UnknownFields | ||
| 916 | sizeCache protoimpl.SizeCache | ||
| 896 | } | 917 | } |
| 897 | 918 | ||
| 898 | func (x *VMDesired) Reset() { | 919 | func (x *VMDesired) Reset() { |
| @@ -1009,13 +1030,6 @@ func (x *VMDesired) GetSshAuthorizedKey() string { | |||
| 1009 | return "" | 1030 | return "" |
| 1010 | } | 1031 | } |
| 1011 | 1032 | ||
| 1012 | func (x *VMDesired) GetSshHostKeyPem() string { | ||
| 1013 | if x != nil { | ||
| 1014 | return x.SshHostKeyPem | ||
| 1015 | } | ||
| 1016 | return "" | ||
| 1017 | } | ||
| 1018 | |||
| 1019 | func (x *VMDesired) GetSshHostCert() string { | 1033 | func (x *VMDesired) GetSshHostCert() string { |
| 1020 | if x != nil { | 1034 | if x != nil { |
| 1021 | return x.SshHostCert | 1035 | return x.SshHostCert |
| @@ -1030,6 +1044,13 @@ func (x *VMDesired) GetSshUserCaAuthorizedKeys() []string { | |||
| 1030 | return nil | 1044 | return nil |
| 1031 | } | 1045 | } |
| 1032 | 1046 | ||
| 1047 | func (x *VMDesired) GetHostCertRequired() bool { | ||
| 1048 | if x != nil { | ||
| 1049 | return x.HostCertRequired | ||
| 1050 | } | ||
| 1051 | return false | ||
| 1052 | } | ||
| 1053 | |||
| 1033 | type DesiredStateSnapshot struct { | 1054 | type DesiredStateSnapshot struct { |
| 1034 | state protoimpl.MessageState `protogen:"open.v1"` | 1055 | state protoimpl.MessageState `protogen:"open.v1"` |
| 1035 | Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen | 1056 | Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen |
| @@ -1573,14 +1594,15 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1573 | "\fdisk_used_gb\x18\a \x01(\x03R\n" + | 1594 | "\fdisk_used_gb\x18\a \x01(\x03R\n" + |
| 1574 | "diskUsedGb\x12 \n" + | 1595 | "diskUsedGb\x12 \n" + |
| 1575 | "\fdisk_free_gb\x18\b \x01(\x03R\n" + | 1596 | "\fdisk_free_gb\x18\b \x01(\x03R\n" + |
| 1576 | "diskFreeGb\"z\n" + | 1597 | "diskFreeGb\"\xa2\x01\n" + |
| 1577 | "\bActualVM\x12\x13\n" + | 1598 | "\bActualVM\x12\x13\n" + |
| 1578 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x14\n" + | 1599 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x14\n" + |
| 1579 | "\x05power\x18\x02 \x01(\tR\x05power\x12\x14\n" + | 1600 | "\x05power\x18\x02 \x01(\tR\x05power\x12\x14\n" + |
| 1580 | "\x05phase\x18\x03 \x01(\tR\x05phase\x12\x0e\n" + | 1601 | "\x05phase\x18\x03 \x01(\tR\x05phase\x12\x0e\n" + |
| 1581 | "\x02ip\x18\x04 \x01(\tR\x02ip\x12\x1d\n" + | 1602 | "\x02ip\x18\x04 \x01(\tR\x02ip\x12\x1d\n" + |
| 1582 | "\n" + | 1603 | "\n" + |
| 1583 | "last_error\x18\x05 \x01(\tR\tlastError\"\x81\x01\n" + | 1604 | "last_error\x18\x05 \x01(\tR\tlastError\x12&\n" + |
| 1605 | "\x0fssh_host_pubkey\x18\x06 \x01(\tR\rsshHostPubkey\"\x81\x01\n" + | ||
| 1584 | "\rQuarantinedVM\x12\x13\n" + | 1606 | "\rQuarantinedVM\x12\x13\n" + |
| 1585 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + | 1607 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + |
| 1586 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + | 1608 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + |
| @@ -1599,7 +1621,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1599 | "guest_cidr\x18\b \x01(\tR\tguestCidr\x126\n" + | 1621 | "guest_cidr\x18\b \x01(\tR\tguestCidr\x126\n" + |
| 1600 | "\texposures\x18\t \x03(\v2\x18.eitri.v1.ExposureActualR\texposures\x12(\n" + | 1622 | "\texposures\x18\t \x03(\v2\x18.eitri.v1.ExposureActualR\texposures\x12(\n" + |
| 1601 | "\x10host_uplink_addr\x18\n" + | 1623 | "\x10host_uplink_addr\x18\n" + |
| 1602 | " \x01(\tR\x0ehostUplinkAddr\"\x85\x04\n" + | 1624 | " \x01(\tR\x0ehostUplinkAddr\"\xa2\x04\n" + |
| 1603 | "\tVMDesired\x12\x13\n" + | 1625 | "\tVMDesired\x12\x13\n" + |
| 1604 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + | 1626 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + |
| 1605 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + | 1627 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + |
| @@ -1619,10 +1641,10 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1619 | "\n" + | 1641 | "\n" + |
| 1620 | "tombstoned\x18\v \x01(\bR\n" + | 1642 | "tombstoned\x18\v \x01(\bR\n" + |
| 1621 | "tombstoned\x12,\n" + | 1643 | "tombstoned\x12,\n" + |
| 1622 | "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12'\n" + | 1644 | "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12\"\n" + |
| 1623 | "\x10ssh_host_key_pem\x18\x10 \x01(\tR\rsshHostKeyPem\x12\"\n" + | ||
| 1624 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + | 1645 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + |
| 1625 | "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeysJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10\"\xc9\x01\n" + | 1646 | "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeys\x12,\n" + |
| 1647 | "\x12host_cert_required\x18\x13 \x01(\bR\x10hostCertRequiredJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10J\x04\b\x10\x10\x11R\x10ssh_host_key_pem\"\xc9\x01\n" + | ||
| 1626 | "\x14DesiredStateSnapshot\x12\x14\n" + | 1648 | "\x14DesiredStateSnapshot\x12\x14\n" + |
| 1627 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" + | 1649 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" + |
| 1628 | "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\x12;\n" + | 1650 | "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\x12;\n" + |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -70,20 +70,19 @@ type AgentUpgrader interface { | |||
| 70 | 70 | ||
| 71 | // API is the HTTP handler container. | 71 | // API is the HTTP handler container. |
| 72 | type API struct { | 72 | type API struct { |
| 73 | cfg Config | 73 | cfg Config |
| 74 | st *store.Store | 74 | st *store.Store |
| 75 | reg *registry.Registry | 75 | reg *registry.Registry |
| 76 | hub *hub.Hub | 76 | hub *hub.Hub |
| 77 | notif *notifier | 77 | notif *notifier |
| 78 | enrolls *ipLimiter // per-client-bucket brake on the unauthenticated enroll endpoint (v4: address, v6: /64) | 78 | enrolls *ipLimiter // per-client-bucket brake on the unauthenticated enroll endpoint (v4: address, v6: /64) |
| 79 | tickets *ticketStore // one-time SSE stream tickets | 79 | tickets *ticketStore // one-time SSE stream tickets |
| 80 | snap *snapshotHub // central SSE snapshot: one marshal fanned to all clients | 80 | snap *snapshotHub // central SSE snapshot: one marshal fanned to all clients |
| 81 | console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) | 81 | console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) |
| 82 | hostCerts HostCertMinter // nil until main wires the SSH host CA (SetHostCertMinter); nil ⇒ gate off | 82 | sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off |
| 83 | sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off | 83 | release ReleaseSource // nil ⇒ release discovery disabled |
| 84 | release ReleaseSource // nil ⇒ release discovery disabled | 84 | upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader) |
| 85 | upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader) | 85 | auth *authFlow // OIDC sign-in relying party (mounted via AuthHandler, outside /api/) |
| 86 | auth *authFlow // OIDC sign-in relying party (mounted via AuthHandler, outside /api/) | ||
| 87 | } | 86 | } |
| 88 | 87 | ||
| 89 | // SetReleaseSource wires release discovery (nil leaves it disabled). | 88 | // SetReleaseSource wires release discovery (nil leaves it disabled). |
| @@ -808,24 +807,11 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 808 | PowerState: req.PowerState, | 807 | PowerState: req.PowerState, |
| 809 | } | 808 | } |
| 810 | 809 | ||
| 811 | // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed | 810 | // The row carries no host key. A guest's host key is generated by the host |
| 812 | // host cert once at create, so the VM presents a verifiable host key clients | 811 | // that runs it and never leaves that machine; the host reports the public |
| 813 | // accept via `@cert-authority` — no TOFU, no host-key-changed warnings when | 812 | // half on its next report and the control plane signs a certificate for it |
| 814 | // names/IPs recycle. The private key is WRITE-ONLY: stored, shipped to the | 813 | // (see syncsvc.signAndRecordHostCert). Creating a VM therefore involves no |
| 815 | // guest via seed, never echoed or logged. | 814 | // key material at all, which is why there is nothing here to guard. |
| 816 | // | ||
| 817 | // The cert principal is <tenant>.<name>: under (tenant,name) uniqueness a bare | ||
| 818 | // name is no longer unique across tenants (spec §F6), so clients dial and | ||
| 819 | // verify VMs by their namespaced connect name. | ||
| 820 | if a.hostCerts != nil { | ||
| 821 | keyPEM, cert, err := a.hostCerts.MintHostCert(host.Tenant + "." + req.Name) | ||
| 822 | if err != nil { | ||
| 823 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 824 | return | ||
| 825 | } | ||
| 826 | vm.SSHHostKey = keyPEM | ||
| 827 | vm.SSHHostCert = cert | ||
| 828 | } | ||
| 829 | 815 | ||
| 830 | if err := a.st.CreateVM(vm); err != nil { | 816 | if err := a.st.CreateVM(vm); err != nil { |
| 831 | switch { | 817 | switch { |
internal/server/api/sshcert.go
| Old | New | ||
|---|---|---|---|
| @@ -3,28 +3,12 @@ package api | |||
| 3 | import ( | 3 | import ( |
| 4 | "net/http" | 4 | "net/http" |
| 5 | "strconv" | 5 | "strconv" |
| 6 | "time" | ||
| 7 | 6 | ||
| 8 | "github.com/a73x/eitri/internal/server/api/types" | 7 | "github.com/a73x/eitri/internal/server/api/types" |
| 9 | "github.com/a73x/eitri/internal/server/sshca" | 8 | "github.com/a73x/eitri/internal/server/sshca" |
| 10 | "golang.org/x/crypto/ssh" | 9 | "golang.org/x/crypto/ssh" |
| 11 | ) | 10 | ) |
| 12 | 11 | ||
| 13 | // HostCertMinter generates and signs a per-VM SSH HOST key + cert at VM | ||
| 14 | // create. The concrete implementation is *HostMinter, wired by main via | ||
| 15 | // SetHostCertMinter when the jump gate is enabled. Nil ⇒ the gate is off and | ||
| 16 | // VMs get no host cert (unchanged TOFU behaviour). | ||
| 17 | type HostCertMinter interface { | ||
| 18 | // MintHostCert returns a fresh host private key (OpenSSH PEM, WRITE-ONLY key | ||
| 19 | // material) and a CA-signed host cert (authorized_keys form) whose sole | ||
| 20 | // principal is the VM name a client dials. | ||
| 21 | MintHostCert(principal string) (keyPEM, cert string, err error) | ||
| 22 | } | ||
| 23 | |||
| 24 | // SetHostCertMinter wires the per-VM SSH host-cert minter. Called once by main | ||
| 25 | // when the jump gate is enabled; a nil minter leaves VMs without host certs. | ||
| 26 | func (a *API) SetHostCertMinter(m HostCertMinter) { a.hostCerts = m } | ||
| 27 | |||
| 28 | // SetSSHCAAuthorizedKey publishes the eitri CA public key (authorized_keys / | 12 | // SetSSHCAAuthorizedKey publishes the eitri CA public key (authorized_keys / |
| 29 | // known_hosts form) served by GET /api/v1/ssh-ca. Called once by main when the | 13 | // known_hosts form) served by GET /api/v1/ssh-ca. Called once by main when the |
| 30 | // jump gate is enabled; empty ⇒ the endpoint 404s. Public material — safe to | 14 | // jump gate is enabled; empty ⇒ the endpoint 404s. Public material — safe to |
| @@ -44,32 +28,6 @@ func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) { | |||
| 44 | writeJSON(w, http.StatusOK, types.SSHCAResponse{CA: a.sshCAKey}) | 28 | writeJSON(w, http.StatusOK, types.SSHCAResponse{CA: a.sshCAKey}) |
| 45 | } | 29 | } |
| 46 | 30 | ||
| 47 | // HostMinter signs per-VM host certificates with the persistent SSH user CA | ||
| 48 | // (which doubles as the host CA in v1). Unlike Minter it has no TTL knob: host | ||
| 49 | // certs are long-lived (sshca.HostCertTTL). | ||
| 50 | type HostMinter struct { | ||
| 51 | ca ssh.Signer | ||
| 52 | now func() time.Time | ||
| 53 | } | ||
| 54 | |||
| 55 | // NewHostMinter builds a HostMinter that signs host certs with ca. | ||
| 56 | func NewHostMinter(ca ssh.Signer) *HostMinter { return &HostMinter{ca: ca, now: time.Now} } | ||
| 57 | |||
| 58 | // MintHostCert generates a fresh ed25519 host key and signs a long-lived host | ||
| 59 | // cert scoped to principal (the VM name). The returned PEM is private key | ||
| 60 | // material — the caller persists it write-only and never logs or echoes it. | ||
| 61 | func (m *HostMinter) MintHostCert(principal string) (keyPEM, cert string, err error) { | ||
| 62 | pem, signer, err := sshca.GenerateHostKey() | ||
| 63 | if err != nil { | ||
| 64 | return "", "", err | ||
| 65 | } | ||
| 66 | c, err := sshca.SignHostCert(m.ca, signer.PublicKey(), []string{principal}, principal, m.now(), sshca.HostCertTTL) | ||
| 67 | if err != nil { | ||
| 68 | return "", "", err | ||
| 69 | } | ||
| 70 | return string(pem), string(ssh.MarshalAuthorizedKey(c)), nil | ||
| 71 | } | ||
| 72 | |||
| 73 | // handleRevokeSSHCert revokes a specific user cert by serial so the jump gate | 31 | // handleRevokeSSHCert revokes a specific user cert by serial so the jump gate |
| 74 | // rejects it at auth before its short TTL expires. Tenant-scoped, idempotent | 32 | // rejects it at auth before its short TTL expires. Tenant-scoped, idempotent |
| 75 | // (re-revoking a serial is a 204 no-op). Revocation is a pure store operation — | 33 | // (re-revoking a serial is a 204 no-op). Revocation is a pure store operation — |
internal/server/api/sshcert_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,6 @@ | |||
| 1 | package api | 1 | package api |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | ||
| 5 | "crypto/ed25519" | 4 | "crypto/ed25519" |
| 6 | "crypto/rand" | 5 | "crypto/rand" |
| 7 | "encoding/json" | 6 | "encoding/json" |
| @@ -34,16 +33,6 @@ func genUserPubKey(t *testing.T) string { | |||
| 34 | return string(ssh.MarshalAuthorizedKey(sp)) | 33 | return string(ssh.MarshalAuthorizedKey(sp)) |
| 35 | } | 34 | } |
| 36 | 35 | ||
| 37 | // parseCert decodes an authorized-keys cert line into an *ssh.Certificate. | ||
| 38 | func parseCert(t *testing.T, line string) *ssh.Certificate { | ||
| 39 | t.Helper() | ||
| 40 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | ||
| 41 | require.NoError(t, err) | ||
| 42 | cert, ok := pk.(*ssh.Certificate) | ||
| 43 | require.True(t, ok, "parsed key must be an *ssh.Certificate") | ||
| 44 | return cert | ||
| 45 | } | ||
| 46 | |||
| 47 | func TestSSHCAEndpointServesCAWhenEnabled(t *testing.T) { | 36 | func TestSSHCAEndpointServesCAWhenEnabled(t *testing.T) { |
| 48 | ts, _, _, _, a := newServer(t) | 37 | ts, _, _, _, a := newServer(t) |
| 49 | a.SetSSHCAAuthorizedKey("ssh-ed25519 AAAAtestca eitri-host-ca") | 38 | a.SetSSHCAAuthorizedKey("ssh-ed25519 AAAAtestca eitri-host-ca") |
| @@ -62,77 +51,31 @@ func TestSSHCAEndpointGateOffIs404(t *testing.T) { | |||
| 62 | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | 51 | assert.Equal(t, http.StatusNotFound, resp.StatusCode) |
| 63 | } | 52 | } |
| 64 | 53 | ||
| 65 | // recordingHostMinter delegates to a real HostMinter but captures the principal | 54 | // TestCreateVMHoldsNoGuestKeyMaterial asserts what a VM create now does with |
| 66 | // argument, so a test can assert the caller namespaced it as <tenant>.<name>. | 55 | // key material: nothing. The guest's host key is generated by the host that |
| 67 | type recordingHostMinter struct { | 56 | // runs it, so the row starts empty and the API answer carries no key field at |
| 68 | inner HostCertMinter | 57 | // all. |
| 69 | principal string | 58 | func TestCreateVMHoldsNoGuestKeyMaterial(t *testing.T) { |
| 70 | } | 59 | ts, st, _, _, _ := newServer(t) |
| 71 | |||
| 72 | func (m *recordingHostMinter) MintHostCert(principal string) (keyPEM, cert string, err error) { | ||
| 73 | m.principal = principal | ||
| 74 | return m.inner.MintHostCert(principal) | ||
| 75 | } | ||
| 76 | |||
| 77 | // TestCreateVMMintsPerVMHostCert asserts that, with the gate enabled, creating a | ||
| 78 | // VM persists a per-VM host private key + a CA-signed host cert scoped to the | ||
| 79 | // namespaced <tenant>.<name> principal — and that neither the private key nor | ||
| 80 | // the raw principal ever appears in the API's VM response. | ||
| 81 | func TestCreateVMMintsPerVMHostCert(t *testing.T) { | ||
| 82 | ts, st, _, _, a := newServer(t) | ||
| 83 | ca := newCASigner(t) | ||
| 84 | rec := &recordingHostMinter{inner: NewHostMinter(ca)} | ||
| 85 | a.SetHostCertMinter(rec) | ||
| 86 | out := enroll(t, ts) | 60 | out := enroll(t, ts) |
| 87 | 61 | ||
| 88 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, | 62 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, |
| 89 | map[string]any{"host_id": out["host_id"], "name": "hosty"}) | 63 | map[string]any{"host_id": out["host_id"], "name": "hosty"}) |
| 90 | require.Equal(t, http.StatusCreated, resp.StatusCode) | 64 | require.Equal(t, http.StatusCreated, resp.StatusCode) |
| 91 | 65 | ||
| 92 | // VM create must pass the namespaced principal to the host-cert minter. | ||
| 93 | assert.Equal(t, "default.hosty", rec.principal, | ||
| 94 | "host-cert principal must be <tenant>.<name>") | ||
| 95 | |||
| 96 | // The store row carries the private key PEM + the cert. | ||
| 97 | vm, err := st.VMByTenantName(testTenant, "hosty") | 66 | vm, err := st.VMByTenantName(testTenant, "hosty") |
| 98 | require.NoError(t, err) | 67 | require.NoError(t, err) |
| 99 | require.NotEmpty(t, vm.SSHHostKey, "per-VM host private key must be persisted") | 68 | assert.Empty(t, vm.SSHHostPubKey, "the host reports its guest's public key; create invents none") |
| 100 | require.NotEmpty(t, vm.SSHHostCert, "per-VM host cert must be persisted") | 69 | assert.Empty(t, vm.SSHHostCert, "there is nothing to certify until a host reports a key") |
| 101 | assert.Contains(t, vm.SSHHostKey, "OPENSSH PRIVATE KEY") | 70 | |
| 102 | |||
| 103 | // The cert is a HOST cert signed by the CA and scoped to <tenant>.<name>. | ||
| 104 | cert := parseCert(t, vm.SSHHostCert) | ||
| 105 | assert.Equal(t, uint32(ssh.HostCert), cert.CertType) | ||
| 106 | assert.Equal(t, []string{"default.hosty"}, cert.ValidPrincipals) | ||
| 107 | checker := &ssh.CertChecker{IsHostAuthority: func(k ssh.PublicKey, _ string) bool { | ||
| 108 | return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal()) | ||
| 109 | }} | ||
| 110 | require.NoError(t, checker.CheckHostKey("default.hosty:22", nil, cert)) | ||
| 111 | |||
| 112 | // The private key must NEVER leak through the VM listing. | ||
| 113 | listResp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil) | 71 | listResp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil) |
| 114 | require.Equal(t, http.StatusOK, listResp.StatusCode) | 72 | require.Equal(t, http.StatusOK, listResp.StatusCode) |
| 115 | body, err := io.ReadAll(listResp.Body) | 73 | body, err := io.ReadAll(listResp.Body) |
| 116 | require.NoError(t, err) | 74 | require.NoError(t, err) |
| 117 | assert.NotContains(t, string(body), "OPENSSH PRIVATE KEY", "host private key must not appear on the wire") | 75 | assert.NotContains(t, string(body), "OPENSSH PRIVATE KEY") |
| 118 | assert.NotContains(t, string(body), "ssh_host_key") | 76 | assert.NotContains(t, string(body), "ssh_host_key") |
| 119 | } | 77 | } |
| 120 | 78 | ||
| 121 | // TestCreateVMWithoutHostMinterHasNoHostCert confirms the gate-off path is | ||
| 122 | // unchanged: no minter wired ⇒ VMs carry no host key/cert. | ||
| 123 | func TestCreateVMWithoutHostMinterHasNoHostCert(t *testing.T) { | ||
| 124 | ts, st, _, _, _ := newServer(t) | ||
| 125 | out := enroll(t, ts) | ||
| 126 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, | ||
| 127 | map[string]any{"host_id": out["host_id"], "name": "plainvm"}) | ||
| 128 | require.Equal(t, http.StatusCreated, resp.StatusCode) | ||
| 129 | |||
| 130 | vm, err := st.VMByTenantName(testTenant, "plainvm") | ||
| 131 | require.NoError(t, err) | ||
| 132 | assert.Empty(t, vm.SSHHostKey) | ||
| 133 | assert.Empty(t, vm.SSHHostCert) | ||
| 134 | } | ||
| 135 | |||
| 136 | // TestSSHCertRevokeBySerial revokes a cert by its raw serial and confirms the | 79 | // TestSSHCertRevokeBySerial revokes a cert by its raw serial and confirms the |
| 137 | // list endpoint reflects it (serial as a string, to survive JS clients). | 80 | // list endpoint reflects it (serial as a string, to survive JS clients). |
| 138 | func TestSSHCertRevokeBySerial(t *testing.T) { | 81 | func TestSSHCertRevokeBySerial(t *testing.T) { |
internal/server/boot/boot.go
| Old | New | ||
|---|---|---|---|
| @@ -177,10 +177,13 @@ func run(cfgPath string) error { | |||
| 177 | if err != nil { | 177 | if err != nil { |
| 178 | return fmt.Errorf("config: %w", err) | 178 | return fmt.Errorf("config: %w", err) |
| 179 | } | 179 | } |
| 180 | // SSH cert minters: no-op when the gate is off, so the endpoint 404s. | 180 | // Publish the host CA: no-op when the gate is off, so the endpoint 404s. |
| 181 | sshGate.wireAPI(a) | 181 | sshGate.wireAPI(a) |
| 182 | 182 | ||
| 183 | svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) | 183 | svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) |
| 184 | // Certify the host keys guests generate for themselves. No-op when the gate | ||
| 185 | // is off, and then no guest waits for a certificate. | ||
| 186 | sshGate.wireSync(svc) | ||
| 184 | // Console broker: the API bridges browser WebSockets to agent console | 187 | // Console broker: the API bridges browser WebSockets to agent console |
| 185 | // streams over the live sync connections the service tracks. | 188 | // streams over the live sync connections the service tracks. |
| 186 | a.SetConsoleDialer(svc) | 189 | a.SetConsoleDialer(svc) |
internal/server/boot/sshgate.go
| Old | New | ||
|---|---|---|---|
| @@ -52,23 +52,42 @@ func setupSSHGate(cfg serverconfig.Config, kek []byte) (*sshGateSetup, error) { | |||
| 52 | return &sshGateSetup{ca: sshGate, listen: cfg.SSHListen, domain: cfg.SSHGateDomain}, nil | 52 | return &sshGateSetup{ca: sshGate, listen: cfg.SSHListen, domain: cfg.SSHGateDomain}, nil |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | // wireAPI installs the per-VM host-cert minter and publishes the HOST CA: when | 55 | // wireAPI publishes the HOST CA: when the jump gate is enabled, eitri serves |
| 56 | // the jump gate is enabled, eitri signs a persistent host key + cert at each VM | 56 | // the host CA pubkey via GET /api/v1/ssh-ca so a client can pin |
| 57 | // create and serves the host CA pubkey via GET /api/v1/ssh-ca. eitri never | 57 | // `@cert-authority` and verify the gate and every VM by certificate. eitri |
| 58 | // mints user certs — user CAs are BYO per-tenant (uploaded, never held here). | 58 | // never mints user certs — user CAs are BYO per-tenant (uploaded, never held |
| 59 | // Left unwired when the gate is off, so the ssh-ca endpoint 404s. | 59 | // here). Left unwired when the gate is off, so the ssh-ca endpoint 404s. |
| 60 | func (g *sshGateSetup) wireAPI(a *api.API) { | 60 | func (g *sshGateSetup) wireAPI(a *api.API) { |
| 61 | if g == nil { | 61 | if g == nil { |
| 62 | return | 62 | return |
| 63 | } | 63 | } |
| 64 | // Per-VM host certs: sign a persistent host key + cert at each VM create, | ||
| 65 | // so VMs present verifiable host keys (clients accept via @cert-authority). | ||
| 66 | a.SetHostCertMinter(api.NewHostMinter(g.ca.HostCA())) | ||
| 67 | // Publish the HOST CA public key so clients can pin `@cert-authority` for | ||
| 68 | // host verification of both the gate and every VM. | ||
| 69 | a.SetSSHCAAuthorizedKey(string(g.ca.HostCAAuthorizedKey())) | 64 | a.SetSSHCAAuthorizedKey(string(g.ca.HostCAAuthorizedKey())) |
| 70 | } | 65 | } |
| 71 | 66 | ||
| 67 | // wireSync gives the sync service the one thing it needs from the gate: the | ||
| 68 | // ability to sign a certificate for a host key a guest's host generated. Left | ||
| 69 | // unwired when the gate is off, which is how a host learns there is no | ||
| 70 | // certificate coming and boots its guests uncertified. | ||
| 71 | func (g *sshGateSetup) wireSync(svc *syncsvc.Service) { | ||
| 72 | if g == nil { | ||
| 73 | return | ||
| 74 | } | ||
| 75 | svc.SetHostCertSigner(guestHostCertSigner{ca: g.ca.HostCA()}) | ||
| 76 | } | ||
| 77 | |||
| 78 | // guestHostCertSigner signs guest host certificates with the fleet's host CA. | ||
| 79 | // It holds a CA signer and a signing rule and nothing else — the principal is | ||
| 80 | // decided by the caller, which is the control plane reading the VM's own row. | ||
| 81 | type guestHostCertSigner struct{ ca ssh.Signer } | ||
| 82 | |||
| 83 | func (s guestHostCertSigner) SignHostCert(pub ssh.PublicKey, principal string) (string, error) { | ||
| 84 | cert, err := sshca.SignHostCert(s.ca, pub, []string{principal}, principal, time.Now(), sshca.HostCertTTL) | ||
| 85 | if err != nil { | ||
| 86 | return "", err | ||
| 87 | } | ||
| 88 | return string(ssh.MarshalAuthorizedKey(cert)), nil | ||
| 89 | } | ||
| 90 | |||
| 72 | // startListener starts the SSH jump gate listener: when enabled, front | 91 | // startListener starts the SSH jump gate listener: when enabled, front |
| 73 | // `ssh -J gate ubuntu@<vm>` with the hardened bastion. It resolves VM names | 92 | // `ssh -J gate ubuntu@<vm>` with the hardened bastion. It resolves VM names |
| 74 | // against the store, tunnels port 22 through the sync connection (svc.OpenTCP), | 93 | // against the store, tunnels port 22 through the sync connection (svc.OpenTCP), |
internal/server/boot/sshgate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -160,3 +160,36 @@ func TestUserCALookupFailsClosed(t *testing.T) { | |||
| 160 | _, ok = lookup(pub) | 160 | _, ok = lookup(pub) |
| 161 | assert.False(t, ok, "a store error must fail closed (reject)") | 161 | assert.False(t, ok, "a store error must fail closed (reject)") |
| 162 | } | 162 | } |
| 163 | |||
| 164 | // TestGuestHostCertSignerCertifiesTheKeyItIsGiven pins the one thing the sync | ||
| 165 | // service asks the gate for: a host certificate over a key the control plane | ||
| 166 | // did not generate, for a principal the control plane chose. | ||
| 167 | func TestGuestHostCertSignerCertifiesTheKeyItIsGiven(t *testing.T) { | ||
| 168 | _, ca, err := sshca.GenerateHostKey() | ||
| 169 | require.NoError(t, err) | ||
| 170 | _, guest, err := sshca.GenerateHostKey() | ||
| 171 | require.NoError(t, err) | ||
| 172 | |||
| 173 | line, err := guestHostCertSigner{ca: ca}.SignHostCert(guest.PublicKey(), "acme.web") | ||
| 174 | require.NoError(t, err) | ||
| 175 | |||
| 176 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | ||
| 177 | require.NoError(t, err) | ||
| 178 | cert, ok := pk.(*ssh.Certificate) | ||
| 179 | require.True(t, ok) | ||
| 180 | assert.Equal(t, uint32(ssh.HostCert), cert.CertType) | ||
| 181 | assert.Equal(t, []string{"acme.web"}, cert.ValidPrincipals) | ||
| 182 | assert.Equal(t, guest.PublicKey().Marshal(), cert.Key.Marshal()) | ||
| 183 | |||
| 184 | checker := &ssh.CertChecker{IsHostAuthority: func(k ssh.PublicKey, _ string) bool { | ||
| 185 | return string(k.Marshal()) == string(ca.PublicKey().Marshal()) | ||
| 186 | }} | ||
| 187 | require.NoError(t, checker.CheckHostKey("acme.web:22", nil, cert)) | ||
| 188 | } | ||
| 189 | |||
| 190 | // TestWireSyncIsANoOpWhenTheGateIsOff: with no CA there is nothing to sign | ||
| 191 | // with, and a nil setup must stay silent rather than panic on the way past. | ||
| 192 | func TestWireSyncIsANoOpWhenTheGateIsOff(t *testing.T) { | ||
| 193 | var off *sshGateSetup | ||
| 194 | assert.NotPanics(t, func() { off.wireSync(nil) }) | ||
| 195 | } | ||
internal/server/store/evolve.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package store | |||
| 3 | import ( | 3 | import ( |
| 4 | "database/sql" | 4 | "database/sql" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "log/slog" | ||
| 6 | ) | 7 | ) |
| 7 | 8 | ||
| 8 | // ensureColumn adds a column to an existing table if it is not already | 9 | // ensureColumn adds a column to an existing table if it is not already |
| @@ -42,3 +43,34 @@ func dropTable(db *sql.DB, table string) error { | |||
| 42 | } | 43 | } |
| 43 | return nil | 44 | return nil |
| 44 | } | 45 | } |
| 46 | |||
| 47 | // dropColumn removes a column that is no longer part of the schema, so an | ||
| 48 | // existing database stops carrying it — and stops carrying whatever was in it. | ||
| 49 | // Idempotent: a database that never had the column, or has already dropped it, | ||
| 50 | // is left alone. | ||
| 51 | // | ||
| 52 | // SQLite has dropped columns in place since 3.35, which is why this is one | ||
| 53 | // statement rather than the rebuild-and-swap dance. That matters for more than | ||
| 54 | // brevity: vms is referenced by exposures ON DELETE CASCADE, so a rebuild that | ||
| 55 | // dropped the old table would take every exposure with it, and it would have to | ||
| 56 | // restate every column added by ensureColumn or silently lose those too. | ||
| 57 | // | ||
| 58 | // table and column are interpolated verbatim (SQLite cannot bind identifiers): | ||
| 59 | // pass trusted compile-time constants only, the same rule ensureColumn states. | ||
| 60 | func dropColumn(db *sql.DB, table, column string) error { | ||
| 61 | var n int | ||
| 62 | err := db.QueryRow( | ||
| 63 | `SELECT count(*) FROM pragma_table_info(?) WHERE name = ?`, table, column, | ||
| 64 | ).Scan(&n) | ||
| 65 | if err != nil { | ||
| 66 | return fmt.Errorf("check %s.%s: %w", table, column, err) | ||
| 67 | } | ||
| 68 | if n == 0 { | ||
| 69 | return nil | ||
| 70 | } | ||
| 71 | if _, err := db.Exec(fmt.Sprintf(`ALTER TABLE %s DROP COLUMN %s`, table, column)); err != nil { | ||
| 72 | return fmt.Errorf("drop %s.%s: %w", table, column, err) | ||
| 73 | } | ||
| 74 | slog.Info("dropped retired column", "table", table, "column", column) | ||
| 75 | return nil | ||
| 76 | } | ||
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -81,12 +81,11 @@ type VM struct { | |||
| 81 | Persistent bool | 81 | Persistent bool |
| 82 | PowerState, Status, LastError, AssignedIP string | 82 | PowerState, Status, LastError, AssignedIP string |
| 83 | SSHAuthorizedKey string | 83 | SSHAuthorizedKey string |
| 84 | // SSHHostKey is the VM's persistent ed25519 host private key (OpenSSH PEM), | 84 | // SSHHostPubKey is the public half of the guest's SSH host key, as its host |
| 85 | // generated once at create when the jump gate is enabled and shipped to the | 85 | // reported it (authorized_keys form). The private half lives on that host |
| 86 | // guest via seed. WRITE-ONLY key material: handled like SSHAuthorizedKey — | 86 | // and the control plane never sees it. SSHHostCert is the certificate the |
| 87 | // never returned in vmResponse and never logged. SSHHostCert is the matching | 87 | // control plane signed for it, for the principal it derived from this row. |
| 88 | // CA-signed host cert (authorized_keys form); public, but grouped here. | 88 | SSHHostPubKey, SSHHostCert string |
| 89 | SSHHostKey, SSHHostCert string | ||
| 90 | // InjectedKey* describe the authorized key eitri installed at create: its | 89 | // InjectedKey* describe the authorized key eitri installed at create: its |
| 91 | // type, SHA256 fingerprint and comment. They are a RECORD of what eitri | 90 | // type, SHA256 fingerprint and comment. They are a RECORD of what eitri |
| 92 | // did, not an input to it — SSHAuthorizedKey is cleared when the key is | 91 | // did, not an input to it — SSHAuthorizedKey is cleared when the key is |
| @@ -153,7 +152,6 @@ CREATE TABLE IF NOT EXISTS vms ( | |||
| 153 | image_sha256 TEXT NOT NULL, | 152 | image_sha256 TEXT NOT NULL, |
| 154 | cloud_init TEXT NOT NULL DEFAULT '', | 153 | cloud_init TEXT NOT NULL DEFAULT '', |
| 155 | ssh_authorized_key TEXT NOT NULL DEFAULT '', | 154 | ssh_authorized_key TEXT NOT NULL DEFAULT '', |
| 156 | ssh_host_key TEXT NOT NULL DEFAULT '', | ||
| 157 | ssh_host_cert TEXT NOT NULL DEFAULT '', | 155 | ssh_host_cert TEXT NOT NULL DEFAULT '', |
| 158 | vcpus INTEGER NOT NULL, | 156 | vcpus INTEGER NOT NULL, |
| 159 | mem_mb INTEGER NOT NULL, | 157 | mem_mb INTEGER NOT NULL, |
| @@ -313,6 +311,10 @@ func Open(path, cidrPool string) (*Store, error) { | |||
| 313 | {"vms", "injected_key_type", "TEXT NOT NULL DEFAULT ''"}, | 311 | {"vms", "injected_key_type", "TEXT NOT NULL DEFAULT ''"}, |
| 314 | {"vms", "injected_key_fp", "TEXT NOT NULL DEFAULT ''"}, | 312 | {"vms", "injected_key_fp", "TEXT NOT NULL DEFAULT ''"}, |
| 315 | {"vms", "injected_key_comment", "TEXT NOT NULL DEFAULT ''"}, | 313 | {"vms", "injected_key_comment", "TEXT NOT NULL DEFAULT ''"}, |
| 314 | // The public half of the guest's SSH host key, as its host reported it. | ||
| 315 | // The private half is the host's and stays there; this is the half the | ||
| 316 | // control plane signs a certificate for. | ||
| 317 | {"vms", "ssh_host_pubkey", "TEXT NOT NULL DEFAULT ''"}, | ||
| 316 | // The address a host presents on the network it reaches the fleet | 318 | // The address a host presents on the network it reaches the fleet |
| 317 | // over. Reported every tick like the guest subnet, and stored for the | 319 | // over. Reported every tick like the guest subnet, and stored for the |
| 318 | // same reason: the console renders `host:port` for every exposure, | 320 | // same reason: the console renders `host:port` for every exposure, |
| @@ -333,6 +335,15 @@ func Open(path, cidrPool string) (*Store, error) { | |||
| 333 | return nil, err | 335 | return nil, err |
| 334 | } | 336 | } |
| 335 | 337 | ||
| 338 | // A guest's host private key belongs to its host, so there is no column for | ||
| 339 | // one. A database written before that was true has both the column and the | ||
| 340 | // keys in it; dropping the column takes them with it. The certificate beside | ||
| 341 | // it is public, is what clients verify the guest by, and is untouched. | ||
| 342 | if err := dropColumn(db, "vms", "ssh_host_key"); err != nil { | ||
| 343 | db.Close() | ||
| 344 | return nil, err | ||
| 345 | } | ||
| 346 | |||
| 336 | // One identity binds at most one tenant (per issuer). Partial index so | 347 | // One identity binds at most one tenant (per issuer). Partial index so |
| 337 | // unbound rows (empty issuer+subject) don't collide. | 348 | // unbound rows (empty issuer+subject) don't collide. |
| 338 | if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS tenants_identity | 349 | if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS tenants_identity |
| @@ -736,13 +747,11 @@ func (s *Store) CreateVM(vm VM) error { | |||
| 736 | 747 | ||
| 737 | _, err = tx.Exec( | 748 | _, err = tx.Exec( |
| 738 | `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, | 749 | `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 739 | ssh_host_key, ssh_host_cert, | ||
| 740 | injected_key_type, injected_key_fp, injected_key_comment, | 750 | injected_key_type, injected_key_fp, injected_key_comment, |
| 741 | vcpus, mem_mb, disk_gb, persistent, power_state, created_at) | 751 | vcpus, mem_mb, disk_gb, persistent, power_state, created_at) |
| 742 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, | 752 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, |
| 743 | vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256, | 753 | vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256, |
| 744 | vm.CloudInit, vm.SSHAuthorizedKey, | 754 | vm.CloudInit, vm.SSHAuthorizedKey, |
| 745 | vm.SSHHostKey, vm.SSHHostCert, | ||
| 746 | vm.InjectedKeyType, vm.InjectedKeyFP, vm.InjectedKeyComment, | 755 | vm.InjectedKeyType, vm.InjectedKeyFP, vm.InjectedKeyComment, |
| 747 | vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, | 756 | vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, |
| 748 | now.Format(time.RFC3339), | 757 | now.Format(time.RFC3339), |
| @@ -1225,13 +1234,35 @@ func (s *Store) RecordVMStatus(id, status, lastErr, ip string) (string, error) { | |||
| 1225 | return ip, nil | 1234 | return ip, nil |
| 1226 | } | 1235 | } |
| 1227 | 1236 | ||
| 1237 | // RecordVMHostKey stores the public host key a host generated for one of its | ||
| 1238 | // guests, together with the certificate the control plane signed for it. | ||
| 1239 | // | ||
| 1240 | // hostID is part of the WHERE clause rather than a check made before it. A host | ||
| 1241 | // may only ever speak for the VMs it holds, and expressing that as a predicate | ||
| 1242 | // instead of a read-then-write leaves no window between the two in which the | ||
| 1243 | // VM could move. A host that reports a key for a VM that is not its own | ||
| 1244 | // changes no rows and gets sql.ErrNoRows. | ||
| 1245 | func (s *Store) RecordVMHostKey(vmID, hostID, pubkey, cert string) error { | ||
| 1246 | res, err := s.db.Exec( | ||
| 1247 | `UPDATE vms SET ssh_host_pubkey=?, ssh_host_cert=? WHERE id=? AND host_id=? AND deleted_at IS NULL`, | ||
| 1248 | pubkey, cert, vmID, hostID, | ||
| 1249 | ) | ||
| 1250 | if err != nil { | ||
| 1251 | return err | ||
| 1252 | } | ||
| 1253 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 1254 | return sql.ErrNoRows | ||
| 1255 | } | ||
| 1256 | return nil | ||
| 1257 | } | ||
| 1258 | |||
| 1228 | // vmColumns is the positional column list every VM SELECT must use, so the | 1259 | // vmColumns is the positional column list every VM SELECT must use, so the |
| 1229 | // order stays locked to scanVM's Scan below (which is positional, not | 1260 | // order stays locked to scanVM's Scan below (which is positional, not |
| 1230 | // name-based). queryVMs is the sole caller, so adding a column is a single | 1261 | // name-based). queryVMs is the sole caller, so adding a column is a single |
| 1231 | // edit here plus scanVM — every VM query goes through it and can't drift out | 1262 | // edit here plus scanVM — every VM query goes through it and can't drift out |
| 1232 | // of lockstep. | 1263 | // of lockstep. |
| 1233 | const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, | 1264 | const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 1234 | ssh_host_key, ssh_host_cert, | 1265 | ssh_host_pubkey, ssh_host_cert, |
| 1235 | injected_key_type, injected_key_fp, injected_key_comment, | 1266 | injected_key_type, injected_key_fp, injected_key_comment, |
| 1236 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | 1267 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, |
| 1237 | created_at, deleted_at` | 1268 | created_at, deleted_at` |
| @@ -1244,7 +1275,7 @@ func scanVM(rows *sql.Rows) (VM, error) { | |||
| 1244 | var deletedAt sql.NullString | 1275 | var deletedAt sql.NullString |
| 1245 | err := rows.Scan( | 1276 | err := rows.Scan( |
| 1246 | &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256, | 1277 | &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256, |
| 1247 | &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert, | 1278 | &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostPubKey, &vm.SSHHostCert, |
| 1248 | &vm.InjectedKeyType, &vm.InjectedKeyFP, &vm.InjectedKeyComment, | 1279 | &vm.InjectedKeyType, &vm.InjectedKeyFP, &vm.InjectedKeyComment, |
| 1249 | &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, | 1280 | &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, |
| 1250 | &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, | 1281 | &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -88,30 +88,114 @@ func TestGetVM(t *testing.T) { | |||
| 88 | assert.NotNil(t, got.DeletedAt) | 88 | assert.NotNil(t, got.DeletedAt) |
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | func TestVMHostKeyAndCertPersist(t *testing.T) { | 91 | func TestRecordVMHostKeyCertifiesOnlyItsOwnHostsVM(t *testing.T) { |
| 92 | s := newStore(t) | 92 | s := newStore(t) |
| 93 | h := enrollHost(t, s) | 93 | h := enrollHost(t, s) |
| 94 | 94 | ||
| 95 | const keyPEM = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAfake\n-----END OPENSSH PRIVATE KEY-----\n" | ||
| 96 | const cert = "ssh-ed25519-cert-v01@openssh.com AAAAfakecert host\n" | ||
| 97 | require.NoError(t, s.CreateVM(VM{ | 95 | require.NoError(t, s.CreateVM(VM{ |
| 98 | ID: "vm1", HostID: h.ID, Name: "with-hostcert", ImageURL: "u", ImageSHA256: "abc", | 96 | ID: "vm1", HostID: h.ID, Name: "with-hostcert", ImageURL: "u", ImageSHA256: "abc", |
| 99 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", | 97 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", |
| 100 | SSHHostKey: keyPEM, SSHHostCert: cert, | ||
| 101 | })) | 98 | })) |
| 99 | // A create writes no key material of any kind: the host owns the key. | ||
| 100 | created, err := s.VMByTenantName(testTenant, "with-hostcert") | ||
| 101 | require.NoError(t, err) | ||
| 102 | assert.Empty(t, created.SSHHostPubKey) | ||
| 103 | assert.Empty(t, created.SSHHostCert) | ||
| 102 | 104 | ||
| 103 | // The private key + cert must round-trip through both read paths the | 105 | const pubkey = "ssh-ed25519 AAAAfakepub guest" |
| 104 | // snapshot/gate rely on: DesiredForHost (agent-facing) and VMByTenantName. | 106 | const cert = "ssh-ed25519-cert-v01@openssh.com AAAAfakecert host\n" |
| 107 | require.NoError(t, s.RecordVMHostKey("vm1", h.ID, pubkey, cert)) | ||
| 108 | |||
| 109 | // Both halves must round-trip through the read paths the snapshot and the | ||
| 110 | // gate rely on: DesiredForHost (agent-facing) and VMByTenantName. | ||
| 105 | _, vms, err := s.DesiredForHost(h.ID) | 111 | _, vms, err := s.DesiredForHost(h.ID) |
| 106 | require.NoError(t, err) | 112 | require.NoError(t, err) |
| 107 | require.Len(t, vms, 1) | 113 | require.Len(t, vms, 1) |
| 108 | assert.Equal(t, keyPEM, vms[0].SSHHostKey) | 114 | assert.Equal(t, pubkey, vms[0].SSHHostPubKey) |
| 109 | assert.Equal(t, cert, vms[0].SSHHostCert) | 115 | assert.Equal(t, cert, vms[0].SSHHostCert) |
| 110 | 116 | ||
| 111 | byName, err := s.VMByTenantName(testTenant, "with-hostcert") | 117 | byName, err := s.VMByTenantName(testTenant, "with-hostcert") |
| 112 | require.NoError(t, err) | 118 | require.NoError(t, err) |
| 113 | assert.Equal(t, keyPEM, byName.SSHHostKey) | 119 | assert.Equal(t, pubkey, byName.SSHHostPubKey) |
| 114 | assert.Equal(t, cert, byName.SSHHostCert) | 120 | assert.Equal(t, cert, byName.SSHHostCert) |
| 121 | |||
| 122 | // A host may only speak for the VMs it holds. The host_id is a predicate on | ||
| 123 | // the UPDATE, so a claim from anyone else changes nothing at all. | ||
| 124 | err = s.RecordVMHostKey("vm1", "some-other-host", "ssh-ed25519 AAAAevil x", "evil-cert") | ||
| 125 | require.ErrorIs(t, err, sql.ErrNoRows) | ||
| 126 | unchanged, err := s.VMByTenantName(testTenant, "with-hostcert") | ||
| 127 | require.NoError(t, err) | ||
| 128 | assert.Equal(t, pubkey, unchanged.SSHHostPubKey) | ||
| 129 | assert.Equal(t, cert, unchanged.SSHHostCert) | ||
| 130 | } | ||
| 131 | |||
| 132 | func TestRecordVMHostKeyRefusesATombstonedVM(t *testing.T) { | ||
| 133 | s := newStore(t) | ||
| 134 | h := enrollHost(t, s) | ||
| 135 | require.NoError(t, s.CreateVM(VM{ | ||
| 136 | ID: "vm1", HostID: h.ID, Name: "doomed", ImageURL: "u", ImageSHA256: "abc", | ||
| 137 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", | ||
| 138 | })) | ||
| 139 | require.NoError(t, s.TombstoneVM("vm1")) | ||
| 140 | assert.ErrorIs(t, s.RecordVMHostKey("vm1", h.ID, "ssh-ed25519 AAAApub g", "cert"), sql.ErrNoRows) | ||
| 141 | } | ||
| 142 | |||
| 143 | // TestOpenDropsTheEscrowedHostKeyColumn is the upgrade from a database written | ||
| 144 | // when the control plane generated guests' host keys: the column and every key | ||
| 145 | // in it go, and nothing else about the VM does. Dropping the column is what | ||
| 146 | // destroys the keys — there is no sweep to have run, and no way to put one back. | ||
| 147 | func TestOpenDropsTheEscrowedHostKeyColumn(t *testing.T) { | ||
| 148 | path := t.TempDir() + "/eitri.db" | ||
| 149 | s, err := Open(path, "10.77.0.0/16") | ||
| 150 | require.NoError(t, err) | ||
| 151 | tn, err := s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local") | ||
| 152 | require.NoError(t, err) | ||
| 153 | require.Equal(t, testTenant, tn.ID) | ||
| 154 | h := enrollHost(t, s) | ||
| 155 | require.NoError(t, s.CreateVM(VM{ | ||
| 156 | ID: "vm1", HostID: h.ID, Name: "legacy", ImageURL: "u", ImageSHA256: "abc", | ||
| 157 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", | ||
| 158 | })) | ||
| 159 | |||
| 160 | // Put the database back into the shape an earlier release left it: the | ||
| 161 | // column present, a private key in it, and a certificate beside it. | ||
| 162 | _, err = s.db.Exec(`ALTER TABLE vms ADD COLUMN ssh_host_key TEXT NOT NULL DEFAULT ''`) | ||
| 163 | require.NoError(t, err) | ||
| 164 | _, err = s.db.Exec(`UPDATE vms SET ssh_host_key='PRIVATE', ssh_host_cert='cert-line' WHERE id='vm1'`) | ||
| 165 | require.NoError(t, err) | ||
| 166 | // An exposure, because vms is referenced ON DELETE CASCADE: a migration that | ||
| 167 | // rebuilt the table by dropping it would take this row with it. | ||
| 168 | _, err = s.CreateExposure("vm1", 22, 30001) | ||
| 169 | require.NoError(t, err) | ||
| 170 | require.NoError(t, s.Close()) | ||
| 171 | |||
| 172 | up, err := Open(path, "10.77.0.0/16") | ||
| 173 | require.NoError(t, err) | ||
| 174 | t.Cleanup(func() { up.Close() }) | ||
| 175 | |||
| 176 | var cols int | ||
| 177 | require.NoError(t, up.db.QueryRow( | ||
| 178 | `SELECT count(*) FROM pragma_table_info('vms') WHERE name='ssh_host_key'`).Scan(&cols)) | ||
| 179 | assert.Equal(t, 0, cols, "the column, and every key in it, must be gone") | ||
| 180 | |||
| 181 | // Only that column goes. The certificate is public and still in use, the | ||
| 182 | // VM row is intact, and so is everything hanging off it. | ||
| 183 | vm, err := up.GetVM("vm1") | ||
| 184 | require.NoError(t, err) | ||
| 185 | assert.Equal(t, "legacy", vm.Name) | ||
| 186 | assert.Equal(t, "cert-line", vm.SSHHostCert) | ||
| 187 | exps, err := up.ListExposuresForVM("vm1") | ||
| 188 | require.NoError(t, err) | ||
| 189 | assert.Len(t, exps, 1, "dropping a column must not cascade into exposures") | ||
| 190 | |||
| 191 | // Idempotent: a database that has already been through this opens clean. | ||
| 192 | require.NoError(t, up.Close()) | ||
| 193 | again, err := Open(path, "10.77.0.0/16") | ||
| 194 | require.NoError(t, err) | ||
| 195 | t.Cleanup(func() { again.Close() }) | ||
| 196 | vm, err = again.GetVM("vm1") | ||
| 197 | require.NoError(t, err) | ||
| 198 | assert.Equal(t, "cert-line", vm.SSHHostCert) | ||
| 115 | } | 199 | } |
| 116 | 200 | ||
| 117 | func TestSSHCertRevocation(t *testing.T) { | 201 | func TestSSHCertRevocation(t *testing.T) { |
internal/server/syncsvc/hostcert.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,83 @@ | |||
| 1 | package syncsvc | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "log/slog" | ||
| 6 | |||
| 7 | "github.com/a73x/eitri/internal/pb" | ||
| 8 | "golang.org/x/crypto/ssh" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // hostCertSigner signs a guest's public host key for the principal the control | ||
| 12 | // plane chose. Nil when the fleet has no SSH CA — there is then no certificate | ||
| 13 | // to issue, and buildSnapshot tells hosts not to wait for one. | ||
| 14 | type hostCertSigner interface { | ||
| 15 | SignHostCert(pub ssh.PublicKey, principal string) (certLine string, err error) | ||
| 16 | } | ||
| 17 | |||
| 18 | // SetHostCertSigner wires the guest host-cert signer. Called once at startup | ||
| 19 | // when the jump gate is enabled, alongside the other seams the API hands this | ||
| 20 | // service (SetConsoleDialer, SetAgentUpgrader). | ||
| 21 | func (s *Service) SetHostCertSigner(c hostCertSigner) { s.certs = c } | ||
| 22 | |||
| 23 | // signAndRecordHostCert certifies one guest's host key. | ||
| 24 | // | ||
| 25 | // The AGENT SUPPLIES A KEY, NEVER A NAME. The principal is derived here, from | ||
| 26 | // the VM's own row — so a host cannot obtain a certificate for a name it does | ||
| 27 | // not own, and the host_id predicate inside RecordVMHostKey means it cannot | ||
| 28 | // obtain one for another host's VM either. Those two together are the whole | ||
| 29 | // authorization story for this exchange. | ||
| 30 | func (s *Service) signAndRecordHostCert(hostID, vmID, pubLine string) error { | ||
| 31 | vm, err := s.st.GetVM(vmID) | ||
| 32 | if err != nil { | ||
| 33 | return fmt.Errorf("read vm: %w", err) | ||
| 34 | } | ||
| 35 | if vm.HostID != hostID { | ||
| 36 | return fmt.Errorf("vm %s is not on host %s", vmID, hostID) | ||
| 37 | } | ||
| 38 | if vm.DeletedAt != nil { | ||
| 39 | return fmt.Errorf("vm %s is tombstoned", vmID) | ||
| 40 | } | ||
| 41 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine)) | ||
| 42 | if err != nil { | ||
| 43 | return fmt.Errorf("parse reported host key: %w", err) | ||
| 44 | } | ||
| 45 | // <tenant>.<name> is the connect name clients dial and verify, the same | ||
| 46 | // principal the gate resolves a VM by. | ||
| 47 | cert, err := s.certs.SignHostCert(pub, vm.Tenant+"."+vm.Name) | ||
| 48 | if err != nil { | ||
| 49 | return fmt.Errorf("sign host cert: %w", err) | ||
| 50 | } | ||
| 51 | if err := s.st.RecordVMHostKey(vmID, hostID, pubLine, cert); err != nil { | ||
| 52 | return fmt.Errorf("record host cert: %w", err) | ||
| 53 | } | ||
| 54 | // Push rather than wait for the next tick: the guest is not booting until | ||
| 55 | // this certificate reaches it. | ||
| 56 | s.hub.Poke(hostID) | ||
| 57 | return nil | ||
| 58 | } | ||
| 59 | |||
| 60 | // certifyReportedHostKeys signs every newly-reported guest host key in one | ||
| 61 | // report. It runs ahead of — and outside — the status write-through loop, | ||
| 62 | // which only looks at VMs in phase ready or failed: a VM waiting for its | ||
| 63 | // certificate reports `creating`, and is exactly the VM that needs one. | ||
| 64 | // | ||
| 65 | // A failure is logged and dropped, like every other per-VM failure in a report: | ||
| 66 | // the key rides every later report too, so the next tick tries again. | ||
| 67 | func (s *Service) certifyReportedHostKeys(hostID string, vms []*pb.ActualVM) { | ||
| 68 | if s.certs == nil { | ||
| 69 | return | ||
| 70 | } | ||
| 71 | for _, v := range vms { | ||
| 72 | pub := v.GetSshHostPubkey() | ||
| 73 | if pub == "" { | ||
| 74 | continue | ||
| 75 | } | ||
| 76 | vmID := v.GetVmId() | ||
| 77 | if err := s.certTrack.writeThrough(vmID, pub, func() error { | ||
| 78 | return s.signAndRecordHostCert(hostID, vmID, pub) | ||
| 79 | }); err != nil { | ||
| 80 | slog.Warn("sign guest host cert", "vm", vmID, "host", hostID, "err", err) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
internal/server/syncsvc/hostcert_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,169 @@ | |||
| 1 | package syncsvc | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "crypto/ed25519" | ||
| 6 | "crypto/rand" | ||
| 7 | "errors" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/pb" | ||
| 11 | "github.com/a73x/eitri/internal/server/store" | ||
| 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/require" | ||
| 14 | "golang.org/x/crypto/ssh" | ||
| 15 | "google.golang.org/protobuf/proto" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // countingSigner is a hostCertSigner that records what it was asked to sign, so | ||
| 19 | // a test can assert BOTH the principal the control plane chose and how often it | ||
| 20 | // spent a signature. | ||
| 21 | type countingSigner struct { | ||
| 22 | calls int | ||
| 23 | principals []string | ||
| 24 | err error | ||
| 25 | } | ||
| 26 | |||
| 27 | func (c *countingSigner) SignHostCert(pub ssh.PublicKey, principal string) (string, error) { | ||
| 28 | c.calls++ | ||
| 29 | c.principals = append(c.principals, principal) | ||
| 30 | if c.err != nil { | ||
| 31 | return "", c.err | ||
| 32 | } | ||
| 33 | return "cert-for:" + principal + ":" + string(ssh.MarshalAuthorizedKey(pub)), nil | ||
| 34 | } | ||
| 35 | |||
| 36 | // testPubKey returns a real ed25519 public key in authorized_keys form. | ||
| 37 | func testPubKey(t *testing.T) string { | ||
| 38 | t.Helper() | ||
| 39 | pub, _, err := ed25519.GenerateKey(rand.Reader) | ||
| 40 | require.NoError(t, err) | ||
| 41 | sshPub, err := ssh.NewPublicKey(pub) | ||
| 42 | require.NoError(t, err) | ||
| 43 | return string(bytes.TrimSpace(ssh.MarshalAuthorizedKey(sshPub))) | ||
| 44 | } | ||
| 45 | |||
| 46 | // reportKey builds the report a host sends for a VM that has generated its host | ||
| 47 | // key and is waiting for the certificate. | ||
| 48 | func reportKey(vmID, pubkey string) *pb.ActualStateReport { | ||
| 49 | return &pb.ActualStateReport{Vms: []*pb.ActualVM{ | ||
| 50 | {VmId: vmID, Phase: "creating", Power: "stopped", SshHostPubkey: pubkey}, | ||
| 51 | }} | ||
| 52 | } | ||
| 53 | |||
| 54 | func TestReportedHostKeyIsCertifiedForTheNameOnTheRow(t *testing.T) { | ||
| 55 | f := setup(t) | ||
| 56 | signer := &countingSigner{} | ||
| 57 | f.svc.SetHostCertSigner(signer) | ||
| 58 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 59 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 60 | |||
| 61 | pubkey := testPubKey(t) | ||
| 62 | f.svc.applyReport(f.host.ID, reportKey("vm1", pubkey)) | ||
| 63 | |||
| 64 | // The agent supplies a key, never a name: the principal comes from the row. | ||
| 65 | assert.Equal(t, []string{testTenant + ".a"}, signer.principals) | ||
| 66 | |||
| 67 | vm, err := f.st.GetVM("vm1") | ||
| 68 | require.NoError(t, err) | ||
| 69 | assert.Equal(t, pubkey, vm.SSHHostPubKey) | ||
| 70 | assert.Contains(t, vm.SSHHostCert, "cert-for:"+testTenant+".a:") | ||
| 71 | |||
| 72 | // Every host repeats every VM's key every tick, forever. Steady state must | ||
| 73 | // cost nothing. | ||
| 74 | for range 5 { | ||
| 75 | f.svc.applyReport(f.host.ID, reportKey("vm1", pubkey)) | ||
| 76 | } | ||
| 77 | assert.Equal(t, 1, signer.calls, "an unchanged key must not be re-signed") | ||
| 78 | } | ||
| 79 | |||
| 80 | func TestReportedHostKeyForAnotherHostsVMIsRefused(t *testing.T) { | ||
| 81 | f := setup(t) | ||
| 82 | signer := &countingSigner{} | ||
| 83 | f.svc.SetHostCertSigner(signer) | ||
| 84 | |||
| 85 | tok, _ := f.st.CreateEnrollmentToken(testTenant) | ||
| 86 | other, err := f.st.RedeemEnrollmentToken(tok, store.EnrollFacts{Name: "other", OS: "linux", Arch: "amd64", Provisioner: "cloudhv"}) | ||
| 87 | require.NoError(t, err) | ||
| 88 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 89 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 90 | |||
| 91 | f.svc.applyReport(other.ID, reportKey("vm1", testPubKey(t))) | ||
| 92 | |||
| 93 | assert.Equal(t, 0, signer.calls, "a host may only speak for the VMs it holds") | ||
| 94 | vm, err := f.st.GetVM("vm1") | ||
| 95 | require.NoError(t, err) | ||
| 96 | assert.Empty(t, vm.SSHHostPubKey) | ||
| 97 | assert.Empty(t, vm.SSHHostCert) | ||
| 98 | } | ||
| 99 | |||
| 100 | func TestTombstonedVMIsNotCertified(t *testing.T) { | ||
| 101 | f := setup(t) | ||
| 102 | signer := &countingSigner{} | ||
| 103 | f.svc.SetHostCertSigner(signer) | ||
| 104 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 105 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 106 | require.NoError(t, f.st.TombstoneVM("vm1")) | ||
| 107 | |||
| 108 | f.svc.applyReport(f.host.ID, reportKey("vm1", testPubKey(t))) | ||
| 109 | assert.Equal(t, 0, signer.calls) | ||
| 110 | } | ||
| 111 | |||
| 112 | func TestNoSignerWiredIgnoresReportedHostKeys(t *testing.T) { | ||
| 113 | f := setup(t) | ||
| 114 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 115 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 116 | |||
| 117 | f.svc.applyReport(f.host.ID, reportKey("vm1", testPubKey(t))) | ||
| 118 | |||
| 119 | vm, err := f.st.GetVM("vm1") | ||
| 120 | require.NoError(t, err) | ||
| 121 | assert.Empty(t, vm.SSHHostCert) | ||
| 122 | |||
| 123 | // And with no CA, no guest is held waiting for a certificate that will | ||
| 124 | // never come. | ||
| 125 | snap, err := f.svc.buildSnapshot(f.host.ID) | ||
| 126 | require.NoError(t, err) | ||
| 127 | require.Len(t, snap.Vms, 1) | ||
| 128 | assert.False(t, snap.Vms[0].HostCertRequired) | ||
| 129 | } | ||
| 130 | |||
| 131 | func TestAFailedSigningIsRetriedOnTheNextReport(t *testing.T) { | ||
| 132 | f := setup(t) | ||
| 133 | signer := &countingSigner{err: errors.New("CA unavailable")} | ||
| 134 | f.svc.SetHostCertSigner(signer) | ||
| 135 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 136 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 137 | |||
| 138 | pubkey := testPubKey(t) | ||
| 139 | f.svc.applyReport(f.host.ID, reportKey("vm1", pubkey)) | ||
| 140 | signer.err = nil | ||
| 141 | f.svc.applyReport(f.host.ID, reportKey("vm1", pubkey)) | ||
| 142 | |||
| 143 | assert.Equal(t, 2, signer.calls, "a rejected signature must not be remembered as done") | ||
| 144 | vm, err := f.st.GetVM("vm1") | ||
| 145 | require.NoError(t, err) | ||
| 146 | assert.NotEmpty(t, vm.SSHHostCert) | ||
| 147 | } | ||
| 148 | |||
| 149 | // TestSnapshotCarriesNoPrivateKeyMaterial is the blunt assertion: whatever else | ||
| 150 | // changes, a desired-state snapshot must never contain a private key. The | ||
| 151 | // control plane has none to send, and this is what would notice if it did. | ||
| 152 | func TestSnapshotCarriesNoPrivateKeyMaterial(t *testing.T) { | ||
| 153 | f := setup(t) | ||
| 154 | f.svc.SetHostCertSigner(&countingSigner{}) | ||
| 155 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 156 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 157 | f.svc.applyReport(f.host.ID, reportKey("vm1", testPubKey(t))) | ||
| 158 | |||
| 159 | snap, err := f.svc.buildSnapshot(f.host.ID) | ||
| 160 | require.NoError(t, err) | ||
| 161 | require.Len(t, snap.Vms, 1) | ||
| 162 | assert.True(t, snap.Vms[0].HostCertRequired, "a fleet with a CA requires certified guests") | ||
| 163 | assert.NotEmpty(t, snap.Vms[0].SshHostCert, "the certificate goes down; the key never came up") | ||
| 164 | |||
| 165 | raw, err := proto.Marshal(snap) | ||
| 166 | require.NoError(t, err) | ||
| 167 | assert.False(t, bytes.Contains(raw, []byte("PRIVATE KEY")), | ||
| 168 | "no private key may appear anywhere in a snapshot") | ||
| 169 | } | ||
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -48,6 +48,13 @@ type Service struct { | |||
| 48 | recorder vmStatusRecorder | 48 | recorder vmStatusRecorder |
| 49 | tracker *statusTracker | 49 | tracker *statusTracker |
| 50 | netTrack *netTracker | 50 | netTrack *netTracker |
| 51 | // certs signs a guest's reported host key. Nil when the fleet has no SSH | ||
| 52 | // CA: there is then no certificate to issue and none to require. | ||
| 53 | certs hostCertSigner | ||
| 54 | // certTrack remembers the public key each VM was last certified for, so the | ||
| 55 | // steady state — every host repeating every VM's key every tick, forever — | ||
| 56 | // costs no database work. Same shape and same reason as netTrack. | ||
| 57 | certTrack *netTracker | ||
| 51 | // uplinkTrack remembers the uplink address each host last had WRITTEN, for | 58 | // uplinkTrack remembers the uplink address each host last had WRITTEN, for |
| 52 | // the same reason netTrack does: every host reports every tick forever, and | 59 | // the same reason netTrack does: every host reports every tick forever, and |
| 53 | // the store runs on a single connection. | 60 | // the store runs on a single connection. |
| @@ -87,7 +94,7 @@ func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, se | |||
| 87 | } | 94 | } |
| 88 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, | 95 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, |
| 89 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(), netTrack: newNetTracker(), | 96 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(), netTrack: newNetTracker(), |
| 90 | uplinkTrack: newNetTracker(), offers: map[string]*pb.AgentUpgrade{}} | 97 | uplinkTrack: newNetTracker(), certTrack: newNetTracker(), offers: map[string]*pb.AgentUpgrade{}} |
| 91 | } | 98 | } |
| 92 | 99 | ||
| 93 | // Serve accepts QUIC connections until ctx is cancelled. | 100 | // Serve accepts QUIC connections until ctx is cancelled. |
| @@ -304,8 +311,12 @@ func (s *Service) buildSnapshot(hostID string) (*pb.DesiredStateSnapshot, error) | |||
| 304 | Persistent: v.Persistent, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil, | 311 | Persistent: v.Persistent, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil, |
| 305 | SshAuthorizedKey: v.SSHAuthorizedKey, | 312 | SshAuthorizedKey: v.SSHAuthorizedKey, |
| 306 | SshUserCaAuthorizedKeys: cas, | 313 | SshUserCaAuthorizedKeys: cas, |
| 307 | SshHostKeyPem: v.SSHHostKey, | ||
| 308 | SshHostCert: v.SSHHostCert, | 314 | SshHostCert: v.SSHHostCert, |
| 315 | // With a CA in hand the fleet issues host certificates, so a guest | ||
| 316 | // must present one. The host generates the key, reports the public | ||
| 317 | // half, and holds the guest at the gate until the certificate for | ||
| 318 | // it comes back down in a later snapshot. | ||
| 319 | HostCertRequired: s.certs != nil, | ||
| 309 | }) | 320 | }) |
| 310 | } | 321 | } |
| 311 | exps, err := s.st.ListExposuresForHost(hostID) | 322 | exps, err := s.st.ListExposuresForHost(hostID) |
| @@ -403,6 +414,11 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 403 | 414 | ||
| 404 | s.reg.UpdateReport(hostID, r) | 415 | s.reg.UpdateReport(hostID, r) |
| 405 | 416 | ||
| 417 | // Certify any guest host key this host has newly generated. Ahead of the | ||
| 418 | // status loop because it is a precondition for the phases that loop cares | ||
| 419 | // about: a VM awaiting its certificate has not booted yet. | ||
| 420 | s.certifyReportedHostKeys(hostID, rep.GetVms()) | ||
| 421 | |||
| 406 | // Write-through durable status for lifecycle phases ready/failed only. | 422 | // Write-through durable status for lifecycle phases ready/failed only. |
| 407 | // Skip the SELECT+UPDATE when the durable triple (status, last_error, | 423 | // Skip the SELECT+UPDATE when the durable triple (status, last_error, |
| 408 | // effective assigned_ip) is unchanged from the last write we recorded. | 424 | // effective assigned_ip) is unchanged from the last write we recorded. |
| @@ -480,6 +496,7 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 480 | // a stale cached triple — ids are unique, but forgetting is the correct, | 496 | // a stale cached triple — ids are unique, but forgetting is the correct, |
| 481 | // memory-bounding thing regardless of whether the delete succeeds). | 497 | // memory-bounding thing regardless of whether the delete succeeds). |
| 482 | s.tracker.forget(id) | 498 | s.tracker.forget(id) |
| 499 | s.certTrack.forget(id) | ||
| 483 | if err := s.st.HardDeleteVM(id); err != nil { | 500 | if err := s.st.HardDeleteVM(id); err != nil { |
| 484 | slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err) | 501 | slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err) |
| 485 | } else { | 502 | } else { |
proto/eitri/v1/sync.proto
| Old | New | ||
|---|---|---|---|
| @@ -71,6 +71,11 @@ message ActualVM { | |||
| 71 | string phase = 3; // "creating"|"ready"|"failed"|"quarantined" | 71 | string phase = 3; // "creating"|"ready"|"failed"|"quarantined" |
| 72 | string ip = 4; // the address this guest has, however its host came by it | 72 | string ip = 4; // the address this guest has, however its host came by it |
| 73 | string last_error = 5; | 73 | string last_error = 5; |
| 74 | // The guest's ed25519 HOST public key. It is generated on the host, and the | ||
| 75 | // private half never leaves it — this is the only half that travels. Sent on | ||
| 76 | // every report for as long as the VM exists (level-triggered), so a lost | ||
| 77 | // snapshot or a control-plane restart re-certifies without operator action. | ||
| 78 | string ssh_host_pubkey = 6; | ||
| 74 | } | 79 | } |
| 75 | 80 | ||
| 76 | message QuarantinedVM { | 81 | message QuarantinedVM { |
| @@ -124,9 +129,24 @@ message VMDesired { | |||
| 124 | string ssh_authorized_key = 12; | 129 | string ssh_authorized_key = 12; |
| 125 | reserved 13, 14; // formerly mesh_invite / mesh_name (rayfish, removed) | 130 | reserved 13, 14; // formerly mesh_invite / mesh_name (rayfish, removed) |
| 126 | reserved 15; // formerly ssh_user_ca_authorized_key (single gate-wide CA) | 131 | reserved 15; // formerly ssh_user_ca_authorized_key (single gate-wide CA) |
| 127 | string ssh_host_key_pem = 16; // the VM's persistent ed25519 host private key (OpenSSH PEM); seed installs it as /etc/ssh/ssh_host_ed25519_key. WRITE-ONLY key material. Empty when the jump gate is off. | 132 | // A guest's host private key is generated on its host and stays there, so |
| 128 | string ssh_host_cert = 17; // the VM's CA-signed host cert (authorized_keys form); seed installs it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty when the jump gate is off. | 133 | // there is nothing for the control plane to send. Reserved, not reused: an |
| 134 | // agent that predates the exchange would read a new field 16 as the private | ||
| 135 | // key it used to be handed. | ||
| 136 | reserved 16; | ||
| 137 | reserved "ssh_host_key_pem"; | ||
| 138 | // The certificate the control plane signed for the public key the host | ||
| 139 | // reported in ActualVM.ssh_host_pubkey (authorized_keys form); seed installs | ||
| 140 | // it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty until the round trip | ||
| 141 | // completes, and forever when the jump gate is off. | ||
| 142 | string ssh_host_cert = 17; | ||
| 129 | repeated string ssh_user_ca_authorized_keys = 18; // the VM's tenant user-CA set (canonical authorized_keys lines); seed writes them all into TrustedUserCAKeys. Empty when the gate is off / tenant has none. | 143 | repeated string ssh_user_ca_authorized_keys = 18; // the VM's tenant user-CA set (canonical authorized_keys lines); seed writes them all into TrustedUserCAKeys. Empty when the gate is off / tenant has none. |
| 144 | // The fleet has an SSH CA, so this guest must present a certified host key. | ||
| 145 | // A host that has reported its public key waits here until the certificate | ||
| 146 | // arrives rather than booting a guest that clients would refuse. Without it | ||
| 147 | // the agent could not tell "the gate is off, boot uncertified" from "your | ||
| 148 | // certificate has not come back yet" — both are an empty ssh_host_cert. | ||
| 149 | bool host_cert_required = 19; | ||
| 130 | } | 150 | } |
| 131 | 151 | ||
| 132 | message DesiredStateSnapshot { | 152 | message DesiredStateSnapshot { |