a73x

8d63ba78

feat(net): embedded DHCP + per-VM reservation networking

a73x   2026-07-25 14:03

Commit message
feat(net): embedded DHCP + per-VM reservation networking

Guests get their address from an in-agent DHCP responder instead of a static
seed. The agent owns a reservation table keyed by VM MAC (kept in state as the
shared reservation key), answers only reserved MACs with a fail-closed
Offer/Ack on a UDP listener bound to the bridge, and preloads reservations
from state on start. netenv is agent-authoritative via a widened
CreateTap/DeleteTap(vmID); seed emits DHCP network-config and carries no
static IP or gateway.

cmd/eitri-agent/main.go
Old New
@@ -172,12 +172,23 @@ func runAgent(st *state.Store, cfg agentConfig) {
172 prov.Pumps = pumps 172 prov.Pumps = pumps
173 if recs, err := st.LoadVMs(); err == nil { 173 if recs, err := st.LoadVMs(); err == nil {
174 for _, rec := range recs { 174 for _, rec := range recs {
175 if rec.IP != "" {
176 net.AddReservation(rec.Spec.VMID, rec.IP)
177 }
175 if prov.Running(rec.Spec.VMID) { 178 if prov.Running(rec.Spec.VMID) {
176 pumps.Ensure(rec.Spec.VMID) 179 pumps.Ensure(rec.Spec.VMID)
177 } 180 }
178 } 181 }
179 } else { 182 } else {
180 slog.Warn("serial pump adoption skipped; consoles of surviving VMs will be silent", "err", err) 183 slog.Warn("state load failed; surviving VMs' consoles will be silent AND their DHCP reservations are not rebuilt (they may fail to renew until reconcile recreates them)", "err", err)
184 }
185
186 // Start the embedded DHCP responder AFTER reservations are rebuilt from
187 // state, so a surviving guest's renewal is never answered from an empty
188 // table (fail-closed + reservation preload close the gap).
189 if err := net.StartDHCP(ctx); err != nil {
190 slog.Error("start dhcp", "err", err)
191 os.Exit(1)
181 } 192 }
182 193
183 cache := imagecache.New(st.ImagesDir(), realRunner) 194 cache := imagecache.New(st.ImagesDir(), realRunner)
docs/architecture.md
Old New
@@ -37,7 +37,7 @@ bridge IP (`assigned_ip`) via the agent.
37 | **R2** | No `internal/server` package shells out — the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). | 37 | **R2** | No `internal/server` package shells out — the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). |
38 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. | 38 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. |
39 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | 39 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. |
40 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the IPAM seam (`NetEnv.AllocateIP`/`GuestNetwork`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | 40 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the IPAM seam (`NetEnv.AllocateIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. |
41 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | 41 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). |
42 | **R7** | `internal/integration/*` is test infrastructure only — no package under `internal/server/*`, `internal/agent/*`, or `cmd/*` may import it, even transitively. The sanctioned exceptions are the test-tooling launcher binaries that are the infrastructure's entry points: `cmd/eitri-smoketest`, `cmd/eitri-devstack`, `cmd/eitri-sandbox`. | `internal/arch` `TestProductionPlanesDoNotImportIntegrationTestInfra` (transitive). | 42 | **R7** | `internal/integration/*` is test infrastructure only — no package under `internal/server/*`, `internal/agent/*`, or `cmd/*` may import it, even transitively. The sanctioned exceptions are the test-tooling launcher binaries that are the infrastructure's entry points: `cmd/eitri-smoketest`, `cmd/eitri-devstack`, `cmd/eitri-sandbox`. | `internal/arch` `TestProductionPlanesDoNotImportIntegrationTestInfra` (transitive). |
43 43
docs/shape.html
Old New
@@ -123,6 +123,12 @@
123 ] 123 ]
124 }, 124 },
125 { 125 {
126 "importPath": "internal/agent/dhcp",
127 "plane": "data",
128 "synopsis": "Package dhcp is an in-process, reservation-only DHCPv4 responder for the eitri bridge.",
129 "imports": []
130 },
131 {
126 "importPath": "internal/agent/enrollclient", 132 "importPath": "internal/agent/enrollclient",
127 "plane": "data", 133 "plane": "data",
128 "synopsis": "Package enrollclient speaks the control plane's enrollment endpoint.", 134 "synopsis": "Package enrollclient speaks the control plane's enrollment endpoint.",
@@ -153,8 +159,10 @@
153 "plane": "data", 159 "plane": "data",
154 "synopsis": "Package netenv manages the host side of VM networking: bridge eitri0 with the host as .1 gateway, per-VM taps, and NAT for outbound internet.", 160 "synopsis": "Package netenv manages the host side of VM networking: bridge eitri0 with the host as .1 gateway, per-VM taps, and NAT for outbound internet.",
155 "imports": [ 161 "imports": [
162 "internal/agent/dhcp",
156 "internal/agent/exec", 163 "internal/agent/exec",
157 "internal/agent/ipalloc" 164 "internal/agent/ipalloc",
165 "internal/agent/state"
158 ] 166 ]
159 }, 167 },
160 { 168 {
docs/shape.json
Old New
@@ -72,6 +72,12 @@
72 ] 72 ]
73 }, 73 },
74 { 74 {
75 "importPath": "internal/agent/dhcp",
76 "plane": "data",
77 "synopsis": "Package dhcp is an in-process, reservation-only DHCPv4 responder for the eitri bridge.",
78 "imports": []
79 },
80 {
75 "importPath": "internal/agent/enrollclient", 81 "importPath": "internal/agent/enrollclient",
76 "plane": "data", 82 "plane": "data",
77 "synopsis": "Package enrollclient speaks the control plane's enrollment endpoint.", 83 "synopsis": "Package enrollclient speaks the control plane's enrollment endpoint.",
@@ -102,8 +108,10 @@
102 "plane": "data", 108 "plane": "data",
103 "synopsis": "Package netenv manages the host side of VM networking: bridge eitri0 with the host as .1 gateway, per-VM taps, and NAT for outbound internet.", 109 "synopsis": "Package netenv manages the host side of VM networking: bridge eitri0 with the host as .1 gateway, per-VM taps, and NAT for outbound internet.",
104 "imports": [ 110 "imports": [
111 "internal/agent/dhcp",
105 "internal/agent/exec", 112 "internal/agent/exec",
106 "internal/agent/ipalloc" 113 "internal/agent/ipalloc",
114 "internal/agent/state"
107 ] 115 ]
108 }, 116 },
109 { 117 {
go.mod
Old New
@@ -5,6 +5,7 @@ go 1.26.4
5 require ( 5 require (
6 github.com/coder/websocket v1.8.15 6 github.com/coder/websocket v1.8.15
7 github.com/diskfs/go-diskfs v1.9.3 7 github.com/diskfs/go-diskfs v1.9.3
8 github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
8 github.com/modelcontextprotocol/go-sdk v1.6.1 9 github.com/modelcontextprotocol/go-sdk v1.6.1
9 github.com/pkg/sftp v1.13.11 10 github.com/pkg/sftp v1.13.11
10 github.com/quic-go/quic-go v0.48.2 11 github.com/quic-go/quic-go v0.48.2
@@ -27,6 +28,7 @@ require (
27 github.com/google/jsonschema-go v0.4.3 // indirect 28 github.com/google/jsonschema-go v0.4.3 // indirect
28 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect 29 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
29 github.com/google/uuid v1.6.0 // indirect 30 github.com/google/uuid v1.6.0 // indirect
31 github.com/josharian/native v1.1.0 // indirect
30 github.com/klauspost/compress v1.18.5 // indirect 32 github.com/klauspost/compress v1.18.5 // indirect
31 github.com/kr/fs v0.1.0 // indirect 33 github.com/kr/fs v0.1.0 // indirect
32 github.com/mattn/go-isatty v0.0.20 // indirect 34 github.com/mattn/go-isatty v0.0.20 // indirect
@@ -39,6 +41,7 @@ require (
39 github.com/segmentio/asm v1.1.3 // indirect 41 github.com/segmentio/asm v1.1.3 // indirect
40 github.com/segmentio/encoding v0.5.4 // indirect 42 github.com/segmentio/encoding v0.5.4 // indirect
41 github.com/sirupsen/logrus v1.9.4 // indirect 43 github.com/sirupsen/logrus v1.9.4 // indirect
44 github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
42 github.com/ulikunitz/xz v0.5.15 // indirect 45 github.com/ulikunitz/xz v0.5.15 // indirect
43 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 46 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
44 go.uber.org/mock v0.4.0 // indirect 47 go.uber.org/mock v0.4.0 // indirect
go.sum
Old New
@@ -33,12 +33,21 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
33 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
34 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 34 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
35 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 35 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
36 github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82 h1:y5aU8Uvl7eyM5WNgdQvRxbMJb+zo7pD+S72/Yo4pvnQ=
37 github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
38 github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
39 github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
40 github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
36 github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= 41 github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
37 github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 42 github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
38 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 43 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
39 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 44 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
40 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 45 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
41 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 46 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
47 github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
48 github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
49 github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
50 github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
42 github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= 51 github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
43 github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= 52 github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
44 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 53 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -47,6 +56,7 @@ github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
47 github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= 56 github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
48 github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 57 github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
49 github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 58 github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
59 github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
50 github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= 60 github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
51 github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 61 github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
52 github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= 62 github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
@@ -69,6 +79,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
69 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 79 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
70 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 80 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
71 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 81 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
82 github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
83 github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
72 github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= 84 github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
73 github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 85 github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
74 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 86 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -89,6 +101,7 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
89 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 101 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
90 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 102 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
91 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104 golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
92 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 105 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
93 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= 106 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
94 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 107 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
internal/agent/cloudhv/cloudhv.go
Old New
@@ -6,7 +6,6 @@ package cloudhv
6 6
7 import ( 7 import (
8 "context" 8 "context"
9 "crypto/sha256"
10 "fmt" 9 "fmt"
11 "net" 10 "net"
12 "net/http" 11 "net/http"
@@ -25,14 +24,6 @@ import (
25 // chLogMode is the file mode for the cloud-hypervisor diagnostic log. 24 // chLogMode is the file mode for the cloud-hypervisor diagnostic log.
26 const chLogMode = 0o600 25 const chLogMode = 0o600
27 26
28 // MAC returns a deterministic, locally-administered MAC address for vmID.
29 // It uses the QEMU/KVM OUI prefix 52:54:00 and derives the last three
30 // octets from SHA-256(vmID).
31 func MAC(vmID string) string {
32 h := sha256.Sum256([]byte(vmID))
33 return fmt.Sprintf("52:54:00:%02x:%02x:%02x", h[0], h[1], h[2])
34 }
35
36 // PumpHooks is the serial-console pump lifecycle the provisioner drives 27 // PumpHooks is the serial-console pump lifecycle the provisioner drives
37 // (consumer-owned; the concrete implementation is *serialpump.Manager, wired 28 // (consumer-owned; the concrete implementation is *serialpump.Manager, wired
38 // by main — cloudhv must not import serialpump). nil disables the hooks. 29 // by main — cloudhv must not import serialpump). nil disables the hooks.
@@ -64,7 +55,7 @@ func New(st *state.Store, chBin, firmware string, run agentexec.Runner) *Provisi
64 func (p *Provisioner) buildArgs(spec state.VMSpec) []string { 55 func (p *Provisioner) buildArgs(spec state.VMSpec) []string {
65 vmID := spec.VMID 56 vmID := spec.VMID
66 tap := state.TapName(vmID) 57 tap := state.TapName(vmID)
67 mac := MAC(vmID) 58 mac := state.MAC(vmID)
68 59
69 return []string{ 60 return []string{
70 "--api-socket", p.st.SocketPath(vmID), 61 "--api-socket", p.st.SocketPath(vmID),
internal/agent/cloudhv/cloudhv_test.go
Old New
@@ -18,9 +18,9 @@ import (
18 ) 18 )
19 19
20 func TestMACDeterministicAndLocallyAdministered(t *testing.T) { 20 func TestMACDeterministicAndLocallyAdministered(t *testing.T) {
21 m1, m2 := MAC("vm-abc"), MAC("vm-abc") 21 m1, m2 := state.MAC("vm-abc"), state.MAC("vm-abc")
22 assert.Equal(t, m1, m2) 22 assert.Equal(t, m1, m2)
23 assert.NotEqual(t, m1, MAC("vm-def")) 23 assert.NotEqual(t, m1, state.MAC("vm-def"))
24 assert.True(t, strings.HasPrefix(m1, "52:54:00:"), "QEMU/KVM locally-administered OUI") 24 assert.True(t, strings.HasPrefix(m1, "52:54:00:"), "QEMU/KVM locally-administered OUI")
25 } 25 }
26 26
@@ -36,7 +36,7 @@ func TestBuildArgs(t *testing.T) {
36 assert.Contains(t, joined, "size=2048M") 36 assert.Contains(t, joined, "size=2048M")
37 assert.Contains(t, joined, st.DiskPath("vm1")) 37 assert.Contains(t, joined, st.DiskPath("vm1"))
38 assert.Contains(t, joined, st.SeedPath("vm1")) 38 assert.Contains(t, joined, st.SeedPath("vm1"))
39 assert.Contains(t, joined, "tap=eit-vm1,mac="+MAC("vm1")) 39 assert.Contains(t, joined, "tap=eit-vm1,mac="+state.MAC("vm1"))
40 } 40 }
41 41
42 func TestBuildArgsUsesSerialSocket(t *testing.T) { 42 func TestBuildArgsUsesSerialSocket(t *testing.T) {
internal/agent/dhcp/dhcp.go
Old New
@@ -0,0 +1,137 @@
1 // Package dhcp is an in-process, reservation-only DHCPv4 responder for the
2 // eitri bridge. It answers only for MACs it holds a reservation for, replying
3 // with the agent-allocated IP; unknown MACs get no reply (fail-closed). There
4 // is no dynamic pool, so there is no lease-conflict or expiry-reclaim logic —
5 // the agent is the sole authority on addressing.
6 package dhcp
7
8 import (
9 "context"
10 "log/slog"
11 "net"
12 "sync"
13 "time"
14
15 "github.com/insomniacslk/dhcp/dhcpv4"
16 "github.com/insomniacslk/dhcp/dhcpv4/server4"
17 )
18
19 // Server serves reserved DHCPv4 leases on a single interface.
20 type Server struct {
21 iface string
22 gateway net.IP
23 mask net.IPMask
24 dns []net.IP
25 lease time.Duration
26
27 mu sync.RWMutex
28 res map[string]net.IP // key: normalized MAC string
29 }
30
31 // NewServer builds a Server. gateway is the host's address on the bridge (also
32 // the DHCP router and server-identifier); mask is the bridge subnet mask; dns
33 // is handed to guests; lease is the offered lease time.
34 func NewServer(iface string, gateway net.IP, mask net.IPMask, dns []net.IP, lease time.Duration) *Server {
35 return &Server{
36 iface: iface,
37 gateway: gateway,
38 mask: mask,
39 dns: dns,
40 lease: lease,
41 res: make(map[string]net.IP),
42 }
43 }
44
45 // key normalizes a MAC to a stable lookup string (lower-case, colon-separated).
46 func key(mac net.HardwareAddr) string { return mac.String() }
47
48 // SetReservation pins mac to ip. Overwrites any existing reservation for mac.
49 func (s *Server) SetReservation(mac net.HardwareAddr, ip net.IP) {
50 s.mu.Lock()
51 defer s.mu.Unlock()
52 s.res[key(mac)] = ip
53 }
54
55 // RemoveReservation drops any reservation for mac.
56 func (s *Server) RemoveReservation(mac net.HardwareAddr) {
57 s.mu.Lock()
58 defer s.mu.Unlock()
59 delete(s.res, key(mac))
60 }
61
62 // lookup returns the reserved IP for mac.
63 func (s *Server) lookup(mac net.HardwareAddr) (net.IP, bool) {
64 s.mu.RLock()
65 defer s.mu.RUnlock()
66 ip, ok := s.res[key(mac)]
67 return ip, ok
68 }
69
70 // LookupForTest exposes reservation lookup for tests in other packages.
71 func (s *Server) LookupForTest(mac net.HardwareAddr) (net.IP, bool) { return s.lookup(mac) }
72
73 // buildReply produces the DHCP response for req, or (nil, nil) when req's MAC
74 // has no reservation (fail-closed) or is a message type we do not serve. It
75 // touches no sockets, so it is unit-tested directly.
76 func (s *Server) buildReply(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) {
77 ip, ok := s.lookup(req.ClientHWAddr)
78 if !ok {
79 return nil, nil // unknown MAC: no lease
80 }
81 resp, err := dhcpv4.NewReplyFromRequest(req)
82 if err != nil {
83 return nil, err
84 }
85 resp.YourIPAddr = make(net.IP, len(ip))
86 copy(resp.YourIPAddr, ip)
87 resp.UpdateOption(dhcpv4.OptServerIdentifier(s.gateway))
88 resp.UpdateOption(dhcpv4.OptSubnetMask(s.mask))
89 resp.UpdateOption(dhcpv4.OptRouter(s.gateway))
90 resp.UpdateOption(dhcpv4.OptDNS(s.dns...))
91 resp.UpdateOption(dhcpv4.OptIPAddressLeaseTime(s.lease))
92
93 switch req.MessageType() {
94 case dhcpv4.MessageTypeDiscover:
95 resp.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeOffer))
96 case dhcpv4.MessageTypeRequest:
97 resp.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeAck))
98 default:
99 return nil, nil // Release/Decline/Inform: nothing to serve
100 }
101 return resp, nil
102 }
103
104 // handle is the server4 callback: build a reply and, if any, write it back.
105 func (s *Server) handle(conn net.PacketConn, peer net.Addr, m *dhcpv4.DHCPv4) {
106 resp, err := s.buildReply(m)
107 if err != nil {
108 slog.Warn("dhcp buildReply", "err", err, "mac", m.ClientHWAddr)
109 return
110 }
111 if resp == nil {
112 return // fail-closed: no reservation / unserved type
113 }
114 if _, err := conn.WriteTo(resp.ToBytes(), peer); err != nil {
115 slog.Warn("dhcp write", "err", err, "mac", m.ClientHWAddr)
116 }
117 }
118
119 // Start binds a DHCPv4 listener to the configured interface (port 67) and
120 // serves in the background until ctx is cancelled. The interface must already
121 // exist (the bridge is created first). Requires privilege to bind :67.
122 func (s *Server) Start(ctx context.Context) error {
123 srv, err := server4.NewServer(s.iface, &net.UDPAddr{Port: 67}, s.handle)
124 if err != nil {
125 return err
126 }
127 go func() {
128 if err := srv.Serve(); err != nil {
129 slog.Info("dhcp server stopped", "err", err)
130 }
131 }()
132 go func() {
133 <-ctx.Done()
134 _ = srv.Close()
135 }()
136 return nil
137 }
internal/agent/dhcp/dhcp_test.go
Old New
@@ -0,0 +1,111 @@
1 package dhcp
2
3 import (
4 "net"
5 "testing"
6 "time"
7
8 "github.com/insomniacslk/dhcp/dhcpv4"
9 )
10
11 func testServer() *Server {
12 return NewServer("eitri0",
13 net.IPv4(10, 77, 1, 1),
14 net.CIDRMask(24, 32),
15 []net.IP{net.IPv4(1, 1, 1, 1)},
16 12*time.Hour)
17 }
18
19 func mustMAC(t *testing.T, s string) net.HardwareAddr {
20 t.Helper()
21 m, err := net.ParseMAC(s)
22 if err != nil {
23 t.Fatalf("parse mac %q: %v", s, err)
24 }
25 return m
26 }
27
28 func TestReservationSetLookupRemove(t *testing.T) {
29 s := testServer()
30 mac := mustMAC(t, "52:54:00:ab:cd:ef")
31
32 if _, ok := s.lookup(mac); ok {
33 t.Fatal("unknown MAC should not resolve")
34 }
35 s.SetReservation(mac, net.IPv4(10, 77, 1, 7))
36 got, ok := s.lookup(mac)
37 if !ok || !got.Equal(net.IPv4(10, 77, 1, 7)) {
38 t.Fatalf("lookup after set = %v, %v; want 10.77.1.7, true", got, ok)
39 }
40 s.RemoveReservation(mac)
41 if _, ok := s.lookup(mac); ok {
42 t.Fatal("lookup after remove should be false")
43 }
44 }
45
46 func TestReservationLookupIsCaseInsensitiveOnMAC(t *testing.T) {
47 s := testServer()
48 s.SetReservation(mustMAC(t, "52:54:00:AB:CD:EF"), net.IPv4(10, 77, 1, 9))
49 if _, ok := s.lookup(mustMAC(t, "52:54:00:ab:cd:ef")); !ok {
50 t.Fatal("reservation lookup must not depend on MAC hex case")
51 }
52 }
53
54 func request(t *testing.T, mac net.HardwareAddr, mt dhcpv4.MessageType) *dhcpv4.DHCPv4 {
55 t.Helper()
56 m, err := dhcpv4.New(dhcpv4.WithHwAddr(mac), dhcpv4.WithMessageType(mt))
57 if err != nil {
58 t.Fatalf("build request: %v", err)
59 }
60 return m
61 }
62
63 func TestBuildReplyUnknownMACGetsNothing(t *testing.T) {
64 s := testServer()
65 resp, err := s.buildReply(request(t, mustMAC(t, "52:54:00:11:22:33"), dhcpv4.MessageTypeDiscover))
66 if err != nil {
67 t.Fatalf("buildReply err: %v", err)
68 }
69 if resp != nil {
70 t.Fatalf("unknown MAC must get no reply, got %v", resp)
71 }
72 }
73
74 func TestBuildReplyDiscoverOffersReservedIP(t *testing.T) {
75 s := testServer()
76 mac := mustMAC(t, "52:54:00:ab:cd:ef")
77 s.SetReservation(mac, net.IPv4(10, 77, 1, 7))
78
79 resp, err := s.buildReply(request(t, mac, dhcpv4.MessageTypeDiscover))
80 if err != nil {
81 t.Fatalf("buildReply err: %v", err)
82 }
83 if resp == nil {
84 t.Fatal("reserved MAC must get a reply")
85 }
86 if resp.MessageType() != dhcpv4.MessageTypeOffer {
87 t.Fatalf("Discover must yield Offer, got %v", resp.MessageType())
88 }
89 if !resp.YourIPAddr.Equal(net.IPv4(10, 77, 1, 7)) {
90 t.Fatalf("YourIPAddr = %v; want 10.77.1.7", resp.YourIPAddr)
91 }
92 if !resp.Router()[0].Equal(net.IPv4(10, 77, 1, 1)) {
93 t.Fatalf("router = %v; want gateway 10.77.1.1", resp.Router())
94 }
95 if len(resp.DNS()) == 0 || !resp.DNS()[0].Equal(net.IPv4(1, 1, 1, 1)) {
96 t.Fatalf("DNS = %v; want [1.1.1.1]", resp.DNS())
97 }
98 }
99
100 func TestBuildReplyRequestAcks(t *testing.T) {
101 s := testServer()
102 mac := mustMAC(t, "52:54:00:ab:cd:ef")
103 s.SetReservation(mac, net.IPv4(10, 77, 1, 7))
104 resp, err := s.buildReply(request(t, mac, dhcpv4.MessageTypeRequest))
105 if err != nil {
106 t.Fatalf("buildReply err: %v", err)
107 }
108 if resp == nil || resp.MessageType() != dhcpv4.MessageTypeAck {
109 t.Fatalf("Request must yield Ack, got %v", resp)
110 }
111 }
internal/agent/netenv/netenv.go
Old New
@@ -7,14 +7,18 @@ package netenv
7 import ( 7 import (
8 "context" 8 "context"
9 "fmt" 9 "fmt"
10 "net"
10 "net/netip" 11 "net/netip"
11 "os" 12 "os"
12 "path/filepath" 13 "path/filepath"
13 "strconv" 14 "strconv"
14 "strings" 15 "strings"
16 "time"
15 17
18 "github.com/a73x/eitri/internal/agent/dhcp"
16 "github.com/a73x/eitri/internal/agent/exec" 19 "github.com/a73x/eitri/internal/agent/exec"
17 "github.com/a73x/eitri/internal/agent/ipalloc" 20 "github.com/a73x/eitri/internal/agent/ipalloc"
21 "github.com/a73x/eitri/internal/agent/state"
18 ) 22 )
19 23
20 // Bridge is the name of the Linux bridge device created by EnsureBridge. 24 // Bridge is the name of the Linux bridge device created by EnsureBridge.
@@ -28,8 +32,14 @@ type Net struct {
28 // for tests; production checks /sys/class/net/<name>/tun_flags, which 32 // for tests; production checks /sys/class/net/<name>/tun_flags, which
29 // exists only for tun/tap links — no error-string parsing. 33 // exists only for tun/tap links — no error-string parsing.
30 isTap func(name string) bool 34 isTap func(name string) bool
35 dhcp *dhcp.Server
31 } 36 }
32 37
38 // guestDNS is handed to guests as their DHCP DNS servers (option 6). Public
39 // resolvers reachable via the eitri0 masquerade — matches the pre-DHCP static
40 // netplan, and avoids handing guests a host-loopback stub resolver.
41 var guestDNS = []net.IP{net.IPv4(1, 1, 1, 1), net.IPv4(9, 9, 9, 9)}
42
33 // New constructs a Net. cidr must be a valid IPv4 prefix (e.g. "10.77.1.0/24"). 43 // New constructs a Net. cidr must be a valid IPv4 prefix (e.g. "10.77.1.0/24").
34 func New(run exec.Runner, cidr string) (*Net, error) { 44 func New(run exec.Runner, cidr string) (*Net, error) {
35 p, err := netip.ParsePrefix(cidr) 45 p, err := netip.ParsePrefix(cidr)
@@ -39,7 +49,11 @@ func New(run exec.Runner, cidr string) (*Net, error) {
39 if !p.Addr().Is4() { 49 if !p.Addr().Is4() {
40 return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr) 50 return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr)
41 } 51 }
42 return &Net{run: run, cidr: p, isTap: sysfsIsTap}, nil 52 n := &Net{run: run, cidr: p, isTap: sysfsIsTap}
53 gw := net.ParseIP(n.Gateway())
54 mask := net.CIDRMask(p.Bits(), 32)
55 n.dhcp = dhcp.NewServer(Bridge, gw, mask, guestDNS, 12*time.Hour)
56 return n, nil
43 } 57 }
44 58
45 // sysfsIsTap reports whether name is an L2 TAP link: /sys/class/net/<name>/ 59 // sysfsIsTap reports whether name is an L2 TAP link: /sys/class/net/<name>/
@@ -81,11 +95,6 @@ func (n *Net) AllocateIP(_ context.Context, used []string) (string, error) {
81 return ipalloc.Alloc(n.cidr.String(), used) 95 return ipalloc.Alloc(n.cidr.String(), used)
82 } 96 }
83 97
84 // GuestNetwork returns the gateway (.1) and prefix length the guest is configured
85 // with — network properties the addressing seam owns, not bridge mechanics.
86 func (n *Net) GuestNetwork() (string, int) {
87 return n.Gateway(), n.cidr.Bits()
88 }
89 98
90 // tolerated reports whether err carries one of the given substrings in either 99 // tolerated reports whether err carries one of the given substrings in either
91 // the command stdout or the error message. iproute2 puts the same condition in 100 // the command stdout or the error message. iproute2 puts the same condition in
@@ -186,8 +195,28 @@ func (n *Net) EnsureBridge(ctx context.Context) error {
186 return nil 195 return nil
187 } 196 }
188 197
189 // CreateTap creates a TAP device and attaches it to the eitri0 bridge. 198 // StartDHCP begins serving reserved leases on the bridge. Call after
190 // tap should be the value from state.TapName(vmID). 199 // EnsureBridge (the interface must exist) and after reservations are preloaded.
200 func (n *Net) StartDHCP(ctx context.Context) error { return n.dhcp.Start(ctx) }
201
202 // AddReservation pins vmID's deterministic MAC to ip in the DHCP table. Used at
203 // startup to rebuild the (in-memory) table from durable records, and by
204 // CreateTap. A bad ip is ignored (the record is malformed; nothing to serve).
205 func (n *Net) AddReservation(vmID, ip string) {
206 mac, err := net.ParseMAC(state.MAC(vmID))
207 if err != nil {
208 return
209 }
210 parsed := net.ParseIP(ip)
211 if parsed == nil {
212 return
213 }
214 n.dhcp.SetReservation(mac, parsed)
215 }
216
217 // CreateTap creates the VM's TAP device, attaches it to eitri0, and adds the
218 // guest's DHCP reservation (MAC(vmID) -> ip). vmID is the reconcile identity;
219 // ip is the agent-allocated address.
191 // 220 //
192 // Idempotent by existence check, not error-string parsing (same rationale as 221 // Idempotent by existence check, not error-string parsing (same rationale as
193 // EnsureBridge): re-running `ip tuntap add` on a live tap fails with 222 // EnsureBridge): re-running `ip tuntap add` on a live tap fails with
@@ -195,7 +224,8 @@ func (n *Net) EnsureBridge(ctx context.Context) error {
195 // allow-list can never chase across iproute2 versions. A create-retry hitting 224 // allow-list can never chase across iproute2 versions. A create-retry hitting
196 // the previous attempt's tap must not mask the retry's real error. Pinned by 225 // the previous attempt's tap must not mask the retry's real error. Pinned by
197 // TestCreateTapIdempotentWhenTapExists. 226 // TestCreateTapIdempotentWhenTapExists.
198 func (n *Net) CreateTap(ctx context.Context, tap string) error { 227 func (n *Net) CreateTap(ctx context.Context, vmID, ip string) error {
228 tap := state.TapName(vmID)
199 if _, err := n.run(ctx, "ip", "link", "show", "dev", tap); err != nil { 229 if _, err := n.run(ctx, "ip", "link", "show", "dev", tap); err != nil {
200 if _, err := n.best(ctx, "ip", "tuntap", "add", "dev", tap, "mode", "tap"); err != nil { 230 if _, err := n.best(ctx, "ip", "tuntap", "add", "dev", tap, "mode", "tap"); err != nil {
201 return err 231 return err
@@ -209,13 +239,20 @@ func (n *Net) CreateTap(ctx context.Context, tap string) error {
209 if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil { 239 if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil {
210 return err 240 return err
211 } 241 }
212 _, err := n.best(ctx, "ip", "link", "set", tap, "up") 242 if _, err := n.best(ctx, "ip", "link", "set", tap, "up"); err != nil {
213 return err 243 return err
244 }
245 n.AddReservation(vmID, ip)
246 return nil
214 } 247 }
215 248
216 // DeleteTap removes a TAP device. "Cannot find device" errors are tolerated so 249 // DeleteTap removes the VM's DHCP reservation and TAP device. Idempotent:
217 // that Delete is idempotent (the device may have been cleaned up already). 250 // a missing device is tolerated.
218 func (n *Net) DeleteTap(ctx context.Context, tap string) error { 251 func (n *Net) DeleteTap(ctx context.Context, vmID string) error {
252 tap := state.TapName(vmID)
253 if mac, err := net.ParseMAC(state.MAC(vmID)); err == nil {
254 n.dhcp.RemoveReservation(mac)
255 }
219 out, err := n.run(ctx, "ip", "link", "del", tap) 256 out, err := n.run(ctx, "ip", "link", "del", tap)
220 if err != nil { 257 if err != nil {
221 if tolerated(out, err, "Cannot find device") { 258 if tolerated(out, err, "Cannot find device") {
internal/agent/netenv/netenv_test.go
Old New
@@ -4,15 +4,20 @@ import (
4 "context" 4 "context"
5 "errors" 5 "errors"
6 "fmt" 6 "fmt"
7 "net"
7 "os" 8 "os"
8 "strings" 9 "strings"
9 "testing" 10 "testing"
10 11
11 "github.com/a73x/eitri/internal/agent/exec" 12 "github.com/a73x/eitri/internal/agent/exec"
13 "github.com/a73x/eitri/internal/agent/state"
12 "github.com/stretchr/testify/assert" 14 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require" 15 "github.com/stretchr/testify/require"
14 ) 16 )
15 17
18 // stateMAC re-exposes state.MAC for the test without a separate import block.
19 func stateMAC(vmID string) string { return state.MAC(vmID) }
20
16 type call struct { 21 type call struct {
17 name string 22 name string
18 args string 23 args string
@@ -96,7 +101,7 @@ func TestTapLifecycle(t *testing.T) {
96 } 101 }
97 run, calls := recorder(nil, errs) 102 run, calls := recorder(nil, errs)
98 n, _ := New(run, "10.77.1.0/24") 103 n, _ := New(run, "10.77.1.0/24")
99 require.NoError(t, n.CreateTap(context.Background(), "eit-abc123")) 104 require.NoError(t, n.CreateTap(context.Background(), "abc123", "10.77.1.5"))
100 all := joinCalls(calls) 105 all := joinCalls(calls)
101 assert.Contains(t, all, "ip tuntap add dev eit-abc123 mode tap") 106 assert.Contains(t, all, "ip tuntap add dev eit-abc123 mode tap")
102 assert.Contains(t, all, "ip link set eit-abc123 master eitri0") 107 assert.Contains(t, all, "ip link set eit-abc123 master eitri0")
@@ -201,7 +206,7 @@ func TestCreateTapToleratesAlreadyExists(t *testing.T) {
201 }, 206 },
202 ) 207 )
203 n, _ := New(run, "10.77.1.0/24") 208 n, _ := New(run, "10.77.1.0/24")
204 require.NoError(t, n.CreateTap(context.Background(), "eit-x"), 209 require.NoError(t, n.CreateTap(context.Background(), "x", "10.77.1.5"),
205 "already-exists must be tolerated for idempotency") 210 "already-exists must be tolerated for idempotency")
206 }) 211 })
207 } 212 }
@@ -215,7 +220,7 @@ func TestCreateTapPropagatesRealError(t *testing.T) {
215 "ip link show dev eit-x": errors.New(`Device "eit-x" does not exist.`), 220 "ip link show dev eit-x": errors.New(`Device "eit-x" does not exist.`),
216 }) 221 })
217 n, _ := New(run, "10.77.1.0/24") 222 n, _ := New(run, "10.77.1.0/24")
218 err := n.CreateTap(context.Background(), "eit-x") 223 err := n.CreateTap(context.Background(), "x", "10.77.1.5")
219 require.Error(t, err) 224 require.Error(t, err)
220 assert.Contains(t, err.Error(), "Operation not permitted") 225 assert.Contains(t, err.Error(), "Operation not permitted")
221 } 226 }
@@ -226,15 +231,15 @@ func TestDeleteTapToleratesMissingDevice(t *testing.T) {
226 key := "ip link del eit-x" 231 key := "ip link del eit-x"
227 run, _ := recorder(nil, map[string]error{key: errors.New(`Cannot find device "eit-x"`)}) 232 run, _ := recorder(nil, map[string]error{key: errors.New(`Cannot find device "eit-x"`)})
228 n, _ := New(run, "10.77.1.0/24") 233 n, _ := New(run, "10.77.1.0/24")
229 require.NoError(t, n.DeleteTap(context.Background(), "eit-x")) 234 require.NoError(t, n.DeleteTap(context.Background(), "x"))
230 235
231 run2, _ := recorder(map[string]string{key: "Cannot find device"}, map[string]error{key: errors.New("exit status 1")}) 236 run2, _ := recorder(map[string]string{key: "Cannot find device"}, map[string]error{key: errors.New("exit status 1")})
232 n2, _ := New(run2, "10.77.1.0/24") 237 n2, _ := New(run2, "10.77.1.0/24")
233 require.NoError(t, n2.DeleteTap(context.Background(), "eit-x")) 238 require.NoError(t, n2.DeleteTap(context.Background(), "x"))
234 239
235 run3, _ := recorder(nil, map[string]error{key: errors.New("RTNETLINK answers: Operation not permitted")}) 240 run3, _ := recorder(nil, map[string]error{key: errors.New("RTNETLINK answers: Operation not permitted")})
236 n3, _ := New(run3, "10.77.1.0/24") 241 n3, _ := New(run3, "10.77.1.0/24")
237 require.Error(t, n3.DeleteTap(context.Background(), "eit-x")) 242 require.Error(t, n3.DeleteTap(context.Background(), "x"))
238 } 243 }
239 244
240 // TestCreateTapIdempotentWhenTapExists is the regression test for the sandbox 245 // TestCreateTapIdempotentWhenTapExists is the regression test for the sandbox
@@ -257,7 +262,7 @@ func TestCreateTapIdempotentWhenTapExists(t *testing.T) {
257 n, err := New(run, "10.77.1.0/24") 262 n, err := New(run, "10.77.1.0/24")
258 require.NoError(t, err) 263 require.NoError(t, err)
259 n.isTap = func(string) bool { return true } // the leftover IS a real tap 264 n.isTap = func(string) bool { return true } // the leftover IS a real tap
260 require.NoError(t, n.CreateTap(context.Background(), "eit-busy"), 265 require.NoError(t, n.CreateTap(context.Background(), "busy", "10.77.1.5"),
261 "CreateTap on an existing tap must succeed") 266 "CreateTap on an existing tap must succeed")
262 267
263 all := joinCalls(calls) 268 all := joinCalls(calls)
@@ -279,7 +284,7 @@ func TestCreateTapRejectsNonTapDevice(t *testing.T) {
279 require.NoError(t, err) 284 require.NoError(t, err)
280 n.isTap = func(tap string) bool { return false } // not a tun/tap device 285 n.isTap = func(tap string) bool { return false } // not a tun/tap device
281 286
282 err = n.CreateTap(context.Background(), "eit-clash") 287 err = n.CreateTap(context.Background(), "clash", "10.77.1.5")
283 require.Error(t, err) 288 require.Error(t, err)
284 assert.Contains(t, err.Error(), "not a TAP device") 289 assert.Contains(t, err.Error(), "not a TAP device")
285 var p interface{ Permanent() bool } 290 var p interface{ Permanent() bool }
@@ -300,7 +305,7 @@ func TestCreateTapAcceptsExistingRealTap(t *testing.T) {
300 require.NoError(t, err) 305 require.NoError(t, err)
301 n.isTap = func(tap string) bool { return true } 306 n.isTap = func(tap string) bool { return true }
302 307
303 require.NoError(t, n.CreateTap(context.Background(), "eit-ok")) 308 require.NoError(t, n.CreateTap(context.Background(), "ok", "10.77.1.5"))
304 all := joinCalls(calls) 309 all := joinCalls(calls)
305 assert.NotContains(t, all, "ip tuntap add") 310 assert.NotContains(t, all, "ip tuntap add")
306 assert.Contains(t, all, "ip link set eit-ok master eitri0") 311 assert.Contains(t, all, "ip link set eit-ok master eitri0")
@@ -338,3 +343,40 @@ func TestTapConflictErrorIsPermanent(t *testing.T) {
338 var p interface{ Permanent() bool } 343 var p interface{ Permanent() bool }
339 assert.True(t, errors.As(error(e), &p) && p.Permanent()) 344 assert.True(t, errors.As(error(e), &p) && p.Permanent())
340 } 345 }
346
347 func TestCreateTapAddsReservationAndTapCommands(t *testing.T) {
348 errs := map[string]error{
349 "ip link show dev eit-vm-abc12": errors.New("does not exist"),
350 }
351 run, calls := recorder(nil, errs)
352 n, err := New(run, "10.77.1.0/24")
353 require.NoError(t, err)
354
355 require.NoError(t, n.CreateTap(context.Background(), "vm-abc12345", "10.77.1.7"))
356
357 all := joinCalls(calls)
358 assert.Contains(t, all, "ip tuntap add dev eit-vm-abc12 mode tap")
359 assert.Contains(t, all, "ip link set eit-vm-abc12 master eitri0")
360
361 mac, _ := net.ParseMAC(stateMAC("vm-abc12345"))
362 ip, ok := n.dhcp.LookupForTest(mac)
363 require.True(t, ok)
364 assert.Equal(t, "10.77.1.7", ip.String())
365 }
366
367 func TestDeleteTapRemovesReservationAndTap(t *testing.T) {
368 errs := map[string]error{
369 "ip link show dev eit-vm-abc12": errors.New("does not exist"),
370 }
371 run, calls := recorder(nil, errs)
372 n, err := New(run, "10.77.1.0/24")
373 require.NoError(t, err)
374 require.NoError(t, n.CreateTap(context.Background(), "vm-abc12345", "10.77.1.7"))
375
376 require.NoError(t, n.DeleteTap(context.Background(), "vm-abc12345"))
377
378 assert.Contains(t, joinCalls(calls), "ip link del eit-vm-abc12")
379 mac, _ := net.ParseMAC(stateMAC("vm-abc12345"))
380 _, ok := n.dhcp.LookupForTest(mac)
381 assert.False(t, ok, "reservation must be gone after DeleteTap")
382 }
internal/agent/reconcile/reconcile.go
Old New
@@ -43,16 +43,13 @@ type Provisioner interface {
43 43
44 // NetEnv is implemented by the host networking layer (netenv.Net). 44 // NetEnv is implemented by the host networking layer (netenv.Net).
45 type NetEnv interface { 45 type NetEnv interface {
46 CreateTap(ctx context.Context, tap string) error 46 CreateTap(ctx context.Context, vmID, ip string) error
47 DeleteTap(ctx context.Context, tap string) error 47 DeleteTap(ctx context.Context, vmID string) error
48 // AllocateIP returns an unused VM IP for this host's network. `used` lists 48 // AllocateIP returns an unused VM IP for this host's network. `used` lists
49 // addresses already taken. Kept behind the seam so a future central or 49 // addresses already taken. Kept behind the seam so a future central or
50 // per-network allocator can replace host-local allocation without touching 50 // per-network allocator can replace host-local allocation without touching
51 // the reconcile loop. 51 // the reconcile loop.
52 AllocateIP(ctx context.Context, used []string) (string, error) 52 AllocateIP(ctx context.Context, used []string) (string, error)
53 // GuestNetwork returns the gateway address and prefix length the guest is
54 // configured with — network properties, not bridge mechanics.
55 GuestNetwork() (gateway string, prefixLen int)
56 } 53 }
57 54
58 // Engine is the reconcile loop. All fields must be set before calling Step. 55 // Engine is the reconcile loop. All fields must be set before calling Step.
@@ -216,7 +213,7 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac
216 // that honors ctx here would skip the destroy until a later tick, 213 // that honors ctx here would skip the destroy until a later tick,
217 // which the level-triggered loop tolerates but delays. 214 // which the level-triggered loop tolerates but delays.
218 _ = e.Prov.Kill(ctx, id) 215 _ = e.Prov.Kill(ctx, id)
219 if err := e.Net.DeleteTap(ctx, state.TapName(id)); err != nil { 216 if err := e.Net.DeleteTap(ctx, id); err != nil {
220 // TAP deletion failed — e.g. DeleteTap runs `ip link del` under 217 // TAP deletion failed — e.g. DeleteTap runs `ip link del` under
221 // ctx and the ctx expired during a SIGTERM shutdown. KEEP the 218 // ctx and the ctx expired during a SIGTERM shutdown. KEEP the
222 // record so a later tick retries; deleting it here would orphan 219 // record so a later tick retries; deleting it here would orphan
@@ -428,7 +425,7 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStat
428 } 425 }
429 426
430 // Create tap device. 427 // Create tap device.
431 if err := e.Net.CreateTap(ctx, state.TapName(d.VmId)); err != nil { 428 if err := e.Net.CreateTap(ctx, d.VmId, rec.IP); err != nil {
432 e.failCreate(ctx, rec, err, rep) 429 e.failCreate(ctx, rec, err, rep)
433 return 430 return
434 } 431 }
@@ -439,15 +436,11 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStat
439 return 436 return
440 } 437 }
441 438
442 // Build cloud-init seed ISO. Gateway + prefix come from the addressing seam, 439 // Build cloud-init seed ISO. The guest DHCPs its address from the agent's
443 // not derived from a CIDR here — the core holds no network-shaped state. 440 // reservation, so no IP/gateway is baked into the seed.
444 gateway, prefixLen := e.Net.GuestNetwork()
445 if err := e.Seed(e.St.SeedPath(d.VmId), seed.Params{ 441 if err := e.Seed(e.St.SeedPath(d.VmId), seed.Params{
446 Hostname: d.Name, 442 Hostname: d.Name,
447 InstanceID: d.VmId, 443 InstanceID: d.VmId,
448 IP: rec.IP,
449 PrefixLen: prefixLen,
450 Gateway: gateway,
451 SSHAuthorizedKey: d.SshAuthorizedKey, 444 SSHAuthorizedKey: d.SshAuthorizedKey,
452 UserData: d.CloudInit, 445 UserData: d.CloudInit,
453 SSHUserCAAuthorizedKey: d.SshUserCaAuthorizedKey, 446 SSHUserCAAuthorizedKey: d.SshUserCaAuthorizedKey,
@@ -550,7 +543,7 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
550 // (e.g. a foreign device squatting on the name) is reported with 543 // (e.g. a foreign device squatting on the name) is reported with
551 // ITS message — letting Boot fail instead yields an illegible 544 // ITS message — letting Boot fail instead yields an illegible
552 // cloud-hypervisor error for the same root cause. 545 // cloud-hypervisor error for the same root cause.
553 if err := e.Net.CreateTap(ctx, state.TapName(d.VmId)); err != nil { 546 if err := e.Net.CreateTap(ctx, d.VmId, rec.IP); err != nil {
554 e.failConverge(rec, err, rep) 547 e.failConverge(rec, err, rep)
555 return 548 return
556 } 549 }
internal/agent/reconcile/reconcile_test.go
Old New
@@ -3,7 +3,6 @@ package reconcile
3 import ( 3 import (
4 "context" 4 "context"
5 "errors" 5 "errors"
6 "net/netip"
7 "os" 6 "os"
8 "path/filepath" 7 "path/filepath"
9 "testing" 8 "testing"
@@ -69,17 +68,17 @@ type fakeNet struct {
69 allocErr error // one-shot: consumed and cleared on the first AllocateIP call 68 allocErr error // one-shot: consumed and cleared on the first AllocateIP call
70 } 69 }
71 70
72 func (f *fakeNet) CreateTap(_ context.Context, t string) error { 71 func (f *fakeNet) CreateTap(_ context.Context, vmID, ip string) error {
73 f.taps = append(f.taps, t) 72 f.taps = append(f.taps, vmID)
74 return nil 73 return nil
75 } 74 }
76 func (f *fakeNet) DeleteTap(_ context.Context, t string) error { 75 func (f *fakeNet) DeleteTap(_ context.Context, vmID string) error {
77 f.deleted = append(f.deleted, t) 76 f.deleted = append(f.deleted, vmID)
78 return nil 77 return nil
79 } 78 }
80 79
81 // AllocateIP / GuestNetwork mirror netenv's host-local behavior so reconcile 80 // AllocateIP mirrors netenv's host-local behavior so reconcile tests exercise
82 // tests exercise identical addressing through the seam. 81 // identical addressing through the seam.
83 func (f *fakeNet) AllocateIP(_ context.Context, used []string) (string, error) { 82 func (f *fakeNet) AllocateIP(_ context.Context, used []string) (string, error) {
84 if f.allocErr != nil { 83 if f.allocErr != nil {
85 err := f.allocErr 84 err := f.allocErr
@@ -88,10 +87,6 @@ func (f *fakeNet) AllocateIP(_ context.Context, used []string) (string, error) {
88 } 87 }
89 return ipalloc.Alloc(f.cidr, used) 88 return ipalloc.Alloc(f.cidr, used)
90 } 89 }
91 func (f *fakeNet) GuestNetwork() (string, int) {
92 p, _ := netip.ParsePrefix(f.cidr)
93 return p.Masked().Addr().Next().String(), p.Bits()
94 }
95 90
96 type fixture struct { 91 type fixture struct {
97 eng *Engine 92 eng *Engine
@@ -324,7 +319,7 @@ func TestTombstoneQuarantinesThenDestroysAfterGrace(t *testing.T) {
324 recs, _ := f.st.LoadVMs() 319 recs, _ := f.st.LoadVMs()
325 assert.NotContains(t, recs, "vm1") 320 assert.NotContains(t, recs, "vm1")
326 // Fix 6: tap must be cleaned up on destroy 321 // Fix 6: tap must be cleaned up on destroy
327 assert.Contains(t, f.net.deleted, state.TapName("vm1"), "tap cleaned up on destroy") 322 assert.Contains(t, f.net.deleted, "vm1", "tap cleaned up on destroy")
328 } 323 }
329 324
330 func TestVanishedWithoutTombstoneGetsLongGrace(t *testing.T) { 325 func TestVanishedWithoutTombstoneGetsLongGrace(t *testing.T) {
internal/agent/seed/seed.go
Old New
@@ -15,9 +15,6 @@ import (
15 // Params holds the cloud-init configuration for a single VM. 15 // Params holds the cloud-init configuration for a single VM.
16 type Params struct { 16 type Params struct {
17 Hostname string 17 Hostname string
18 IP string
19 PrefixLen int
20 Gateway string
21 SSHAuthorizedKey string 18 SSHAuthorizedKey string
22 UserData string // verbatim if set; default generated otherwise 19 UserData string // verbatim if set; default generated otherwise
23 InstanceID string // used as cloud-init instance-id; falls back to Hostname when empty 20 InstanceID string // used as cloud-init instance-id; falls back to Hostname when empty
@@ -57,8 +54,6 @@ func validateParams(p Params) error {
57 {"Hostname", p.Hostname}, 54 {"Hostname", p.Hostname},
58 {"InstanceID", p.InstanceID}, 55 {"InstanceID", p.InstanceID},
59 {"SSHAuthorizedKey", p.SSHAuthorizedKey}, 56 {"SSHAuthorizedKey", p.SSHAuthorizedKey},
60 {"IP", p.IP},
61 {"Gateway", p.Gateway},
62 } { 57 } {
63 if strings.ContainsAny(f.value, "\n\r") { 58 if strings.ContainsAny(f.value, "\n\r") {
64 return fmt.Errorf("seed: %s must not contain newline or carriage return", f.name) 59 return fmt.Errorf("seed: %s must not contain newline or carriage return", f.name)
@@ -200,22 +195,19 @@ func metaData(p Params) string {
200 return fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", id, p.Hostname) 195 return fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", id, p.Hostname)
201 } 196 }
202 197
203 // networkConfig returns a netplan v2 network-config for a static IP with no DHCP. 198 // networkConfig returns a netplan v2 network-config that DHCPs on the primary
204 func networkConfig(p Params) string { 199 // NIC. The address is served by the host's embedded DHCP responder from the
205 return fmt.Sprintf(`network: 200 // agent's per-VM reservation, so nothing about addressing is baked into the
201 // guest image. DNS is supplied by the DHCP server.
202 func networkConfig() string {
203 return `network:
206 version: 2 204 version: 2
207 ethernets: 205 ethernets:
208 primary: 206 primary:
209 match: 207 match:
210 name: "en*" 208 name: "en*"
211 addresses: 209 dhcp4: true
212 - %s/%d 210 `
213 routes:
214 - to: default
215 via: %s
216 nameservers:
217 addresses: [1.1.1.1, 9.9.9.9]
218 `, p.IP, p.PrefixLen, p.Gateway)
219 } 211 }
220 212
221 // Build creates a cloud-init NoCloud seed ISO at outPath. 213 // Build creates a cloud-init NoCloud seed ISO at outPath.
@@ -287,7 +279,7 @@ func Build(outPath string, p Params) error {
287 files := map[string]string{ 279 files := map[string]string{
288 "/user-data": userData(p), 280 "/user-data": userData(p),
289 "/meta-data": metaData(p), 281 "/meta-data": metaData(p),
290 "/network-config": networkConfig(p), 282 "/network-config": networkConfig(),
291 } 283 }
292 // Vendor-data carries the eitri user-CA sshd drop-in (empty doc => no file). 284 // Vendor-data carries the eitri user-CA sshd drop-in (empty doc => no file).
293 if vd := vendorDataDoc(p); vd != "" { 285 if vd := vendorDataDoc(p); vd != "" {
internal/agent/seed/seed_test.go
Old New
@@ -15,7 +15,7 @@ import (
15 func TestBuildProducesISOWithNoCloudFiles(t *testing.T) { 15 func TestBuildProducesISOWithNoCloudFiles(t *testing.T) {
16 out := t.TempDir() + "/seed.iso" 16 out := t.TempDir() + "/seed.iso"
17 err := Build(out, Params{ 17 err := Build(out, Params{
18 Hostname: "sandbox-7", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", 18 Hostname: "sandbox-7",
19 SSHAuthorizedKey: "ssh-ed25519 AAAA test@example", 19 SSHAuthorizedKey: "ssh-ed25519 AAAA test@example",
20 }) 20 })
21 require.NoError(t, err) 21 require.NoError(t, err)
@@ -78,10 +78,14 @@ func TestUserDataCustomPassthrough(t *testing.T) {
78 "advanced users own their user-data verbatim") 78 "advanced users own their user-data verbatim")
79 } 79 }
80 80
81 func TestNetworkConfigStaticIP(t *testing.T) { 81 func TestNetworkConfigUsesDHCP(t *testing.T) {
82 nc := networkConfig(Params{IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1"}) 82 got := networkConfig()
83 assert.Contains(t, nc, "10.77.1.2/24") 83 if !strings.Contains(got, "dhcp4: true") {
84 assert.Contains(t, nc, "10.77.1.1") 84 t.Fatalf("network-config should enable DHCP, got:\n%s", got)
85 }
86 if strings.Contains(got, "addresses:") {
87 t.Fatalf("network-config must not pin a static address, got:\n%s", got)
88 }
85 } 89 }
86 90
87 // --- C1b: seed injection-defense tests --- 91 // --- C1b: seed injection-defense tests ---
@@ -89,7 +93,7 @@ func TestNetworkConfigStaticIP(t *testing.T) {
89 func TestBuildRejectsNewlineInHostname(t *testing.T) { 93 func TestBuildRejectsNewlineInHostname(t *testing.T) {
90 out := t.TempDir() + "/seed.iso" 94 out := t.TempDir() + "/seed.iso"
91 err := Build(out, Params{ 95 err := Build(out, Params{
92 Hostname: "a\nb", IP: "10.0.0.1", PrefixLen: 24, Gateway: "10.0.0.1", 96 Hostname: "a\nb",
93 }) 97 })
94 assert.Error(t, err, "Build must reject Hostname containing newline") 98 assert.Error(t, err, "Build must reject Hostname containing newline")
95 } 99 }
@@ -97,32 +101,16 @@ func TestBuildRejectsNewlineInHostname(t *testing.T) {
97 func TestBuildRejectsNewlineInSSHKey(t *testing.T) { 101 func TestBuildRejectsNewlineInSSHKey(t *testing.T) {
98 out := t.TempDir() + "/seed.iso" 102 out := t.TempDir() + "/seed.iso"
99 err := Build(out, Params{ 103 err := Build(out, Params{
100 Hostname: "ok", IP: "10.0.0.1", PrefixLen: 24, Gateway: "10.0.0.1", 104 Hostname: "ok",
101 SSHAuthorizedKey: "ssh-ed25519 AAAA\ninjected: yaml", 105 SSHAuthorizedKey: "ssh-ed25519 AAAA\ninjected: yaml",
102 }) 106 })
103 assert.Error(t, err, "Build must reject SSHAuthorizedKey containing newline") 107 assert.Error(t, err, "Build must reject SSHAuthorizedKey containing newline")
104 } 108 }
105 109
106 func TestBuildRejectsNewlineInIP(t *testing.T) {
107 out := t.TempDir() + "/seed.iso"
108 err := Build(out, Params{
109 Hostname: "ok", IP: "10.0.0.1\nbad", PrefixLen: 24, Gateway: "10.0.0.1",
110 })
111 assert.Error(t, err, "Build must reject IP containing newline")
112 }
113
114 func TestBuildRejectsCarriageReturnInGateway(t *testing.T) {
115 out := t.TempDir() + "/seed.iso"
116 err := Build(out, Params{
117 Hostname: "ok", IP: "10.0.0.1", PrefixLen: 24, Gateway: "10.0.0.1\rinjected",
118 })
119 assert.Error(t, err, "Build must reject Gateway containing carriage return")
120 }
121
122 func TestBuildDoesNotRejectMultilineUserData(t *testing.T) { 110 func TestBuildDoesNotRejectMultilineUserData(t *testing.T) {
123 out := t.TempDir() + "/seed.iso" 111 out := t.TempDir() + "/seed.iso"
124 err := Build(out, Params{ 112 err := Build(out, Params{
125 Hostname: "ok", IP: "10.0.0.1", PrefixLen: 24, Gateway: "10.0.0.1", 113 Hostname: "ok",
126 UserData: "#cloud-config\npackages: [htop]\n", 114 UserData: "#cloud-config\npackages: [htop]\n",
127 }) 115 })
128 assert.NoError(t, err, "UserData is exempt from newline validation") 116 assert.NoError(t, err, "UserData is exempt from newline validation")
@@ -160,7 +148,7 @@ func readISOFile(t *testing.T, isoPath, name string) []byte {
160 func TestBuildWithoutCAKeepsThreeFileLayout(t *testing.T) { 148 func TestBuildWithoutCAKeepsThreeFileLayout(t *testing.T) {
161 out := t.TempDir() + "/seed.iso" 149 out := t.TempDir() + "/seed.iso"
162 require.NoError(t, Build(out, Params{ 150 require.NoError(t, Build(out, Params{
163 Hostname: "plain", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", 151 Hostname: "plain",
164 SSHAuthorizedKey: "ssh-ed25519 AAAA", 152 SSHAuthorizedKey: "ssh-ed25519 AAAA",
165 })) 153 }))
166 d, err := diskfs.Open(out, diskfs.WithSectorSize(2048)) 154 d, err := diskfs.Open(out, diskfs.WithSectorSize(2048))
@@ -188,7 +176,7 @@ func TestVendorDataInjectsUserCATrust(t *testing.T) {
188 // A VM with a CA key gets a vendor-data carrying the sshd drop-in. 176 // A VM with a CA key gets a vendor-data carrying the sshd drop-in.
189 out := t.TempDir() + "/seed.iso" 177 out := t.TempDir() + "/seed.iso"
190 require.NoError(t, Build(out, Params{ 178 require.NoError(t, Build(out, Params{
191 Hostname: "plain", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", 179 Hostname: "plain",
192 SSHAuthorizedKey: "ssh-ed25519 AAAA user@host", 180 SSHAuthorizedKey: "ssh-ed25519 AAAA user@host",
193 SSHUserCAAuthorizedKey: testUserCAKey, 181 SSHUserCAAuthorizedKey: testUserCAKey,
194 })) 182 }))
@@ -218,7 +206,7 @@ func TestVendorDataInjectsHostKeyAndCert(t *testing.T) {
218 // A VM with a host key + cert gets them installed and sshd pointed at them. 206 // A VM with a host key + cert gets them installed and sshd pointed at them.
219 out := t.TempDir() + "/seed.iso" 207 out := t.TempDir() + "/seed.iso"
220 require.NoError(t, Build(out, Params{ 208 require.NoError(t, Build(out, Params{
221 Hostname: "with-hostcert", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", 209 Hostname: "with-hostcert",
222 SSHAuthorizedKey: "ssh-ed25519 AAAA user@host", 210 SSHAuthorizedKey: "ssh-ed25519 AAAA user@host",
223 SSHUserCAAuthorizedKey: testUserCAKey, 211 SSHUserCAAuthorizedKey: testUserCAKey,
224 SSHHostKeyPEM: testHostKeyPEM, 212 SSHHostKeyPEM: testHostKeyPEM,
@@ -293,7 +281,7 @@ func TestNoVendorDataWhenCAKeyEmpty(t *testing.T) {
293 // Empty CA key: no drop-in, no vendor-data at all. 281 // Empty CA key: no drop-in, no vendor-data at all.
294 out := t.TempDir() + "/seed.iso" 282 out := t.TempDir() + "/seed.iso"
295 require.NoError(t, Build(out, Params{ 283 require.NoError(t, Build(out, Params{
296 Hostname: "plain", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", 284 Hostname: "plain",
297 SSHAuthorizedKey: "ssh-ed25519 AAAA user@host", 285 SSHAuthorizedKey: "ssh-ed25519 AAAA user@host",
298 })) 286 }))
299 d, err := diskfs.Open(out, diskfs.WithSectorSize(2048)) 287 d, err := diskfs.Open(out, diskfs.WithSectorSize(2048))
internal/agent/state/state.go
Old New
@@ -4,7 +4,9 @@
4 package state 4 package state
5 5
6 import ( 6 import (
7 "crypto/sha256"
7 "encoding/json" 8 "encoding/json"
9 "fmt"
8 "os" 10 "os"
9 "path/filepath" 11 "path/filepath"
10 "strconv" 12 "strconv"
@@ -87,6 +89,16 @@ func TapName(vmID string) string {
87 return "eit-" + prefix 89 return "eit-" + prefix
88 } 90 }
89 91
92 // MAC returns a deterministic, locally-administered MAC address for vmID.
93 // It uses the QEMU/KVM OUI prefix 52:54:00 and derives the last three octets
94 // from SHA-256(vmID). Shared by the hypervisor backend (guest NIC address) and
95 // the networking layer (DHCP reservation key) so both agree on one value
96 // without either importing the other.
97 func MAC(vmID string) string {
98 h := sha256.Sum256([]byte(vmID))
99 return fmt.Sprintf("52:54:00:%02x:%02x:%02x", h[0], h[1], h[2])
100 }
101
90 // atomicWrite writes data to path using a tmp file + rename so that readers 102 // atomicWrite writes data to path using a tmp file + rename so that readers
91 // never see a partial write. 103 // never see a partial write.
92 func atomicWrite(path string, data []byte) error { 104 func atomicWrite(path string, data []byte) error {
internal/agent/state/state_test.go
Old New
@@ -56,6 +56,20 @@ func TestDeleteVMRemovesRecordAndDir(t *testing.T) {
56 assert.True(t, os.IsNotExist(err)) 56 assert.True(t, os.IsNotExist(err))
57 } 57 }
58 58
59 func TestMACIsDeterministicAndLocallyAdministered(t *testing.T) {
60 got := MAC("vm-abc123")
61 if got != MAC("vm-abc123") {
62 t.Fatalf("MAC not deterministic: %q vs %q", got, MAC("vm-abc123"))
63 }
64 // QEMU/KVM OUI prefix, lower-case hex, 6 octets.
65 if len(got) != 17 || got[:9] != "52:54:00:" {
66 t.Fatalf("unexpected MAC shape: %q", got)
67 }
68 if MAC("vm-abc123") == MAC("vm-xyz789") {
69 t.Fatal("distinct vmIDs produced identical MACs")
70 }
71 }
72
59 func TestSerialSocketPath(t *testing.T) { 73 func TestSerialSocketPath(t *testing.T) {
60 s := open(t) 74 s := open(t)
61 p := s.SerialSocketPath("vm1") 75 p := s.SerialSocketPath("vm1")
internal/agent/syncclient/client_test.go
Old New
@@ -36,12 +36,11 @@ func (noopProv) Running(string) bool { retur
36 36
37 type noopNet struct{} 37 type noopNet struct{}
38 38
39 func (noopNet) CreateTap(context.Context, string) error { return nil } 39 func (noopNet) CreateTap(context.Context, string, string) error { return nil }
40 func (noopNet) DeleteTap(context.Context, string) error { return nil } 40 func (noopNet) DeleteTap(context.Context, string) error { return nil }
41 func (noopNet) AllocateIP(context.Context, []string) (string, error) { 41 func (noopNet) AllocateIP(context.Context, []string) (string, error) {
42 return "10.77.1.2", nil 42 return "10.77.1.2", nil
43 } 43 }
44 func (noopNet) GuestNetwork() (string, int) { return "10.77.1.1", 24 }
45 44
46 // testQUICIdle is the deliberately-short idle timeout the test listeners use so 45 // testQUICIdle is the deliberately-short idle timeout the test listeners use so
47 // reconnect/drop cases don't wait out the production SyncMaxIdleTimeout. 46 // reconnect/drop cases don't wait out the production SyncMaxIdleTimeout.