Compare commits
7 commits
10c9bc2985
...
9b90defbdb
| Author | SHA1 | Date | |
|---|---|---|---|
|
9b90defbdb |
|||
|
37a9353dc0 |
|||
|
8f15e4d383 |
|||
|
0938d1e0e0 |
|||
|
5918feed83 |
|||
|
19750975c2 |
|||
|
cbf580e91e |
33 changed files with 3371 additions and 27 deletions
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
78
cmd/dhcp/main.go
Normal file
78
cmd/dhcp/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
31
go.mod
31
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
|
||||
)
|
||||
|
|
|
|||
54
go.sum
54
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=
|
||||
|
|
|
|||
83
internal/api/dhcp/convert.go
Normal file
83
internal/api/dhcp/convert.go
Normal file
|
|
@ -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
|
||||
}
|
||||
116
internal/api/dhcp/digest.go
Normal file
116
internal/api/dhcp/digest.go
Normal file
|
|
@ -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
|
||||
}
|
||||
173
internal/api/dhcp/digest_test.go
Normal file
173
internal/api/dhcp/digest_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
58
internal/api/dhcp/models.go
Normal file
58
internal/api/dhcp/models.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
29
internal/api/dhcp/paths.go
Normal file
29
internal/api/dhcp/paths.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
69
internal/api/dhcp/paths_test.go
Normal file
69
internal/api/dhcp/paths_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
190
internal/api/dhcp/server.go
Normal file
190
internal/api/dhcp/server.go
Normal file
|
|
@ -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
|
||||
}
|
||||
104
internal/client/dhcp/client.go
Normal file
104
internal/client/dhcp/client.go
Normal file
|
|
@ -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
|
||||
}
|
||||
364
internal/client/dhcp/client_test.go
Normal file
364
internal/client/dhcp/client_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
17
internal/config/agent/dhcp.go
Normal file
17
internal/config/agent/dhcp.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
56
internal/config/agent/dhcp_test.go
Normal file
56
internal/config/agent/dhcp_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
29
internal/dhcpd/dhcpd.go
Normal file
29
internal/dhcpd/dhcpd.go
Normal file
|
|
@ -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
|
||||
}
|
||||
60
internal/dhcpd/engine.go
Normal file
60
internal/dhcpd/engine.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
195
internal/dhcpd/engine_test.go
Normal file
195
internal/dhcpd/engine_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
59
internal/dhcpd/fuzz_test.go
Normal file
59
internal/dhcpd/fuzz_test.go
Normal file
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
68
internal/dhcpd/listener.go
Normal file
68
internal/dhcpd/listener.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
206
internal/dhcpd/listener_test.go
Normal file
206
internal/dhcpd/listener_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
97
internal/dhcpd/reply.go
Normal file
97
internal/dhcpd/reply.go
Normal file
|
|
@ -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...)
|
||||
}
|
||||
339
internal/dhcpd/reply_test.go
Normal file
339
internal/dhcpd/reply_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
255
internal/dhcpd/store.go
Normal file
255
internal/dhcpd/store.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
327
internal/dhcpd/store_test.go
Normal file
327
internal/dhcpd/store_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
92
pkg/db/statefile/statefile.go
Normal file
92
pkg/db/statefile/statefile.go
Normal file
|
|
@ -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
|
||||
}
|
||||
189
pkg/db/statefile/statefile_test.go
Normal file
189
pkg/db/statefile/statefile_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
scripts/run-dhcp-in-netns.sh
Normal file
25
scripts/run-dhcp-in-netns.sh
Normal file
|
|
@ -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 <netns>_<bridge>" >&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"
|
||||
10
systemd/dhcp@.service
Normal file
10
systemd/dhcp@.service
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue