a73x

c994de4a

refactor: one home each for facts the tree stated in several

a73x   2026-08-12 19:17

Commit message
refactor: one home each for facts the tree stated in several

Mechanical, behavior-preserving deduplication the review flagged. Each
fact now has a single source; the copies are gone. No wire, golden, or
behavior change (docs/shape.{json,html} regenerate to reflect the new
package and import edges).

- Hex SHA-256 validation: add names.IsSHA256Hex to the dependency-free
  wire leaf and delete the `^[a-f0-9]{64}$` regex from imagecache, api,
  and config. joinblob keeps its own copy on purpose — it is itself a
  wire-plane leaf (arch R9) and may import no internal package; noted in
  place so a future sweep does not break the graph.

- Permanent-error producer: add the internal/agent/permanent leaf
  (permanent.Errorf) and route cloudhv, vfkit, imagecache, and netenv
  through it, deleting three byte-identical permanentError/permanentf
  copies and netenv's ad-hoc tapConflictError. The consumer-side check
  stays where it was (reconcile matches Permanent() structurally); only
  the minting boilerplate is shared. Stale reconcile comment fixed.

- Host column list: add const hostColumns and use it in GetHost and
  listHosts, matching the vmColumns/exposureColumns/tenantColumns idiom.

- CA freezing: export store.FreezeCAs and call it from the api create
  path, deleting the identical api.freezeTrustedCAs copy.

- Connect name: add names.ConnectName(tenant, name) and build the
  <tenant>.<name> form through it in vmssh, gateclient, and cli. The web
  copy is untouched, as instructed.

- mcpserver Options.Delegator is now required (non-nil by contract): drop
  the dead nil branch and fold the two toolset tests into one.

