a73x

81b4b6c1

fix: three boundaries the audit found open

a73x   2026-09-02 05:53

Commit message
fix: three boundaries the audit found open

An image the fleet boots stands alone. A qcow2 may name a backing file, and
the decoder follows the name: absolute paths as written, relative ones against
the download's directory, opened by the agent as root and read through into
the raw disk wherever a cluster is unallocated. The cache now refuses an image
that declares one, permanently, before a byte is decoded, and hands the
decoder a value that offers only ReaderAt so a relative reference has no
filename to resolve against. The refusal names the image, never the path it
asked for.

A host speaks only for the VMs it holds, on every fact it reports. The status
write and the hard delete matched on VM id alone while their siblings for the
network lease and the guest host key carried host_id; a VM id is not a secret,
cloud-init hands it to the guest as instance-id. Both statements now name the
host as a predicate, the idiom the lease write already argues for, and the
server's own sweep of tombstones whose host is gone uses ForceDeleteVM, the one
delete that scopes to no host and has one caller.

A source-address certificate is honoured at the gate and refused inside. The
gate returns the certificate's critical options from its public-key callback,
which is where x/crypto's serverAuthenticate reads the restriction and measures
it against the address the connection arrived from; the plane runs on the host
network, so that address is the client's. Delegation cannot keep that promise,
since eitri reaches a guest from its host, so a delegation certificate carrying
the option is refused with the reason. x/crypto moves to v0.55.0, whose
GO-2026-6303 is the same gap on the library's side.

docs/assumptions.md records that a guest image is self-contained;
connecting.md says which certificate option the gate enforces.

Issue: 08f40f157abb587838edae47b7002fb3bcdde7d4
Issue: 860df1fdc765418aaffc4598232830dd69c20a80
Issue: 39c0defe9ad3df545d95f18026327145e251d442
Patch: 3170f758faf406897dcacc4e3d605a2a8ba1fb03

