From cbf580e91e0c7eea0250eacd56dbf3a97400f0b3 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 31 Aug 2026 15:28:51 +0200 Subject: [PATCH 1/7] f-46: build: bump go to 1.25 #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La bibliothèque DHCP retenue pour #46 (github.com/insomniacslk/dhcp) déclare go 1.25.0. Relèvement de la directive et de la toolchain ; go.sum est inchangé, la bibliothèque n'entrant qu'avec le code qui l'importe. Vérifié : build darwin, build linux/amd64 en CGO_ENABLED=0 des trois binaires, go vet, et trois passes de go test ./... sans échec. Signed-off-by: GnomeZworc --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index a4b0b38..cc743cb 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module git.g3e.fr/syonad/two -go 1.24.0 +go 1.25.0 -toolchain go1.24.11 +toolchain go1.25.14 require ( github.com/beorn7/perks v1.0.1 // indirect From 19750975c24681eb9b8f4220a6c0c66eaaf7845c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 31 Aug 2026 15:28:57 +0200 Subject: [PATCH 2/7] f-46: ci: align go version with the module #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.yml épinglait setup-go sur 1.21 alors que le module déclarait go 1.24.0 : la compilation se faisait en réalité avec la toolchain téléchargée par GOTOOLCHAIN=auto, et le workflow affichait une version qui n'était pas celle qui compilait. Le passage à go-version-file: go.mod, qui supprimerait la dérive pour de bon, est renvoyé à #48 — un input de setup-go n'est validable qu'en poussant un tag tant que rien ne se déclenche sur push. Signed-off-by: GnomeZworc --- .forgejo/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 9cd29e6..d80e151 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-go@v5 with: - go-version: "1.21" + go-version: "1.25.14" - name: Build du projet run: | echo "Building for ${BINARI}/${GOOS}/${GOARCH} (release: ${RELEASE_CIBLE})" From 5918feed83a1ae099307db14afe929156c449a19 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 31 Aug 2026 15:51:36 +0200 Subject: [PATCH 3/7] f-46: dhcpd: build dhcp replies over insomniacslk/dhcp #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/dhcpd décide quelles options partent vers quelle MAC, sans I/O ni état. L'encodage RFC 3442 de l'option 121 est délégué à la bibliothèque, qui le teste elle-même. L'option 3 est omise plutôt qu'envoyée vide : la dhcp-option=3 nue n'existait que pour empêcher dnsmasq d'annoncer sa propre adresse. La route par défaut est conditionnée au subnet (DefaultGateway) et à l'interface (Host.DefaultRoute), ce qui remplace le mécanisme de tags. 24 tests plus un fuzz sur BuildReply. Les assertions d'encodage sont validées par mutation. Signed-off-by: GnomeZworc --- go.mod | 27 +-- go.sum | 54 ++++-- internal/dhcpd/dhcpd.go | 29 ++++ internal/dhcpd/fuzz_test.go | 59 +++++++ internal/dhcpd/reply.go | 97 +++++++++++ internal/dhcpd/reply_test.go | 326 +++++++++++++++++++++++++++++++++++ 6 files changed, 570 insertions(+), 22 deletions(-) create mode 100644 internal/dhcpd/dhcpd.go create mode 100644 internal/dhcpd/fuzz_test.go create mode 100644 internal/dhcpd/reply.go create mode 100644 internal/dhcpd/reply_test.go diff --git a/go.mod b/go.mod index cc743cb..0e59eb9 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,21 @@ go 1.25.0 toolchain go1.25.14 +require ( + github.com/coreos/go-systemd/v22 v22.6.0 + github.com/dgraph-io/badger/v4 v4.8.0 + github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 + github.com/spf13/viper v1.21.0 + github.com/vishvananda/netlink v1.3.1 + github.com/vishvananda/netns v0.0.5 + golang.org/x/sys v0.45.0 +) + require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/coreos/go-systemd/v22 v22.6.0 // indirect - github.com/dgraph-io/badger/v4 v4.8.0 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -17,11 +27,11 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/josharian/native v1.1.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect + github.com/pierrec/lz4/v4 v4.1.14 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -29,18 +39,15 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/vishvananda/netlink v1.3.1 // indirect - github.com/vishvananda/netns v0.0.5 // indirect + github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 402452d..271450a 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,18 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs= github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w= github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -23,12 +29,29 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2 h1:rhbGNh5bIA6JYahHdphjFiIbavqVspsvXFvTpBR7dQE= +github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2/go.mod h1:tGfUTcnFYGYvVNCaZZhwlJySU/fQQxh9TmpsFzWXnnY= +github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -37,6 +60,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -49,8 +74,12 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -63,24 +92,25 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/dhcpd/dhcpd.go b/internal/dhcpd/dhcpd.go new file mode 100644 index 0000000..3d1f95b --- /dev/null +++ b/internal/dhcpd/dhcpd.go @@ -0,0 +1,29 @@ +package dhcpd + +import ( + "net" + "time" +) + +const LeaseTime = 12 * time.Hour + +func DNSServers() []net.IP { + return []net.IP{ + net.IPv4(1, 1, 1, 1), + net.IPv4(8, 8, 8, 8), + } +} + +type SubnetConfig struct { + Network *net.IPNet + InterfaceIP net.IP + VPCRoute *net.IPNet + DefaultGateway net.IP +} + +type Host struct { + MAC net.HardwareAddr + IP net.IP + VM string + DefaultRoute bool +} diff --git a/internal/dhcpd/fuzz_test.go b/internal/dhcpd/fuzz_test.go new file mode 100644 index 0000000..b152bb9 --- /dev/null +++ b/internal/dhcpd/fuzz_test.go @@ -0,0 +1,59 @@ +package dhcpd + +import ( + "net" + "testing" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func FuzzBuildReply(f *testing.F) { + mac, err := net.ParseMAC("00:22:33:00:00:0a") + if err != nil { + f.Fatalf("ParseMAC: %v", err) + } + for _, kind := range []dhcpv4.MessageType{ + dhcpv4.MessageTypeDiscover, + dhcpv4.MessageTypeRequest, + dhcpv4.MessageTypeRelease, + dhcpv4.MessageTypeDecline, + } { + req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac)) + if err != nil { + f.Fatalf("New request: %v", err) + } + f.Add(req.ToBytes()) + } + + _, network, err := net.ParseCIDR("10.0.5.0/24") + if err != nil { + f.Fatalf("ParseCIDR: %v", err) + } + _, vpcRoute, err := net.ParseCIDR("10.0.0.0/16") + if err != nil { + f.Fatalf("ParseCIDR: %v", err) + } + + c := SubnetConfig{ + Network: network, + InterfaceIP: net.ParseIP("10.0.5.1"), + VPCRoute: vpcRoute, + DefaultGateway: net.ParseIP("10.0.5.254"), + } + h := Host{MAC: mac, IP: net.ParseIP("10.0.5.10"), VM: "vm-fuzz", DefaultRoute: true} + + f.Fuzz(func(t *testing.T, raw []byte) { + req, err := dhcpv4.FromBytes(raw) + if err != nil { + return + } + reply, err := BuildReply(c, h, req) + if err != nil { + return + } + if reply == nil { + t.Fatal("nil reply without an error") + } + reply.ToBytes() + }) +} diff --git a/internal/dhcpd/reply.go b/internal/dhcpd/reply.go new file mode 100644 index 0000000..a603b41 --- /dev/null +++ b/internal/dhcpd/reply.go @@ -0,0 +1,97 @@ +package dhcpd + +import ( + "errors" + "fmt" + "net" + + "git.g3e.fr/syonad/two/internal/metadata" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +var ( + ErrNoInterfaceIP = errors.New("interface ip is required: guests would have no route to the metadata server") + ErrNoNetwork = errors.New("subnet network is required") + ErrNoHostIP = errors.New("host ip is required") + ErrNoRequest = errors.New("request is nil") +) + +func metadataRoute() *net.IPNet { + return &net.IPNet{ + IP: net.ParseIP(metadata.ServiceIP).To4(), + Mask: net.CIDRMask(32, 32), + } +} + +func defaultRoute() *net.IPNet { + return &net.IPNet{ + IP: net.IPv4zero.To4(), + Mask: net.CIDRMask(0, 32), + } +} + +func Routes(c SubnetConfig, h Host) (dhcpv4.Routes, error) { + if c.InterfaceIP == nil { + return nil, ErrNoInterfaceIP + } + + routes := dhcpv4.Routes{{Dest: metadataRoute(), Router: c.InterfaceIP}} + + if c.VPCRoute != nil { + routes = append(routes, &dhcpv4.Route{Dest: c.VPCRoute, Router: c.InterfaceIP}) + } + if h.DefaultRoute && c.DefaultGateway != nil { + routes = append(routes, &dhcpv4.Route{Dest: defaultRoute(), Router: c.DefaultGateway}) + } + return routes, nil +} + +func replyType(req *dhcpv4.DHCPv4) (dhcpv4.MessageType, error) { + switch req.MessageType() { + case dhcpv4.MessageTypeDiscover: + return dhcpv4.MessageTypeOffer, nil + case dhcpv4.MessageTypeRequest: + return dhcpv4.MessageTypeAck, nil + default: + return 0, fmt.Errorf("no reply built for message type %s", req.MessageType()) + } +} + +func BuildReply(c SubnetConfig, h Host, req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) { + if req == nil { + return nil, ErrNoRequest + } + if c.Network == nil { + return nil, ErrNoNetwork + } + if h.IP == nil { + return nil, ErrNoHostIP + } + + kind, err := replyType(req) + if err != nil { + return nil, err + } + + routes, err := Routes(c, h) + if err != nil { + return nil, err + } + + mods := []dhcpv4.Modifier{ + dhcpv4.WithMessageType(kind), + dhcpv4.WithServerIP(c.InterfaceIP), + dhcpv4.WithYourIP(h.IP), + dhcpv4.WithNetmask(c.Network.Mask), + dhcpv4.WithLeaseTime(uint32(LeaseTime.Seconds())), + dhcpv4.WithOption(dhcpv4.OptServerIdentifier(c.InterfaceIP)), + dhcpv4.WithOption(dhcpv4.OptDNS(DNSServers()...)), + dhcpv4.WithOption(dhcpv4.OptClasslessStaticRoute(routes...)), + } + if h.DefaultRoute && c.DefaultGateway != nil { + mods = append(mods, dhcpv4.WithOption(dhcpv4.OptRouter(c.DefaultGateway))) + } + + return dhcpv4.NewReplyFromRequest(req, mods...) +} diff --git a/internal/dhcpd/reply_test.go b/internal/dhcpd/reply_test.go new file mode 100644 index 0000000..f96a126 --- /dev/null +++ b/internal/dhcpd/reply_test.go @@ -0,0 +1,326 @@ +package dhcpd + +import ( + "bytes" + "errors" + "net" + "testing" + + "git.g3e.fr/syonad/two/internal/metadata" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func cidr(t *testing.T, s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("ParseCIDR(%q): %v", s, err) + } + return n +} + +func testConfig(t *testing.T) SubnetConfig { + t.Helper() + return SubnetConfig{ + Network: cidr(t, "10.0.5.0/24"), + InterfaceIP: net.ParseIP("10.0.5.1"), + } +} + +func testHost(t *testing.T) Host { + t.Helper() + mac, err := net.ParseMAC("00:22:33:00:00:0a") + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + return Host{MAC: mac, IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true} +} + +func request(t *testing.T, kind dhcpv4.MessageType, mac net.HardwareAddr) *dhcpv4.DHCPv4 { + t.Helper() + req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac)) + if err != nil { + t.Fatalf("New request: %v", err) + } + return req +} + +func encodedRoute(t *testing.T, routes dhcpv4.Routes, dest string) []byte { + t.Helper() + for _, r := range routes { + if r.Dest.String() == dest { + return dhcpv4.Routes{r}.ToBytes() + } + } + t.Fatalf("no route to %s in %s", dest, routes) + return nil +} + +func TestRoutes_AlwaysCarriesTheMetadataRoute(t *testing.T) { + c := testConfig(t) + routes, err := Routes(c, Host{IP: net.ParseIP("10.0.5.10")}) + if err != nil { + t.Fatalf("Routes: %v", err) + } + if len(routes) != 1 { + t.Fatalf("expected the metadata route alone, got %s", routes) + } + if got := routes[0].Dest.String(); got != metadata.ServiceIP+"/32" { + t.Errorf("destination = %s, want %s/32", got, metadata.ServiceIP) + } + if !routes[0].Router.Equal(c.InterfaceIP) { + t.Errorf("next-hop = %s, want the subnet interface ip %s", routes[0].Router, c.InterfaceIP) + } +} + +func TestRoutes_WithoutInterfaceIPIsRejected(t *testing.T) { + _, err := Routes(SubnetConfig{Network: cidr(t, "10.0.5.0/24")}, testHost(t)) + if !errors.Is(err, ErrNoInterfaceIP) { + t.Fatalf("error = %v, want ErrNoInterfaceIP", err) + } +} + +func TestRoutes_VPCRouteUsesTheInterfaceIPAsNextHop(t *testing.T) { + c := testConfig(t) + c.VPCRoute = cidr(t, "10.0.0.0/16") + c.DefaultGateway = net.ParseIP("192.0.2.1") + + routes, err := Routes(c, testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + for _, r := range routes { + if r.Dest.String() != "10.0.0.0/16" { + continue + } + if !r.Router.Equal(c.InterfaceIP) { + t.Fatalf("vpc route next-hop = %s, want %s", r.Router, c.InterfaceIP) + } + return + } + t.Fatalf("no vpc route in %s", routes) +} + +func TestRoutes_NoDefaultRouteWithoutDefaultGateway(t *testing.T) { + routes, err := Routes(testConfig(t), testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + for _, r := range routes { + if ones, _ := r.Dest.Mask.Size(); ones == 0 { + t.Fatalf("unexpected default route in %s", routes) + } + } +} + +func TestRoutes_NoDefaultRouteWhenTheInterfaceDoesNotReceiveIt(t *testing.T) { + c := testConfig(t) + c.DefaultGateway = net.ParseIP("10.0.5.254") + + h := testHost(t) + h.DefaultRoute = false + + routes, err := Routes(c, h) + if err != nil { + t.Fatalf("Routes: %v", err) + } + for _, r := range routes { + if ones, _ := r.Dest.Mask.Size(); ones == 0 { + t.Fatalf("a secondary interface must not receive the default route, got %s", routes) + } + } +} + +func TestRoutes_DefaultRouteEncodesZeroDestinationOctets(t *testing.T) { + c := testConfig(t) + c.DefaultGateway = net.ParseIP("10.0.5.254") + + routes, err := Routes(c, testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + + want := []byte{0x00, 10, 0, 5, 254} + if got := encodedRoute(t, routes, "0.0.0.0/0"); !bytes.Equal(got, want) { + t.Errorf("default route encoding = % x, want % x", got, want) + } +} + +func TestRoutes_UnalignedPrefixEncodesOnlyItsSignificantOctets(t *testing.T) { + c := testConfig(t) + c.VPCRoute = cidr(t, "10.16.0.0/12") + + routes, err := Routes(c, testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + + want := []byte{0x0c, 10, 16, 10, 0, 5, 1} + if got := encodedRoute(t, routes, "10.16.0.0/12"); !bytes.Equal(got, want) { + t.Errorf("/12 encoding = % x, want % x", got, want) + } +} + +func TestRoutes_MetadataRouteEncodesOnFourDestinationOctets(t *testing.T) { + routes, err := Routes(testConfig(t), testHost(t)) + if err != nil { + t.Fatalf("Routes: %v", err) + } + + want := []byte{0x20, 169, 254, 169, 254, 10, 0, 5, 1} + if got := encodedRoute(t, routes, metadata.ServiceIP+"/32"); !bytes.Equal(got, want) { + t.Errorf("metadata route encoding = % x, want % x", got, want) + } +} + +func TestBuildReply_DiscoverIsAnsweredWithAnOffer(t *testing.T) { + h := testHost(t) + reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDiscover, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if reply.MessageType() != dhcpv4.MessageTypeOffer { + t.Errorf("message type = %s, want OFFER", reply.MessageType()) + } +} + +func TestBuildReply_RequestIsAnsweredWithAnAck(t *testing.T) { + h := testHost(t) + reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if reply.MessageType() != dhcpv4.MessageTypeAck { + t.Errorf("message type = %s, want ACK", reply.MessageType()) + } +} + +func TestBuildReply_ReleaseGetsNoReply(t *testing.T) { + h := testHost(t) + if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRelease, h.MAC)); err == nil { + t.Fatal("a RELEASE must not produce a reply") + } +} + +func TestBuildReply_DeclineGetsNoReply(t *testing.T) { + h := testHost(t) + if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDecline, h.MAC)); err == nil { + t.Fatal("a DECLINE must not produce a reply") + } +} + +func TestBuildReply_CarriesAddressMaskLeaseAndServerIdentifier(t *testing.T) { + c := testConfig(t) + h := testHost(t) + + reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + + if !reply.YourIPAddr.Equal(h.IP) { + t.Errorf("yiaddr = %s, want %s", reply.YourIPAddr, h.IP) + } + if got := net.IP(reply.SubnetMask()).String(); got != net.IP(c.Network.Mask).String() { + t.Errorf("netmask = %s, want %s", got, net.IP(c.Network.Mask)) + } + if got := reply.IPAddressLeaseTime(0); got != LeaseTime { + t.Errorf("lease time = %s, want %s", got, LeaseTime) + } + if got := reply.ServerIdentifier(); !got.Equal(c.InterfaceIP) { + t.Errorf("server identifier = %s, want %s", got, c.InterfaceIP) + } + if got := reply.DNS(); len(got) != 2 || !got[0].Equal(net.IPv4(1, 1, 1, 1)) || !got[1].Equal(net.IPv4(8, 8, 8, 8)) { + t.Errorf("dns = %v, want 1.1.1.1 and 8.8.8.8", got) + } +} + +func TestBuildReply_NoRouterOptionWithoutDefaultRoute(t *testing.T) { + h := testHost(t) + reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if got := reply.Router(); len(got) != 0 { + t.Errorf("router option = %v, want none: the guest would use the server as its gateway", got) + } +} + +func TestBuildReply_RouterOptionCarriesTheDefaultGateway(t *testing.T) { + c := testConfig(t) + c.DefaultGateway = net.ParseIP("10.0.5.254") + + h := testHost(t) + reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + + got := reply.Router() + if len(got) != 1 || !got[0].Equal(c.DefaultGateway) { + t.Errorf("router option = %v, want [%s]", got, c.DefaultGateway) + } +} + +func TestBuildReply_SecondaryInterfaceGetsNoRouterOption(t *testing.T) { + c := testConfig(t) + c.DefaultGateway = net.ParseIP("10.0.5.254") + + h := testHost(t) + h.DefaultRoute = false + + reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if got := reply.Router(); len(got) != 0 { + t.Errorf("router option = %v, want none on a secondary interface", got) + } +} + +func TestBuildReply_ClasslessStaticRouteIsPresent(t *testing.T) { + h := testHost(t) + reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC)) + if err != nil { + t.Fatalf("BuildReply: %v", err) + } + if got := reply.ClasslessStaticRoute(); len(got) == 0 { + t.Fatal("option 121 missing: cloud-init would have no route to the metadata server") + } +} + +func TestBuildReply_WithoutInterfaceIPIsRejected(t *testing.T) { + c := testConfig(t) + c.InterfaceIP = nil + + h := testHost(t) + if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoInterfaceIP) { + t.Fatalf("error = %v, want ErrNoInterfaceIP", err) + } +} + +func TestBuildReply_WithoutNetworkIsRejected(t *testing.T) { + c := testConfig(t) + c.Network = nil + + h := testHost(t) + if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoNetwork) { + t.Fatalf("error = %v, want ErrNoNetwork", err) + } +} + +func TestBuildReply_WithoutHostIPIsRejected(t *testing.T) { + h := testHost(t) + h.IP = nil + + if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, testHost(t).MAC)); !errors.Is(err, ErrNoHostIP) { + t.Fatalf("error = %v, want ErrNoHostIP", err) + } +} + +func TestBuildReply_NilRequestIsRejected(t *testing.T) { + if _, err := BuildReply(testConfig(t), testHost(t), nil); !errors.Is(err, ErrNoRequest) { + t.Fatalf("error = %v, want ErrNoRequest", err) + } +} From 0938d1e0e0e726834d8a12d8ffb92a2f74f5275a Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 31 Aug 2026 16:06:25 +0200 Subject: [PATCH 4/7] f-46: dhcpd: add the decision engine and the persisted state #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store porte la configuration du subnet et les réservations par MAC, et les persiste dans un .state écrit de façon atomique (temp + chmod 0600 + rename). Chaque écriture remplace le fichier, donc un chmod externe est effacé. Les snapshots sont à la fois la forme sérialisée et la forme d'entrée des ordres : validation au moment où l'agent pousse, pas quand un guest demande une adresse. Les hôtes sont triés par MAC, base du digest de L3. Handle rend (nil, nil) — le silence est un retour normal — pour un type de message non traité, un subnet non configuré ou une MAC inconnue. La MAC est normalisée à l'écriture comme au chargement. 58 tests, -race propre, dix mutations toutes détectées. Signed-off-by: GnomeZworc --- internal/dhcpd/engine.go | 66 +++++++ internal/dhcpd/engine_test.go | 204 ++++++++++++++++++++ internal/dhcpd/state.go | 285 ++++++++++++++++++++++++++++ internal/dhcpd/state_test.go | 346 ++++++++++++++++++++++++++++++++++ 4 files changed, 901 insertions(+) create mode 100644 internal/dhcpd/engine.go create mode 100644 internal/dhcpd/engine_test.go create mode 100644 internal/dhcpd/state.go create mode 100644 internal/dhcpd/state_test.go diff --git a/internal/dhcpd/engine.go b/internal/dhcpd/engine.go new file mode 100644 index 0000000..15f81de --- /dev/null +++ b/internal/dhcpd/engine.go @@ -0,0 +1,66 @@ +package dhcpd + +import ( + "net" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func answerable(req *dhcpv4.DHCPv4) bool { + switch req.MessageType() { + case dhcpv4.MessageTypeDiscover, dhcpv4.MessageTypeRequest: + return true + default: + return false + } +} + +func (s *Store) Handle(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) { + if req == nil { + return nil, ErrNoRequest + } + if !answerable(req) { + return nil, nil + } + + subnet, configured := s.Subnet() + if !configured { + return nil, nil + } + + host, known := s.Lookup(req.ClientHWAddr) + if !known { + return nil, nil + } + + return BuildReply(subnet, host, req) +} + +func (s *Store) Probe(mac string) (*dhcpv4.DHCPv4, error) { + key, err := normalizeMAC(mac) + if err != nil { + return nil, err + } + + subnet, configured := s.Subnet() + if !configured { + return nil, ErrNotConfigured + } + + parsed, err := net.ParseMAC(key) + if err != nil { + return nil, err + } + + host, known := s.Lookup(parsed) + if !known { + return nil, nil + } + + req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(parsed)) + if err != nil { + return nil, err + } + + return BuildReply(subnet, host, req) +} diff --git a/internal/dhcpd/engine_test.go b/internal/dhcpd/engine_test.go new file mode 100644 index 0000000..f035493 --- /dev/null +++ b/internal/dhcpd/engine_test.go @@ -0,0 +1,204 @@ +package dhcpd + +import ( + "errors" + "net" + "testing" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func configuredStore(t *testing.T) *Store { + t.Helper() + s, _ := loadedStore(t) + if err := s.SetSubnet(testSubnetSnapshot()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + return s +} + +func mac(t *testing.T, s string) net.HardwareAddr { + t.Helper() + m, err := net.ParseMAC(s) + if err != nil { + t.Fatalf("ParseMAC(%q): %v", s, err) + } + return m +} + +func TestHandle_KnownMACGetsAnOfferOnDiscover(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply == nil { + t.Fatal("a known mac must be answered") + } + if reply.MessageType() != dhcpv4.MessageTypeOffer { + t.Errorf("message type = %s, want OFFER", reply.MessageType()) + } + if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("yiaddr = %s, want the reserved 10.0.5.10", reply.YourIPAddr) + } +} + +func TestHandle_UnknownMACIsAnsweredWithSilence(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff"))) + if err != nil { + t.Fatalf("an unknown mac is not an error: %v", err) + } + if reply != nil { + t.Error("an unknown mac must get no reply, not a NAK") + } +} + +func TestHandle_UnconfiguredSubnetIsAnsweredWithSilence(t *testing.T) { + s, _ := loadedStore(t) + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply != nil { + t.Error("without a subnet configuration the server must stay silent") + } +} + +func TestHandle_ReleaseIsANoOp(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("a RELEASE is not an error: %v", err) + } + if reply != nil { + t.Error("a RELEASE must get no reply: reservations are static") + } +} + +func TestHandle_DeclineIsANoOp(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDecline, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("a DECLINE is not an error: %v", err) + } + if reply != nil { + t.Error("a DECLINE must get no reply: there is nothing to release") + } +} + +func TestHandle_RequestIsAnsweredWithAnAck(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply == nil || reply.MessageType() != dhcpv4.MessageTypeAck { + t.Fatalf("reply = %v, want an ACK", reply) + } +} + +func TestHandle_NilRequestIsRejected(t *testing.T) { + s := configuredStore(t) + + if _, err := s.Handle(nil); !errors.Is(err, ErrNoRequest) { + t.Fatalf("error = %v, want ErrNoRequest", err) + } +} + +func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) { + s := configuredStore(t) + if err := s.DelHost("00:22:33:00:00:0a"); err != nil { + t.Fatalf("DelHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply != nil { + t.Error("a deleted host must no longer be served") + } +} + +func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Probe("00:22:33:00:00:0A") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if reply == nil { + t.Fatal("a known mac must be described") + } + if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr) + } + if got := reply.ClasslessStaticRoute(); len(got) != 3 { + t.Errorf("routes = %s, want metadata, vpc and default", got) + } +} + +func TestProbe_UnknownMACReturnsNothing(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Probe("00:22:33:ff:ff:ff") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if reply != nil { + t.Error("an unknown mac must describe no reply") + } +} + +func TestProbe_WithoutSubnetConfigurationIsRejected(t *testing.T) { + s, _ := loadedStore(t) + + if _, err := s.Probe("00:22:33:00:00:0a"); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("error = %v, want ErrNotConfigured", err) + } +} + +func TestProbe_InvalidMACIsRejected(t *testing.T) { + s := configuredStore(t) + + if _, err := s.Probe("nope"); err == nil { + t.Fatal("an invalid mac must be reported") + } +} + +func TestHandle_SecondaryInterfaceGetsNoDefaultRoute(t *testing.T) { + s := configuredStore(t) + snap := testHostSnapshot() + snap.MAC = "00:22:33:00:00:0b" + snap.IP = "10.0.5.11" + snap.DefaultRoute = false + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0b"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if got := reply.Router(); len(got) != 0 { + t.Errorf("router option = %v, want none on a secondary interface", got) + } + for _, r := range reply.ClasslessStaticRoute() { + if ones, _ := r.Dest.Mask.Size(); ones == 0 { + t.Errorf("unexpected default route for a secondary interface: %s", reply.ClasslessStaticRoute()) + } + } +} diff --git a/internal/dhcpd/state.go b/internal/dhcpd/state.go new file mode 100644 index 0000000..79f1905 --- /dev/null +++ b/internal/dhcpd/state.go @@ -0,0 +1,285 @@ +package dhcpd + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sort" + "sync" +) + +const ( + stateFileMode = 0o600 + stateDirMode = 0o700 +) + +var ( + ErrNoMAC = errors.New("host mac is required") + ErrNotConfigured = errors.New("subnet is not configured") +) + +type SubnetSnapshot struct { + Network string `json:"network"` + InterfaceIP string `json:"interface_ip"` + VPCRoute string `json:"vpc_route,omitempty"` + DefaultGateway string `json:"default_gateway,omitempty"` +} + +type HostSnapshot struct { + MAC string `json:"mac"` + IP string `json:"ip"` + VM string `json:"vm,omitempty"` + DefaultRoute bool `json:"default_route"` +} + +type Snapshot struct { + Subnet *SubnetSnapshot `json:"subnet,omitempty"` + Hosts []HostSnapshot `json:"hosts"` +} + +type Store struct { + mu sync.RWMutex + path string + subnet SubnetConfig + configured bool + hosts map[string]Host +} + +func NewStore(path string) *Store { + return &Store{path: path, hosts: make(map[string]Host)} +} + +func normalizeMAC(s string) (string, error) { + mac, err := net.ParseMAC(s) + if err != nil { + return "", fmt.Errorf("invalid mac %q: %w", s, err) + } + return mac.String(), nil +} + +func (s *Store) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s.persist() + } + if err != nil { + return fmt.Errorf("read %s: %w", s.path, err) + } + + var snap Snapshot + if len(raw) > 0 { + if err := json.Unmarshal(raw, &snap); err != nil { + return fmt.Errorf("parse %s: %w", s.path, err) + } + } + return s.apply(snap) +} + +func (s *Store) apply(snap Snapshot) error { + subnet := SubnetConfig{} + configured := false + if snap.Subnet != nil { + parsed, err := parseSubnet(*snap.Subnet) + if err != nil { + return err + } + subnet = parsed + configured = true + } + + hosts := make(map[string]Host, len(snap.Hosts)) + for _, h := range snap.Hosts { + host, err := parseHost(h) + if err != nil { + return err + } + hosts[host.MAC.String()] = host + } + + s.subnet = subnet + s.configured = configured + s.hosts = hosts + return nil +} + +func parseSubnet(s SubnetSnapshot) (SubnetConfig, error) { + _, network, err := net.ParseCIDR(s.Network) + if err != nil { + return SubnetConfig{}, fmt.Errorf("invalid network %q: %w", s.Network, err) + } + + interfaceIP := net.ParseIP(s.InterfaceIP) + if interfaceIP == nil { + return SubnetConfig{}, ErrNoInterfaceIP + } + + c := SubnetConfig{Network: network, InterfaceIP: interfaceIP} + + if s.VPCRoute != "" { + _, vpcRoute, err := net.ParseCIDR(s.VPCRoute) + if err != nil { + return SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", s.VPCRoute, err) + } + c.VPCRoute = vpcRoute + } + if s.DefaultGateway != "" { + gw := net.ParseIP(s.DefaultGateway) + if gw == nil { + return SubnetConfig{}, fmt.Errorf("invalid default gateway %q", s.DefaultGateway) + } + c.DefaultGateway = gw + } + return c, nil +} + +func parseHost(h HostSnapshot) (Host, error) { + mac, err := net.ParseMAC(h.MAC) + if err != nil { + return Host{}, fmt.Errorf("invalid mac %q: %w", h.MAC, err) + } + ip := net.ParseIP(h.IP) + if ip == nil { + return Host{}, fmt.Errorf("invalid host ip %q", h.IP) + } + return Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil +} + +func (s *Store) SetSubnet(snap SubnetSnapshot) error { + c, err := parseSubnet(snap) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.subnet = c + s.configured = true + return s.persist() +} + +func (s *Store) SetHost(snap HostSnapshot) error { + if snap.MAC == "" { + return ErrNoMAC + } + host, err := parseHost(snap) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.hosts[host.MAC.String()] = host + return s.persist() +} + +func (s *Store) DelHost(mac string) error { + key, err := normalizeMAC(mac) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.hosts, key) + return s.persist() +} + +func (s *Store) Lookup(mac net.HardwareAddr) (Host, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + h, ok := s.hosts[mac.String()] + return h, ok +} + +func (s *Store) Subnet() (SubnetConfig, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.subnet, s.configured +} + +func (s *Store) Snapshot() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.snapshot() +} + +func (s *Store) snapshot() Snapshot { + snap := Snapshot{Hosts: make([]HostSnapshot, 0, len(s.hosts))} + + if s.configured { + sub := SubnetSnapshot{ + Network: s.subnet.Network.String(), + InterfaceIP: s.subnet.InterfaceIP.String(), + } + if s.subnet.VPCRoute != nil { + sub.VPCRoute = s.subnet.VPCRoute.String() + } + if s.subnet.DefaultGateway != nil { + sub.DefaultGateway = s.subnet.DefaultGateway.String() + } + snap.Subnet = &sub + } + + for _, h := range s.hosts { + snap.Hosts = append(snap.Hosts, HostSnapshot{ + MAC: h.MAC.String(), + IP: h.IP.String(), + VM: h.VM, + DefaultRoute: h.DefaultRoute, + }) + } + sort.Slice(snap.Hosts, func(i, j int) bool { return snap.Hosts[i].MAC < snap.Hosts[j].MAC }) + + return snap +} + +func (s *Store) persist() error { + raw, err := json.Marshal(s.snapshot()) + if err != nil { + return fmt.Errorf("encode state: %w", err) + } + + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, stateDirMode); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".tmp") + if err != nil { + return fmt.Errorf("create temp state in %s: %w", dir, err) + } + defer os.Remove(tmp.Name()) + + if err := tmp.Chmod(stateFileMode); err != nil { + tmp.Close() + return fmt.Errorf("chmod %s: %w", tmp.Name(), err) + } + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return fmt.Errorf("write %s: %w", tmp.Name(), err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync %s: %w", tmp.Name(), err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close %s: %w", tmp.Name(), err) + } + + if err := os.Rename(tmp.Name(), s.path); err != nil { + return fmt.Errorf("rename %s to %s: %w", tmp.Name(), s.path, err) + } + return nil +} diff --git a/internal/dhcpd/state_test.go b/internal/dhcpd/state_test.go new file mode 100644 index 0000000..7240ddb --- /dev/null +++ b/internal/dhcpd/state_test.go @@ -0,0 +1,346 @@ +package dhcpd + +import ( + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "testing" +) + +func statePath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "vp-admin_br-000001.state") +} + +func testSubnetSnapshot() SubnetSnapshot { + return SubnetSnapshot{ + Network: "10.0.5.0/24", + InterfaceIP: "10.0.5.1", + VPCRoute: "10.0.0.0/16", + DefaultGateway: "10.0.5.254", + } +} + +func testHostSnapshot() HostSnapshot { + return HostSnapshot{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true} +} + +func loadedStore(t *testing.T) (*Store, string) { + t.Helper() + path := statePath(t) + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + return s, path +} + +func TestStore_LoadCreatesTheStateFileWhenAbsent(t *testing.T) { + _, path := loadedStore(t) + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("the state file must be created on load: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: the file exposes tenant mac and ip", got) + } +} + +func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) { + s, path := loadedStore(t) + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got) + } +} + +func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) { + path := statePath(t) + if err := os.WriteFile(path, nil, stateFileMode); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if _, configured := s.Subnet(); configured { + t.Error("an empty state file must not report a configured subnet") + } +} + +func TestStore_LoadRejectsCorruptedState(t *testing.T) { + path := statePath(t) + if err := os.WriteFile(path, []byte("{not json"), stateFileMode); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := NewStore(path).Load(); err == nil { + t.Fatal("a corrupted state file must be reported, not silently ignored") + } +} + +func TestStore_SetSubnetIsPersisted(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetSubnet(testSubnetSnapshot()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + c, configured := reloaded.Subnet() + if !configured { + t.Fatal("subnet lost across a restart") + } + if got := c.Network.String(); got != "10.0.5.0/24" { + t.Errorf("network = %s, want 10.0.5.0/24", got) + } + if !c.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) { + t.Errorf("interface ip = %s, want 10.0.5.1", c.InterfaceIP) + } + if got := c.VPCRoute.String(); got != "10.0.0.0/16" { + t.Errorf("vpc route = %s, want 10.0.0.0/16", got) + } + if !c.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) { + t.Errorf("default gateway = %s, want 10.0.5.254", c.DefaultGateway) + } +} + +func TestStore_SetSubnetRejectsAMissingInterfaceIP(t *testing.T) { + s, _ := loadedStore(t) + snap := testSubnetSnapshot() + snap.InterfaceIP = "" + + if err := s.SetSubnet(snap); !errors.Is(err, ErrNoInterfaceIP) { + t.Fatalf("error = %v, want ErrNoInterfaceIP", err) + } +} + +func TestStore_SetSubnetRejectsAnInvalidNetwork(t *testing.T) { + s, _ := loadedStore(t) + snap := testSubnetSnapshot() + snap.Network = "10.0.5.0" + + if err := s.SetSubnet(snap); err == nil { + t.Fatal("a network without a prefix length must be rejected") + } +} + +func TestStore_SetHostIsPersistedAndFound(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + mac, err := net.ParseMAC("00:22:33:00:00:0a") + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + h, known := reloaded.Lookup(mac) + if !known { + t.Fatal("host lost across a restart") + } + if !h.IP.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("ip = %s, want 10.0.5.10", h.IP) + } + if h.VM != "vm-test" { + t.Errorf("vm = %q, want vm-test", h.VM) + } + if !h.DefaultRoute { + t.Error("default route flag lost across a restart") + } +} + +func TestStore_SetHostIsIdempotentOnTheSameMAC(t *testing.T) { + s, _ := loadedStore(t) + snap := testHostSnapshot() + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + + snap.IP = "10.0.5.11" + snap.DefaultRoute = false + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + + got := s.Snapshot() + if len(got.Hosts) != 1 { + t.Fatalf("hosts = %d, want 1: the mac is the key", len(got.Hosts)) + } + if got.Hosts[0].IP != "10.0.5.11" || got.Hosts[0].DefaultRoute { + t.Errorf("entry = %+v, want the second order to have replaced the first", got.Hosts[0]) + } +} + +func TestStore_SetHostNormalizesTheMACCase(t *testing.T) { + s, _ := loadedStore(t) + snap := testHostSnapshot() + snap.MAC = "00:22:33:AA:BB:CC" + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + + mac, err := net.ParseMAC("00:22:33:aa:bb:cc") + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + if _, known := s.Lookup(mac); !known { + t.Error("an uppercase mac must be found in lowercase: the key would diverge") + } +} + +func TestStore_LoadNormalizesTheMACCase(t *testing.T) { + path := statePath(t) + raw, err := json.Marshal(Snapshot{Hosts: []HostSnapshot{{ + MAC: "00:22:33:AA:BB:CC", IP: "10.0.5.12", VM: "vm-test", DefaultRoute: true, + }}}) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(path, raw, stateFileMode); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + m, err := net.ParseMAC("00:22:33:aa:bb:cc") + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + if _, known := s.Lookup(m); !known { + t.Error("a reloaded uppercase mac must be keyed in lowercase: the host would silently stop being served") + } +} + +func TestStore_SetHostRejectsAMissingMAC(t *testing.T) { + s, _ := loadedStore(t) + snap := testHostSnapshot() + snap.MAC = "" + + if err := s.SetHost(snap); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) + } +} + +func TestStore_SetHostRejectsAnInvalidIP(t *testing.T) { + s, _ := loadedStore(t) + snap := testHostSnapshot() + snap.IP = "10.0.5.300" + + if err := s.SetHost(snap); err == nil { + t.Fatal("an invalid host ip must be rejected") + } +} + +func TestStore_DelHostRemovesTheEntry(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + if err := s.DelHost("00:22:33:00:00:0A"); err != nil { + t.Fatalf("DelHost: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := len(reloaded.Snapshot().Hosts); got != 0 { + t.Errorf("hosts = %d, want 0 after deletion", got) + } +} + +func TestStore_DelHostOnAnUnknownMACIsNotAnError(t *testing.T) { + s, _ := loadedStore(t) + if err := s.DelHost("00:22:33:ff:ff:ff"); err != nil { + t.Errorf("deleting an absent entry must be idempotent, got %v", err) + } +} + +func TestStore_DelHostRejectsAnInvalidMAC(t *testing.T) { + s, _ := loadedStore(t) + if err := s.DelHost("not-a-mac"); err == nil { + t.Fatal("an invalid mac must be reported") + } +} + +func TestStore_SnapshotSortsHostsByMAC(t *testing.T) { + s, _ := loadedStore(t) + for _, mac := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} { + snap := testHostSnapshot() + snap.MAC = mac + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + } + + hosts := s.Snapshot().Hosts + for i := 1; i < len(hosts); i++ { + if hosts[i-1].MAC >= hosts[i].MAC { + t.Fatalf("hosts are not sorted: %v", hosts) + } + } +} + +func TestStore_PersistLeavesNoTemporaryFileBehind(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 { + t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries) + } +} + +func TestStore_PersistedStateIsValidJSON(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetSubnet(testSubnetSnapshot()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var snap Snapshot + if err := json.Unmarshal(raw, &snap); err != nil { + t.Fatalf("the state file must stay parseable: %v", err) + } + if snap.Subnet == nil || len(snap.Hosts) != 1 { + t.Errorf("snapshot = %+v, want one subnet and one host", snap) + } +} From 8f15e4d383148615b8b11c7011431b9c4cd1c6b0 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 31 Aug 2026 18:35:31 +0200 Subject: [PATCH 5/7] f-46: statefile: add a generic atomic state file #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistance d'un état de composant dans un fichier, générique sur le type stocké : temp + chmod 0600 + sync + rename, répertoire en 0700. Chaque écriture remplace le fichier, donc un chmod externe est effacé, et un échec d'encodage ne tronque pas l'état précédent. Load crée le fichier absent et rend le zéro du type ; un fichier vide est un point de départ valide, un fichier corrompu est une erreur. Dans pkg/ plutôt que sous internal/dhcpd : une réutilisation par l'intel est envisagée. Écarte Badger pour cet usage — une instance par subnet coûterait ~74 Mio et 4 goroutines pour ~1 Kio d'état dans un tmpfs, plus un LOCK résiduel après un arrêt brutal. 11 tests, quatre mutations toutes détectées. Signed-off-by: GnomeZworc --- pkg/db/statefile/statefile.go | 92 ++++++++++++++ pkg/db/statefile/statefile_test.go | 189 +++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 pkg/db/statefile/statefile.go create mode 100644 pkg/db/statefile/statefile_test.go diff --git a/pkg/db/statefile/statefile.go b/pkg/db/statefile/statefile.go new file mode 100644 index 0000000..fc62524 --- /dev/null +++ b/pkg/db/statefile/statefile.go @@ -0,0 +1,92 @@ +package statefile + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + FileMode = 0o600 + DirMode = 0o700 +) + +type File[T any] struct { + path string +} + +func New[T any](path string) *File[T] { + return &File[T]{path: path} +} + +func (f *File[T]) Path() string { + return f.path +} + +func (f *File[T]) Load() (T, error) { + var value T + + raw, err := os.ReadFile(f.path) + if errors.Is(err, os.ErrNotExist) { + return value, f.Save(value) + } + if err != nil { + return value, fmt.Errorf("read %s: %w", f.path, err) + } + if len(raw) == 0 { + return value, nil + } + if err := json.Unmarshal(raw, &value); err != nil { + var zero T + return zero, fmt.Errorf("parse %s: %w", f.path, err) + } + return value, nil +} + +func (f *File[T]) Save(value T) error { + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode state for %s: %w", f.path, err) + } + + dir := filepath.Dir(f.path) + if err := os.MkdirAll(dir, DirMode); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, filepath.Base(f.path)+".tmp") + if err != nil { + return fmt.Errorf("create temp state in %s: %w", dir, err) + } + defer os.Remove(tmp.Name()) + + if err := tmp.Chmod(FileMode); err != nil { + tmp.Close() + return fmt.Errorf("chmod %s: %w", tmp.Name(), err) + } + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return fmt.Errorf("write %s: %w", tmp.Name(), err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync %s: %w", tmp.Name(), err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close %s: %w", tmp.Name(), err) + } + + if err := os.Rename(tmp.Name(), f.path); err != nil { + return fmt.Errorf("rename %s to %s: %w", tmp.Name(), f.path, err) + } + return nil +} + +func (f *File[T]) Remove() error { + if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", f.path, err) + } + return nil +} diff --git a/pkg/db/statefile/statefile_test.go b/pkg/db/statefile/statefile_test.go new file mode 100644 index 0000000..70e2a85 --- /dev/null +++ b/pkg/db/statefile/statefile_test.go @@ -0,0 +1,189 @@ +package statefile + +import ( + "os" + "path/filepath" + "testing" +) + +type payload struct { + Name string `json:"name"` + Items []string `json:"items"` +} + +func path(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "component.state") +} + +func TestLoad_CreatesTheFileWhenAbsent(t *testing.T) { + p := path(t) + f := New[payload](p) + + value, err := f.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if value.Name != "" || len(value.Items) != 0 { + t.Errorf("value = %+v, want the zero value", value) + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("the file must be created on load: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600", got) + } +} + +func TestLoad_CreatesTheDirectoryWhenAbsent(t *testing.T) { + p := filepath.Join(t.TempDir(), "nested", "deeper", "component.state") + + if _, err := New[payload](p).Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + info, err := os.Stat(filepath.Dir(p)) + if err != nil { + t.Fatalf("the directory must be created: %v", err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Errorf("directory mode = %o, want 700", got) + } +} + +func TestLoad_EmptyFileYieldsTheZeroValue(t *testing.T) { + p := path(t) + if err := os.WriteFile(p, nil, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + value, err := New[payload](p).Load() + if err != nil { + t.Fatalf("an empty file is a valid starting point: %v", err) + } + if value.Name != "" { + t.Errorf("value = %+v, want the zero value", value) + } +} + +func TestLoad_CorruptedFileIsReported(t *testing.T) { + p := path(t) + if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, err := New[payload](p).Load(); err == nil { + t.Fatal("a corrupted file must be reported, not silently ignored") + } +} + +func TestSave_RoundTrips(t *testing.T) { + p := path(t) + f := New[payload](p) + + want := payload{Name: "vp-admin_br-000001", Items: []string{"a", "b"}} + if err := f.Save(want); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := New[payload](p).Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Name != want.Name || len(got.Items) != len(want.Items) { + t.Errorf("value = %+v, want %+v", got, want) + } +} + +func TestSave_RestoresTheModeAfterAnExternalChmod(t *testing.T) { + p := path(t) + f := New[payload](p) + if _, err := f.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if err := os.Chmod(p, 0o644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + if err := f.Save(payload{Name: "x"}); err != nil { + t.Fatalf("Save: %v", err) + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: each save must replace the file, not edit it in place", got) + } +} + +func TestSave_LeavesNoTemporaryFileBehind(t *testing.T) { + p := path(t) + f := New[payload](p) + + for range 3 { + if err := f.Save(payload{Name: "x"}); err != nil { + t.Fatalf("Save: %v", err) + } + } + + entries, err := os.ReadDir(filepath.Dir(p)) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 { + t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries) + } +} + +func TestSave_DoesNotTruncateOnEncodingFailure(t *testing.T) { + p := path(t) + good := New[payload](p) + if err := good.Save(payload{Name: "kept"}); err != nil { + t.Fatalf("Save: %v", err) + } + + broken := New[chan int](p) + if err := broken.Save(make(chan int)); err == nil { + t.Fatal("an unencodable value must be reported") + } + + got, err := good.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Name != "kept" { + t.Errorf("value = %+v, want the previous state untouched", got) + } +} + +func TestRemove_DeletesTheFile(t *testing.T) { + p := path(t) + f := New[payload](p) + if _, err := f.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + if err := f.Remove(); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("the file must be gone, got %v", err) + } +} + +func TestRemove_OnAnAbsentFileIsNotAnError(t *testing.T) { + if err := New[payload](path(t)).Remove(); err != nil { + t.Errorf("removing an absent file must be idempotent, got %v", err) + } +} + +func TestPath_ReportsTheFileItOwns(t *testing.T) { + p := path(t) + if got := New[payload](p).Path(); got != p { + t.Errorf("Path = %s, want %s", got, p) + } +} From 37a9353dc0641a025a995d4e3cda1d6b5eb6124e Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 31 Aug 2026 18:35:43 +0200 Subject: [PATCH 6/7] f-46: dhcpapi: add the control socket, its protocol and its client #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le contrat et le listener vont dans internal/api/dhcp (package dhcpapi), sur la forme de internal/api/agent, et le client dans internal/client/dhcp. Chaînage des imports : statefile <- dhcpd <- dhcpapi <- dhcpclient, sans cycle. dhcpd parle net.IP et net.IPNet et garde ses structs disque privées ; dhcpapi parle chaînes JSON et convertit à la frontière. Un même type portait jusqu'ici le format du fil, la signature du Store et le format du .state — ce qui couplait le fichier au protocole alors que le ticket le décrit comme un détail interne. Le digest est calculé sur une forme canonique partagée par les deux côtés : MAC, IP et CIDR normalisés, hôtes triés, doublon de MAC refusé. Un écart de digest signale donc une vraie divergence, pas une différence d'écriture. Le listener pose un recover par connexion, plafonne les lignes à 64 Kio, refuse une ligne malformée sans fermer la connexion, écoute en 0600 et supprime une socket résiduelle avant le bind. Le client pose une deadline. 101 tests au total, -race propre, treize mutations toutes détectées. Signed-off-by: GnomeZworc --- internal/api/dhcp/convert.go | 83 ++++ internal/api/dhcp/digest.go | 116 ++++++ internal/api/dhcp/digest_test.go | 173 +++++++++ internal/api/dhcp/models.go | 58 +++ internal/api/dhcp/server.go | 190 +++++++++ internal/client/dhcp/client.go | 104 +++++ internal/client/dhcp/client_test.go | 364 ++++++++++++++++++ internal/dhcpd/engine.go | 16 +- internal/dhcpd/engine_test.go | 39 +- internal/dhcpd/reply_test.go | 23 +- internal/dhcpd/state.go | 285 -------------- internal/dhcpd/store.go | 255 ++++++++++++ .../dhcpd/{state_test.go => store_test.go} | 237 ++++++------ 13 files changed, 1490 insertions(+), 453 deletions(-) create mode 100644 internal/api/dhcp/convert.go create mode 100644 internal/api/dhcp/digest.go create mode 100644 internal/api/dhcp/digest_test.go create mode 100644 internal/api/dhcp/models.go create mode 100644 internal/api/dhcp/server.go create mode 100644 internal/client/dhcp/client.go create mode 100644 internal/client/dhcp/client_test.go delete mode 100644 internal/dhcpd/state.go create mode 100644 internal/dhcpd/store.go rename internal/dhcpd/{state_test.go => store_test.go} (55%) diff --git a/internal/api/dhcp/convert.go b/internal/api/dhcp/convert.go new file mode 100644 index 0000000..77dd7c4 --- /dev/null +++ b/internal/api/dhcp/convert.go @@ -0,0 +1,83 @@ +package dhcpapi + +import ( + "fmt" + "net" + + "git.g3e.fr/syonad/two/internal/dhcpd" +) + +func (s Subnet) toConfig() (dhcpd.SubnetConfig, error) { + _, network, err := net.ParseCIDR(s.Network) + if err != nil { + return dhcpd.SubnetConfig{}, fmt.Errorf("invalid network %q: %w", s.Network, err) + } + interfaceIP := net.ParseIP(s.InterfaceIP) + if interfaceIP == nil { + return dhcpd.SubnetConfig{}, fmt.Errorf("invalid interface ip %q", s.InterfaceIP) + } + + c := dhcpd.SubnetConfig{Network: network, InterfaceIP: interfaceIP} + + if s.VPCRoute != "" { + if _, c.VPCRoute, err = net.ParseCIDR(s.VPCRoute); err != nil { + return dhcpd.SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", s.VPCRoute, err) + } + } + if s.DefaultGateway != "" { + if c.DefaultGateway = net.ParseIP(s.DefaultGateway); c.DefaultGateway == nil { + return dhcpd.SubnetConfig{}, fmt.Errorf("invalid default gateway %q", s.DefaultGateway) + } + } + return c, nil +} + +func (h Host) toHost() (dhcpd.Host, error) { + mac, err := net.ParseMAC(h.MAC) + if err != nil { + return dhcpd.Host{}, fmt.Errorf("invalid mac %q: %w", h.MAC, err) + } + ip := net.ParseIP(h.IP) + if ip == nil { + return dhcpd.Host{}, fmt.Errorf("invalid host ip %q", h.IP) + } + return dhcpd.Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil +} + +func subnetFromConfig(c dhcpd.SubnetConfig) Subnet { + s := Subnet{ + Network: c.Network.String(), + InterfaceIP: c.InterfaceIP.String(), + } + if c.VPCRoute != nil { + s.VPCRoute = c.VPCRoute.String() + } + if c.DefaultGateway != nil { + s.DefaultGateway = c.DefaultGateway.String() + } + return s +} + +func hostFromHost(h dhcpd.Host) Host { + return Host{ + MAC: h.MAC.String(), + IP: h.IP.String(), + VM: h.VM, + DefaultRoute: h.DefaultRoute, + } +} + +func stateFromStore(store *dhcpd.Store) State { + state := State{Hosts: make([]Host, 0)} + + if config, configured := store.Subnet(); configured { + subnet := subnetFromConfig(config) + state.Subnet = &subnet + } + for _, h := range store.Hosts() { + state.Hosts = append(state.Hosts, hostFromHost(h)) + } + SortHosts(state.Hosts) + + return state +} diff --git a/internal/api/dhcp/digest.go b/internal/api/dhcp/digest.go new file mode 100644 index 0000000..f311b22 --- /dev/null +++ b/internal/api/dhcp/digest.go @@ -0,0 +1,116 @@ +package dhcpapi + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "sort" +) + +func canonicalMAC(s string) (string, error) { + mac, err := net.ParseMAC(s) + if err != nil { + return "", fmt.Errorf("invalid mac %q: %w", s, err) + } + return mac.String(), nil +} + +func canonicalIP(s string) (string, error) { + ip := net.ParseIP(s) + if ip == nil { + return "", fmt.Errorf("invalid ip %q", s) + } + return ip.String(), nil +} + +func canonicalCIDR(s string) (string, error) { + _, network, err := net.ParseCIDR(s) + if err != nil { + return "", fmt.Errorf("invalid cidr %q: %w", s, err) + } + return network.String(), nil +} + +func SortHosts(hosts []Host) { + sort.Slice(hosts, func(i, j int) bool { return hosts[i].MAC < hosts[j].MAC }) +} + +func CanonicalSubnet(s Subnet) (Subnet, error) { + network, err := canonicalCIDR(s.Network) + if err != nil { + return Subnet{}, err + } + interfaceIP, err := canonicalIP(s.InterfaceIP) + if err != nil { + return Subnet{}, err + } + + out := Subnet{Network: network, InterfaceIP: interfaceIP} + + if s.VPCRoute != "" { + if out.VPCRoute, err = canonicalCIDR(s.VPCRoute); err != nil { + return Subnet{}, err + } + } + if s.DefaultGateway != "" { + if out.DefaultGateway, err = canonicalIP(s.DefaultGateway); err != nil { + return Subnet{}, err + } + } + return out, nil +} + +func CanonicalHost(h Host) (Host, error) { + mac, err := canonicalMAC(h.MAC) + if err != nil { + return Host{}, err + } + ip, err := canonicalIP(h.IP) + if err != nil { + return Host{}, err + } + return Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil +} + +func Canonical(s State) (State, error) { + out := State{Hosts: make([]Host, 0, len(s.Hosts))} + + if s.Subnet != nil { + subnet, err := CanonicalSubnet(*s.Subnet) + if err != nil { + return State{}, err + } + out.Subnet = &subnet + } + + seen := make(map[string]struct{}, len(s.Hosts)) + for _, h := range s.Hosts { + host, err := CanonicalHost(h) + if err != nil { + return State{}, err + } + if _, dup := seen[host.MAC]; dup { + return State{}, fmt.Errorf("duplicate mac %s", host.MAC) + } + seen[host.MAC] = struct{}{} + out.Hosts = append(out.Hosts, host) + } + SortHosts(out.Hosts) + + return out, nil +} + +func Digest(s State) (string, error) { + canonical, err := Canonical(s) + if err != nil { + return "", err + } + raw, err := json.Marshal(canonical) + if err != nil { + return "", fmt.Errorf("encode state: %w", err) + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} diff --git a/internal/api/dhcp/digest_test.go b/internal/api/dhcp/digest_test.go new file mode 100644 index 0000000..fe5dd29 --- /dev/null +++ b/internal/api/dhcp/digest_test.go @@ -0,0 +1,173 @@ +package dhcpapi + +import ( + "testing" +) + +func subnet() Subnet { + return Subnet{ + Network: "10.0.5.0/24", + InterfaceIP: "10.0.5.1", + VPCRoute: "10.0.0.0/16", + DefaultGateway: "10.0.5.254", + } +} + +func hosts() []Host { + return []Host{ + {MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-a", DefaultRoute: true}, + {MAC: "00:22:33:00:00:0b", IP: "10.0.5.11", VM: "vm-b"}, + } +} + +func digestOf(t *testing.T, s State) string { + t.Helper() + d, err := Digest(s) + if err != nil { + t.Fatalf("Digest: %v", err) + } + return d +} + +func TestDigest_IsStableAcrossHostOrder(t *testing.T) { + sub := subnet() + a := State{Subnet: &sub, Hosts: hosts()} + + reversed := hosts() + reversed[0], reversed[1] = reversed[1], reversed[0] + b := State{Subnet: &sub, Hosts: reversed} + + if digestOf(t, a) != digestOf(t, b) { + t.Error("host order must not change the digest: the watchdog would report a phantom drift") + } +} + +func TestDigest_IsStableAcrossMACCase(t *testing.T) { + sub := subnet() + a := State{Subnet: &sub, Hosts: hosts()} + + upper := hosts() + upper[0].MAC = "00:22:33:00:00:0A" + b := State{Subnet: &sub, Hosts: upper} + + if digestOf(t, a) != digestOf(t, b) { + t.Error("mac case must not change the digest") + } +} + +func TestDigest_IsStableAcrossIPv4InIPv6Notation(t *testing.T) { + sub := subnet() + a := State{Subnet: &sub, Hosts: hosts()} + + mapped := subnet() + mapped.InterfaceIP = "::ffff:10.0.5.1" + b := State{Subnet: &mapped, Hosts: hosts()} + + if digestOf(t, a) != digestOf(t, b) { + t.Error("the same address written in ipv4-mapped form must hash alike") + } +} + +func TestDigest_ChangesWhenAHostIPChanges(t *testing.T) { + sub := subnet() + a := State{Subnet: &sub, Hosts: hosts()} + + moved := hosts() + moved[0].IP = "10.0.5.99" + b := State{Subnet: &sub, Hosts: moved} + + if digestOf(t, a) == digestOf(t, b) { + t.Error("a changed reservation must change the digest") + } +} + +func TestDigest_ChangesWhenTheDefaultRouteFlagChanges(t *testing.T) { + sub := subnet() + a := State{Subnet: &sub, Hosts: hosts()} + + flipped := hosts() + flipped[0].DefaultRoute = false + b := State{Subnet: &sub, Hosts: flipped} + + if digestOf(t, a) == digestOf(t, b) { + t.Error("the default route flag is part of the served state") + } +} + +func TestDigest_ChangesWhenTheSubnetChanges(t *testing.T) { + sub := subnet() + a := State{Subnet: &sub, Hosts: hosts()} + + other := subnet() + other.DefaultGateway = "10.0.5.253" + b := State{Subnet: &other, Hosts: hosts()} + + if digestOf(t, a) == digestOf(t, b) { + t.Error("the subnet configuration is part of the served state") + } +} + +func TestDigest_DistinguishesNoSubnetFromAConfiguredOne(t *testing.T) { + sub := subnet() + configured := State{Subnet: &sub} + bare := State{} + + if digestOf(t, configured) == digestOf(t, bare) { + t.Error("an unconfigured subnet must not hash like a configured one") + } +} + +func TestDigest_EmptyAndNilHostsHashAlike(t *testing.T) { + if digestOf(t, State{Hosts: nil}) != digestOf(t, State{Hosts: []Host{}}) { + t.Error("nil and empty host lists describe the same state") + } +} + +func TestDigest_RejectsAnInvalidMAC(t *testing.T) { + if _, err := Digest(State{Hosts: []Host{{MAC: "nope", IP: "10.0.5.10"}}}); err == nil { + t.Fatal("an invalid mac must be reported, not hashed") + } +} + +func TestDigest_RejectsAnInvalidIP(t *testing.T) { + if _, err := Digest(State{Hosts: []Host{{MAC: "00:22:33:00:00:0a", IP: "10.0.5.300"}}}); err == nil { + t.Fatal("an invalid ip must be reported, not hashed") + } +} + +func TestCanonical_RejectsADuplicateMAC(t *testing.T) { + dup := []Host{ + {MAC: "00:22:33:00:00:0a", IP: "10.0.5.10"}, + {MAC: "00:22:33:00:00:0A", IP: "10.0.5.11"}, + } + if _, err := Canonical(State{Hosts: dup}); err == nil { + t.Fatal("the same mac twice is an inconsistent state, not something to hash") + } +} + +func TestCanonical_NormalizesTheNetworkToItsBaseAddress(t *testing.T) { + sub := subnet() + sub.Network = "10.0.5.42/24" + + got, err := CanonicalSubnet(sub) + if err != nil { + t.Fatalf("CanonicalSubnet: %v", err) + } + if got.Network != "10.0.5.0/24" { + t.Errorf("network = %s, want 10.0.5.0/24", got.Network) + } +} + +func TestCanonical_SortsHostsByMAC(t *testing.T) { + unsorted := []Host{ + {MAC: "00:22:33:00:00:0c", IP: "10.0.5.12"}, + {MAC: "00:22:33:00:00:0a", IP: "10.0.5.10"}, + } + got, err := Canonical(State{Hosts: unsorted}) + if err != nil { + t.Fatalf("Canonical: %v", err) + } + if got.Hosts[0].MAC != "00:22:33:00:00:0a" { + t.Errorf("hosts = %v, want sorted by mac", got.Hosts) + } +} diff --git a/internal/api/dhcp/models.go b/internal/api/dhcp/models.go new file mode 100644 index 0000000..b0268cf --- /dev/null +++ b/internal/api/dhcp/models.go @@ -0,0 +1,58 @@ +package dhcpapi + +type Verb string + +const ( + VerbSetSubnet Verb = "set-subnet" + VerbSetHost Verb = "set-host" + VerbDelHost Verb = "del-host" + VerbGetState Verb = "get-state" + VerbProbe Verb = "probe" +) + +const MaxMessageBytes = 64 * 1024 + +type Subnet struct { + Network string `json:"network"` + InterfaceIP string `json:"interface_ip"` + VPCRoute string `json:"vpc_route,omitempty"` + DefaultGateway string `json:"default_gateway,omitempty"` +} + +type Host struct { + MAC string `json:"mac"` + IP string `json:"ip"` + VM string `json:"vm,omitempty"` + DefaultRoute bool `json:"default_route"` +} + +type State struct { + Subnet *Subnet `json:"subnet,omitempty"` + Hosts []Host `json:"hosts"` +} + +type Lease struct { + MAC string `json:"mac"` + IP string `json:"ip"` + Netmask string `json:"netmask"` + Router string `json:"router,omitempty"` + DNS []string `json:"dns"` + Routes []string `json:"routes"` + LeaseSeconds uint32 `json:"lease_seconds"` +} + +type Request struct { + Verb Verb `json:"verb"` + Subnet *Subnet `json:"subnet,omitempty"` + Host *Host `json:"host,omitempty"` + MAC string `json:"mac,omitempty"` +} + +type Response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + State *State `json:"state,omitempty"` + Digest string `json:"digest,omitempty"` + Lease *Lease `json:"lease,omitempty"` + Served bool `json:"served,omitempty"` +} diff --git a/internal/api/dhcp/server.go b/internal/api/dhcp/server.go new file mode 100644 index 0000000..879ac58 --- /dev/null +++ b/internal/api/dhcp/server.go @@ -0,0 +1,190 @@ +package dhcpapi + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "os" + "path/filepath" + + "git.g3e.fr/syonad/two/internal/dhcpd" + "git.g3e.fr/syonad/two/pkg/db/statefile" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +const SocketMode = 0o600 + +type Server struct { + store *dhcpd.Store + listener net.Listener + logger *slog.Logger +} + +func Listen(store *dhcpd.Store, path string, logger *slog.Logger) (*Server, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, statefile.DirMode); err != nil { + return nil, fmt.Errorf("create %s: %w", dir, err) + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("remove stale socket %s: %w", path, err) + } + + listener, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("listen on %s: %w", path, err) + } + if err := os.Chmod(path, SocketMode); err != nil { + listener.Close() + return nil, fmt.Errorf("chmod %s: %w", path, err) + } + + return &Server{store: store, listener: listener, logger: logger}, nil +} + +func (s *Server) Addr() string { + return s.listener.Addr().String() +} + +func (s *Server) Close() error { + return s.listener.Close() +} + +func (s *Server) Serve() error { + for { + conn, err := s.listener.Accept() + if err != nil { + return err + } + go s.handleConn(conn) + } +} + +func (s *Server) handleConn(conn net.Conn) { + defer conn.Close() + defer func() { + if r := recover(); r != nil { + s.logger.Error("control connection panicked", "panic", r) + } + }() + + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 0, 4096), MaxMessageBytes) + encoder := json.NewEncoder(conn) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var req Request + if err := json.Unmarshal(line, &req); err != nil { + if err := encoder.Encode(failure(fmt.Errorf("malformed request: %w", err))); err != nil { + return + } + continue + } + + if err := encoder.Encode(s.dispatch(req)); err != nil { + return + } + } + if err := scanner.Err(); err != nil { + s.logger.Error("control connection read failed", "error", err) + } +} + +func failure(err error) Response { + return Response{OK: false, Error: err.Error()} +} + +func (s *Server) dispatch(req Request) Response { + switch req.Verb { + case VerbSetSubnet: + if req.Subnet == nil { + return failure(errors.New("set-subnet requires a subnet")) + } + config, err := req.Subnet.toConfig() + if err != nil { + return failure(err) + } + if err := s.store.SetSubnet(config); err != nil { + return failure(err) + } + return Response{OK: true} + + case VerbSetHost: + if req.Host == nil { + return failure(errors.New("set-host requires a host")) + } + host, err := req.Host.toHost() + if err != nil { + return failure(err) + } + if err := s.store.SetHost(host); err != nil { + return failure(err) + } + return Response{OK: true} + + case VerbDelHost: + mac, err := net.ParseMAC(req.MAC) + if err != nil { + return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err)) + } + if err := s.store.DelHost(mac); err != nil { + return failure(err) + } + return Response{OK: true} + + case VerbGetState: + state := stateFromStore(s.store) + digest, err := Digest(state) + if err != nil { + return failure(err) + } + return Response{OK: true, State: &state, Digest: digest} + + case VerbProbe: + mac, err := net.ParseMAC(req.MAC) + if err != nil { + return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err)) + } + reply, err := s.store.Probe(mac) + if err != nil { + return failure(err) + } + if reply == nil { + return Response{OK: true, Served: false} + } + return Response{OK: true, Served: true, Lease: leaseFromReply(mac, reply)} + + default: + return failure(fmt.Errorf("unknown verb %q", req.Verb)) + } +} + +func leaseFromReply(mac net.HardwareAddr, reply *dhcpv4.DHCPv4) *Lease { + lease := &Lease{ + MAC: mac.String(), + IP: reply.YourIPAddr.String(), + Netmask: net.IP(reply.SubnetMask()).String(), + DNS: make([]string, 0, 2), + Routes: make([]string, 0, 3), + LeaseSeconds: uint32(reply.IPAddressLeaseTime(0).Seconds()), + } + + if routers := reply.Router(); len(routers) > 0 { + lease.Router = routers[0].String() + } + for _, dns := range reply.DNS() { + lease.DNS = append(lease.DNS, dns.String()) + } + for _, route := range reply.ClasslessStaticRoute() { + lease.Routes = append(lease.Routes, route.Dest.String()+" via "+route.Router.String()) + } + return lease +} diff --git a/internal/client/dhcp/client.go b/internal/client/dhcp/client.go new file mode 100644 index 0000000..d9133ae --- /dev/null +++ b/internal/client/dhcp/client.go @@ -0,0 +1,104 @@ +package dhcpclient + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "net" + "time" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" +) + +const DefaultTimeout = 5 * time.Second + +var ErrNotServed = errors.New("mac is not served by this subnet") + +type Client struct { + path string + timeout time.Duration +} + +func New(path string) *Client { + return &Client{path: path, timeout: DefaultTimeout} +} + +func (c *Client) WithTimeout(d time.Duration) *Client { + return &Client{path: c.path, timeout: d} +} + +func (c *Client) call(req dhcpapi.Request) (dhcpapi.Response, error) { + conn, err := net.DialTimeout("unix", c.path, c.timeout) + if err != nil { + return dhcpapi.Response{}, fmt.Errorf("dial %s: %w", c.path, err) + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(c.timeout)); err != nil { + return dhcpapi.Response{}, fmt.Errorf("set deadline on %s: %w", c.path, err) + } + + raw, err := json.Marshal(req) + if err != nil { + return dhcpapi.Response{}, fmt.Errorf("encode %s: %w", req.Verb, err) + } + if _, err := conn.Write(append(raw, '\n')); err != nil { + return dhcpapi.Response{}, fmt.Errorf("send %s: %w", req.Verb, err) + } + + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 0, 4096), dhcpapi.MaxMessageBytes) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return dhcpapi.Response{}, fmt.Errorf("read reply to %s: %w", req.Verb, err) + } + return dhcpapi.Response{}, fmt.Errorf("no reply to %s", req.Verb) + } + + var resp dhcpapi.Response + if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { + return dhcpapi.Response{}, fmt.Errorf("parse reply to %s: %w", req.Verb, err) + } + if !resp.OK { + return resp, fmt.Errorf("%s refused: %s", req.Verb, resp.Error) + } + return resp, nil +} + +func (c *Client) SetSubnet(subnet dhcpapi.Subnet) error { + _, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbSetSubnet, Subnet: &subnet}) + return err +} + +func (c *Client) SetHost(host dhcpapi.Host) error { + _, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbSetHost, Host: &host}) + return err +} + +func (c *Client) DelHost(mac string) error { + _, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbDelHost, MAC: mac}) + return err +} + +func (c *Client) GetState() (dhcpapi.State, string, error) { + resp, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbGetState}) + if err != nil { + return dhcpapi.State{}, "", err + } + if resp.State == nil { + return dhcpapi.State{}, "", errors.New("get-state returned no state") + } + return *resp.State, resp.Digest, nil +} + +func (c *Client) Probe(mac string) (dhcpapi.Lease, error) { + resp, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbProbe, MAC: mac}) + if err != nil { + return dhcpapi.Lease{}, err + } + if !resp.Served || resp.Lease == nil { + return dhcpapi.Lease{}, ErrNotServed + } + return *resp.Lease, nil +} diff --git a/internal/client/dhcp/client_test.go b/internal/client/dhcp/client_test.go new file mode 100644 index 0000000..0bf3866 --- /dev/null +++ b/internal/client/dhcp/client_test.go @@ -0,0 +1,364 @@ +package dhcpclient + +import ( + "errors" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpd" +) + +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "dhcpd") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func testSubnet() dhcpapi.Subnet { + return dhcpapi.Subnet{ + Network: "10.0.5.0/24", + InterfaceIP: "10.0.5.1", + VPCRoute: "10.0.0.0/16", + DefaultGateway: "10.0.5.254", + } +} + +func testHost() dhcpapi.Host { + return dhcpapi.Host{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true} +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func serve(t *testing.T) (*Client, *dhcpd.Store, string) { + t.Helper() + + dir := shortTempDir(t) + store := dhcpd.NewStore(filepath.Join(dir, "s.state")) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + socketPath := filepath.Join(dir, "s.sock") + server, err := dhcpapi.Listen(store, socketPath, discardLogger()) + if err != nil { + t.Fatalf("Listen: %v", err) + } + go server.Serve() + t.Cleanup(func() { server.Close() }) + + return New(socketPath), store, socketPath +} + +func TestListen_SocketIsOwnerOnly(t *testing.T) { + _, _, socketPath := serve(t) + + info, err := os.Stat(socketPath) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: whoever reaches it rewrites the subnet addressing", got) + } +} + +func TestListen_ReplacesAStaleSocketFile(t *testing.T) { + dir := shortTempDir(t) + socketPath := filepath.Join(dir, "stale.sock") + if err := os.WriteFile(socketPath, []byte("leftover"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + store := dhcpd.NewStore(filepath.Join(dir, "s.state")) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + server, err := dhcpapi.Listen(store, socketPath, discardLogger()) + if err != nil { + t.Fatalf("a socket left by an unclean stop must not block startup: %v", err) + } + server.Close() +} + +func TestSetSubnet_ReachesTheStore(t *testing.T) { + client, store, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if _, configured := store.Subnet(); !configured { + t.Error("the subnet configuration did not reach the store") + } +} + +func TestSetSubnet_InvalidNetworkIsRefused(t *testing.T) { + client, _, _ := serve(t) + + subnet := testSubnet() + subnet.Network = "10.0.5.0" + err := client.SetSubnet(subnet) + if err == nil { + t.Fatal("an invalid network must be refused") + } + if !strings.Contains(err.Error(), "refused") { + t.Errorf("error = %v, want the server refusal to surface", err) + } +} + +func TestSetHost_IsIdempotent(t *testing.T) { + client, store, _ := serve(t) + + for range 3 { + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + } + if got := len(store.Hosts()); got != 1 { + t.Errorf("hosts = %d, want 1: set-host replaces the entry for that mac", got) + } +} + +func TestSetHost_InvalidMACIsRefused(t *testing.T) { + client, _, _ := serve(t) + + host := testHost() + host.MAC = "nope" + if err := client.SetHost(host); err == nil { + t.Fatal("an invalid mac must be refused") + } +} + +func TestDelHost_RemovesTheEntry(t *testing.T) { + client, store, _ := serve(t) + + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + if err := client.DelHost(testHost().MAC); err != nil { + t.Fatalf("DelHost: %v", err) + } + if got := len(store.Hosts()); got != 0 { + t.Errorf("hosts = %d, want 0", got) + } +} + +func TestDelHost_UnknownMACIsNotAnError(t *testing.T) { + client, _, _ := serve(t) + + if err := client.DelHost("00:22:33:ff:ff:ff"); err != nil { + t.Errorf("deleting an absent entry must be idempotent, got %v", err) + } +} + +func TestDelHost_InvalidMACIsRefused(t *testing.T) { + client, _, _ := serve(t) + + if err := client.DelHost("not-a-mac"); err == nil { + t.Fatal("an invalid mac must be refused") + } +} + +func TestGetState_ReturnsStateAndDigest(t *testing.T) { + client, _, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + state, digest, err := client.GetState() + if err != nil { + t.Fatalf("GetState: %v", err) + } + if state.Subnet == nil || len(state.Hosts) != 1 { + t.Fatalf("state = %+v, want one subnet and one host", state) + } + if digest == "" { + t.Fatal("the digest is what the watchdog compares") + } + + local, err := dhcpapi.Digest(state) + if err != nil { + t.Fatalf("Digest: %v", err) + } + if local != digest { + t.Errorf("digest recomputed locally = %s, server said %s: the canonical form diverges", local, digest) + } +} + +func TestGetState_DigestFollowsTheState(t *testing.T) { + client, _, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + _, before, err := client.GetState() + if err != nil { + t.Fatalf("GetState: %v", err) + } + + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + _, after, err := client.GetState() + if err != nil { + t.Fatalf("GetState: %v", err) + } + + if before == after { + t.Error("adding a reservation must change the digest") + } +} + +func TestProbe_DescribesWhatWouldBeSent(t *testing.T) { + client, _, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := client.SetHost(testHost()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + lease, err := client.Probe("00:22:33:00:00:0A") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if lease.IP != "10.0.5.10" { + t.Errorf("ip = %s, want 10.0.5.10", lease.IP) + } + if lease.Netmask != "255.255.255.0" { + t.Errorf("netmask = %s, want 255.255.255.0", lease.Netmask) + } + if lease.Router != "10.0.5.254" { + t.Errorf("router = %s, want 10.0.5.254", lease.Router) + } + if len(lease.DNS) != 2 { + t.Errorf("dns = %v, want two servers", lease.DNS) + } + if len(lease.Routes) != 3 { + t.Errorf("routes = %v, want metadata, vpc and default", lease.Routes) + } + if lease.LeaseSeconds != 43200 { + t.Errorf("lease = %ds, want 43200", lease.LeaseSeconds) + } + if lease.MAC != "00:22:33:00:00:0a" { + t.Errorf("mac = %s, want the normalized form", lease.MAC) + } +} + +func TestProbe_UnservedMACIsReported(t *testing.T) { + client, _, _ := serve(t) + + if err := client.SetSubnet(testSubnet()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if _, err := client.Probe("00:22:33:ff:ff:ff"); !errors.Is(err, ErrNotServed) { + t.Fatalf("error = %v, want ErrNotServed", err) + } +} + +func TestProbe_WithoutSubnetConfigurationIsRefused(t *testing.T) { + client, _, _ := serve(t) + + if _, err := client.Probe("00:22:33:00:00:0a"); err == nil { + t.Fatal("probing an unconfigured subnet must be refused") + } +} + +func TestCall_UnknownVerbIsRefused(t *testing.T) { + _, _, socketPath := serve(t) + + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte(`{"verb":"drop-everything"}` + "\n")); err != nil { + t.Fatalf("Write: %v", err) + } + + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Read: %v", err) + } + if !strings.Contains(string(buf[:n]), "unknown verb") { + t.Errorf("reply = %s, want an unknown verb refusal", buf[:n]) + } +} + +func TestCall_MalformedLineIsRefusedWithoutClosingTheConnection(t *testing.T) { + _, _, socketPath := serve(t) + + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("{not json\n" + `{"verb":"get-state"}` + "\n")); err != nil { + t.Fatalf("Write: %v", err) + } + + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Read: %v", err) + } + if !strings.Contains(string(buf[:n]), "malformed request") { + t.Errorf("first reply = %s, want a malformed request refusal", buf[:n]) + } +} + +func TestCall_OnAnAbsentSocketFails(t *testing.T) { + client := New(filepath.Join(shortTempDir(t), "nothing.sock")) + + if err := client.DelHost("00:22:33:00:00:0a"); err == nil { + t.Fatal("an absent socket must be reported") + } +} + +func TestCall_HonoursItsTimeout(t *testing.T) { + socketPath := filepath.Join(shortTempDir(t), "mute.sock") + + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer listener.Close() + + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + time.Sleep(3 * time.Second) + }() + + client := New(socketPath).WithTimeout(150 * time.Millisecond) + start := time.Now() + if err := client.DelHost("00:22:33:00:00:0a"); err == nil { + t.Fatal("a mute server must not hang the caller") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("returned after %s, want the 150ms deadline to apply", elapsed) + } +} diff --git a/internal/dhcpd/engine.go b/internal/dhcpd/engine.go index 15f81de..3c431da 100644 --- a/internal/dhcpd/engine.go +++ b/internal/dhcpd/engine.go @@ -36,10 +36,9 @@ func (s *Store) Handle(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) { return BuildReply(subnet, host, req) } -func (s *Store) Probe(mac string) (*dhcpv4.DHCPv4, error) { - key, err := normalizeMAC(mac) - if err != nil { - return nil, err +func (s *Store) Probe(mac net.HardwareAddr) (*dhcpv4.DHCPv4, error) { + if len(mac) == 0 { + return nil, ErrNoMAC } subnet, configured := s.Subnet() @@ -47,17 +46,12 @@ func (s *Store) Probe(mac string) (*dhcpv4.DHCPv4, error) { return nil, ErrNotConfigured } - parsed, err := net.ParseMAC(key) - if err != nil { - return nil, err - } - - host, known := s.Lookup(parsed) + host, known := s.Lookup(mac) if !known { return nil, nil } - req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(parsed)) + req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(mac)) if err != nil { return nil, err } diff --git a/internal/dhcpd/engine_test.go b/internal/dhcpd/engine_test.go index f035493..ae40cc3 100644 --- a/internal/dhcpd/engine_test.go +++ b/internal/dhcpd/engine_test.go @@ -11,24 +11,15 @@ import ( func configuredStore(t *testing.T) *Store { t.Helper() s, _ := loadedStore(t) - if err := s.SetSubnet(testSubnetSnapshot()); err != nil { + if err := s.SetSubnet(fullConfig(t)); err != nil { t.Fatalf("SetSubnet: %v", err) } - if err := s.SetHost(testHostSnapshot()); err != nil { + if err := s.SetHost(testHost(t)); err != nil { t.Fatalf("SetHost: %v", err) } return s } -func mac(t *testing.T, s string) net.HardwareAddr { - t.Helper() - m, err := net.ParseMAC(s) - if err != nil { - t.Fatalf("ParseMAC(%q): %v", s, err) - } - return m -} - func TestHandle_KnownMACGetsAnOfferOnDiscover(t *testing.T) { s := configuredStore(t) @@ -61,7 +52,7 @@ func TestHandle_UnknownMACIsAnsweredWithSilence(t *testing.T) { func TestHandle_UnconfiguredSubnetIsAnsweredWithSilence(t *testing.T) { s, _ := loadedStore(t) - if err := s.SetHost(testHostSnapshot()); err != nil { + if err := s.SetHost(testHost(t)); err != nil { t.Fatalf("SetHost: %v", err) } @@ -120,7 +111,7 @@ func TestHandle_NilRequestIsRejected(t *testing.T) { func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) { s := configuredStore(t) - if err := s.DelHost("00:22:33:00:00:0a"); err != nil { + if err := s.DelHost(mac(t, "00:22:33:00:00:0a")); err != nil { t.Fatalf("DelHost: %v", err) } @@ -136,7 +127,7 @@ func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) { func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) { s := configuredStore(t) - reply, err := s.Probe("00:22:33:00:00:0A") + reply, err := s.Probe(mac(t, "00:22:33:00:00:0A")) if err != nil { t.Fatalf("Probe: %v", err) } @@ -154,7 +145,7 @@ func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) { func TestProbe_UnknownMACReturnsNothing(t *testing.T) { s := configuredStore(t) - reply, err := s.Probe("00:22:33:ff:ff:ff") + reply, err := s.Probe(mac(t, "00:22:33:ff:ff:ff")) if err != nil { t.Fatalf("Probe: %v", err) } @@ -166,26 +157,26 @@ func TestProbe_UnknownMACReturnsNothing(t *testing.T) { func TestProbe_WithoutSubnetConfigurationIsRejected(t *testing.T) { s, _ := loadedStore(t) - if _, err := s.Probe("00:22:33:00:00:0a"); !errors.Is(err, ErrNotConfigured) { + if _, err := s.Probe(mac(t, "00:22:33:00:00:0a")); !errors.Is(err, ErrNotConfigured) { t.Fatalf("error = %v, want ErrNotConfigured", err) } } -func TestProbe_InvalidMACIsRejected(t *testing.T) { +func TestProbe_EmptyMACIsRejected(t *testing.T) { s := configuredStore(t) - if _, err := s.Probe("nope"); err == nil { - t.Fatal("an invalid mac must be reported") + if _, err := s.Probe(nil); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) } } func TestHandle_SecondaryInterfaceGetsNoDefaultRoute(t *testing.T) { s := configuredStore(t) - snap := testHostSnapshot() - snap.MAC = "00:22:33:00:00:0b" - snap.IP = "10.0.5.11" - snap.DefaultRoute = false - if err := s.SetHost(snap); err != nil { + h := testHost(t) + h.MAC = mac(t, "00:22:33:00:00:0b") + h.IP = net.ParseIP("10.0.5.11") + h.DefaultRoute = false + if err := s.SetHost(h); err != nil { t.Fatalf("SetHost: %v", err) } diff --git a/internal/dhcpd/reply_test.go b/internal/dhcpd/reply_test.go index f96a126..702b430 100644 --- a/internal/dhcpd/reply_test.go +++ b/internal/dhcpd/reply_test.go @@ -28,13 +28,26 @@ func testConfig(t *testing.T) SubnetConfig { } } +func fullConfig(t *testing.T) SubnetConfig { + t.Helper() + c := testConfig(t) + c.VPCRoute = cidr(t, "10.0.0.0/16") + c.DefaultGateway = net.ParseIP("10.0.5.254") + return c +} + +func mac(t *testing.T, s string) net.HardwareAddr { + t.Helper() + m, err := net.ParseMAC(s) + if err != nil { + t.Fatalf("ParseMAC(%q): %v", s, err) + } + return m +} + func testHost(t *testing.T) Host { t.Helper() - mac, err := net.ParseMAC("00:22:33:00:00:0a") - if err != nil { - t.Fatalf("ParseMAC: %v", err) - } - return Host{MAC: mac, IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true} + return Host{MAC: mac(t, "00:22:33:00:00:0a"), IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true} } func request(t *testing.T, kind dhcpv4.MessageType, mac net.HardwareAddr) *dhcpv4.DHCPv4 { diff --git a/internal/dhcpd/state.go b/internal/dhcpd/state.go deleted file mode 100644 index 79f1905..0000000 --- a/internal/dhcpd/state.go +++ /dev/null @@ -1,285 +0,0 @@ -package dhcpd - -import ( - "encoding/json" - "errors" - "fmt" - "net" - "os" - "path/filepath" - "sort" - "sync" -) - -const ( - stateFileMode = 0o600 - stateDirMode = 0o700 -) - -var ( - ErrNoMAC = errors.New("host mac is required") - ErrNotConfigured = errors.New("subnet is not configured") -) - -type SubnetSnapshot struct { - Network string `json:"network"` - InterfaceIP string `json:"interface_ip"` - VPCRoute string `json:"vpc_route,omitempty"` - DefaultGateway string `json:"default_gateway,omitempty"` -} - -type HostSnapshot struct { - MAC string `json:"mac"` - IP string `json:"ip"` - VM string `json:"vm,omitempty"` - DefaultRoute bool `json:"default_route"` -} - -type Snapshot struct { - Subnet *SubnetSnapshot `json:"subnet,omitempty"` - Hosts []HostSnapshot `json:"hosts"` -} - -type Store struct { - mu sync.RWMutex - path string - subnet SubnetConfig - configured bool - hosts map[string]Host -} - -func NewStore(path string) *Store { - return &Store{path: path, hosts: make(map[string]Host)} -} - -func normalizeMAC(s string) (string, error) { - mac, err := net.ParseMAC(s) - if err != nil { - return "", fmt.Errorf("invalid mac %q: %w", s, err) - } - return mac.String(), nil -} - -func (s *Store) Load() error { - s.mu.Lock() - defer s.mu.Unlock() - - raw, err := os.ReadFile(s.path) - if errors.Is(err, os.ErrNotExist) { - return s.persist() - } - if err != nil { - return fmt.Errorf("read %s: %w", s.path, err) - } - - var snap Snapshot - if len(raw) > 0 { - if err := json.Unmarshal(raw, &snap); err != nil { - return fmt.Errorf("parse %s: %w", s.path, err) - } - } - return s.apply(snap) -} - -func (s *Store) apply(snap Snapshot) error { - subnet := SubnetConfig{} - configured := false - if snap.Subnet != nil { - parsed, err := parseSubnet(*snap.Subnet) - if err != nil { - return err - } - subnet = parsed - configured = true - } - - hosts := make(map[string]Host, len(snap.Hosts)) - for _, h := range snap.Hosts { - host, err := parseHost(h) - if err != nil { - return err - } - hosts[host.MAC.String()] = host - } - - s.subnet = subnet - s.configured = configured - s.hosts = hosts - return nil -} - -func parseSubnet(s SubnetSnapshot) (SubnetConfig, error) { - _, network, err := net.ParseCIDR(s.Network) - if err != nil { - return SubnetConfig{}, fmt.Errorf("invalid network %q: %w", s.Network, err) - } - - interfaceIP := net.ParseIP(s.InterfaceIP) - if interfaceIP == nil { - return SubnetConfig{}, ErrNoInterfaceIP - } - - c := SubnetConfig{Network: network, InterfaceIP: interfaceIP} - - if s.VPCRoute != "" { - _, vpcRoute, err := net.ParseCIDR(s.VPCRoute) - if err != nil { - return SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", s.VPCRoute, err) - } - c.VPCRoute = vpcRoute - } - if s.DefaultGateway != "" { - gw := net.ParseIP(s.DefaultGateway) - if gw == nil { - return SubnetConfig{}, fmt.Errorf("invalid default gateway %q", s.DefaultGateway) - } - c.DefaultGateway = gw - } - return c, nil -} - -func parseHost(h HostSnapshot) (Host, error) { - mac, err := net.ParseMAC(h.MAC) - if err != nil { - return Host{}, fmt.Errorf("invalid mac %q: %w", h.MAC, err) - } - ip := net.ParseIP(h.IP) - if ip == nil { - return Host{}, fmt.Errorf("invalid host ip %q", h.IP) - } - return Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil -} - -func (s *Store) SetSubnet(snap SubnetSnapshot) error { - c, err := parseSubnet(snap) - if err != nil { - return err - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.subnet = c - s.configured = true - return s.persist() -} - -func (s *Store) SetHost(snap HostSnapshot) error { - if snap.MAC == "" { - return ErrNoMAC - } - host, err := parseHost(snap) - if err != nil { - return err - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.hosts[host.MAC.String()] = host - return s.persist() -} - -func (s *Store) DelHost(mac string) error { - key, err := normalizeMAC(mac) - if err != nil { - return err - } - - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.hosts, key) - return s.persist() -} - -func (s *Store) Lookup(mac net.HardwareAddr) (Host, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - h, ok := s.hosts[mac.String()] - return h, ok -} - -func (s *Store) Subnet() (SubnetConfig, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.subnet, s.configured -} - -func (s *Store) Snapshot() Snapshot { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.snapshot() -} - -func (s *Store) snapshot() Snapshot { - snap := Snapshot{Hosts: make([]HostSnapshot, 0, len(s.hosts))} - - if s.configured { - sub := SubnetSnapshot{ - Network: s.subnet.Network.String(), - InterfaceIP: s.subnet.InterfaceIP.String(), - } - if s.subnet.VPCRoute != nil { - sub.VPCRoute = s.subnet.VPCRoute.String() - } - if s.subnet.DefaultGateway != nil { - sub.DefaultGateway = s.subnet.DefaultGateway.String() - } - snap.Subnet = &sub - } - - for _, h := range s.hosts { - snap.Hosts = append(snap.Hosts, HostSnapshot{ - MAC: h.MAC.String(), - IP: h.IP.String(), - VM: h.VM, - DefaultRoute: h.DefaultRoute, - }) - } - sort.Slice(snap.Hosts, func(i, j int) bool { return snap.Hosts[i].MAC < snap.Hosts[j].MAC }) - - return snap -} - -func (s *Store) persist() error { - raw, err := json.Marshal(s.snapshot()) - if err != nil { - return fmt.Errorf("encode state: %w", err) - } - - dir := filepath.Dir(s.path) - if err := os.MkdirAll(dir, stateDirMode); err != nil { - return fmt.Errorf("create %s: %w", dir, err) - } - - tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".tmp") - if err != nil { - return fmt.Errorf("create temp state in %s: %w", dir, err) - } - defer os.Remove(tmp.Name()) - - if err := tmp.Chmod(stateFileMode); err != nil { - tmp.Close() - return fmt.Errorf("chmod %s: %w", tmp.Name(), err) - } - if _, err := tmp.Write(raw); err != nil { - tmp.Close() - return fmt.Errorf("write %s: %w", tmp.Name(), err) - } - if err := tmp.Sync(); err != nil { - tmp.Close() - return fmt.Errorf("sync %s: %w", tmp.Name(), err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close %s: %w", tmp.Name(), err) - } - - if err := os.Rename(tmp.Name(), s.path); err != nil { - return fmt.Errorf("rename %s to %s: %w", tmp.Name(), s.path, err) - } - return nil -} diff --git a/internal/dhcpd/store.go b/internal/dhcpd/store.go new file mode 100644 index 0000000..86b756d --- /dev/null +++ b/internal/dhcpd/store.go @@ -0,0 +1,255 @@ +package dhcpd + +import ( + "errors" + "fmt" + "net" + "sort" + "sync" + + "git.g3e.fr/syonad/two/pkg/db/statefile" +) + +var ( + ErrNoMAC = errors.New("host mac is required") + ErrNotConfigured = errors.New("subnet is not configured") +) + +type diskSubnet struct { + Network string `json:"network"` + InterfaceIP string `json:"interface_ip"` + VPCRoute string `json:"vpc_route,omitempty"` + DefaultGateway string `json:"default_gateway,omitempty"` +} + +type diskHost struct { + MAC string `json:"mac"` + IP string `json:"ip"` + VM string `json:"vm,omitempty"` + DefaultRoute bool `json:"default_route"` +} + +type diskState struct { + Subnet *diskSubnet `json:"subnet,omitempty"` + Hosts []diskHost `json:"hosts"` +} + +type Store struct { + mu sync.RWMutex + file *statefile.File[diskState] + subnet SubnetConfig + configured bool + hosts map[string]Host +} + +func NewStore(path string) *Store { + return &Store{ + file: statefile.New[diskState](path), + hosts: make(map[string]Host), + } +} + +func (s *Store) Path() string { + return s.file.Path() +} + +func (s *Store) Load() error { + state, err := s.file.Load() + if err != nil { + return err + } + + subnet := SubnetConfig{} + configured := false + if state.Subnet != nil { + parsed, err := subnetFromDisk(*state.Subnet) + if err != nil { + return err + } + subnet = parsed + configured = true + } + + hosts := make(map[string]Host, len(state.Hosts)) + for _, h := range state.Hosts { + host, err := hostFromDisk(h) + if err != nil { + return err + } + hosts[host.MAC.String()] = host + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.subnet = subnet + s.configured = configured + s.hosts = hosts + return nil +} + +func subnetFromDisk(d diskSubnet) (SubnetConfig, error) { + _, network, err := net.ParseCIDR(d.Network) + if err != nil { + return SubnetConfig{}, fmt.Errorf("invalid network %q: %w", d.Network, err) + } + interfaceIP := net.ParseIP(d.InterfaceIP) + if interfaceIP == nil { + return SubnetConfig{}, ErrNoInterfaceIP + } + + c := SubnetConfig{Network: network, InterfaceIP: interfaceIP} + + if d.VPCRoute != "" { + if _, c.VPCRoute, err = net.ParseCIDR(d.VPCRoute); err != nil { + return SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", d.VPCRoute, err) + } + } + if d.DefaultGateway != "" { + if c.DefaultGateway = net.ParseIP(d.DefaultGateway); c.DefaultGateway == nil { + return SubnetConfig{}, fmt.Errorf("invalid default gateway %q", d.DefaultGateway) + } + } + return c, nil +} + +func hostFromDisk(d diskHost) (Host, error) { + mac, err := net.ParseMAC(d.MAC) + if err != nil { + return Host{}, fmt.Errorf("invalid mac %q: %w", d.MAC, err) + } + ip := net.ParseIP(d.IP) + if ip == nil { + return Host{}, fmt.Errorf("invalid host ip %q", d.IP) + } + return Host{MAC: mac, IP: ip, VM: d.VM, DefaultRoute: d.DefaultRoute}, nil +} + +func (s *Store) SetSubnet(c SubnetConfig) error { + if c.Network == nil { + return ErrNoNetwork + } + if c.InterfaceIP == nil { + return ErrNoInterfaceIP + } + + s.mu.Lock() + defer s.mu.Unlock() + + previous, wasConfigured := s.subnet, s.configured + s.subnet, s.configured = c, true + + if err := s.persist(); err != nil { + s.subnet, s.configured = previous, wasConfigured + return err + } + return nil +} + +func (s *Store) SetHost(h Host) error { + if len(h.MAC) == 0 { + return ErrNoMAC + } + if h.IP == nil { + return ErrNoHostIP + } + + key := h.MAC.String() + + s.mu.Lock() + defer s.mu.Unlock() + + previous, existed := s.hosts[key] + s.hosts[key] = h + + if err := s.persist(); err != nil { + if existed { + s.hosts[key] = previous + } else { + delete(s.hosts, key) + } + return err + } + return nil +} + +func (s *Store) DelHost(mac net.HardwareAddr) error { + if len(mac) == 0 { + return ErrNoMAC + } + key := mac.String() + + s.mu.Lock() + defer s.mu.Unlock() + + previous, existed := s.hosts[key] + delete(s.hosts, key) + + if err := s.persist(); err != nil { + if existed { + s.hosts[key] = previous + } + return err + } + return nil +} + +func (s *Store) Lookup(mac net.HardwareAddr) (Host, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + h, ok := s.hosts[mac.String()] + return h, ok +} + +func (s *Store) Subnet() (SubnetConfig, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.subnet, s.configured +} + +func (s *Store) Hosts() []Host { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.sortedHosts() +} + +func (s *Store) sortedHosts() []Host { + hosts := make([]Host, 0, len(s.hosts)) + for _, h := range s.hosts { + hosts = append(hosts, h) + } + sort.Slice(hosts, func(i, j int) bool { return hosts[i].MAC.String() < hosts[j].MAC.String() }) + return hosts +} + +func (s *Store) persist() error { + state := diskState{Hosts: make([]diskHost, 0, len(s.hosts))} + + if s.configured { + sub := diskSubnet{ + Network: s.subnet.Network.String(), + InterfaceIP: s.subnet.InterfaceIP.String(), + } + if s.subnet.VPCRoute != nil { + sub.VPCRoute = s.subnet.VPCRoute.String() + } + if s.subnet.DefaultGateway != nil { + sub.DefaultGateway = s.subnet.DefaultGateway.String() + } + state.Subnet = &sub + } + + for _, h := range s.sortedHosts() { + state.Hosts = append(state.Hosts, diskHost{ + MAC: h.MAC.String(), + IP: h.IP.String(), + VM: h.VM, + DefaultRoute: h.DefaultRoute, + }) + } + + return s.file.Save(state) +} diff --git a/internal/dhcpd/state_test.go b/internal/dhcpd/store_test.go similarity index 55% rename from internal/dhcpd/state_test.go rename to internal/dhcpd/store_test.go index 7240ddb..11a57d5 100644 --- a/internal/dhcpd/state_test.go +++ b/internal/dhcpd/store_test.go @@ -14,19 +14,6 @@ func statePath(t *testing.T) string { return filepath.Join(t.TempDir(), "vp-admin_br-000001.state") } -func testSubnetSnapshot() SubnetSnapshot { - return SubnetSnapshot{ - Network: "10.0.5.0/24", - InterfaceIP: "10.0.5.1", - VPCRoute: "10.0.0.0/16", - DefaultGateway: "10.0.5.254", - } -} - -func testHostSnapshot() HostSnapshot { - return HostSnapshot{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true} -} - func loadedStore(t *testing.T) (*Store, string) { t.Helper() path := statePath(t) @@ -49,28 +36,12 @@ func TestStore_LoadCreatesTheStateFileWhenAbsent(t *testing.T) { } } -func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) { - s, path := loadedStore(t) - if err := os.Chmod(path, 0o644); err != nil { - t.Fatalf("Chmod: %v", err) - } - - if err := s.SetHost(testHostSnapshot()); err != nil { - t.Fatalf("SetHost: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("Stat: %v", err) - } - if got := info.Mode().Perm(); got != 0o600 { - t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got) - } -} - func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) { path := statePath(t) - if err := os.WriteFile(path, nil, stateFileMode); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, nil, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } @@ -85,7 +56,7 @@ func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) { func TestStore_LoadRejectsCorruptedState(t *testing.T) { path := statePath(t) - if err := os.WriteFile(path, []byte("{not json"), stateFileMode); err != nil { + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } @@ -94,9 +65,21 @@ func TestStore_LoadRejectsCorruptedState(t *testing.T) { } } +func TestStore_LoadRejectsAnInvalidStoredMAC(t *testing.T) { + path := statePath(t) + raw := []byte(`{"hosts":[{"mac":"nope","ip":"10.0.5.10","default_route":true}]}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := NewStore(path).Load(); err == nil { + t.Fatal("an unparseable stored mac must be reported") + } +} + func TestStore_SetSubnetIsPersisted(t *testing.T) { s, path := loadedStore(t) - if err := s.SetSubnet(testSubnetSnapshot()); err != nil { + if err := s.SetSubnet(fullConfig(t)); err != nil { t.Fatalf("SetSubnet: %v", err) } @@ -125,27 +108,27 @@ func TestStore_SetSubnetIsPersisted(t *testing.T) { func TestStore_SetSubnetRejectsAMissingInterfaceIP(t *testing.T) { s, _ := loadedStore(t) - snap := testSubnetSnapshot() - snap.InterfaceIP = "" + c := fullConfig(t) + c.InterfaceIP = nil - if err := s.SetSubnet(snap); !errors.Is(err, ErrNoInterfaceIP) { + if err := s.SetSubnet(c); !errors.Is(err, ErrNoInterfaceIP) { t.Fatalf("error = %v, want ErrNoInterfaceIP", err) } } -func TestStore_SetSubnetRejectsAnInvalidNetwork(t *testing.T) { +func TestStore_SetSubnetRejectsAMissingNetwork(t *testing.T) { s, _ := loadedStore(t) - snap := testSubnetSnapshot() - snap.Network = "10.0.5.0" + c := fullConfig(t) + c.Network = nil - if err := s.SetSubnet(snap); err == nil { - t.Fatal("a network without a prefix length must be rejected") + if err := s.SetSubnet(c); !errors.Is(err, ErrNoNetwork) { + t.Fatalf("error = %v, want ErrNoNetwork", err) } } func TestStore_SetHostIsPersistedAndFound(t *testing.T) { s, path := loadedStore(t) - if err := s.SetHost(testHostSnapshot()); err != nil { + if err := s.SetHost(testHost(t)); err != nil { t.Fatalf("SetHost: %v", err) } @@ -154,11 +137,7 @@ func TestStore_SetHostIsPersistedAndFound(t *testing.T) { t.Fatalf("Load: %v", err) } - mac, err := net.ParseMAC("00:22:33:00:00:0a") - if err != nil { - t.Fatalf("ParseMAC: %v", err) - } - h, known := reloaded.Lookup(mac) + h, known := reloaded.Lookup(mac(t, "00:22:33:00:00:0a")) if !known { t.Fatal("host lost across a restart") } @@ -175,52 +154,43 @@ func TestStore_SetHostIsPersistedAndFound(t *testing.T) { func TestStore_SetHostIsIdempotentOnTheSameMAC(t *testing.T) { s, _ := loadedStore(t) - snap := testHostSnapshot() - if err := s.SetHost(snap); err != nil { + h := testHost(t) + if err := s.SetHost(h); err != nil { t.Fatalf("SetHost: %v", err) } - snap.IP = "10.0.5.11" - snap.DefaultRoute = false - if err := s.SetHost(snap); err != nil { + h.IP = net.ParseIP("10.0.5.11") + h.DefaultRoute = false + if err := s.SetHost(h); err != nil { t.Fatalf("SetHost: %v", err) } - got := s.Snapshot() - if len(got.Hosts) != 1 { - t.Fatalf("hosts = %d, want 1: the mac is the key", len(got.Hosts)) + hosts := s.Hosts() + if len(hosts) != 1 { + t.Fatalf("hosts = %d, want 1: the mac is the key", len(hosts)) } - if got.Hosts[0].IP != "10.0.5.11" || got.Hosts[0].DefaultRoute { - t.Errorf("entry = %+v, want the second order to have replaced the first", got.Hosts[0]) + if !hosts[0].IP.Equal(net.ParseIP("10.0.5.11")) || hosts[0].DefaultRoute { + t.Errorf("entry = %+v, want the second order to have replaced the first", hosts[0]) } } -func TestStore_SetHostNormalizesTheMACCase(t *testing.T) { +func TestStore_LookupNormalizesTheMACCase(t *testing.T) { s, _ := loadedStore(t) - snap := testHostSnapshot() - snap.MAC = "00:22:33:AA:BB:CC" - if err := s.SetHost(snap); err != nil { + h := testHost(t) + h.MAC = mac(t, "00:22:33:AA:BB:CC") + if err := s.SetHost(h); err != nil { t.Fatalf("SetHost: %v", err) } - mac, err := net.ParseMAC("00:22:33:aa:bb:cc") - if err != nil { - t.Fatalf("ParseMAC: %v", err) - } - if _, known := s.Lookup(mac); !known { + if _, known := s.Lookup(mac(t, "00:22:33:aa:bb:cc")); !known { t.Error("an uppercase mac must be found in lowercase: the key would diverge") } } func TestStore_LoadNormalizesTheMACCase(t *testing.T) { path := statePath(t) - raw, err := json.Marshal(Snapshot{Hosts: []HostSnapshot{{ - MAC: "00:22:33:AA:BB:CC", IP: "10.0.5.12", VM: "vm-test", DefaultRoute: true, - }}}) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - if err := os.WriteFile(path, raw, stateFileMode); err != nil { + raw := []byte(`{"hosts":[{"mac":"00:22:33:AA:BB:CC","ip":"10.0.5.12","default_route":true}]}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } @@ -228,42 +198,37 @@ func TestStore_LoadNormalizesTheMACCase(t *testing.T) { if err := s.Load(); err != nil { t.Fatalf("Load: %v", err) } - - m, err := net.ParseMAC("00:22:33:aa:bb:cc") - if err != nil { - t.Fatalf("ParseMAC: %v", err) - } - if _, known := s.Lookup(m); !known { + if _, known := s.Lookup(mac(t, "00:22:33:aa:bb:cc")); !known { t.Error("a reloaded uppercase mac must be keyed in lowercase: the host would silently stop being served") } } func TestStore_SetHostRejectsAMissingMAC(t *testing.T) { s, _ := loadedStore(t) - snap := testHostSnapshot() - snap.MAC = "" + h := testHost(t) + h.MAC = nil - if err := s.SetHost(snap); !errors.Is(err, ErrNoMAC) { + if err := s.SetHost(h); !errors.Is(err, ErrNoMAC) { t.Fatalf("error = %v, want ErrNoMAC", err) } } -func TestStore_SetHostRejectsAnInvalidIP(t *testing.T) { +func TestStore_SetHostRejectsAMissingIP(t *testing.T) { s, _ := loadedStore(t) - snap := testHostSnapshot() - snap.IP = "10.0.5.300" + h := testHost(t) + h.IP = nil - if err := s.SetHost(snap); err == nil { - t.Fatal("an invalid host ip must be rejected") + if err := s.SetHost(h); !errors.Is(err, ErrNoHostIP) { + t.Fatalf("error = %v, want ErrNoHostIP", err) } } func TestStore_DelHostRemovesTheEntry(t *testing.T) { s, path := loadedStore(t) - if err := s.SetHost(testHostSnapshot()); err != nil { + if err := s.SetHost(testHost(t)); err != nil { t.Fatalf("SetHost: %v", err) } - if err := s.DelHost("00:22:33:00:00:0A"); err != nil { + if err := s.DelHost(mac(t, "00:22:33:00:00:0A")); err != nil { t.Fatalf("DelHost: %v", err) } @@ -271,76 +236,92 @@ func TestStore_DelHostRemovesTheEntry(t *testing.T) { if err := reloaded.Load(); err != nil { t.Fatalf("Load: %v", err) } - if got := len(reloaded.Snapshot().Hosts); got != 0 { + if got := len(reloaded.Hosts()); got != 0 { t.Errorf("hosts = %d, want 0 after deletion", got) } } func TestStore_DelHostOnAnUnknownMACIsNotAnError(t *testing.T) { s, _ := loadedStore(t) - if err := s.DelHost("00:22:33:ff:ff:ff"); err != nil { + if err := s.DelHost(mac(t, "00:22:33:ff:ff:ff")); err != nil { t.Errorf("deleting an absent entry must be idempotent, got %v", err) } } -func TestStore_DelHostRejectsAnInvalidMAC(t *testing.T) { +func TestStore_DelHostRejectsAnEmptyMAC(t *testing.T) { s, _ := loadedStore(t) - if err := s.DelHost("not-a-mac"); err == nil { - t.Fatal("an invalid mac must be reported") + if err := s.DelHost(nil); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) } } -func TestStore_SnapshotSortsHostsByMAC(t *testing.T) { +func TestStore_HostsAreSortedByMAC(t *testing.T) { s, _ := loadedStore(t) - for _, mac := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} { - snap := testHostSnapshot() - snap.MAC = mac - if err := s.SetHost(snap); err != nil { + for _, m := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} { + h := testHost(t) + h.MAC = mac(t, m) + if err := s.SetHost(h); err != nil { t.Fatalf("SetHost: %v", err) } } - hosts := s.Snapshot().Hosts + hosts := s.Hosts() for i := 1; i < len(hosts); i++ { - if hosts[i-1].MAC >= hosts[i].MAC { + if hosts[i-1].MAC.String() >= hosts[i].MAC.String() { t.Fatalf("hosts are not sorted: %v", hosts) } } } -func TestStore_PersistLeavesNoTemporaryFileBehind(t *testing.T) { +func TestStore_PersistedStateIsSortedOnDisk(t *testing.T) { s, path := loadedStore(t) - if err := s.SetHost(testHostSnapshot()); err != nil { - t.Fatalf("SetHost: %v", err) - } - - entries, err := os.ReadDir(filepath.Dir(path)) - if err != nil { - t.Fatalf("ReadDir: %v", err) - } - if len(entries) != 1 { - t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries) - } -} - -func TestStore_PersistedStateIsValidJSON(t *testing.T) { - s, path := loadedStore(t) - if err := s.SetSubnet(testSubnetSnapshot()); err != nil { - t.Fatalf("SetSubnet: %v", err) - } - if err := s.SetHost(testHostSnapshot()); err != nil { - t.Fatalf("SetHost: %v", err) + for _, m := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a"} { + h := testHost(t) + h.MAC = mac(t, m) + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } } raw, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile: %v", err) } - var snap Snapshot - if err := json.Unmarshal(raw, &snap); err != nil { + var state struct { + Hosts []struct { + MAC string `json:"mac"` + } `json:"hosts"` + } + if err := json.Unmarshal(raw, &state); err != nil { t.Fatalf("the state file must stay parseable: %v", err) } - if snap.Subnet == nil || len(snap.Hosts) != 1 { - t.Errorf("snapshot = %+v, want one subnet and one host", snap) + if len(state.Hosts) != 2 || state.Hosts[0].MAC != "00:22:33:00:00:0a" { + t.Errorf("hosts on disk = %+v, want sorted by mac", state.Hosts) + } +} + +func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) { + s, path := loadedStore(t) + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + if err := s.SetHost(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got) + } +} + +func TestStore_PathReportsTheStateFile(t *testing.T) { + s, path := loadedStore(t) + if got := s.Path(); got != path { + t.Errorf("Path = %s, want %s", got, path) } } From 9b90defbdba9fbab93659ef97001f06e40854dd0 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 31 Aug 2026 19:48:50 +0200 Subject: [PATCH 7/7] f-46: dhcp: add the dhcp binary, its unit and its wrapper #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/dhcp reçoit quatre paramètres en clair — interface, state, socket, conf — sur le modèle de dnsmasq. Il ne compose aucun chemin, ne découpe aucun nom composite et ignore le netns dans lequel il tourne : seul le wrapper en a besoin, pour y entrer. La boucle de lecture UDP est écrite à la main plutôt que confiée à server4.Serve, qui lance une goroutine par datagramme sans borne et ne pose aucun recover. Elle traite en ligne, réutilise un tampon de 1500 octets et pose un recover par datagramme. NewIPv4UDPConn est conservé pour le SO_BROADCAST et le bind à l'interface. Une réponse destinée à un client sans adresse part en broadcast. dhcp.run_dir n'est pas une clé de configuration : le wrapper code /run/two/dhcp en dur et le Go utilise dhcpapi.DefaultRunDir, comme dhcp.DefaultConfDir pour dnsmasq. Un test lit le script et vérifie que les deux s'accordent. Le défaut dhcp.backend reste dnsmasq : un agent.yml de 0.1.0 se comporte comme avant. deploy.sh et le pipeline publient le binaire, l'unit et le script. La boucle est testée sur une vraie socket UDP en loopback. Neuf mutations, dont une qui a révélé que le recover n'était couvert par rien. Signed-off-by: GnomeZworc --- .forgejo/workflows/release-pipeline.yml | 5 + cmd/dhcp/main.go | 78 +++++++++ conf/agent/config.exemple.yml | 10 ++ internal/api/dhcp/paths.go | 29 ++++ internal/api/dhcp/paths_test.go | 69 ++++++++ internal/config/agent/dhcp.go | 17 ++ internal/config/agent/dhcp_test.go | 56 +++++++ internal/config/agent/struct.go | 4 + internal/dhcpd/listener.go | 68 ++++++++ internal/dhcpd/listener_test.go | 206 ++++++++++++++++++++++++ scripts/deploy.sh | 4 +- scripts/run-dhcp-in-netns.sh | 25 +++ systemd/dhcp@.service | 10 ++ 13 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 cmd/dhcp/main.go create mode 100644 internal/api/dhcp/paths.go create mode 100644 internal/api/dhcp/paths_test.go create mode 100644 internal/config/agent/dhcp.go create mode 100644 internal/config/agent/dhcp_test.go create mode 100644 internal/dhcpd/listener.go create mode 100644 internal/dhcpd/listener_test.go create mode 100644 scripts/run-dhcp-in-netns.sh create mode 100644 systemd/dhcp@.service diff --git a/.forgejo/workflows/release-pipeline.yml b/.forgejo/workflows/release-pipeline.yml index 53e2ad3..b4e018a 100644 --- a/.forgejo/workflows/release-pipeline.yml +++ b/.forgejo/workflows/release-pipeline.yml @@ -51,6 +51,7 @@ jobs: binaries: - metadata - agent + - dhcp uses: ./.forgejo/workflows/build.yml with: tag: ${{ needs.set-release-target.outputs.release_cible }} @@ -69,10 +70,14 @@ jobs: include: - path: scripts/run-dnsmasq-in-netns.sh name: run-dnsmasq-in-netns.sh + - path: scripts/run-dhcp-in-netns.sh + name: run-dhcp-in-netns.sh - path: systemd/agent.service name: agent.service - path: systemd/dnsmasq@.service name: dnsmasq@.service + - path: systemd/dhcp@.service + name: dhcp@.service - path: systemd/metadata@.service name: metadata@.service steps: diff --git a/cmd/dhcp/main.go b/cmd/dhcp/main.go new file mode 100644 index 0000000..ab5b7fb --- /dev/null +++ b/cmd/dhcp/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "flag" + "fmt" + "net" + "os" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcpd" + "git.g3e.fr/syonad/two/pkg/logger" + + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/server4" +) + +var ( + confFile = flag.String("conf", "/etc/two/agent.yml", "configuration file") + iface = flag.String("interface", "", "bridge to serve, already present in the current network namespace") + statePath = flag.String("state", "", "state file owned by this process") + socketPath = flag.String("socket", "", "control socket the agent talks to") +) + +func main() { + flag.Parse() + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "dhcp: %v\n", err) + os.Exit(1) + } +} + +func run() error { + for name, value := range map[string]string{ + "-interface": *iface, + "-state": *statePath, + "-socket": *socketPath, + } { + if value == "" { + return fmt.Errorf("%s is required", name) + } + } + + cfg, err := configuration.LoadConfig(*confFile) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + log := logger.New(cfg.Logger.Level, cfg.Logger.Debug).With("bridge", *iface) + + store := dhcpd.NewStore(*statePath) + if err := store.Load(); err != nil { + return fmt.Errorf("load state: %w", err) + } + + control, err := dhcpapi.Listen(store, *socketPath, log) + if err != nil { + return fmt.Errorf("listen on the control socket: %w", err) + } + defer control.Close() + + go func() { + if err := control.Serve(); err != nil { + log.Error("control socket stopped", "error", err) + } + }() + + conn, err := server4.NewIPv4UDPConn(*iface, &net.UDPAddr{Port: dhcpv4.ServerPort}) + if err != nil { + return fmt.Errorf("bind udp/%d on %s: %w", dhcpv4.ServerPort, *iface, err) + } + defer conn.Close() + + log.Info("dhcp server started", "state", store.Path(), "socket", control.Addr()) + + return store.Serve(conn, log) +} diff --git a/conf/agent/config.exemple.yml b/conf/agent/config.exemple.yml index 72e88f8..de0f6ae 100644 --- a/conf/agent/config.exemple.yml +++ b/conf/agent/config.exemple.yml @@ -39,6 +39,16 @@ interfaces: metadata: run_dir: "/run/two/metadata" +# DHCP backend used for the subnets created by this agent. +# dnsmasq : dnsmasq@ instances driven by generated config files +# two : the built-in dhcp binary, driven over a unix socket +# Switching backends is a manual operation: drain the hypervisor, change this +# value, restart the agent. There is no hot migration. +# The per-subnet control socket and state file live in /run/two/dhcp, which is +# not configurable: the wrapper script hardcodes it too. +dhcp: + backend: dnsmasq + # QEMU runtime paths qemu: # UEFI firmware (requires apt install ovmf on Debian/Ubuntu) diff --git a/internal/api/dhcp/paths.go b/internal/api/dhcp/paths.go new file mode 100644 index 0000000..fcd5b09 --- /dev/null +++ b/internal/api/dhcp/paths.go @@ -0,0 +1,29 @@ +package dhcpapi + +import ( + "path/filepath" +) + +const ( + DefaultRunDir = "/run/two/dhcp" + SocketExt = ".sock" + StateExt = ".state" + UnitExt = ".service" + UnitName = "dhcp@" +) + +func Instance(vpc, bridge string) string { + return vpc + "_" + bridge +} + +func Unit(instance string) string { + return UnitName + instance + UnitExt +} + +func SocketPath(runDir, instance string) string { + return filepath.Join(runDir, instance+SocketExt) +} + +func StatePath(runDir, instance string) string { + return filepath.Join(runDir, instance+StateExt) +} diff --git a/internal/api/dhcp/paths_test.go b/internal/api/dhcp/paths_test.go new file mode 100644 index 0000000..8ad3658 --- /dev/null +++ b/internal/api/dhcp/paths_test.go @@ -0,0 +1,69 @@ +package dhcpapi + +import ( + "os" + "strings" + "testing" +) + +func TestInstance_JoinsVPCAndBridge(t *testing.T) { + if got := Instance("vp-admin", "br-000001"); got != "vp-admin_br-000001" { + t.Errorf("Instance = %s, want vp-admin_br-000001", got) + } +} + +func TestUnit_NamesTheTemplatedService(t *testing.T) { + if got := Unit(Instance("vp-admin", "br-000001")); got != "dhcp@vp-admin_br-000001.service" { + t.Errorf("Unit = %s", got) + } +} + +func TestSocketPath_SitsUnderTheRunDir(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if got != "/run/two/dhcp/vp-admin_br-000001.sock" { + t.Errorf("SocketPath = %s", got) + } +} + +func TestStatePath_SitsUnderTheRunDir(t *testing.T) { + got := StatePath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if got != "/run/two/dhcp/vp-admin_br-000001.state" { + t.Errorf("StatePath = %s", got) + } +} + +func TestPaths_NameTheVPCSoAListingIsReadable(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if !strings.Contains(got, "vp-admin") { + t.Errorf("path = %s, want the vpc visible when listing the run dir", got) + } +} + +func TestPaths_DistinguishTwoSubnetsOfTheSameVPC(t *testing.T) { + a := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + b := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000002")) + if a == b { + t.Error("two subnets must not share a control socket") + } +} + +func TestSocketPath_StaysUnderTheUnixPathLimit(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-000000", "br-000000")) + if len(got) > 100 { + t.Errorf("socket path is %d bytes (%s): sun_path caps at 104 on darwin and 108 on linux", len(got), got) + } +} + +func TestDefaultRunDir_MatchesTheWrapperScript(t *testing.T) { + const script = "../../../scripts/run-dhcp-in-netns.sh" + + raw, err := os.ReadFile(script) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + + want := `RUN_DIR="` + DefaultRunDir + `"` + if !strings.Contains(string(raw), want) { + t.Errorf("%s does not set %s: the agent would talk to a socket the server never creates", script, want) + } +} diff --git a/internal/config/agent/dhcp.go b/internal/config/agent/dhcp.go new file mode 100644 index 0000000..f26f023 --- /dev/null +++ b/internal/config/agent/dhcp.go @@ -0,0 +1,17 @@ +package configuration + +import "fmt" + +const ( + BackendDnsmasq = "dnsmasq" + BackendTwo = "two" +) + +func ValidBackend(backend string) error { + switch backend { + case BackendDnsmasq, BackendTwo: + return nil + default: + return fmt.Errorf("unknown dhcp backend %q: expected %q or %q", backend, BackendDnsmasq, BackendTwo) + } +} diff --git a/internal/config/agent/dhcp_test.go b/internal/config/agent/dhcp_test.go new file mode 100644 index 0000000..6796e56 --- /dev/null +++ b/internal/config/agent/dhcp_test.go @@ -0,0 +1,56 @@ +package configuration + +import ( + "os" + "path/filepath" + "testing" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "agent.yml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path +} + +func TestValidBackend_AcceptsTheTwoKnownBackends(t *testing.T) { + for _, backend := range []string{BackendDnsmasq, BackendTwo} { + if err := ValidBackend(backend); err != nil { + t.Errorf("ValidBackend(%q) = %v, want nil", backend, err) + } + } +} + +func TestValidBackend_RejectsAnythingElse(t *testing.T) { + for _, backend := range []string{"", "dhcpd", "DNSMASQ", "two "} { + if err := ValidBackend(backend); err == nil { + t.Errorf("ValidBackend(%q) = nil, want an error", backend) + } + } +} + +func TestLoadConfig_DefaultsToDnsmasq(t *testing.T) { + path := writeConfig(t, "database:\n path: /tmp/two\n") + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.DHCP.Backend != BackendDnsmasq { + t.Errorf("backend = %q, want %q: a 0.1.0 config must keep behaving as before", cfg.DHCP.Backend, BackendDnsmasq) + } +} + +func TestLoadConfig_ReadsTheTwoBackend(t *testing.T) { + path := writeConfig(t, "dhcp:\n backend: two\n") + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.DHCP.Backend != BackendTwo { + t.Errorf("backend = %q, want two", cfg.DHCP.Backend) + } +} diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index 92a8677..d4afea1 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -31,6 +31,9 @@ type Config struct { Metadata struct { RunDir string `mapstructure:"run_dir"` } `mapstructure:"metadata"` + DHCP struct { + Backend string `mapstructure:"backend"` + } `mapstructure:"dhcp"` Admin struct { Enabled bool `mapstructure:"enabled"` Address string `mapstructure:"address"` @@ -67,6 +70,7 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("dispatcher.timeout_seconds", 300) v.SetDefault("dispatcher.poll_seconds", 2) v.SetDefault("metadata.run_dir", "/run/two/metadata") + v.SetDefault("dhcp.backend", BackendDnsmasq) v.SetDefault("qemu.ovmf_code_path", "/usr/share/OVMF/OVMF_CODE.fd") v.SetDefault("qemu.ovmf_vars_template", "/usr/share/OVMF/OVMF_VARS.fd") v.SetDefault("qemu.uefi_vars_dir", "/run/two/vms/uefi") diff --git a/internal/dhcpd/listener.go b/internal/dhcpd/listener.go new file mode 100644 index 0000000..af7bde5 --- /dev/null +++ b/internal/dhcpd/listener.go @@ -0,0 +1,68 @@ +package dhcpd + +import ( + "log/slog" + "net" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +const MaxDatagramBytes = 1500 + +var clientBroadcast = net.IPv4bcast + +func replyTo(peer net.Addr) net.Addr { + udp, ok := peer.(*net.UDPAddr) + if !ok { + return peer + } + if udp.IP == nil || udp.IP.IsUnspecified() { + return &net.UDPAddr{IP: clientBroadcast, Port: udp.Port} + } + return udp +} + +func (s *Store) serveDatagram(conn net.PacketConn, raw []byte, peer net.Addr, logger *slog.Logger) { + defer func() { + if r := recover(); r != nil { + logger.Error("dhcp datagram handling panicked", "peer", peer, "panic", r) + } + }() + + req, err := dhcpv4.FromBytes(raw) + if err != nil { + logger.Debug("malformed dhcp datagram", "peer", peer, "error", err) + return + } + + reply, err := s.Handle(req) + if err != nil { + logger.Error("building dhcp reply failed", + "peer", peer, "mac", req.ClientHWAddr, "type", req.MessageType(), "error", err) + return + } + if reply == nil { + logger.Debug("no reply for datagram", "mac", req.ClientHWAddr, "type", req.MessageType()) + return + } + + target := replyTo(peer) + if _, err := conn.WriteTo(reply.ToBytes(), target); err != nil { + logger.Error("sending dhcp reply failed", "target", target, "mac", req.ClientHWAddr, "error", err) + return + } + logger.Info("dhcp reply sent", + "mac", req.ClientHWAddr, "type", reply.MessageType(), "ip", reply.YourIPAddr, "target", target) +} + +func (s *Store) Serve(conn net.PacketConn, logger *slog.Logger) error { + buf := make([]byte, MaxDatagramBytes) + + for { + n, peer, err := conn.ReadFrom(buf) + if err != nil { + return err + } + s.serveDatagram(conn, buf[:n], peer, logger) + } +} diff --git a/internal/dhcpd/listener_test.go b/internal/dhcpd/listener_test.go new file mode 100644 index 0000000..26000a7 --- /dev/null +++ b/internal/dhcpd/listener_test.go @@ -0,0 +1,206 @@ +package dhcpd + +import ( + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func discard() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func loopbackPair(t *testing.T) (*net.UDPConn, *net.UDPConn) { + t.Helper() + + server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("ListenUDP server: %v", err) + } + t.Cleanup(func() { server.Close() }) + + client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("ListenUDP client: %v", err) + } + t.Cleanup(func() { client.Close() }) + + return server, client +} + +func exchange(t *testing.T, s *Store, raw []byte) *dhcpv4.DHCPv4 { + t.Helper() + + server, client := loopbackPair(t) + go s.Serve(server, discard()) + + if _, err := client.WriteToUDP(raw, server.LocalAddr().(*net.UDPAddr)); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + + if err := client.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + n, _, err := client.ReadFromUDP(buf) + if err != nil { + return nil + } + + reply, err := dhcpv4.FromBytes(buf[:n]) + if err != nil { + t.Fatalf("the reply must be a valid dhcp packet: %v", err) + } + return reply +} + +func TestServe_AnswersAKnownMAC(t *testing.T) { + s := configuredStore(t) + + reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes()) + if reply == nil { + t.Fatal("a known mac must be answered on the wire") + } + if reply.MessageType() != dhcpv4.MessageTypeOffer { + t.Errorf("message type = %s, want OFFER", reply.MessageType()) + } + if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr) + } +} + +func TestServe_StaysSilentForAnUnknownMAC(t *testing.T) { + s := configuredStore(t) + + if reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff")).ToBytes()); reply != nil { + t.Errorf("an unknown mac must get nothing on the wire, got %s", reply.MessageType()) + } +} + +func TestServe_StaysSilentOnARelease(t *testing.T) { + s := configuredStore(t) + + if reply := exchange(t, s, request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a")).ToBytes()); reply != nil { + t.Errorf("a RELEASE must get nothing on the wire, got %s", reply.MessageType()) + } +} + +func TestServe_SurvivesAMalformedDatagram(t *testing.T) { + s := configuredStore(t) + server, client := loopbackPair(t) + go s.Serve(server, discard()) + + target := server.LocalAddr().(*net.UDPAddr) + for _, garbage := range [][]byte{{}, {0x01}, make([]byte, 1200)} { + if _, err := client.WriteToUDP(garbage, target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + } + + if _, err := client.WriteToUDP(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes(), target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + if _, _, err := client.ReadFromUDP(buf); err != nil { + t.Fatalf("the loop must survive garbage and keep serving: %v", err) + } +} + +func TestServe_ReturnsWhenTheConnectionCloses(t *testing.T) { + s := configuredStore(t) + server, _ := loopbackPair(t) + + done := make(chan error, 1) + go func() { done <- s.Serve(server, discard()) }() + + server.Close() + select { + case err := <-done: + if err == nil { + t.Error("Serve must report why it stopped") + } + case <-time.After(time.Second): + t.Fatal("Serve did not return after the connection closed") + } +} + +type explodingConn struct { + net.PacketConn + mu sync.Mutex + writes int +} + +func (c *explodingConn) WriteTo(b []byte, addr net.Addr) (int, error) { + c.mu.Lock() + first := c.writes == 0 + c.writes++ + c.mu.Unlock() + + if first { + panic("write exploded") + } + return c.PacketConn.WriteTo(b, addr) +} + +func TestServe_SurvivesAPanicWhileHandlingADatagram(t *testing.T) { + s := configuredStore(t) + server, client := loopbackPair(t) + go s.Serve(&explodingConn{PacketConn: server}, discard()) + + target := server.LocalAddr().(*net.UDPAddr) + raw := request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes() + + for range 2 { + if _, err := client.WriteToUDP(raw, target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + time.Sleep(50 * time.Millisecond) + } + + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + if _, _, err := client.ReadFromUDP(buf); err != nil { + t.Fatalf("a panic on one datagram must not kill the serving loop: %v", err) + } +} + +func TestReplyTo_BroadcastsWhenTheClientHasNoAddress(t *testing.T) { + got := replyTo(&net.UDPAddr{IP: net.IPv4zero, Port: 68}) + + udp, ok := got.(*net.UDPAddr) + if !ok { + t.Fatalf("target = %T, want *net.UDPAddr", got) + } + if !udp.IP.Equal(net.IPv4bcast) { + t.Errorf("target = %s, want 255.255.255.255: the client cannot be reached by unicast yet", udp.IP) + } + if udp.Port != 68 { + t.Errorf("port = %d, want the client port to be kept", udp.Port) + } +} + +func TestReplyTo_KeepsTheUnicastPeerWhenItHasAnAddress(t *testing.T) { + got := replyTo(&net.UDPAddr{IP: net.ParseIP("10.0.5.10"), Port: 68}) + + udp := got.(*net.UDPAddr) + if !udp.IP.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("target = %s, want the renewing client itself", udp.IP) + } +} + +func TestReplyTo_BroadcastsWhenThePeerIPIsNil(t *testing.T) { + udp := replyTo(&net.UDPAddr{Port: 68}).(*net.UDPAddr) + if !udp.IP.Equal(net.IPv4bcast) { + t.Errorf("target = %s, want 255.255.255.255", udp.IP) + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 07d7668..d6b1828 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -116,7 +116,7 @@ start_services () { profile_units () { case "${1}" in - kvm) echo "agent.service dnsmasq@.service metadata@.service" ;; + kvm) echo "agent.service dnsmasq@.service dhcp@.service metadata@.service" ;; intel) echo "" ;; *) return 1 ;; esac @@ -124,7 +124,7 @@ profile_units () { profile_binaries () { case "${1}" in - kvm) echo "agent metadata run-dnsmasq-in-netns.sh" ;; + kvm) echo "agent metadata dhcp run-dnsmasq-in-netns.sh run-dhcp-in-netns.sh" ;; intel) echo "" ;; *) return 1 ;; esac diff --git a/scripts/run-dhcp-in-netns.sh b/scripts/run-dhcp-in-netns.sh new file mode 100644 index 0000000..c09050f --- /dev/null +++ b/scripts/run-dhcp-in-netns.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +# Expects one argument: netns_bridge (e.g. vpc-00003_br-00002 or vpc1_br0) +# The netns is only needed here, to enter it. The server is handed its bridge +# and its two file paths, and knows nothing of the namespace it runs in. +arg="$1" +NETNS="${arg%%_*}" +BRIDGE="${arg#*_}" +RUN_DIR="/run/two/dhcp" + +if [[ "${NETNS}" == "${arg}" || -z "${NETNS}" || -z "${BRIDGE}" ]] +then + echo "instance ${arg} is not _" >&2 + exit 1 +fi + +echo "start dhcp ${arg}" + +exec ip netns exec "${NETNS}" \ + /opt/two/bin/dhcp \ + -conf /etc/two/agent.yml \ + -interface "${BRIDGE}" \ + -state "${RUN_DIR}/${arg}.state" \ + -socket "${RUN_DIR}/${arg}.sock" diff --git a/systemd/dhcp@.service b/systemd/dhcp@.service new file mode 100644 index 0000000..e29ff73 --- /dev/null +++ b/systemd/dhcp@.service @@ -0,0 +1,10 @@ +[Unit] +Description=two dhcp server in netns %i +After=network.target + +[Service] +Type=simple +ExecStart=/opt/two/bin/run-dhcp-in-netns.sh %i + +[Install] +WantedBy=multi-user.target