docs/shape.html
Old New
@@ -138,6 +138,7 @@
138 "internal/agent/exec", 138 "internal/agent/exec",
139 "internal/agent/hostinfo", 139 "internal/agent/hostinfo",
140 "internal/agent/hyperlog", 140 "internal/agent/hyperlog",
141 "internal/agent/permanent",
141 "internal/agent/pidfile", 142 "internal/agent/pidfile",
142 "internal/agent/state" 143 "internal/agent/state"
143 ] 144 ]
@@ -189,7 +190,10 @@
189 "importPath": "internal/agent/imagecache", 190 "importPath": "internal/agent/imagecache",
190 "plane": "data", 191 "plane": "data",
191 "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).", 192 "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).",
192 "imports": [] 193 "imports": [
194 "internal/agent/permanent",
195 "internal/names"
196 ]
193 }, 197 },
194 { 198 {
195 "importPath": "internal/agent/ipalloc", 199 "importPath": "internal/agent/ipalloc",
@@ -204,10 +208,17 @@
204 "imports": [ 208 "imports": [
205 "internal/agent/dhcp", 209 "internal/agent/dhcp",
206 "internal/agent/exec", 210 "internal/agent/exec",
211 "internal/agent/permanent",
207 "internal/agent/state" 212 "internal/agent/state"
208 ] 213 ]
209 }, 214 },
210 { 215 {
216 "importPath": "internal/agent/permanent",
217 "plane": "data",
218 "synopsis": "Package permanent mints agent errors that no retry can fix.",
219 "imports": []
220 },
221 {
211 "importPath": "internal/agent/pidfile", 222 "importPath": "internal/agent/pidfile",
212 "plane": "data", 223 "plane": "data",
213 "synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.", 224 "synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.",
@@ -300,6 +311,7 @@
300 "internal/agent/exec", 311 "internal/agent/exec",
301 "internal/agent/hostinfo", 312 "internal/agent/hostinfo",
302 "internal/agent/hyperlog", 313 "internal/agent/hyperlog",
314 "internal/agent/permanent",
303 "internal/agent/pidfile", 315 "internal/agent/pidfile",
304 "internal/agent/state" 316 "internal/agent/state"
305 ] 317 ]
@@ -316,6 +328,7 @@
316 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.", 328 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.",
317 "imports": [ 329 "imports": [
318 "internal/guest", 330 "internal/guest",
331 "internal/names",
319 "internal/server/api/client" 332 "internal/server/api/client"
320 ] 333 ]
321 }, 334 },
@@ -335,7 +348,9 @@
335 "importPath": "internal/gateclient", 348 "importPath": "internal/gateclient",
336 "plane": "tooling", 349 "plane": "tooling",
337 "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.", 350 "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.",
338 "imports": [] 351 "imports": [
352 "internal/names"
353 ]
339 }, 354 },
340 { 355 {
341 "importPath": "internal/guest", 356 "importPath": "internal/guest",
@@ -461,7 +476,9 @@
461 "importPath": "internal/server/config", 476 "importPath": "internal/server/config",
462 "plane": "control", 477 "plane": "control",
463 "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", 478 "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.",
464 "imports": [] 479 "imports": [
480 "internal/names"
481 ]
465 }, 482 },
466 { 483 {
467 "importPath": "internal/server/delegation", 484 "importPath": "internal/server/delegation",
@@ -558,7 +575,9 @@
558 "importPath": "internal/server/vmssh", 575 "importPath": "internal/server/vmssh",
559 "plane": "control", 576 "plane": "control",
560 "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.", 577 "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.",
561 "imports": [] 578 "imports": [
579 "internal/names"
580 ]
562 }, 581 },
563 { 582 {
564 "importPath": "internal/server/web", 583 "importPath": "internal/server/web",
docs/shape.json
Old New
@@ -87,6 +87,7 @@
87 "internal/agent/exec", 87 "internal/agent/exec",
88 "internal/agent/hostinfo", 88 "internal/agent/hostinfo",
89 "internal/agent/hyperlog", 89 "internal/agent/hyperlog",
90 "internal/agent/permanent",
90 "internal/agent/pidfile", 91 "internal/agent/pidfile",
91 "internal/agent/state" 92 "internal/agent/state"
92 ] 93 ]
@@ -138,7 +139,10 @@
138 "importPath": "internal/agent/imagecache", 139 "importPath": "internal/agent/imagecache",
139 "plane": "data", 140 "plane": "data",
140 "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).", 141 "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).",
141 "imports": [] 142 "imports": [
143 "internal/agent/permanent",
144 "internal/names"
145 ]
142 }, 146 },
143 { 147 {
144 "importPath": "internal/agent/ipalloc", 148 "importPath": "internal/agent/ipalloc",
@@ -153,10 +157,17 @@
153 "imports": [ 157 "imports": [
154 "internal/agent/dhcp", 158 "internal/agent/dhcp",
155 "internal/agent/exec", 159 "internal/agent/exec",
160 "internal/agent/permanent",
156 "internal/agent/state" 161 "internal/agent/state"
157 ] 162 ]
158 }, 163 },
159 { 164 {
165 "importPath": "internal/agent/permanent",
166 "plane": "data",
167 "synopsis": "Package permanent mints agent errors that no retry can fix.",
168 "imports": []
169 },
170 {
160 "importPath": "internal/agent/pidfile", 171 "importPath": "internal/agent/pidfile",
161 "plane": "data", 172 "plane": "data",
162 "synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.", 173 "synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.",
@@ -249,6 +260,7 @@
249 "internal/agent/exec", 260 "internal/agent/exec",
250 "internal/agent/hostinfo", 261 "internal/agent/hostinfo",
251 "internal/agent/hyperlog", 262 "internal/agent/hyperlog",
263 "internal/agent/permanent",
252 "internal/agent/pidfile", 264 "internal/agent/pidfile",
253 "internal/agent/state" 265 "internal/agent/state"
254 ] 266 ]
@@ -265,6 +277,7 @@
265 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.", 277 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.",
266 "imports": [ 278 "imports": [
267 "internal/guest", 279 "internal/guest",
280 "internal/names",
268 "internal/server/api/client" 281 "internal/server/api/client"
269 ] 282 ]
270 }, 283 },
@@ -284,7 +297,9 @@
284 "importPath": "internal/gateclient", 297 "importPath": "internal/gateclient",
285 "plane": "tooling", 298 "plane": "tooling",
286 "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.", 299 "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.",
287 "imports": [] 300 "imports": [
301 "internal/names"
302 ]
288 }, 303 },
289 { 304 {
290 "importPath": "internal/guest", 305 "importPath": "internal/guest",
@@ -410,7 +425,9 @@
410 "importPath": "internal/server/config", 425 "importPath": "internal/server/config",
411 "plane": "control", 426 "plane": "control",
412 "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", 427 "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.",
413 "imports": [] 428 "imports": [
429 "internal/names"
430 ]
414 }, 431 },
415 { 432 {
416 "importPath": "internal/server/delegation", 433 "importPath": "internal/server/delegation",
@@ -507,7 +524,9 @@
507 "importPath": "internal/server/vmssh", 524 "importPath": "internal/server/vmssh",
508 "plane": "control", 525 "plane": "control",
509 "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.", 526 "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.",
510 "imports": [] 527 "imports": [
528 "internal/names"
529 ]
511 }, 530 },
512 { 531 {
513 "importPath": "internal/server/web", 532 "importPath": "internal/server/web",
internal/agent/cloudhv/cloudhv.go
Old New
@@ -19,6 +19,7 @@ import (
19 agentexec "github.com/a73x/eitri/internal/agent/exec" 19 agentexec "github.com/a73x/eitri/internal/agent/exec"
20 "github.com/a73x/eitri/internal/agent/hostinfo" 20 "github.com/a73x/eitri/internal/agent/hostinfo"
21 "github.com/a73x/eitri/internal/agent/hyperlog" 21 "github.com/a73x/eitri/internal/agent/hyperlog"
22 "github.com/a73x/eitri/internal/agent/permanent"
22 "github.com/a73x/eitri/internal/agent/pidfile" 23 "github.com/a73x/eitri/internal/agent/pidfile"
23 "github.com/a73x/eitri/internal/agent/state" 24 "github.com/a73x/eitri/internal/agent/state"
24 ) 25 )
@@ -159,19 +160,6 @@ func (p *Provisioner) buildArgs(spec state.VMSpec) []string {
159 // and small enough that DiskGB<<30 can never overflow int64 (2^50 max). 160 // and small enough that DiskGB<<30 can never overflow int64 (2^50 max).
160 const maxDiskGB = 1 << 20 161 const maxDiskGB = 1 << 20
161 162
162 // permanentError marks provisioning failures no retry can fix (the disk
163 // guards); reconcile checks the Permanent() marker and terminal-fails
164 // immediately instead of burning its retry budget.
165 type permanentError struct{ err error }
166
167 func (e permanentError) Error() string { return e.err.Error() }
168 func (e permanentError) Unwrap() error { return e.err }
169 func (e permanentError) Permanent() bool { return true }
170
171 func permanentf(format string, args ...any) error {
172 return permanentError{err: fmt.Errorf(format, args...)}
173 }
174
175 // Preflight passes unconditionally: this backend exists only on a host that 163 // Preflight passes unconditionally: this backend exists only on a host that
176 // runs guests, and the one thing it needs beyond itself — the 164 // runs guests, and the one thing it needs beyond itself — the
177 // cloud-hypervisor binary — is installed by the agent's bootstrap step at 165 // cloud-hypervisor binary — is installed by the agent's bootstrap step at
@@ -196,10 +184,10 @@ func (p *Provisioner) PrepareRootDisk(ctx context.Context, spec state.VMSpec, ba
196 return fmt.Errorf("stat base image %s: %w", basePath, err) 184 return fmt.Errorf("stat base image %s: %w", basePath, err)
197 } 185 }
198 if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB { 186 if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB {
199 return permanentf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB)) 187 return permanent.Errorf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB))
200 } 188 }
201 if targetBytes := spec.DiskGB << 30; targetBytes < base.Size() { 189 if targetBytes := spec.DiskGB << 30; targetBytes < base.Size() {
202 return permanentf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest", 190 return permanent.Errorf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest",
203 spec.DiskGB, targetBytes, basePath, base.Size()) 191 spec.DiskGB, targetBytes, basePath, base.Size())
204 } 192 }
205 diskPath := p.st.DiskPath(spec.VMID) 193 diskPath := p.st.DiskPath(spec.VMID)
internal/agent/cloudhv/cloudhv_test.go
Old New
@@ -14,6 +14,7 @@ import (
14 "testing" 14 "testing"
15 "time" 15 "time"
16 16
17 "github.com/a73x/eitri/internal/agent/permanent"
17 "github.com/a73x/eitri/internal/agent/pidfile" 18 "github.com/a73x/eitri/internal/agent/pidfile"
18 "github.com/a73x/eitri/internal/agent/state" 19 "github.com/a73x/eitri/internal/agent/state"
19 "github.com/stretchr/testify/assert" 20 "github.com/stretchr/testify/assert"
@@ -577,7 +578,7 @@ func TestBootPreservesThePermanenceMarkerFromTheNetwork(t *testing.T) {
577 require.NoError(t, err) 578 require.NoError(t, err)
578 579
579 fn := newFakeNet() 580 fn := newFakeNet()
580 fn.tapErr = permanentError{err: errors.New("name collision")} 581 fn.tapErr = permanent.Errorf("name collision")
581 p := New(st, "/nonexistent/cloud-hypervisor", "fw", nil, fn) 582 p := New(st, "/nonexistent/cloud-hypervisor", "fw", nil, fn)
582 583
583 err = p.Boot(context.Background(), "vm-perm", state.VMSpec{VMID: "vm-perm", VCPUs: 1, MemMB: 128}) 584 err = p.Boot(context.Background(), "vm-perm", state.VMSpec{VMID: "vm-perm", VCPUs: 1, MemMB: 128})
internal/agent/imagecache/format.go
Old New
@@ -43,20 +43,6 @@ func sniff(head []byte) imageFormat {
43 } 43 }
44 } 44 }
45 45
46 // permanentError marks a failure no retry can fix. reconcile matches the
47 // Permanent() method structurally (errors.As against an anonymous interface),
48 // so this mirrors the backends' markers instead of sharing one: what a VMM
49 // driver and an image cache call permanent have nothing else in common.
50 type permanentError struct{ err error }
51
52 func (e permanentError) Error() string { return e.err.Error() }
53 func (e permanentError) Unwrap() error { return e.err }
54 func (e permanentError) Permanent() bool { return true }
55
56 func permanentf(format string, args ...any) error {
57 return permanentError{err: fmt.Errorf(format, args...)}
58 }
59
60 // sniffFile classifies the image file at path. A file shorter than magicLen is 46 // sniffFile classifies the image file at path. A file shorter than magicLen is
61 // read in full and classified on what it has, so a truncated or empty image is 47 // read in full and classified on what it has, so a truncated or empty image is
62 // raw rather than an error — the checksum has already vouched for the bytes by 48 // raw rather than an error — the checksum has already vouched for the bytes by
internal/agent/imagecache/imagecache.go
Old New
@@ -27,10 +27,11 @@ import (
27 "net/http" 27 "net/http"
28 "os" 28 "os"
29 "path/filepath" 29 "path/filepath"
30 "regexp"
31 "sort" 30 "sort"
32 "time" 31 "time"
33 32
33 "github.com/a73x/eitri/internal/agent/permanent"
34 "github.com/a73x/eitri/internal/names"
34 "github.com/lima-vm/go-qcow2reader" 35 "github.com/lima-vm/go-qcow2reader"
35 "github.com/lima-vm/go-qcow2reader/convert" 36 "github.com/lima-vm/go-qcow2reader/convert"
36 "github.com/lima-vm/go-qcow2reader/image" 37 "github.com/lima-vm/go-qcow2reader/image"
@@ -45,10 +46,6 @@ type httpDoer interface {
45 Do(*http.Request) (*http.Response, error) 46 Do(*http.Request) (*http.Response, error)
46 } 47 }
47 48
48 // sha256Re matches a valid lowercase hex SHA-256 digest (exactly 64 chars).
49 // Checked before building any filesystem path to prevent path traversal.
50 var sha256Re = regexp.MustCompile(`^[a-f0-9]{64}$`)
51
52 type Cache struct { 49 type Cache struct {
53 dir string 50 dir string
54 http httpDoer 51 http httpDoer
@@ -178,7 +175,7 @@ func (c *Cache) fetch(ctx context.Context, url, sha string, progress func(done,
178 func (c *Cache) Ensure(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) { 175 func (c *Cache) Ensure(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) {
179 // Guard path traversal: sha becomes part of the cache file path. Checked 176 // Guard path traversal: sha becomes part of the cache file path. Checked
180 // before the singleflight so a bad digest can never key an entry. 177 // before the singleflight so a bad digest can never key an entry.
181 if !sha256Re.MatchString(sha) { 178 if !names.IsSHA256Hex(sha) {
182 return "", fmt.Errorf("invalid sha256: %q", sha) 179 return "", fmt.Errorf("invalid sha256: %q", sha)
183 } 180 }
184 181
@@ -288,7 +285,7 @@ func decompress(url, src, dst string) error {
288 // The download's sha already matched, so a broken gzip header is a 285 // The download's sha already matched, so a broken gzip header is a
289 // property of the published artifact: re-fetching returns the same bytes 286 // property of the published artifact: re-fetching returns the same bytes
290 // and fails the same way. 287 // and fails the same way.
291 return permanentf("imagecache: gzip header: %v", err) 288 return permanent.Errorf("imagecache: gzip header: %v", err)
292 } 289 }
293 defer zr.Close() 290 defer zr.Close()
294 291
@@ -308,7 +305,7 @@ func decompress(url, src, dst string) error {
308 if sniff(head) == formatQcow2 { 305 if sniff(head) == formatQcow2 {
309 // Permanent for the same reason a broken header is: the sha matched, so 306 // Permanent for the same reason a broken header is: the sha matched, so
310 // these bytes are what the publisher published. 307 // these bytes are what the publisher published.
311 return permanentf("imagecache: %s decompresses to a qcow2 image; gzip must wrap a RAW image — publish the qcow2 ungzipped (eitri decodes it) or gzip the raw", url) 308 return permanent.Errorf("imagecache: %s decompresses to a qcow2 image; gzip must wrap a RAW image — publish the qcow2 ungzipped (eitri decodes it) or gzip the raw", url)
312 } 309 }
313 310
314 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) 311 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
@@ -348,12 +345,12 @@ func openImage(url, path string) (*imageFile, error) {
348 img, err := qcow2reader.Open(f) 345 img, err := qcow2reader.Open(f)
349 if err != nil { 346 if err != nil {
350 f.Close() 347 f.Close()
351 return nil, permanentf("imagecache: %s: %v", url, err) 348 return nil, permanent.Errorf("imagecache: %s: %v", url, err)
352 } 349 }
353 if err := img.Readable(); err != nil { 350 if err := img.Readable(); err != nil {
354 img.Close() 351 img.Close()
355 f.Close() 352 f.Close()
356 return nil, permanentf("imagecache: %s is a %s image eitri cannot read: %v", url, img.Type(), err) 353 return nil, permanent.Errorf("imagecache: %s is a %s image eitri cannot read: %v", url, img.Type(), err)
357 } 354 }
358 return &imageFile{Image: img, f: f}, nil 355 return &imageFile{Image: img, f: f}, nil
359 } 356 }
internal/agent/netenv/netenv.go
Old New
@@ -17,6 +17,7 @@ import (
17 17
18 "github.com/a73x/eitri/internal/agent/dhcp" 18 "github.com/a73x/eitri/internal/agent/dhcp"
19 "github.com/a73x/eitri/internal/agent/exec" 19 "github.com/a73x/eitri/internal/agent/exec"
20 "github.com/a73x/eitri/internal/agent/permanent"
20 "github.com/a73x/eitri/internal/agent/state" 21 "github.com/a73x/eitri/internal/agent/state"
21 ) 22 )
22 23
@@ -79,15 +80,6 @@ func sysfsIsTapAt(root, name string) bool {
79 return flags&iffTap != 0 80 return flags&iffTap != 0
80 } 81 }
81 82
82 // tapConflictError marks the name-collision failure permanent: a foreign
83 // device squatting on the tap name never resolves by retrying (reconcile's
84 // Permanent() marker — burns the budget at once when that lands).
85 type tapConflictError struct{ err error }
86
87 func (e tapConflictError) Error() string { return e.err.Error() }
88 func (e tapConflictError) Unwrap() error { return e.err }
89 func (e tapConflictError) Permanent() bool { return true }
90
91 // Gateway returns the host-side IP (.1) on the bridge, as a bare address string. 83 // Gateway returns the host-side IP (.1) on the bridge, as a bare address string.
92 func (n *Net) Gateway() string { return n.cidr.Masked().Addr().Next().String() } 84 func (n *Net) Gateway() string { return n.cidr.Masked().Addr().Next().String() }
93 85
@@ -274,7 +266,7 @@ func (n *Net) CreateTap(ctx context.Context, vmID, ip string) error {
274 // Name collision or stale device: silently enslaving a non-tap would 266 // Name collision or stale device: silently enslaving a non-tap would
275 // surface later as an illegible cloud-hypervisor failure. Fail loudly 267 // surface later as an illegible cloud-hypervisor failure. Fail loudly
276 // here instead (pinned by TestCreateTapRejectsNonTapDevice). 268 // here instead (pinned by TestCreateTapRejectsNonTapDevice).
277 return tapConflictError{err: fmt.Errorf("link %s exists but is not a TAP device — name collision or stale interface; remove it or rename the VM", tap)} 269 return permanent.Errorf("link %s exists but is not a TAP device — name collision or stale interface; remove it or rename the VM", tap)
278 } 270 }
279 if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil { 271 if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil {
280 return err 272 return err
internal/agent/netenv/netenv_test.go
Old New
@@ -3,7 +3,6 @@ package netenv
3 import ( 3 import (
4 "context" 4 "context"
5 "errors" 5 "errors"
6 "fmt"
7 "net" 6 "net"
8 "os" 7 "os"
9 "strings" 8 "strings"
@@ -334,15 +333,9 @@ func TestSysfsIsTap(t *testing.T) {
334 assert.False(t, isTap("absent"), "missing link is not a tap") 333 assert.False(t, isTap("absent"), "missing link is not a tap")
335 } 334 }
336 335
337 // TestTapConflictErrorIsPermanent pins the marker + unwrap on the conflict error. 336 // The tap name-collision failure is marked permanent (reconcile terminal-fails
338 func TestTapConflictErrorIsPermanent(t *testing.T) { 337 // on it in one attempt); TestCreateTapRejectsNonTapDevice pins that on the
339 inner := fmt.Errorf("boom") 338 // production path, and internal/agent/permanent owns the marker's unit test.
340 e := tapConflictError{err: inner}
341 assert.Equal(t, "boom", e.Error())
342 assert.Equal(t, inner, e.Unwrap())
343 var p interface{ Permanent() bool }
344 assert.True(t, errors.As(error(e), &p) && p.Permanent())
345 }
346 339
347 func TestCreateTapAddsReservationAndTapCommands(t *testing.T) { 340 func TestCreateTapAddsReservationAndTapCommands(t *testing.T) {
348 errs := map[string]error{ 341 errs := map[string]error{
internal/agent/permanent/permanent.go
Old New
@@ -0,0 +1,30 @@
1 // Package permanent mints agent errors that no retry can fix. A provisioner or
2 // the image cache returns one when a create can never succeed as asked — a disk
3 // that would have to shrink, an image digest that will never match, a vfkit
4 // binary the host cannot legally run — so the reconcile loop spends its whole
5 // retry budget at once instead of grinding on a failure that is deterministic.
6 //
7 // Only the producer lives here. The consumer-side check does not: reconcile
8 // matches the Permanent() marker structurally (errors.As against an anonymous
9 // interface), so nothing imports this package to READ a permanent error, only
10 // to MINT one. That split is deliberate — what a VMM driver and an image cache
11 // call permanent have nothing else in common, and the decision to terminal-fail
12 // stays where the retry budget is spent.
13 package permanent
14
15 import "fmt"
16
17 // permanentError carries the marker. It is unexported: callers mint one through
18 // Errorf and read it back through the structural Permanent() check, never by
19 // name.
20 type permanentError struct{ err error }
21
22 func (e permanentError) Error() string { return e.err.Error() }
23 func (e permanentError) Unwrap() error { return e.err }
24 func (e permanentError) Permanent() bool { return true }
25
26 // Errorf returns a permanent error with a formatted message. Wrap a cause with
27 // %w to keep it in the chain (errors.Is/As still traverse it).
28 func Errorf(format string, args ...any) error {
29 return permanentError{err: fmt.Errorf(format, args...)}
30 }
internal/agent/permanent/permanent_test.go
Old New
@@ -0,0 +1,22 @@
1 package permanent
2
3 import (
4 "errors"
5 "testing"
6 )
7
8 func TestErrorfCarriesTheMarkerAndKeepsTheCause(t *testing.T) {
9 inner := errors.New("boom")
10 e := Errorf("wrapped: %w", inner)
11
12 if got := e.Error(); got != "wrapped: boom" {
13 t.Fatalf("Error() = %q, want %q", got, "wrapped: boom")
14 }
15 if !errors.Is(e, inner) {
16 t.Fatal("Errorf must keep the wrapped cause in the chain")
17 }
18 var p interface{ Permanent() bool }
19 if !errors.As(e, &p) || !p.Permanent() {
20 t.Fatal("Errorf must carry the Permanent() marker reconcile terminal-fails on")
21 }
22 }
internal/agent/reconcile/reconcile.go
Old New
@@ -854,7 +854,8 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record,
854 // permanent reports whether err (anywhere in its chain) carries the 854 // permanent reports whether err (anywhere in its chain) carries the
855 // consumer-owned permanence marker — the provisioner's way of saying no 855 // consumer-owned permanence marker — the provisioner's way of saying no
856 // retry can ever succeed (e.g. the disk-shrink guard). Consumer-side 856 // retry can ever succeed (e.g. the disk-shrink guard). Consumer-side
857 // interface per the R5 convention: reconcile declares it, cloudhv implements. 857 // interface per the R5 convention: reconcile declares it; the producers
858 // (cloudhv, vfkit, imagecache, netenv) mint one via internal/agent/permanent.
858 func permanent(err error) bool { 859 func permanent(err error) bool {
859 var p interface{ Permanent() bool } 860 var p interface{ Permanent() bool }
860 return errors.As(err, &p) && p.Permanent() 861 return errors.As(err, &p) && p.Permanent()
internal/agent/vfkit/vfkit.go
Old New
@@ -33,6 +33,7 @@ import (
33 agentexec "github.com/a73x/eitri/internal/agent/exec" 33 agentexec "github.com/a73x/eitri/internal/agent/exec"
34 "github.com/a73x/eitri/internal/agent/hostinfo" 34 "github.com/a73x/eitri/internal/agent/hostinfo"
35 "github.com/a73x/eitri/internal/agent/hyperlog" 35 "github.com/a73x/eitri/internal/agent/hyperlog"
36 "github.com/a73x/eitri/internal/agent/permanent"
36 "github.com/a73x/eitri/internal/agent/pidfile" 37 "github.com/a73x/eitri/internal/agent/pidfile"
37 "github.com/a73x/eitri/internal/agent/state" 38 "github.com/a73x/eitri/internal/agent/state"
38 ) 39 )
@@ -110,20 +111,6 @@ func New(st *state.Store, bin string, run agentexec.Runner) *Provisioner {
110 } 111 }
111 } 112 }
112 113
113 // permanentError marks a failure no retry can fix. reconcile matches the
114 // Permanent() method structurally (errors.As against an anonymous interface),
115 // so this mirrors cloudhv's marker rather than sharing one — a shared type
116 // would be the only thing the two VMM drivers have in common.
117 type permanentError struct{ err error }
118
119 func (e permanentError) Error() string { return e.err.Error() }
120 func (e permanentError) Unwrap() error { return e.err }
121 func (e permanentError) Permanent() bool { return true }
122
123 func permanentf(format string, args ...any) error {
124 return permanentError{err: fmt.Errorf(format, args...)}
125 }
126
127 // Preflight refuses a host that has no vfkit. The one thing this backend needs 114 // Preflight refuses a host that has no vfkit. The one thing this backend needs
128 // beyond itself cannot be installed by the agent the way cloud-hypervisor can: 115 // beyond itself cannot be installed by the agent the way cloud-hypervisor can:
129 // vfkit must carry Apple's virtualization entitlement, and an entitlement 116 // vfkit must carry Apple's virtualization entitlement, and an entitlement
@@ -141,11 +128,11 @@ func permanentf(format string, args ...any) error {
141 // vfkit, after the image download, in a sentence about URIs. 128 // vfkit, after the image download, in a sentence about URIs.
142 func (p *Provisioner) Preflight(_ context.Context) error { 129 func (p *Provisioner) Preflight(_ context.Context) error {
143 if _, err := p.lookPath(p.bin); err != nil { 130 if _, err := p.lookPath(p.bin); err != nil {
144 return permanentf("vfkit not found on this host (looked for %q): install it with `brew install vfkit` — "+ 131 return permanent.Errorf("vfkit not found on this host (looked for %q): install it with `brew install vfkit` — "+
145 "the macOS backend runs guests through it, and it must be Apple-entitled, so the agent cannot install it itself", p.bin) 132 "the macOS backend runs guests through it, and it must be Apple-entitled, so the agent cannot install it itself", p.bin)
146 } 133 }
147 if sock := p.sockPath(strings.Repeat("0", vmIDLen)); len(sock) > maxSocketPath { 134 if sock := p.sockPath(strings.Repeat("0", vmIDLen)); len(sock) > maxSocketPath {
148 return permanentf("state directory is too deep for macOS: a VM's control socket would be %q, %d bytes against the %d-byte limit — "+ 135 return permanent.Errorf("state directory is too deep for macOS: a VM's control socket would be %q, %d bytes against the %d-byte limit — "+
149 "run the agent with a shorter --state-dir", sock, len(sock), maxSocketPath) 136 "run the agent with a shorter --state-dir", sock, len(sock), maxSocketPath)
150 } 137 }
151 return nil 138 return nil
@@ -262,11 +249,11 @@ func (p *Provisioner) PrepareRootDisk(ctx context.Context, spec state.VMSpec, ba
262 return fmt.Errorf("stat base image %s: %w", basePath, err) 249 return fmt.Errorf("stat base image %s: %w", basePath, err)
263 } 250 }
264 if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB { 251 if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB {
265 return permanentf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB)) 252 return permanent.Errorf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB))
266 } 253 }
267 targetBytes := spec.DiskGB << 30 254 targetBytes := spec.DiskGB << 30
268 if targetBytes < base.Size() { 255 if targetBytes < base.Size() {
269 return permanentf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest", 256 return permanent.Errorf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest",
270 spec.DiskGB, targetBytes, basePath, base.Size()) 257 spec.DiskGB, targetBytes, basePath, base.Size())
271 } 258 }
272 if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil { 259 if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil {
internal/cli/sshcmd.go
Old New
@@ -11,6 +11,7 @@ import (
11 "syscall" 11 "syscall"
12 12
13 "github.com/a73x/eitri/internal/guest" 13 "github.com/a73x/eitri/internal/guest"
14 "github.com/a73x/eitri/internal/names"
14 "github.com/a73x/eitri/internal/server/api/client" 15 "github.com/a73x/eitri/internal/server/api/client"
15 ) 16 )
16 17
@@ -44,7 +45,7 @@ func SSHArgv(e Env, vm string, extra []string) []string {
44 "-o", "StrictHostKeyChecking=yes", 45 "-o", "StrictHostKeyChecking=yes",
45 "-o", "UserKnownHostsFile=" + e.KnownHosts, 46 "-o", "UserKnownHostsFile=" + e.KnownHosts,
46 "-i", e.Key, 47 "-i", e.Key,
47 guest.LoginUser + "@" + e.Tenant + "." + vm, 48 guest.LoginUser + "@" + names.ConnectName(e.Tenant, vm),
48 } 49 }
49 return append(argv, extra...) 50 return append(argv, extra...)
50 } 51 }
internal/gateclient/auth.go
Old New
@@ -16,6 +16,7 @@ import (
16 "sync" 16 "sync"
17 "time" 17 "time"
18 18
19 "github.com/a73x/eitri/internal/names"
19 "golang.org/x/crypto/ssh" 20 "golang.org/x/crypto/ssh"
20 ) 21 )
21 22
@@ -154,7 +155,7 @@ func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, erro
154 } 155 }
155 g.mu.Lock() 156 g.mu.Lock()
156 defer g.mu.Unlock() 157 defer g.mu.Unlock()
157 return g.tenant + "." + vmName, nil 158 return names.ConnectName(g.tenant, vmName), nil
158 } 159 }
159 160
160 // needsMintLocked reports whether the cached cert is absent or expires 161 // needsMintLocked reports whether the cached cert is absent or expires
internal/joinblob/joinblob.go
Old New
@@ -21,7 +21,9 @@ const Prefix = "eitri_join_"
21 // version is the current blob format version. 21 // version is the current blob format version.
22 const version = 1 22 const version = 1
23 23
24 // sha256Hex matches exactly 64 lowercase hex characters. 24 // sha256Hex matches exactly 64 lowercase hex characters. names.IsSHA256Hex is
25 // the same check, but joinblob is a wire-plane leaf (arch R9) and may import no
26 // other internal package — so this one stays its own copy on purpose.
25 var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`) 27 var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`)
26 28
27 // Fields is the decoded content of a join blob. 29 // Fields is the decoded content of a join blob.
internal/mcpserver/server.go
Old New
@@ -14,7 +14,7 @@ import (
14 14
15 // Options is what a server needs beyond the tools themselves. 15 // Options is what a server needs beyond the tools themselves.
16 type Options struct { 16 type Options struct {
17 Delegator Delegator // non-nil ⇒ expose the delegate tools 17 Delegator Delegator // required (non-nil): every server exposes the delegate tools
18 SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection 18 SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection
19 } 19 }
20 20
@@ -79,24 +79,22 @@ func NewServer(t *Tools, opts Options) *mcp.Server {
79 "currently holds a delegation and when it expires, and the gate address. Read-only. Call it first "+ 79 "currently holds a delegation and when it expires, and the gate address. Read-only. Call it first "+
80 "when an SSH operation fails, instead of guessing which step was skipped.", 80 "when an SSH operation fails, instead of guessing which step was skipped.",
81 t.TenantInfo) 81 t.TenantInfo)
82 if opts.Delegator != nil { 82 dg := opts.Delegator
83 dg := opts.Delegator 83 register(s, "delegate_begin",
84 register(s, "delegate_begin", 84 "Ask for the public key your CA is to sign, so eitri can reach your VMs. eitri holds no signing key and "+
85 "Ask for the public key your CA is to sign, so eitri can reach your VMs. eitri holds no signing key and "+ 85 "cannot sign this itself — that is the point. Show the human the public key and the command, and wait "+
86 "cannot sign this itself — that is the point. Show the human the public key and the command, and wait "+ 86 "for them to hand back the certificate; do not try to produce it yourself. The key stays the same "+
87 "for them to hand back the certificate; do not try to produce it yourself. The key stays the same "+ 87 "until the control plane restarts — a delegation is held in memory, so after a restart call this "+
88 "until the control plane restarts — a delegation is held in memory, so after a restart call this "+ 88 "tool again for the new key rather than reusing an old certificate.",
89 "tool again for the new key rather than reusing an old certificate.", 89 func(ctx context.Context, _ DelegateBeginIn) (BeginResult, error) { return dg.Begin(ctx) })
90 func(ctx context.Context, _ DelegateBeginIn) (BeginResult, error) { return dg.Begin(ctx) }) 90 register(s, "delegate_complete",
91 register(s, "delegate_complete", 91 "Hand back the certificate your CA signed. Certificates are public material, so passing one as an "+
92 "Hand back the certificate your CA signed. Certificates are public material, so passing one as an "+ 92 "argument is safe — it is not a key and grants nothing without the key eitri holds in memory. "+
93 "argument is safe — it is not a key and grants nothing without the key eitri holds in memory. "+ 93 "Afterwards eitri can reach your VMs until the certificate expires. Because it chains to a CA your "+
94 "Afterwards eitri can reach your VMs until the certificate expires. Because it chains to a CA your "+ 94 "tenant already registered, VMs created before this call accept it too.",
95 "tenant already registered, VMs created before this call accept it too.", 95 func(ctx context.Context, in DelegateCompleteIn) (DelegationResult, error) {
96 func(ctx context.Context, in DelegateCompleteIn) (DelegationResult, error) { 96 return dg.Complete(ctx, in.Certificate)
97 return dg.Complete(ctx, in.Certificate) 97 })
98 })
99 }
100 return s 98 return s
101 } 99 }
102 100
internal/mcpserver/server_test.go
Old New
@@ -86,19 +86,13 @@ func describe(t *testing.T, cs *mcp.ClientSession, name string) string {
86 return "" 86 return ""
87 } 87 }
88 88
89 // TestNewServerExposesTheCommonTools: without a Delegator the server offers the 89 // TestNewServerExposesEveryTool: the Delegator is required, so there is only
90 // VM tools and ca_upload, and nothing else — there is nothing to delegate 90 // one server shape and it offers the VM tools, ca_upload, tenant_info, and both
91 // through. 91 // delegate tools. A change here is a change to the published contract.
92 func TestNewServerExposesTheCommonTools(t *testing.T) { 92 func TestNewServerExposesEveryTool(t *testing.T) {
93 names := toolNames(t, NewServer(&Tools{}, Options{}))
94 assert.ElementsMatch(t, commonTools, names)
95 }
96
97 // TestNewServerAddsTheDelegateToolsForARemoteTransport: the two extra tools
98 // appear only when a transport can carry the exchange.
99 func TestNewServerAddsTheDelegateToolsForARemoteTransport(t *testing.T) {
100 names := toolNames(t, NewServer(&Tools{}, Options{Delegator: &fakeDelegator{}})) 93 names := toolNames(t, NewServer(&Tools{}, Options{Delegator: &fakeDelegator{}}))
101 assert.ElementsMatch(t, append(append([]string{}, commonTools...), "delegate_begin", "delegate_complete"), names) 94 want := append(append([]string{}, commonTools...), "delegate_begin", "delegate_complete")
95 assert.ElementsMatch(t, want, names)
102 } 96 }
103 97
104 // TestDelegateBeginDescriptionStopsAModelSigningItItself: the description is 98 // TestDelegateBeginDescriptionStopsAModelSigningItItself: the description is
internal/names/names.go
Old New
@@ -12,3 +12,19 @@ var rfc1123Label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
12 12
13 // IsRFC1123Label reports whether s is a valid RFC-1123 DNS label. 13 // IsRFC1123Label reports whether s is a valid RFC-1123 DNS label.
14 func IsRFC1123Label(s string) bool { return rfc1123Label.MatchString(s) } 14 func IsRFC1123Label(s string) bool { return rfc1123Label.MatchString(s) }
15
16 // sha256Hex matches a lowercase hex SHA-256 digest: exactly 64 hex characters.
17 var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`)
18
19 // IsSHA256Hex reports whether s is a lowercase hex SHA-256 digest (exactly 64
20 // hex characters) — the shape of an image content digest and of the cert
21 // fingerprint on a join blob. Both planes check it before building a filesystem
22 // path or trusting the value, so it lives in this dependency-free leaf.
23 func IsSHA256Hex(s string) bool { return sha256Hex.MatchString(s) }
24
25 // ConnectName returns a VM's connect name, "<tenant>.<name>" — the form the SSH
26 // gate resolves and the VM's host certificate is issued for. It splits back
27 // unambiguously on the first dot only because tenant ids and VM names are both
28 // dot-free (IsRFC1123Label rejects dots), so the leaf that enforces that shape
29 // is also the one place that composes the name from it.
30 func ConnectName(tenant, name string) string { return tenant + "." + name }
internal/server/api/api.go
Old New
@@ -11,7 +11,6 @@ import (
11 "fmt" 11 "fmt"
12 "log/slog" 12 "log/slog"
13 "net/http" 13 "net/http"
14 "regexp"
15 "strings" 14 "strings"
16 "time" 15 "time"
17 16
@@ -28,10 +27,8 @@ import (
28 "github.com/a73x/eitri/internal/server/store" 27 "github.com/a73x/eitri/internal/server/store"
29 ) 28 )
30 29
31 // VM names are validated as RFC-1123 DNS labels via internal/names. 30 // VM names are validated as RFC-1123 DNS labels, and image digests as hex
32 31 // SHA-256, via internal/names.
33 // sha256Hex matches exactly 64 lowercase hex characters.
34 var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`)
35 32
36 // DefaultImage is the image applied to one-click VM creates. 33 // DefaultImage is the image applied to one-click VM creates.
37 type DefaultImage struct { 34 type DefaultImage struct {
@@ -734,7 +731,7 @@ func validateCreateVM(req *types.CreateVMRequest) (string, int) {
734 } 731 }
735 // Exactly 64 lowercase hex digits — catches a misconfigured server default 732 // Exactly 64 lowercase hex digits — catches a misconfigured server default
736 // (e.g. "pinned") at create time with a clear error instead of a later mismatch. 733 // (e.g. "pinned") at create time with a clear error instead of a later mismatch.
737 if !sha256Hex.MatchString(req.ImageSHA256) { 734 if !names.IsSHA256Hex(req.ImageSHA256) {
738 return "invalid image_sha256", http.StatusBadRequest 735 return "invalid image_sha256", http.StatusBadRequest
739 } 736 }
740 // Resource floors (post-defaults, so a zero has already become the default). 737 // Resource floors (post-defaults, so a zero has already become the default).
@@ -934,7 +931,7 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
934 931
935 // Frozen from the set the refusal above just checked, so the row records 932 // Frozen from the set the refusal above just checked, so the row records
936 // exactly what permitted it to exist. 933 // exactly what permitted it to exist.
937 TrustedCAs: freezeTrustedCAs(tenantCAs), 934 TrustedCAs: store.FreezeCAs(tenantCAs),
938 935
939 VCPUs: req.VCPUs, 936 VCPUs: req.VCPUs,
940 MemMB: req.MemMB, 937 MemMB: req.MemMB,
internal/server/api/trustedcas.go
Old New
@@ -3,39 +3,11 @@ package api
3 import ( 3 import (
4 "github.com/a73x/eitri/internal/server/api/types" 4 "github.com/a73x/eitri/internal/server/api/types"
5 "github.com/a73x/eitri/internal/server/store" 5 "github.com/a73x/eitri/internal/server/store"
6 "golang.org/x/crypto/ssh"
7 ) 6 )
8 7
9 // freezeTrustedCAs turns the tenant's registered CA set into the record that 8 // The create path freezes a tenant's CA set onto the VM row through
10 // goes onto a VM row at create — the set that guest will trust for the rest of 9 // store.FreezeCAs — the same helper the snapshot path uses, so both write an
11 // its life, whatever the tenant registers or removes afterwards. 10 // identical record from one source.
12 //
13 // It carries the authorized_keys line as well as the fingerprint because the
14 // row has to SERVE this trust to the agent that bakes it, and a fingerprint is
15 // one-way. The fingerprint is computed once, here, for the same reason the
16 // injected key's is (see describeKey): the console re-reads these rows on a
17 // tick and parsing a key per read would be work repeated forever.
18 //
19 // A CA whose stored line will not parse still contributes its line, with an
20 // empty fingerprint. The line is what reaches the guest, so dropping the entry
21 // would quietly narrow the trust the tenant asked for; an empty fingerprint
22 // says "we could not name this one", which is a display problem, not a reason
23 // to change what the guest trusts.
24 func freezeTrustedCAs(cas []store.TenantUserCA) []store.TrustedCA {
25 out := make([]store.TrustedCA, 0, len(cas))
26 for _, c := range cas {
27 fp := ""
28 if pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(c.Pubkey)); err == nil {
29 fp = ssh.FingerprintSHA256(pub)
30 }
31 out = append(out, store.TrustedCA{
32 Label: c.Label,
33 Fingerprint: fp,
34 AuthorizedKey: c.Pubkey,
35 })
36 }
37 return out
38 }
39 11
40 // trustedCAs renders a VM row's frozen CA set for the wire, or nil when the row 12 // trustedCAs renders a VM row's frozen CA set for the wire, or nil when the row
41 // carries no record — a VM created before the set was written down. nil is the 13 // carries no record — a VM created before the set was written down. nil is the
internal/server/config/load.go
Old New
@@ -12,16 +12,12 @@ import (
12 "maps" 12 "maps"
13 "net/url" 13 "net/url"
14 "os" 14 "os"
15 "regexp"
16 "slices" 15 "slices"
17 "strings" 16 "strings"
18 "time" 17 "time"
19 )
20 18
21 // defaultImageSHARe matches a valid lowercase hex SHA-256 digest. Kept local 19 "github.com/a73x/eitri/internal/names"
22 // to the control plane rather than shared with the agent's imagecache: R1 20 )
23 // forbids the control plane importing the data plane, even transitively.
24 var defaultImageSHARe = regexp.MustCompile(`^[a-f0-9]{64}$`)
25 21
26 // Load reads path, decodes the JSON, and enforces every startup invariant the 22 // Load reads path, decodes the JSON, and enforces every startup invariant the
27 // server refuses to boot without. It warns (but does not fail) on the retired 23 // server refuses to boot without. It warns (but does not fail) on the retired
@@ -104,7 +100,7 @@ func validate(cfg Config) error {
104 return fmt.Errorf(`default_images has an empty architecture key (want a GOARCH, e.g. "amd64")`) 100 return fmt.Errorf(`default_images has an empty architecture key (want a GOARCH, e.g. "amd64")`)
105 case img.URL == "": 101 case img.URL == "":
106 return fmt.Errorf("default_images[%q].url is required", arch) 102 return fmt.Errorf("default_images[%q].url is required", arch)
107 case !defaultImageSHARe.MatchString(img.SHA256): 103 case !names.IsSHA256Hex(img.SHA256):
108 return fmt.Errorf("default_images[%q].sha256 malformed (want 64 lowercase hex chars), got %q", arch, img.SHA256) 104 return fmt.Errorf("default_images[%q].sha256 malformed (want 64 lowercase hex chars), got %q", arch, img.SHA256)
109 } 105 }
110 } 106 }
internal/server/store/store.go
Old New
@@ -641,11 +641,17 @@ func (s *Store) RedeemEnrollmentToken(tok string, f EnrollFacts) (Host, error) {
641 }, nil 641 }, nil
642 } 642 }
643 643
644 // hostColumns is the positional column list every host SELECT uses, so the
645 // order stays locked to the positional Scans in GetHost and listHosts below.
646 // Adding a column is one edit here plus the two Scans — no query can drift out
647 // of lockstep. Mirrors vmColumns/exposureColumns/tenantColumns.
648 const hostColumns = `id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt, uplink_addr`
649
644 func (s *Store) GetHost(id string) (Host, error) { 650 func (s *Store) GetHost(id string) (Host, error) {
645 var h Host 651 var h Host
646 var enrolledAt string 652 var enrolledAt string
647 err := s.db.QueryRow( 653 err := s.db.QueryRow(
648 `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt, uplink_addr FROM hosts WHERE id=?`, id, 654 `SELECT `+hostColumns+` FROM hosts WHERE id=?`, id,
649 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt, &h.UplinkAddr) 655 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt, &h.UplinkAddr)
650 if err != nil { 656 if err != nil {
651 return Host{}, err 657 return Host{}, err
@@ -775,7 +781,7 @@ type querier interface {
775 func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } 781 func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) }
776 782
777 func listHosts(q querier) ([]Host, error) { 783 func listHosts(q querier) ([]Host, error) {
778 rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt, uplink_addr FROM hosts`) 784 rows, err := q.Query(`SELECT ` + hostColumns + ` FROM hosts`)
779 if err != nil { 785 if err != nil {
780 return nil, err 786 return nil, err
781 } 787 }
internal/server/store/trustedcas.go
Old New
@@ -52,7 +52,7 @@ func (s *Store) backfillTrustedCAs() error {
52 if err != nil { 52 if err != nil {
53 return fmt.Errorf("list user cas for %s: %w", r.tenant, err) 53 return fmt.Errorf("list user cas for %s: %w", r.tenant, err)
54 } 54 }
55 b, err := json.Marshal(freezeCAs(cas)) 55 b, err := json.Marshal(FreezeCAs(cas))
56 if err != nil { 56 if err != nil {
57 return fmt.Errorf("marshal frozen cas for %s: %w", r.tenant, err) 57 return fmt.Errorf("marshal frozen cas for %s: %w", r.tenant, err)
58 } 58 }
@@ -96,19 +96,25 @@ func (s *Store) unrecordedVMs() ([]unrecordedVM, error) {
96 return out, nil 96 return out, nil
97 } 97 }
98 98
99 // freezeCAs turns a tenant's registered CAs into the record that goes onto a VM 99 // FreezeCAs turns a tenant's registered CA set into the record that goes onto a
100 // row: the label, the SHA256 fingerprint OpenSSH would print, and the 100 // VM row at create — the set that guest will trust for the rest of its life,
101 // authorized_keys line itself — the line because the row has to SERVE this trust 101 // whatever the tenant registers or removes afterwards.
102 // to an agent and a fingerprint is one-way. A key line that will not parse still
103 // contributes its line with an empty fingerprint, since the line is what reaches
104 // the guest and dropping it would quietly narrow the tenant's trust.
105 // 102 //
106 // This is the same record api.freezeTrustedCAs writes at create, deliberately 103 // It carries the authorized_keys line as well as the SHA256 fingerprint
107 // producing an identical shape — the JSON is the tag set on TrustedCA, which 104 // OpenSSH would print, because the row has to SERVE this trust to the agent
108 // both share. It is a second copy because the API package imports this one and 105 // that bakes it and a fingerprint is one-way. The fingerprint is computed once,
109 // not the other way around; collapsing the two means moving the create-path 106 // here, so a console re-reading these rows on a tick does not parse a key per
110 // helper down here, which is a change to the API layer. 107 // read forever.
111 func freezeCAs(cas []TenantUserCA) []TrustedCA { 108 //
109 // A CA whose stored line will not parse still contributes its line, with an
110 // empty fingerprint. The line is what reaches the guest, so dropping the entry
111 // would quietly narrow the trust the tenant asked for; an empty fingerprint
112 // says "we could not name this one", a display problem, not a reason to change
113 // what the guest trusts.
114 //
115 // Exported so the create path (internal/server/api) freezes exactly what the
116 // snapshot path does, from one source — the API package already imports store.
117 func FreezeCAs(cas []TenantUserCA) []TrustedCA {
112 out := make([]TrustedCA, 0, len(cas)) 118 out := make([]TrustedCA, 0, len(cas))
113 for _, c := range cas { 119 for _, c := range cas {
114 fp := "" 120 fp := ""
internal/server/vmssh/vmssh.go
Old New
@@ -24,6 +24,7 @@ import (
24 "strings" 24 "strings"
25 "time" 25 "time"
26 26
27 "github.com/a73x/eitri/internal/names"
27 "golang.org/x/crypto/ssh" 28 "golang.org/x/crypto/ssh"
28 ) 29 )
29 30
@@ -84,7 +85,7 @@ type Dialer struct {
84 // ConnectName returns the VM's <tenant>.<name> connect name — the form the gate 85 // ConnectName returns the VM's <tenant>.<name> connect name — the form the gate
85 // resolves and the VM's host certificate names. 86 // resolves and the VM's host certificate names.
86 func (d *Dialer) ConnectName(_ context.Context, vmName string) (string, error) { 87 func (d *Dialer) ConnectName(_ context.Context, vmName string) (string, error) {
87 return d.Tenant + "." + vmName, nil 88 return names.ConnectName(d.Tenant, vmName), nil
88 } 89 }
89 90
90 // Dial opens an authenticated SSH connection to the VM named vmName. The caller 91 // Dial opens an authenticated SSH connection to the VM named vmName. The caller