a73x

2874212c

feat(server): a guest's host key never leaves its host

a73x   2026-08-08 12:00

Commit message
feat(server): a guest's host key never leaves its host

A guest's SSH host key is generated on the machine that runs it. The host
writes the private half into that VM's state directory, reports the public
half upward, and the control plane signs a certificate for it — for the
principal it derives from the VM's own row, never a name the host supplied.
The certificate comes back down in the next snapshot and goes into the
guest's seed beside the key that was there all along.

The control plane therefore holds no key it could lose. What it holds is a
public key and a certificate, both of which are public by construction, and
the one thing it can do with them is what it already does at the jump gate:
prove a guest is the guest it claims to be.

Two properties hold the exchange together. A host waits: with a CA in the
fleet, a guest is not booted until its certificate arrives, because a guest
that came up with an uncertified key would keep it — converge never rebuilds
a seed. And waiting is not failing: the wait sits ahead of the create retry
budget, so a control plane that is slow to sign costs a tick, not a VM.

The public key rides every report for as long as the VM exists, so a lost
snapshot or a control-plane restart re-certifies with no operator step, and
the load-or-create key on disk means a host that restarts mid-wait comes back
holding the key it is waiting to have signed.

Opening a database sweeps `vms.ssh_host_key` clear, once. The column is
dropped in v0.0.5.

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 {