8d63ba78
feat(net): embedded DHCP + per-VM reservation networking
a73x 2026-07-25 14:03
Commit message
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. |