Compare commits

..

7 commits

Author SHA1 Message Date
9b90defbdb
f-46: dhcp: add the dhcp binary, its unit and its wrapper #46
cmd/dhcp reçoit quatre paramètres en clair — interface, state, socket, conf —
sur le modèle de dnsmasq. Il ne compose aucun chemin, ne découpe aucun nom
composite et ignore le netns dans lequel il tourne : seul le wrapper en a besoin,
pour y entrer.

La boucle de lecture UDP est écrite à la main plutôt que confiée à
server4.Serve, qui lance une goroutine par datagramme sans borne et ne pose
aucun recover. Elle traite en ligne, réutilise un tampon de 1500 octets et pose
un recover par datagramme. NewIPv4UDPConn est conservé pour le SO_BROADCAST et
le bind à l'interface. Une réponse destinée à un client sans adresse part en
broadcast.

dhcp.run_dir n'est pas une clé de configuration : le wrapper code /run/two/dhcp
en dur et le Go utilise dhcpapi.DefaultRunDir, comme dhcp.DefaultConfDir pour
dnsmasq. Un test lit le script et vérifie que les deux s'accordent.

Le défaut dhcp.backend reste dnsmasq : un agent.yml de 0.1.0 se comporte comme
avant. deploy.sh et le pipeline publient le binaire, l'unit et le script.

La boucle est testée sur une vraie socket UDP en loopback. Neuf mutations, dont
une qui a révélé que le recover n'était couvert par rien.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-05 23:15:48 +02:00
37a9353dc0
f-46: dhcpapi: add the control socket, its protocol and its client #46
Le contrat et le listener vont dans internal/api/dhcp (package dhcpapi), sur la
forme de internal/api/agent, et le client dans internal/client/dhcp. Chaînage
des imports : statefile <- dhcpd <- dhcpapi <- dhcpclient, sans cycle.

dhcpd parle net.IP et net.IPNet et garde ses structs disque privées ; dhcpapi
parle chaînes JSON et convertit à la frontière. Un même type portait jusqu'ici
le format du fil, la signature du Store et le format du .state — ce qui couplait
le fichier au protocole alors que le ticket le décrit comme un détail interne.

Le digest est calculé sur une forme canonique partagée par les deux côtés : MAC,
IP et CIDR normalisés, hôtes triés, doublon de MAC refusé. Un écart de digest
signale donc une vraie divergence, pas une différence d'écriture.

Le listener pose un recover par connexion, plafonne les lignes à 64 Kio, refuse
une ligne malformée sans fermer la connexion, écoute en 0600 et supprime une
socket résiduelle avant le bind. Le client pose une deadline.

101 tests au total, -race propre, treize mutations toutes détectées.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-05 23:15:47 +02:00
8f15e4d383
f-46: statefile: add a generic atomic state file #46
Persistance d'un état de composant dans un fichier, générique sur le type
stocké : temp + chmod 0600 + sync + rename, répertoire en 0700. Chaque écriture
remplace le fichier, donc un chmod externe est effacé, et un échec d'encodage ne
tronque pas l'état précédent.

Load crée le fichier absent et rend le zéro du type ; un fichier vide est un
point de départ valide, un fichier corrompu est une erreur.

Dans pkg/ plutôt que sous internal/dhcpd : une réutilisation par l'intel est
envisagée. Écarte Badger pour cet usage — une instance par subnet coûterait
~74 Mio et 4 goroutines pour ~1 Kio d'état dans un tmpfs, plus un LOCK résiduel
après un arrêt brutal.

11 tests, quatre mutations toutes détectées.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-05 23:15:46 +02:00
0938d1e0e0
f-46: dhcpd: add the decision engine and the persisted state #46
Store porte la configuration du subnet et les réservations par MAC, et les
persiste dans un .state écrit de façon atomique (temp + chmod 0600 + rename).
Chaque écriture remplace le fichier, donc un chmod externe est effacé.

Les snapshots sont à la fois la forme sérialisée et la forme d'entrée des
ordres : validation au moment où l'agent pousse, pas quand un guest demande une
adresse. Les hôtes sont triés par MAC, base du digest de L3.

Handle rend (nil, nil) — le silence est un retour normal — pour un type de
message non traité, un subnet non configuré ou une MAC inconnue. La MAC est
normalisée à l'écriture comme au chargement.

58 tests, -race propre, dix mutations toutes détectées.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-05 23:15:45 +02:00
5918feed83
f-46: dhcpd: build dhcp replies over insomniacslk/dhcp #46
internal/dhcpd décide quelles options partent vers quelle MAC, sans I/O ni
état. L'encodage RFC 3442 de l'option 121 est délégué à la bibliothèque, qui le
teste elle-même.

L'option 3 est omise plutôt qu'envoyée vide : la dhcp-option=3 nue n'existait
que pour empêcher dnsmasq d'annoncer sa propre adresse. La route par défaut est
conditionnée au subnet (DefaultGateway) et à l'interface (Host.DefaultRoute),
ce qui remplace le mécanisme de tags.

24 tests plus un fuzz sur BuildReply. Les assertions d'encodage sont validées
par mutation.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-05 23:15:44 +02:00
19750975c2
f-46: ci: align go version with the module #46
build.yml épinglait setup-go sur 1.21 alors que le module déclarait go 1.24.0 :
la compilation se faisait en réalité avec la toolchain téléchargée par
GOTOOLCHAIN=auto, et le workflow affichait une version qui n'était pas celle qui
compilait.

Le passage à go-version-file: go.mod, qui supprimerait la dérive pour de bon,
est renvoyé à #48 — un input de setup-go n'est validable qu'en poussant un tag
tant que rien ne se déclenche sur push.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-05 23:15:44 +02:00
cbf580e91e
f-46: build: bump go to 1.25 #46
La bibliothèque DHCP retenue pour #46 (github.com/insomniacslk/dhcp) déclare
go 1.25.0. Relèvement de la directive et de la toolchain ; go.sum est inchangé,
la bibliothèque n'entrant qu'avec le code qui l'importe.

Vérifié : build darwin, build linux/amd64 en CGO_ENABLED=0 des trois binaires,
go vet, et trois passes de go test ./... sans échec.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-09-05 23:15:38 +02:00
33 changed files with 3371 additions and 27 deletions

View file

@ -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})"

View file

@ -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
View 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)
}

View file

@ -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
View file

@ -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
View file

@ -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=

View 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
View 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
}

View 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)
}
}

View 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"`
}

View 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)
}

View 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
View 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
}

View 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
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View file

@ -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
View 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
View 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)
}

View 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())
}
}
}

View 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()
})
}

View 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)
}
}

View 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
View 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...)
}

View 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
View 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)
}

View 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)
}
}

View 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
}

View 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)
}
}

View file

@ -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

View 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
View 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