81b4b6c1
fix: three boundaries the audit found open
a73x 2026-09-02 05:53
Commit message
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 | } | ||