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})" 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/go.mod b/go.mod index a4b0b38..0e59eb9 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,24 @@ 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/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/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/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/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/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/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/engine.go b/internal/dhcpd/engine.go new file mode 100644 index 0000000..3c431da --- /dev/null +++ b/internal/dhcpd/engine.go @@ -0,0 +1,60 @@ +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 net.HardwareAddr) (*dhcpv4.DHCPv4, error) { + if len(mac) == 0 { + return nil, ErrNoMAC + } + + subnet, configured := s.Subnet() + if !configured { + return nil, ErrNotConfigured + } + + host, known := s.Lookup(mac) + if !known { + return nil, nil + } + + req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(mac)) + 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..ae40cc3 --- /dev/null +++ b/internal/dhcpd/engine_test.go @@ -0,0 +1,195 @@ +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(fullConfig(t)); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := s.SetHost(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + return s +} + +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(testHost(t)); 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(mac(t, "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(mac(t, "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(mac(t, "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(mac(t, "00:22:33:00:00:0a")); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("error = %v, want ErrNotConfigured", err) + } +} + +func TestProbe_EmptyMACIsRejected(t *testing.T) { + s := configuredStore(t) + + 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) + 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) + } + + 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/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/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/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..702b430 --- /dev/null +++ b/internal/dhcpd/reply_test.go @@ -0,0 +1,339 @@ +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 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() + 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 { + 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) + } +} 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/store_test.go b/internal/dhcpd/store_test.go new file mode 100644 index 0000000..11a57d5 --- /dev/null +++ b/internal/dhcpd/store_test.go @@ -0,0 +1,327 @@ +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 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_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) { + path := statePath(t) + 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) + } + + 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"), 0o600); 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_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(fullConfig(t)); 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) + c := fullConfig(t) + c.InterfaceIP = nil + + if err := s.SetSubnet(c); !errors.Is(err, ErrNoInterfaceIP) { + t.Fatalf("error = %v, want ErrNoInterfaceIP", err) + } +} + +func TestStore_SetSubnetRejectsAMissingNetwork(t *testing.T) { + s, _ := loadedStore(t) + c := fullConfig(t) + c.Network = nil + + 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(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + h, known := reloaded.Lookup(mac(t, "00:22:33:00:00:0a")) + 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) + h := testHost(t) + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } + + h.IP = net.ParseIP("10.0.5.11") + h.DefaultRoute = false + if err := s.SetHost(h); err != nil { + t.Fatalf("SetHost: %v", err) + } + + hosts := s.Hosts() + if len(hosts) != 1 { + t.Fatalf("hosts = %d, want 1: the mac is the key", len(hosts)) + } + 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_LookupNormalizesTheMACCase(t *testing.T) { + s, _ := loadedStore(t) + 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) + } + + 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 := []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) + } + + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + 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) + h := testHost(t) + h.MAC = nil + + if err := s.SetHost(h); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) + } +} + +func TestStore_SetHostRejectsAMissingIP(t *testing.T) { + s, _ := loadedStore(t) + h := testHost(t) + h.IP = nil + + 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(testHost(t)); err != nil { + t.Fatalf("SetHost: %v", err) + } + if err := s.DelHost(mac(t, "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.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(mac(t, "00:22:33:ff:ff:ff")); err != nil { + t.Errorf("deleting an absent entry must be idempotent, got %v", err) + } +} + +func TestStore_DelHostRejectsAnEmptyMAC(t *testing.T) { + s, _ := loadedStore(t) + if err := s.DelHost(nil); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) + } +} + +func TestStore_HostsAreSortedByMAC(t *testing.T) { + s, _ := loadedStore(t) + 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.Hosts() + for i := 1; i < len(hosts); i++ { + if hosts[i-1].MAC.String() >= hosts[i].MAC.String() { + t.Fatalf("hosts are not sorted: %v", hosts) + } + } +} + +func TestStore_PersistedStateIsSortedOnDisk(t *testing.T) { + s, path := loadedStore(t) + 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 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 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) + } +} 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) + } +} 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