docs/assumptions.md
Old New
@@ -184,6 +184,21 @@ directly, and a gzipped qcow2 is refused rather than decoded.
184 fail at boot; nobody publishes one, and the fix if they do is to decompress to 184 fail at boot; nobody publishes one, and the fix if they do is to decompress to
185 a temp file and hand it to the decoder. 185 a temp file and hand it to the decoder.
186 186
187 ### A guest image is self-contained
188
189 A qcow2 may name a backing file, and the decoder follows the name: an absolute
190 path as written, a relative one against the download's directory, opened by
191 the agent as root and read through wherever the image left a cluster
192 unallocated. eitri refuses any image that names one, before a byte is decoded.
193 Underpins letting a tenant point `image_url` anywhere: the artifact can only
194 carry what the tenant put in it, never what sat beside it on the host.
195 **Proven** in code: the refusal is a permanent error naming the URL, the
196 decoder is handed a reader with no filename so a relative reference cannot
197 resolve even if the check is bypassed, and a fixture whose backing file holds
198 a sentinel decodes to nothing that contains it. No published cloud image ships
199 with a backing file; one that does is a snapshot, not an image, and was never
200 going to boot elsewhere.
201
187 ### vfkit is the helper we would have written 202 ### vfkit is the helper we would have written
188 203
189 Virtualization.framework is reachable only from Objective-C or Swift, so a Mac 204 Virtualization.framework is reachable only from Objective-C or Swift, so a Mac
docs/connecting.md
Old New
@@ -223,6 +223,12 @@ The client is a thin wrapper over three steps you can run by hand:
223 your cert, resolves names within that tenant, and rejects a foreign-prefixed 223 your cert, resolves names within that tenant, and rejects a foreign-prefixed
224 name. 224 name.
225 225
226 The gate honours a `source-address` critical option (`-O source-address=…`)
227 against the address it sees you connect from. The guest does not see that
228 address—the hop to it leaves from the host—so a cert that carries the option
229 is refused when offered through delegation, where only the plane would ever
230 present it.
231
226 ## Certs are short-lived 232 ## Certs are short-lived
227 233
228 Self-signed certs should carry a short validity (`-V +30m` above). When one 234 Self-signed certs should carry a short validity (`-V +30m` above). When one
go.mod
Old New
@@ -13,8 +13,8 @@ require (
13 github.com/quic-go/quic-go v0.49.1 13 github.com/quic-go/quic-go v0.49.1
14 github.com/stretchr/testify v1.11.1 14 github.com/stretchr/testify v1.11.1
15 github.com/yuin/goldmark v1.8.4 15 github.com/yuin/goldmark v1.8.4
16 golang.org/x/crypto v0.54.0 16 golang.org/x/crypto v0.55.0
17 golang.org/x/net v0.56.0 17 golang.org/x/net v0.57.0
18 golang.org/x/oauth2 v0.36.0 18 golang.org/x/oauth2 v0.36.0
19 golang.org/x/sync v0.22.0 19 golang.org/x/sync v0.22.0
20 golang.org/x/sys v0.47.0 20 golang.org/x/sys v0.47.0
go.sum
Old New
@@ -95,14 +95,14 @@ github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
95 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 95 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
96 go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= 96 go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
97 go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 97 go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
98 golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= 98 golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
99 golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= 99 golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
100 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 100 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
101 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 101 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
102 golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 102 golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
103 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 103 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
104 golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= 104 golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
105 golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= 105 golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
106 golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= 106 golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
107 golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 107 golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
108 golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= 108 golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -115,8 +115,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
115 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 115 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
116 golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= 116 golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
117 golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= 117 golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
118 golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= 118 golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
119 golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= 119 golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
120 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 120 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
121 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 121 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
122 golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= 122 golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
internal/agent/imagecache/imagecache.go
Old New
@@ -35,6 +35,7 @@ import (
35 "github.com/lima-vm/go-qcow2reader" 35 "github.com/lima-vm/go-qcow2reader"
36 "github.com/lima-vm/go-qcow2reader/convert" 36 "github.com/lima-vm/go-qcow2reader/convert"
37 "github.com/lima-vm/go-qcow2reader/image" 37 "github.com/lima-vm/go-qcow2reader/image"
38 "github.com/lima-vm/go-qcow2reader/image/qcow2"
38 "github.com/lima-vm/go-qcow2reader/image/raw" 39 "github.com/lima-vm/go-qcow2reader/image/raw"
39 "golang.org/x/sync/singleflight" 40 "golang.org/x/sync/singleflight"
40 ) 41 )
@@ -342,11 +343,22 @@ func openImage(url, path string) (*imageFile, error) {
342 if err != nil { 343 if err != nil {
343 return nil, fmt.Errorf("imagecache: open download: %w", err) 344 return nil, fmt.Errorf("imagecache: open download: %w", err)
344 } 345 }
345 img, err := qcow2reader.Open(f) 346 img, err := qcow2reader.Open(readerAtOnly{f})
346 if err != nil { 347 if err != nil {
347 f.Close() 348 f.Close()
348 return nil, permanent.Errorf("imagecache: %s: %v", url, err) 349 return nil, permanent.Errorf("imagecache: %s: %v", url, err)
349 } 350 }
351 // A base image stands alone: everything the guest disk holds comes from the
352 // bytes whose checksum was verified, and nothing is read in from the host
353 // filesystem beside them. qcow2 is the only format go-qcow2reader follows an
354 // external reference for — the rest are stubs that decode nothing — so this
355 // is the whole of that boundary. Checked before Readable() so the refusal
356 // speaks about the image rather than repeating the path it asked for.
357 if q, ok := img.(*qcow2.Qcow2); ok && (q.BackingFileOffset != 0 || q.BackingFile != "") {
358 img.Close()
359 f.Close()
360 return nil, permanent.Errorf("imagecache: %s declares a backing file; eitri boots only self-contained images — flatten it (qemu-img convert) and publish the result", url)
361 }
350 if err := img.Readable(); err != nil { 362 if err := img.Readable(); err != nil {
351 img.Close() 363 img.Close()
352 f.Close() 364 f.Close()
@@ -355,6 +367,13 @@ func openImage(url, path string) (*imageFile, error) {
355 return &imageFile{Image: img, f: f}, nil 367 return &imageFile{Image: img, f: f}, nil
356 } 368 }
357 369
370 // readerAtOnly hands the decoder the download's bytes and nothing else. The
371 // qcow2 decoder resolves a relative backing path against the name of the file
372 // it was given, and reaches that name through an interface an *os.File
373 // satisfies; a value that offers only ReaderAt cannot answer it. Closing stays
374 // with imageFile, which owns the file.
375 type readerAtOnly struct{ io.ReaderAt }
376
358 // imageFile ties the decoded image to the file underneath it, so one Close 377 // imageFile ties the decoded image to the file underneath it, so one Close
359 // releases both. 378 // releases both.
360 type imageFile struct { 379 type imageFile struct {
internal/agent/imagecache/imagecache_test.go
Old New
@@ -5,6 +5,7 @@ import (
5 "compress/gzip" 5 "compress/gzip"
6 "context" 6 "context"
7 "crypto/sha256" 7 "crypto/sha256"
8 "encoding/binary"
8 "encoding/hex" 9 "encoding/hex"
9 "net/http" 10 "net/http"
10 "net/http/httptest" 11 "net/http/httptest"
@@ -371,9 +372,9 @@ func gzipped(t *testing.T, body []byte) []byte {
371 return buf.Bytes() 372 return buf.Bytes()
372 } 373 }
373 374
374 // qcow2 prefixes body with the qcow2 magic, so a test image is classified the 375 // withQcow2Magic prefixes body with the qcow2 magic, so a test image is
375 // way a real one would be. 376 // classified the way a real one would be.
376 func qcow2(body []byte) []byte { 377 func withQcow2Magic(body []byte) []byte {
377 return append([]byte{'Q', 'F', 'I', 0xfb}, body...) 378 return append([]byte{'Q', 'F', 'I', 0xfb}, body...)
378 } 379 }
379 380
@@ -426,7 +427,7 @@ func TestEnsureRejectsCorruptGzipPermanently(t *testing.T) {
426 // reason no error names. Permanent because the sha already matched: the bytes 427 // reason no error names. Permanent because the sha already matched: the bytes
427 // are what the publisher published, and re-fetching cannot change them. 428 // are what the publisher published, and re-fetching cannot change them.
428 func TestEnsureRejectsGzippedQcow2Permanently(t *testing.T) { 429 func TestEnsureRejectsGzippedQcow2Permanently(t *testing.T) {
429 ts, sum := serve(t, gzipped(t, qcow2(bytes.Repeat([]byte{0x42}, 4096)))) 430 ts, sum := serve(t, gzipped(t, withQcow2Magic(bytes.Repeat([]byte{0x42}, 4096))))
430 dir := t.TempDir() 431 dir := t.TempDir()
431 c := New(dir) 432 c := New(dir)
432 433
@@ -524,3 +525,137 @@ func TestEnsureCacheHitReportsNothing(t *testing.T) {
524 require.NoError(t, err) 525 require.NoError(t, err)
525 assert.False(t, called, "a cache hit fetches nothing, so it reports nothing") 526 assert.False(t, called, "a cache hit fetches nothing, so it reports nothing")
526 } 527 }
528
529 // qcow2WithBackingFile builds a qcow2 image whose header names backing as its
530 // backing file. The image is one 64 KiB cluster with an all-zero L1 table, so
531 // every cluster is unallocated and a decoder that honours the backing file
532 // reads the whole virtual disk out of it. Version 2 keeps the header to the
533 // fields that matter here; the eight zero bytes after it are the end-of-
534 // extensions marker.
535 func qcow2WithBackingFile(t *testing.T, backing string) []byte {
536 t.Helper()
537 const (
538 clusterBits = 16
539 clusterSize = 1 << clusterBits
540 nameOffset = 512
541 l1TableOff = clusterSize
542 virtualBytes = clusterSize
543 )
544 var h bytes.Buffer
545 write := func(v any) {
546 require.NoError(t, binary.Write(&h, binary.BigEndian, v))
547 }
548 h.WriteString("QFI\xfb")
549 write(uint32(2)) // version
550 write(uint64(nameOffset)) // backing file offset
551 write(uint32(len(backing))) // backing file size
552 write(uint32(clusterBits)) // cluster bits
553 write(uint64(virtualBytes)) // virtual disk size
554 write(uint32(0)) // crypt method: none
555 write(uint32(1)) // L1 entries
556 write(uint64(l1TableOff)) // L1 table offset
557 write(uint64(0)) // refcount table offset
558 write(uint32(0)) // refcount table clusters
559 write(uint32(0)) // snapshot count
560 write(uint64(0)) // snapshots offset
561 require.Equal(t, 72, h.Len(), "the v2 header is 72 bytes")
562
563 img := make([]byte, l1TableOff+clusterSize)
564 copy(img, h.Bytes())
565 copy(img[nameOffset:], backing)
566 return img
567 }
568
569 // TestEnsureRefusesQcow2WithBackingFile pins the boundary a base image may not
570 // cross: eitri boots only self-contained images, so an image that declares a
571 // backing file is refused rather than decoded with host files read in through
572 // it. Both spellings a qcow2 header allows are covered — an absolute path,
573 // which needs nothing from the file underneath, and a relative one, which
574 // resolves against the directory the download landed in.
575 func TestEnsureRefusesQcow2WithBackingFile(t *testing.T) {
576 const sentinel = "SENTINEL-host-credential-must-never-reach-a-guest"
577
578 for _, tc := range []struct {
579 name string
580 backing func(root string) string
581 }{
582 {"absolute path", func(root string) string { return filepath.Join(root, "secret.txt") }},
583 {"relative path escaping the cache dir", func(string) string { return "../secret.txt" }},
584 } {
585 t.Run(tc.name, func(t *testing.T) {
586 root := t.TempDir()
587 require.NoError(t, os.WriteFile(filepath.Join(root, "secret.txt"), []byte(sentinel), 0o600))
588 dir := filepath.Join(root, "cache")
589 require.NoError(t, os.Mkdir(dir, 0o755))
590
591 ts, sum := serve(t, qcow2WithBackingFile(t, tc.backing(root)))
592 c := New(dir)
593
594 _, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
595 require.Error(t, err, "an image declaring a backing file must not be cached")
596 var perm interface{ Permanent() bool }
597 require.ErrorAs(t, err, &perm, "the header is the artifact's own, so retrying it cannot help")
598 assert.True(t, perm.Permanent())
599 assert.NotContains(t, err.Error(), "secret.txt",
600 "the refusal names the image, never the path the image asked for")
601
602 entries, err := os.ReadDir(dir)
603 require.NoError(t, err)
604 assert.Empty(t, entries, "a refused image must leave nothing in the cache")
605 for _, p := range readAll(t, dir) {
606 assert.NotContains(t, string(p), sentinel, "no host file may reach a cache entry")
607 }
608 })
609 }
610 }
611
612 // readAll returns the contents of every file under dir, so a test can assert
613 // that something never made it into any of them.
614 func readAll(t *testing.T, dir string) [][]byte {
615 t.Helper()
616 var out [][]byte
617 require.NoError(t, filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
618 if err != nil || d.IsDir() {
619 return err
620 }
621 b, err := os.ReadFile(p)
622 if err != nil {
623 return err
624 }
625 out = append(out, b)
626 return nil
627 }))
628 return out
629 }
630
631 // TestEnsureRejectsUnsupportedContainerPermanently pins the containers eitri
632 // recognises but cannot decode. They are named in the refusal and rejected,
633 // rather than falling through to raw and reaching a guest as a disk whose first
634 // sector is a header. Permanent because the sha already matched: these bytes are
635 // what the publisher published.
636 func TestEnsureRejectsUnsupportedContainerPermanently(t *testing.T) {
637 for _, tc := range []struct {
638 format string
639 magic string
640 }{
641 {"vmdk", "KDMV"},
642 {"vhdx", "vhdxfile"},
643 } {
644 t.Run(tc.format, func(t *testing.T) {
645 ts, sum := serve(t, append([]byte(tc.magic), bytes.Repeat([]byte{0x00}, 512)...))
646 dir := t.TempDir()
647 c := New(dir)
648
649 _, err := c.Ensure(context.Background(), ts.URL+"/img."+tc.format, sum, nil)
650 require.Error(t, err)
651 var perm interface{ Permanent() bool }
652 require.ErrorAs(t, err, &perm, "a container eitri cannot decode can only fail the same way twice")
653 assert.True(t, perm.Permanent())
654 assert.Contains(t, err.Error(), tc.format, "the operator must learn what the image actually is")
655
656 entries, err := os.ReadDir(dir)
657 require.NoError(t, err)
658 assert.Empty(t, entries, "a rejected image must leave nothing in the cache")
659 })
660 }
661 }
internal/server/api/api.go
Old New
@@ -236,7 +236,7 @@ func (a *API) sweepAbandonedVMs(now time.Time) bool {
236 if st, ok := a.reg.Get(vm.HostID); ok && st.Online { 236 if st, ok := a.reg.Get(vm.HostID); ok && st.Online {
237 continue 237 continue
238 } 238 }
239 if err := a.st.HardDeleteVM(vm.ID); err != nil { 239 if err := a.st.ForceDeleteVM(vm.ID); err != nil {
240 slog.Warn("sweep abandoned VM failed", "vm", vm.ID, "host", vm.HostID, "err", err) 240 slog.Warn("sweep abandoned VM failed", "vm", vm.ID, "host", vm.HostID, "err", err)
241 continue 241 continue
242 } 242 }
internal/server/api/capacity_api_test.go
Old New
@@ -245,7 +245,7 @@ func TestCreateVMCountsATombstonedVMUntilItIsReaped(t *testing.T) {
245 "the caller already deleted one; sending them to delete another is the lie") 245 "the caller already deleted one; sending them to delete another is the lie")
246 246
247 // The agent acks the destroy: the row goes, and with it the commitment. 247 // The agent acks the destroy: the row goes, and with it the commitment.
248 require.NoError(t, dbst.HardDeleteVM(created["id"])) 248 require.NoError(t, dbst.HardDeleteVM(created["id"], out["host_id"]))
249 assert.Equal(t, 201, createVM(t, ts, out["host_id"], "second", 1, 4096, 40).StatusCode, 249 assert.Equal(t, 201, createVM(t, ts, out["host_id"], "second", 1, 4096, 40).StatusCode,
250 "a reaped VM holds nothing") 250 "a reaped VM holds nothing")
251 } 251 }
internal/server/delegation/delegation.go
Old New
@@ -154,6 +154,17 @@ func (k *Keyring) Complete(tenant, certLine string, trusted func(ssh.PublicKey)
154 strings.Join(cert.ValidPrincipals, " "), k.Principal, k.Principal) 154 strings.Join(cert.ValidPrincipals, " "), k.Principal, k.Principal)
155 } 155 }
156 156
157 // A source-address restriction is a promise no one on this path can keep.
158 // eitri connects to the guest from its host, so the address the guest
159 // measures is the host's, not the address of whoever asked eitri to
160 // connect. Refusing here says so; accepting would hand back a delegation
161 // that works until the first guest silently declines it.
162 if addrs, ok := cert.CriticalOptions["source-address"]; ok {
163 return Delegation{}, fmt.Errorf("that certificate is restricted to source-address %s, and a guest would "+
164 "measure that against the address of the host eitri connects from, not yours — re-sign it without "+
165 "`-O source-address=`", addrs)
166 }
167
157 // One call does the signature, the principal and the validity window, all 168 // One call does the signature, the principal and the validity window, all
158 // against this keyring's clock. 169 // against this keyring's clock.
159 checker := &ssh.CertChecker{ 170 checker := &ssh.CertChecker{
internal/server/delegation/delegation_test.go
Old New
@@ -45,6 +45,7 @@ type certOpts struct {
45 validAfter time.Time 45 validAfter time.Time
46 validBefore time.Time 46 validBefore time.Time
47 key ssh.PublicKey // defaults to the pubLine argument 47 key ssh.PublicKey // defaults to the pubLine argument
48 critical map[string]string
48 } 49 }
49 50
50 // sign builds the certificate a tenant would produce with `ssh-keygen -s`. 51 // sign builds the certificate a tenant would produce with `ssh-keygen -s`.
@@ -76,9 +77,12 @@ func sign(t *testing.T, ca ssh.Signer, pubLine string, o certOpts) string {
76 ValidPrincipals: o.principals, 77 ValidPrincipals: o.principals,
77 ValidAfter: uint64(o.validAfter.Unix()), 78 ValidAfter: uint64(o.validAfter.Unix()),
78 ValidBefore: uint64(o.validBefore.Unix()), 79 ValidBefore: uint64(o.validBefore.Unix()),
79 Permissions: ssh.Permissions{Extensions: map[string]string{ 80 Permissions: ssh.Permissions{
80 "permit-pty": "", "permit-port-forwarding": "", 81 CriticalOptions: o.critical,
81 }}, 82 Extensions: map[string]string{
83 "permit-pty": "", "permit-port-forwarding": "",
84 },
85 },
82 } 86 }
83 require.NoError(t, cert.SignCert(rand.Reader, ca)) 87 require.NoError(t, cert.SignCert(rand.Reader, ca))
84 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert))) 88 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
@@ -367,3 +371,23 @@ func TestConcurrentUseIsSafe(t *testing.T) {
367 } 371 }
368 wg.Wait() 372 wg.Wait()
369 } 373 }
374
375 // TestCompleteRefusesSourceAddress proves a delegation certificate restricted
376 // by source-address is refused with a diagnostic rather than accepted and then
377 // rejected opaquely by the guest, which sees its host's address on the inner
378 // hop.
379 func TestCompleteRefusesSourceAddress(t *testing.T) {
380 k := New(fixedNow, "ubuntu")
381 ca := newCA(t)
382 pub, err := k.Begin("acme")
383 require.NoError(t, err)
384
385 line := sign(t, ca, pub, certOpts{critical: map[string]string{"source-address": "203.0.113.0/24"}})
386 _, err = k.Complete("acme", line, trusts(ca))
387 require.Error(t, err)
388 assert.Contains(t, err.Error(), "source-address")
389 assert.Contains(t, err.Error(), "203.0.113.0/24", "the refusal must name the restriction to re-sign without")
390
391 _, ok := k.Signer("acme")
392 assert.False(t, ok, "a refused certificate must leave no delegation behind")
393 }
internal/server/mcphttp/mcphttp_test.go
Old New
@@ -113,7 +113,7 @@ func seedVM(t *testing.T, st *store.Store, tenant, hostName, vmName string) {
113 ID: vmName + "-id", HostID: h.ID, Name: vmName, 113 ID: vmName + "-id", HostID: h.ID, Name: vmName,
114 ImageURL: "https://images.example/x.img", ImageSHA256: strings.Repeat("a", 64), 114 ImageURL: "https://images.example/x.img", ImageSHA256: strings.Repeat("a", 64),
115 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) 115 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
116 _, err = st.RecordVMStatus(vmName+"-id", "ready", "", "10.77.0.5") 116 _, err = st.RecordVMStatus(vmName+"-id", h.ID, "ready", "", "10.77.0.5")
117 require.NoError(t, err) 117 require.NoError(t, err)
118 } 118 }
119 119
internal/server/sshgate/gate.go
Old New
@@ -180,6 +180,15 @@ func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize A
180 return nil, err 180 return nil, err
181 } 181 }
182 return &ssh.Permissions{ 182 return &ssh.Permissions{
183 // The cert's own restrictions, carried back so the server layer
184 // applies them. CheckCert above skips `source-address` on purpose;
185 // x/crypto's serverAuthenticate is what checks it, against the
186 // address the connection actually came from, and it reads it from
187 // the permissions returned here. The plane runs on the host
188 // network, so that address is the client's, not a proxy's. Every
189 // other critical option is still refused by CheckCert, which has
190 // no SupportedCriticalOptions set.
191 CriticalOptions: cert.CriticalOptions,
183 Extensions: map[string]string{ 192 Extensions: map[string]string{
184 // The tenant of the CA that signed this cert, resolved from the 193 // The tenant of the CA that signed this cert, resolved from the
185 // registered tenant CA set. Downstream authz compares this against 194 // registered tenant CA set. Downstream authz compares this against
internal/server/sshgate/gate_test.go
Old New
@@ -786,3 +786,68 @@ func TestGateAuthenticatedConnDoesNotHoldStartupSlot(t *testing.T) {
786 _, err = second.Dial("tcp", "default.vm1:22") 786 _, err = second.Dial("tcp", "default.vm1:22")
787 require.NoError(t, err, "an established tunnel must not consume a startup slot") 787 require.NoError(t, err, "an established tunnel must not consume a startup slot")
788 } 788 }
789
790 // mintCertSignerCriticalOptions signs clientKey with ca into a user cert
791 // carrying the given critical options, so a test can present a cert whose
792 // restrictions the gate is expected to honour or refuse.
793 func mintCertSignerCriticalOptions(t *testing.T, ca, clientKey ssh.Signer, opts map[string]string) ssh.Signer {
794 t.Helper()
795 cert := &ssh.Certificate{
796 Key: clientKey.PublicKey(),
797 Serial: 1,
798 CertType: ssh.UserCert,
799 KeyId: "ubuntu",
800 ValidPrincipals: []string{"ubuntu"},
801 ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()),
802 ValidBefore: uint64(time.Now().Add(time.Hour).Unix()),
803 Permissions: ssh.Permissions{CriticalOptions: opts},
804 }
805 require.NoError(t, cert.SignCert(rand.Reader, ca))
806 cs, err := ssh.NewCertSigner(cert, clientKey)
807 require.NoError(t, err)
808 return cs
809 }
810
811 // TestGateEnforcesSourceAddress proves the gate honours a tenant CA's
812 // source-address restriction: a cert scoped to a network the client is not on
813 // fails auth, and the same cert scoped to the client's own network tunnels.
814 // Tests dial from 127.0.0.1, so 203.0.113.0/24 is out and 127.0.0.0/8 is in.
815 func TestGateEnforcesSourceAddress(t *testing.T) {
816 ca := newSigner(t)
817
818 outside := startGate(t, ca.PublicKey(), true)
819 cfg := &ssh.ClientConfig{
820 User: "ubuntu",
821 Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSignerCriticalOptions(t, ca, newSigner(t),
822 map[string]string{"source-address": "203.0.113.0/24"}))},
823 HostKeyCallback: ssh.FixedHostKey(outside.hostKey.PublicKey()),
824 Timeout: 5 * time.Second,
825 }
826 _, err := ssh.Dial("tcp", outside.addr, cfg)
827 require.Error(t, err, "a cert restricted to a network the client is not on must fail auth")
828
829 inside := startGate(t, ca.PublicKey(), true)
830 client := dialClient(t, inside, mintCertSignerCriticalOptions(t, ca, newSigner(t),
831 map[string]string{"source-address": "127.0.0.0/8"}))
832 conn, err := client.Dial("tcp", "default.vm1:22")
833 require.NoError(t, err, "a cert restricted to the client's own network must still tunnel")
834 _ = conn.Close()
835 }
836
837 // TestGateRejectsUnknownCriticalOption pins that source-address is the only
838 // restriction the gate can honour: any other critical option is a promise it
839 // cannot keep, so the cert fails auth rather than being silently relaxed.
840 func TestGateRejectsUnknownCriticalOption(t *testing.T) {
841 ca := newSigner(t)
842 tg := startGate(t, ca.PublicKey(), true)
843
844 cfg := &ssh.ClientConfig{
845 User: "ubuntu",
846 Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSignerCriticalOptions(t, ca, newSigner(t),
847 map[string]string{"force-command": "/bin/false"}))},
848 HostKeyCallback: ssh.FixedHostKey(tg.hostKey.PublicKey()),
849 Timeout: 5 * time.Second,
850 }
851 _, err := ssh.Dial("tcp", tg.addr, cfg)
852 require.Error(t, err, "a cert carrying a critical option the gate cannot enforce must fail auth")
853 }
internal/server/store/allocation_test.go
Old New
@@ -75,7 +75,7 @@ func TestCommittedOnHostCountsTombstonesUntilTheyAreReaped(t *testing.T) {
75 assert.Equal(t, Alloc{VCPUs: 6, MemMB: 6144, DiskGB: 30}, held.Held(), 75 assert.Equal(t, Alloc{VCPUs: 6, MemMB: 6144, DiskGB: 30}, held.Held(),
76 "a VM being destroyed still occupies the host") 76 "a VM being destroyed still occupies the host")
77 77
78 require.NoError(t, s.HardDeleteVM(dying.ID)) 78 require.NoError(t, s.HardDeleteVM(dying.ID, h.ID))
79 held, err = s.CommittedOnHost(h.ID) 79 held, err = s.CommittedOnHost(h.ID)
80 require.NoError(t, err) 80 require.NoError(t, err)
81 assert.Equal(t, Alloc{VCPUs: 2, MemMB: 2048, DiskGB: 10}, held.Held(), 81 assert.Equal(t, Alloc{VCPUs: 2, MemMB: 2048, DiskGB: 10}, held.Held(),
internal/server/store/decommission_test.go
Old New
@@ -64,7 +64,7 @@ func TestRemoveHostDoesNotReissueItsSubnet(t *testing.T) {
64 64
65 vm := makeVM(t, s, h1, "vm-a") 65 vm := makeVM(t, s, h1, "vm-a")
66 require.NoError(t, s.DecommissionHost(h1.ID)) 66 require.NoError(t, s.DecommissionHost(h1.ID))
67 require.NoError(t, s.HardDeleteVM(vm.ID)) 67 require.NoError(t, s.HardDeleteVM(vm.ID, h1.ID))
68 require.NoError(t, s.RemoveHost(h1.ID)) 68 require.NoError(t, s.RemoveHost(h1.ID))
69 69
70 _, err := s.GetHost(h1.ID) 70 _, err := s.GetHost(h1.ID)
@@ -112,6 +112,6 @@ func TestRemoveHostDrainSequence(t *testing.T) {
112 require.NoError(t, s2.DecommissionHost(h2.ID)) 112 require.NoError(t, s2.DecommissionHost(h2.ID))
113 assert.ErrorContains(t, s2.RemoveHost(h2.ID), "not drained", "tombstoned but not reaped: a row the agent has not acked still counts") 113 assert.ErrorContains(t, s2.RemoveHost(h2.ID), "not drained", "tombstoned but not reaped: a row the agent has not acked still counts")
114 114
115 require.NoError(t, s2.HardDeleteVM(vm.ID)) 115 require.NoError(t, s2.HardDeleteVM(vm.ID, h2.ID))
116 require.NoError(t, s2.RemoveHost(h2.ID), "reaped: RemoveHost should now succeed") 116 require.NoError(t, s2.RemoveHost(h2.ID), "reaped: RemoveHost should now succeed")
117 } 117 }
internal/server/store/exposures_test.go
Old New
@@ -291,7 +291,7 @@ func TestReapingAVMDestroysItsExposures(t *testing.T) {
291 require.NoError(t, err) 291 require.NoError(t, err)
292 292
293 require.NoError(t, s.TombstoneVM(vm.ID)) 293 require.NoError(t, s.TombstoneVM(vm.ID))
294 require.NoError(t, s.HardDeleteVM(vm.ID)) 294 require.NoError(t, s.HardDeleteVM(vm.ID, h.ID))
295 295
296 _, err = s.GetExposure(e.ID) 296 _, err = s.GetExposure(e.ID)
297 assert.ErrorIs(t, err, sql.ErrNoRows, "an exposure belongs to its VM and goes with it") 297 assert.ErrorIs(t, err, sql.ErrNoRows, "an exposure belongs to its VM and goes with it")
internal/server/store/store.go
Old New
@@ -951,10 +951,29 @@ func (s *Store) RestoreVM(id string) error {
951 return s.mutate(`UPDATE vms SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL`, id) 951 return s.mutate(`UPDATE vms SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL`, id)
952 } 952 }
953 953
954 // HardDeleteVM removes a tombstoned VM row (only tombstoned rows are deletable; 954 // HardDeleteVM removes a tombstoned VM row on the ack from the host that holds
955 // sql.ErrNoRows otherwise) and bumps the epoch. Called after the agent acks the 955 // it, and bumps the epoch. Only tombstoned rows are deletable — a live row gets
956 // destroy. 956 // sql.ErrNoRows, so an ack can never skip the teardown grace and leave a running
957 func (s *Store) HardDeleteVM(id string) error { 957 // guest orphaned.
958 //
959 // hostID is a predicate rather than a check made before the statement, the same
960 // idiom RecordVMNetworkIP and RecordVMHostKey use and for the same reason: a
961 // host may only ever speak for the VMs it holds, and a predicate leaves no
962 // read-then-write window in which the VM could move. The window this closes is
963 // the teardown grace itself: an ack naming a VM some other host holds would end
964 // that VM's restore window before its own host had reaped it. sql.ErrNoRows
965 // covers all three refusals — the row is gone, it is live, or another host
966 // holds it — and the caller cannot tell which.
967 func (s *Store) HardDeleteVM(id, hostID string) error {
968 return s.mutate(`DELETE FROM vms WHERE id=? AND host_id=? AND deleted_at IS NOT NULL`, id, hostID)
969 }
970
971 // ForceDeleteVM removes a tombstoned VM row on the server's own authority, with
972 // no host to name: the backstop for a tombstone whose host is gone or offline
973 // past its grace, where no agent will ever ack. Callers are the server's own
974 // sweeps, which read the row first and answer to their own authorization; the
975 // host-supplied path is HardDeleteVM, which is scoped.
976 func (s *Store) ForceDeleteVM(id string) error {
958 return s.mutate(`DELETE FROM vms WHERE id=? AND deleted_at IS NOT NULL`, id) 977 return s.mutate(`DELETE FROM vms WHERE id=? AND deleted_at IS NOT NULL`, id)
959 } 978 }
960 979
@@ -1391,9 +1410,17 @@ func usableAddress(ip string) bool {
1391 !addr.IsLinkLocalUnicast() && !addr.IsLinkLocalMulticast() && !addr.IsMulticast() 1410 !addr.IsLinkLocalUnicast() && !addr.IsLinkLocalMulticast() && !addr.IsMulticast()
1392 } 1411 }
1393 1412
1394 // RecordVMStatus persists one VM's (status, last_error, assigned_ip) and returns 1413 // RecordVMStatus persists one VM's (status, last_error, assigned_ip) as reported
1395 // the address it wrote — empty when it wrote none, either because the report 1414 // by the host holding it, and returns the address it wrote — empty when it wrote
1396 // carried none or because the address was unusable. 1415 // none, either because the report carried none or because the address was
1416 // unusable.
1417 //
1418 // hostID is a predicate rather than a check made before the statement, the same
1419 // idiom RecordVMNetworkIP and RecordVMHostKey use and for the same reason: a
1420 // host may only ever speak for the VMs it holds, and a predicate leaves no
1421 // read-then-write window in which the VM could move. A status reported for a VM
1422 // that is not the reporter's own changes no rows and gets sql.ErrNoRows, the
1423 // same answer a report for a row that is gone gets.
1397 // 1424 //
1398 // An unusable address must NOT block the (status, last_error) transition: a 1425 // An unusable address must NOT block the (status, last_error) transition: a
1399 // genuinely-failed VM still needs status=failed persisted durably. So it is 1426 // genuinely-failed VM still needs status=failed persisted durably. So it is
@@ -1404,14 +1431,14 @@ func usableAddress(ip string) bool {
1404 // that silently blanks its argument and reports success is how syncsvc's status 1431 // that silently blanks its argument and reports success is how syncsvc's status
1405 // cache came to remember addresses that were never stored, and then suppress 1432 // cache came to remember addresses that were never stored, and then suppress
1406 // every later report that would have corrected them. 1433 // every later report that would have corrected them.
1407 func (s *Store) RecordVMStatus(id, status, lastErr, ip string) (string, error) { 1434 func (s *Store) RecordVMStatus(id, hostID, status, lastErr, ip string) (string, error) {
1408 if ip != "" && !usableAddress(ip) { 1435 if ip != "" && !usableAddress(ip) {
1409 ip = "" 1436 ip = ""
1410 } 1437 }
1411 1438
1412 res, err := s.db.Exec( 1439 res, err := s.db.Exec(
1413 `UPDATE vms SET status=?, last_error=?, assigned_ip=CASE WHEN ?='' THEN assigned_ip ELSE ? END WHERE id=?`, 1440 `UPDATE vms SET status=?, last_error=?, assigned_ip=CASE WHEN ?='' THEN assigned_ip ELSE ? END WHERE id=? AND host_id=?`,
1414 status, lastErr, ip, ip, id, 1441 status, lastErr, ip, ip, id, hostID,
1415 ) 1442 )
1416 if err != nil { 1443 if err != nil {
1417 return "", err 1444 return "", err
internal/server/store/store_test.go
Old New
@@ -131,7 +131,7 @@ func TestRecordVMNetworkIPKeepsBothAddresses(t *testing.T) {
131 })) 131 }))
132 132
133 // A fresh row has the reservation and nothing from the site's DHCP yet. 133 // A fresh row has the reservation and nothing from the site's DHCP yet.
134 _, err := s.RecordVMStatus("vm-lan", "ready", "", "10.77.1.2") 134 _, err := s.RecordVMStatus("vm-lan", h.ID, "ready", "", "10.77.1.2")
135 require.NoError(t, err) 135 require.NoError(t, err)
136 got, err := s.GetVM("vm-lan") 136 got, err := s.GetVM("vm-lan")
137 require.NoError(t, err) 137 require.NoError(t, err)
@@ -145,7 +145,7 @@ func TestRecordVMNetworkIPKeepsBothAddresses(t *testing.T) {
145 assert.Equal(t, "10.77.1.2", got.AssignedIP, "the private address is a separate fact") 145 assert.Equal(t, "10.77.1.2", got.AssignedIP, "the private address is a separate fact")
146 146
147 // And the status write does not disturb the discovered one either. 147 // And the status write does not disturb the discovered one either.
148 _, err = s.RecordVMStatus("vm-lan", "failed", "boom", "10.77.1.2") 148 _, err = s.RecordVMStatus("vm-lan", h.ID, "failed", "boom", "10.77.1.2")
149 require.NoError(t, err) 149 require.NoError(t, err)
150 got, err = s.GetVM("vm-lan") 150 got, err = s.GetVM("vm-lan")
151 require.NoError(t, err) 151 require.NoError(t, err)
@@ -235,6 +235,56 @@ func TestRecordVMNetworkIPRefusesAnotherHostsVM(t *testing.T) {
235 assert.Empty(t, got.NetworkIP, "a claim from a host that does not hold this VM writes nothing") 235 assert.Empty(t, got.NetworkIP, "a claim from a host that does not hold this VM writes nothing")
236 } 236 }
237 237
238 // TestRecordVMStatusRefusesAnotherHostsVM pins the same idiom RecordVMNetworkIP
239 // and RecordVMHostKey use: host_id is a predicate on the UPDATE, not a check
240 // made before it, so a lifecycle line reported for a VM that belongs to a
241 // different host changes no rows and gets sql.ErrNoRows. The three fields it
242 // guards are the ones the owning tenant reads for whether its guest is alive
243 // and where to reach it.
244 func TestRecordVMStatusRefusesAnotherHostsVM(t *testing.T) {
245 s := newStore(t)
246 h := enrollHost(t, s)
247 require.NoError(t, s.CreateVM(VM{
248 ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", ImageSHA256: "s",
249 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
250 }))
251 _, err := s.RecordVMStatus("vm1", h.ID, "ready", "", "10.77.1.2")
252 require.NoError(t, err)
253
254 _, err = s.RecordVMStatus("vm1", "some-other-host", "failed", "seized", "10.9.9.9")
255 require.ErrorIs(t, err, sql.ErrNoRows)
256
257 got, err := s.GetVM("vm1")
258 require.NoError(t, err)
259 assert.Equal(t, "ready", got.Status, "a claim from a host that does not hold this VM writes nothing")
260 assert.Empty(t, got.LastError)
261 assert.Equal(t, "10.77.1.2", got.AssignedIP)
262 }
263
264 // TestHardDeleteVMRefusesAnotherHostsVM pins the predicate on the reap. A
265 // tombstoned row is a restore window, and an ack from a host that does not hold
266 // the VM would close that window before its own host had torn the guest down.
267 // ForceDeleteVM is the deliberate exception, and it is the server's own.
268 func TestHardDeleteVMRefusesAnotherHostsVM(t *testing.T) {
269 s := newStore(t)
270 h := enrollHost(t, s)
271 vm := makeVM(t, s, h, "leaving-1")
272 require.NoError(t, s.TombstoneVM(vm.ID))
273
274 err := s.HardDeleteVM(vm.ID, "some-other-host")
275 require.ErrorIs(t, err, sql.ErrNoRows)
276 _, err = s.GetVM(vm.ID)
277 require.NoError(t, err, "the refused ack must leave the row inside its grace window")
278
279 require.NoError(t, s.RestoreVM(vm.ID), "the window a foreign ack must not close is still open")
280
281 require.NoError(t, s.TombstoneVM(vm.ID))
282 require.NoError(t, s.ForceDeleteVM(vm.ID),
283 "the server's own backstop names no host: it runs when the host that would ack is gone")
284 _, err = s.GetVM(vm.ID)
285 assert.ErrorIs(t, err, sql.ErrNoRows)
286 }
287
238 func TestRecordVMHostKeyCertifiesOnlyItsOwnHostsVM(t *testing.T) { 288 func TestRecordVMHostKeyCertifiesOnlyItsOwnHostsVM(t *testing.T) {
239 s := newStore(t) 289 s := newStore(t)
240 h := enrollHost(t, s) 290 h := enrollHost(t, s)
@@ -659,7 +709,7 @@ func TestSpecMutationsBumpEpochButStatusWritesDoNot(t *testing.T) {
659 e2, _ := s.Epoch() 709 e2, _ := s.Epoch()
660 assert.Equal(t, e1+1, e2, "power edit bumps") 710 assert.Equal(t, e1+1, e2, "power edit bumps")
661 711
662 _, err := s.RecordVMStatus("vm1", "ready", "", "10.77.1.2") 712 _, err := s.RecordVMStatus("vm1", h.ID, "ready", "", "10.77.1.2")
663 require.NoError(t, err) 713 require.NoError(t, err)
664 e3, _ := s.Epoch() 714 e3, _ := s.Epoch()
665 assert.Equal(t, e2, e3, "agent-reported status does NOT bump") 715 assert.Equal(t, e2, e3, "agent-reported status does NOT bump")
@@ -668,7 +718,7 @@ func TestSpecMutationsBumpEpochButStatusWritesDoNot(t *testing.T) {
668 e4, _ := s.Epoch() 718 e4, _ := s.Epoch()
669 assert.Equal(t, e3+1, e4, "tombstone bumps") 719 assert.Equal(t, e3+1, e4, "tombstone bumps")
670 720
671 require.NoError(t, s.HardDeleteVM("vm1")) 721 require.NoError(t, s.HardDeleteVM("vm1", h.ID))
672 e5, _ := s.Epoch() 722 e5, _ := s.Epoch()
673 assert.Equal(t, e4+1, e5, "hard-delete bumps") 723 assert.Equal(t, e4+1, e5, "hard-delete bumps")
674 } 724 }
@@ -726,10 +776,10 @@ func TestRecordVMStatusKeepsReachableAddressesAndDropsUnusableOnes(t *testing.T)
726 h := enrollHost(t, s) // 10.77.1.0/24 776 h := enrollHost(t, s) // 10.77.1.0/24
727 require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", 777 require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u",
728 ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) 778 ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
729 _, err := s.RecordVMStatus("vm1", "ready", "", prior) 779 _, err := s.RecordVMStatus("vm1", h.ID, "ready", "", prior)
730 require.NoError(t, err) 780 require.NoError(t, err)
731 781
732 written, err := s.RecordVMStatus("vm1", "failed", "boom", tc.ip) 782 written, err := s.RecordVMStatus("vm1", h.ID, "failed", "boom", tc.ip)
733 require.NoError(t, err, "the address must never fail the status write") 783 require.NoError(t, err, "the address must never fail the status write")
734 784
735 vm, err := s.GetVM("vm1") 785 vm, err := s.GetVM("vm1")
@@ -778,7 +828,8 @@ func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) {
778 828
779 func TestRecordVMStatusUnknownVMErrors(t *testing.T) { 829 func TestRecordVMStatusUnknownVMErrors(t *testing.T) {
780 s := newStore(t) 830 s := newStore(t)
781 _, err := s.RecordVMStatus("nope", "ready", "", "") 831 h := enrollHost(t, s)
832 _, err := s.RecordVMStatus("nope", h.ID, "ready", "", "")
782 assert.Error(t, err) 833 assert.Error(t, err)
783 } 834 }
784 835
@@ -1376,10 +1427,10 @@ func TestRecordHostUplinkRefusesWhatIsNotAnAddress(t *testing.T) {
1376 assert.Equal(t, "", got.UplinkAddr, "a value that is not an address never reaches the row") 1427 assert.Equal(t, "", got.UplinkAddr, "a value that is not an address never reaches the row")
1377 } 1428 }
1378 1429
1379 // TestHardDeleteVMRefusesLiveRows pins the tombstone-only guard. Both callers 1430 // TestHardDeleteVMRefusesLiveRows pins the tombstone-only guard both delete
1380 // swallow HardDeleteVM's error — the decommission sweep skips to the next host 1431 // statements carry. Every caller swallows the error — the server's own sweep
1381 // and the sync ack path only warns — so no HTTP status ever surfaces this 1432 // skips to the next row and the sync ack path only warns — so no HTTP status
1382 // refusal and the store is the only altitude with a signal. 1433 // ever surfaces this refusal and the store is the only altitude with a signal.
1383 func TestHardDeleteVMRefusesLiveRows(t *testing.T) { 1434 func TestHardDeleteVMRefusesLiveRows(t *testing.T) {
1384 s := newStore(t) 1435 s := newStore(t)
1385 h := enrollHost(t, s) 1436 h := enrollHost(t, s)
@@ -1387,9 +1438,11 @@ func TestHardDeleteVMRefusesLiveRows(t *testing.T) {
1387 _, err := s.CreateExposure(vm.ID, 22, 10022, "tcp") 1438 _, err := s.CreateExposure(vm.ID, 22, 10022, "tcp")
1388 require.NoError(t, err) 1439 require.NoError(t, err)
1389 1440
1390 err = s.HardDeleteVM(vm.ID) 1441 err = s.HardDeleteVM(vm.ID, h.ID)
1391 assert.ErrorIs(t, err, sql.ErrNoRows, 1442 assert.ErrorIs(t, err, sql.ErrNoRows,
1392 "HardDeleteVM must refuse a live row: deleting it here skips the tombstone/teardown grace entirely, so the agent never learns the guest should die and it runs on orphaned") 1443 "HardDeleteVM must refuse a live row: deleting it here skips the tombstone/teardown grace entirely, so the agent never learns the guest should die and it runs on orphaned")
1444 assert.ErrorIs(t, s.ForceDeleteVM(vm.ID), sql.ErrNoRows,
1445 "the server's backstop is unscoped by host, never by the tombstone: it reaps what an absent agent cannot ack, not what is running")
1393 1446
1394 _, err = s.GetVM(vm.ID) 1447 _, err = s.GetVM(vm.ID)
1395 assert.NoError(t, err, "the refused delete must leave the row standing") 1448 assert.NoError(t, err, "the refused delete must leave the row standing")
@@ -1399,7 +1452,7 @@ func TestHardDeleteVMRefusesLiveRows(t *testing.T) {
1399 "the refused delete must not fire ON DELETE CASCADE: the VM's exposures went with it, tearing down published ports for a guest that is still running") 1452 "the refused delete must not fire ON DELETE CASCADE: the VM's exposures went with it, tearing down published ports for a guest that is still running")
1400 1453
1401 require.NoError(t, s.TombstoneVM(vm.ID)) 1454 require.NoError(t, s.TombstoneVM(vm.ID))
1402 require.NoError(t, s.HardDeleteVM(vm.ID), "a tombstoned row is deletable — that is the whole point of the guard") 1455 require.NoError(t, s.HardDeleteVM(vm.ID, h.ID), "a tombstoned row is deletable — that is the whole point of the guard")
1403 _, err = s.GetVM(vm.ID) 1456 _, err = s.GetVM(vm.ID)
1404 assert.Error(t, err, "the row is gone") 1457 assert.Error(t, err, "the row is gone")
1405 exps, err = s.ListExposuresForVM(vm.ID) 1458 exps, err = s.ListExposuresForVM(vm.ID)
internal/server/syncsvc/syncsvc.go
Old New
@@ -27,7 +27,7 @@ const defaultWriteTimeout = 30 * time.Second
27 // direct s.st call) lets tests substitute a counting fake to prove that 27 // direct s.st call) lets tests substitute a counting fake to prove that
28 // unchanged reports perform no write. 28 // unchanged reports perform no write.
29 type vmStatusRecorder interface { 29 type vmStatusRecorder interface {
30 RecordVMStatus(id, status, lastErr, ip string) (string, error) 30 RecordVMStatus(id, hostID, status, lastErr, ip string) (string, error)
31 } 31 }
32 32
33 // Service is the QUIC server end of the agent reconcile stream. 33 // Service is the QUIC server end of the agent reconcile stream.
@@ -495,7 +495,7 @@ func (s *Service) applyReport(hostID string, rep *pb.Report) {
495 continue 495 continue
496 } 496 }
497 err := s.tracker.writeThrough(vmID, phase, v.GetLastError(), v.GetIp(), func() (string, error) { 497 err := s.tracker.writeThrough(vmID, phase, v.GetLastError(), v.GetIp(), func() (string, error) {
498 return s.recorder.RecordVMStatus(vmID, phase, v.GetLastError(), v.GetIp()) 498 return s.recorder.RecordVMStatus(vmID, hostID, phase, v.GetLastError(), v.GetIp())
499 }) 499 })
500 if err != nil { 500 if err != nil {
501 slog.Warn("RecordVMStatus rejected", "vm", vmID, "host", hostID, "err", err) 501 slog.Warn("RecordVMStatus rejected", "vm", vmID, "host", hostID, "err", err)
@@ -545,7 +545,7 @@ func (s *Service) applyReport(hostID string, rep *pb.Report) {
545 s.tracker.forget(id) 545 s.tracker.forget(id)
546 s.certTrack.forget(id) 546 s.certTrack.forget(id)
547 s.netIPTrack.forget(id) 547 s.netIPTrack.forget(id)
548 if err := s.st.HardDeleteVM(id); err != nil { 548 if err := s.st.HardDeleteVM(id, hostID); err != nil {
549 slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err) 549 slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err)
550 } else { 550 } else {
551 anyDeleted = true 551 anyDeleted = true
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -2,6 +2,7 @@ package syncsvc
2 2
3 import ( 3 import (
4 "context" 4 "context"
5 "database/sql"
5 "errors" 6 "errors"
6 "io" 7 "io"
7 "runtime" 8 "runtime"
@@ -225,11 +226,11 @@ type countingRecorder struct {
225 calls int 226 calls int
226 } 227 }
227 228
228 func (c *countingRecorder) RecordVMStatus(id, status, lastErr, ip string) (string, error) { 229 func (c *countingRecorder) RecordVMStatus(id, hostID, status, lastErr, ip string) (string, error) {
229 c.mu.Lock() 230 c.mu.Lock()
230 c.calls++ 231 c.calls++
231 c.mu.Unlock() 232 c.mu.Unlock()
232 return c.inner.RecordVMStatus(id, status, lastErr, ip) 233 return c.inner.RecordVMStatus(id, hostID, status, lastErr, ip)
233 } 234 }
234 235
235 func (c *countingRecorder) count() int { 236 func (c *countingRecorder) count() int {
@@ -1091,3 +1092,61 @@ func TestSessionIdentityComesFromCredentialNotHello(t *testing.T) {
1091 _, onlineB := f.reg.Get(hostB.ID) 1092 _, onlineB := f.reg.Get(hostB.ID)
1092 assert.False(t, onlineB, "a spoofed host_id must not make another host read as online") 1093 assert.False(t, onlineB, "a spoofed host_id must not make another host read as online")
1093 } 1094 }
1095
1096 // TestApplyReportRefusesAnotherHostsVM pins the rule the network_ip and host-key
1097 // writes already hold, for the two facts that were left open: a host may only
1098 // ever speak for the VMs it holds. Every id in a Report is agent-supplied, and
1099 // a VM's id is not a secret the way an unguessable handle would be — cloud-init
1100 // hands it to the guest as instance-id — so a VM id reaching a host in another
1101 // tenant is an ordinary thing, not a break-in.
1102 //
1103 // The stakes are the two writes that trust it: the status line the owning
1104 // tenant reads, and the hard delete that ends the teardown grace window early
1105 // enough that a restore has nothing left to restore.
1106 func TestApplyReportRefusesAnotherHostsVM(t *testing.T) {
1107 f := setup(t)
1108
1109 // A second host, under a second tenant. It never holds vm1.
1110 _, err := f.st.CreateTenantForIdentity("https://test-issuer", "beta-subject", "beta@test.local")
1111 require.NoError(t, err)
1112 betaTok, err := f.st.CreateEnrollmentToken("beta")
1113 require.NoError(t, err)
1114 hostB, err := f.st.RedeemEnrollmentToken(betaTok, store.EnrollFacts{
1115 Name: "beta-host", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
1116 require.NoError(t, err)
1117
1118 require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a",
1119 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
1120
1121 // The owning host reports the truth first, so the test measures a change
1122 // against a known state rather than against an empty row.
1123 f.svc.applyReport(f.host.ID, &pb.Report{Vms: []*pb.VMStatus{{
1124 VmId: "vm1", PowerState: "running", Phase: "ready", Ip: "10.77.1.2"}}})
1125 vm, err := f.st.GetVM("vm1")
1126 require.NoError(t, err)
1127 require.Equal(t, "ready", vm.Status)
1128 require.Equal(t, "10.77.1.2", vm.AssignedIP)
1129
1130 // Host B names host A's VM.
1131 f.svc.applyReport(hostB.ID, &pb.Report{Vms: []*pb.VMStatus{{
1132 VmId: "vm1", PowerState: "running", Phase: "failed",
1133 LastError: "seized", Ip: "10.9.9.9"}}})
1134
1135 vm, err = f.st.GetVM("vm1")
1136 require.NoError(t, err)
1137 assert.Equal(t, "ready", vm.Status, "a host that does not hold this VM must not write its status")
1138 assert.Empty(t, vm.LastError, "nor the error line its tenant reads")
1139 assert.Equal(t, "10.77.1.2", vm.AssignedIP, "nor the address every exposure and the gate aim at")
1140
1141 // The same rule on the reap: a tombstoned row is the teardown grace window,
1142 // and only the host holding it may end that window.
1143 require.NoError(t, f.st.TombstoneVM("vm1"))
1144 f.svc.applyReport(hostB.ID, &pb.Report{Destroyed: []string{"vm1"}})
1145 _, err = f.st.GetVM("vm1")
1146 require.NoError(t, err, "a foreign ack must not reap the row out from under its restore window")
1147
1148 // The owning host's ack still reaps, so the guard costs the real path nothing.
1149 f.svc.applyReport(f.host.ID, &pb.Report{Destroyed: []string{"vm1"}})
1150 _, err = f.st.GetVM("vm1")
1151 assert.ErrorIs(t, err, sql.ErrNoRows, "the host that holds the VM still reaps it")
1152 }