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>
This commit is contained in:
GnomeZworc 2026-08-31 15:51:36 +02:00
commit 5918feed83
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
6 changed files with 570 additions and 22 deletions

View file

@ -0,0 +1,326 @@
package dhcpd
import (
"bytes"
"errors"
"net"
"testing"
"git.g3e.fr/syonad/two/internal/metadata"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func cidr(t *testing.T, s string) *net.IPNet {
t.Helper()
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("ParseCIDR(%q): %v", s, err)
}
return n
}
func testConfig(t *testing.T) SubnetConfig {
t.Helper()
return SubnetConfig{
Network: cidr(t, "10.0.5.0/24"),
InterfaceIP: net.ParseIP("10.0.5.1"),
}
}
func testHost(t *testing.T) Host {
t.Helper()
mac, err := net.ParseMAC("00:22:33:00:00:0a")
if err != nil {
t.Fatalf("ParseMAC: %v", err)
}
return Host{MAC: mac, IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true}
}
func request(t *testing.T, kind dhcpv4.MessageType, mac net.HardwareAddr) *dhcpv4.DHCPv4 {
t.Helper()
req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac))
if err != nil {
t.Fatalf("New request: %v", err)
}
return req
}
func encodedRoute(t *testing.T, routes dhcpv4.Routes, dest string) []byte {
t.Helper()
for _, r := range routes {
if r.Dest.String() == dest {
return dhcpv4.Routes{r}.ToBytes()
}
}
t.Fatalf("no route to %s in %s", dest, routes)
return nil
}
func TestRoutes_AlwaysCarriesTheMetadataRoute(t *testing.T) {
c := testConfig(t)
routes, err := Routes(c, Host{IP: net.ParseIP("10.0.5.10")})
if err != nil {
t.Fatalf("Routes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("expected the metadata route alone, got %s", routes)
}
if got := routes[0].Dest.String(); got != metadata.ServiceIP+"/32" {
t.Errorf("destination = %s, want %s/32", got, metadata.ServiceIP)
}
if !routes[0].Router.Equal(c.InterfaceIP) {
t.Errorf("next-hop = %s, want the subnet interface ip %s", routes[0].Router, c.InterfaceIP)
}
}
func TestRoutes_WithoutInterfaceIPIsRejected(t *testing.T) {
_, err := Routes(SubnetConfig{Network: cidr(t, "10.0.5.0/24")}, testHost(t))
if !errors.Is(err, ErrNoInterfaceIP) {
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
}
}
func TestRoutes_VPCRouteUsesTheInterfaceIPAsNextHop(t *testing.T) {
c := testConfig(t)
c.VPCRoute = cidr(t, "10.0.0.0/16")
c.DefaultGateway = net.ParseIP("192.0.2.1")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if r.Dest.String() != "10.0.0.0/16" {
continue
}
if !r.Router.Equal(c.InterfaceIP) {
t.Fatalf("vpc route next-hop = %s, want %s", r.Router, c.InterfaceIP)
}
return
}
t.Fatalf("no vpc route in %s", routes)
}
func TestRoutes_NoDefaultRouteWithoutDefaultGateway(t *testing.T) {
routes, err := Routes(testConfig(t), testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if ones, _ := r.Dest.Mask.Size(); ones == 0 {
t.Fatalf("unexpected default route in %s", routes)
}
}
}
func TestRoutes_NoDefaultRouteWhenTheInterfaceDoesNotReceiveIt(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
h.DefaultRoute = false
routes, err := Routes(c, h)
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if ones, _ := r.Dest.Mask.Size(); ones == 0 {
t.Fatalf("a secondary interface must not receive the default route, got %s", routes)
}
}
}
func TestRoutes_DefaultRouteEncodesZeroDestinationOctets(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x00, 10, 0, 5, 254}
if got := encodedRoute(t, routes, "0.0.0.0/0"); !bytes.Equal(got, want) {
t.Errorf("default route encoding = % x, want % x", got, want)
}
}
func TestRoutes_UnalignedPrefixEncodesOnlyItsSignificantOctets(t *testing.T) {
c := testConfig(t)
c.VPCRoute = cidr(t, "10.16.0.0/12")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x0c, 10, 16, 10, 0, 5, 1}
if got := encodedRoute(t, routes, "10.16.0.0/12"); !bytes.Equal(got, want) {
t.Errorf("/12 encoding = % x, want % x", got, want)
}
}
func TestRoutes_MetadataRouteEncodesOnFourDestinationOctets(t *testing.T) {
routes, err := Routes(testConfig(t), testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x20, 169, 254, 169, 254, 10, 0, 5, 1}
if got := encodedRoute(t, routes, metadata.ServiceIP+"/32"); !bytes.Equal(got, want) {
t.Errorf("metadata route encoding = % x, want % x", got, want)
}
}
func TestBuildReply_DiscoverIsAnsweredWithAnOffer(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDiscover, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if reply.MessageType() != dhcpv4.MessageTypeOffer {
t.Errorf("message type = %s, want OFFER", reply.MessageType())
}
}
func TestBuildReply_RequestIsAnsweredWithAnAck(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if reply.MessageType() != dhcpv4.MessageTypeAck {
t.Errorf("message type = %s, want ACK", reply.MessageType())
}
}
func TestBuildReply_ReleaseGetsNoReply(t *testing.T) {
h := testHost(t)
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRelease, h.MAC)); err == nil {
t.Fatal("a RELEASE must not produce a reply")
}
}
func TestBuildReply_DeclineGetsNoReply(t *testing.T) {
h := testHost(t)
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDecline, h.MAC)); err == nil {
t.Fatal("a DECLINE must not produce a reply")
}
}
func TestBuildReply_CarriesAddressMaskLeaseAndServerIdentifier(t *testing.T) {
c := testConfig(t)
h := testHost(t)
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if !reply.YourIPAddr.Equal(h.IP) {
t.Errorf("yiaddr = %s, want %s", reply.YourIPAddr, h.IP)
}
if got := net.IP(reply.SubnetMask()).String(); got != net.IP(c.Network.Mask).String() {
t.Errorf("netmask = %s, want %s", got, net.IP(c.Network.Mask))
}
if got := reply.IPAddressLeaseTime(0); got != LeaseTime {
t.Errorf("lease time = %s, want %s", got, LeaseTime)
}
if got := reply.ServerIdentifier(); !got.Equal(c.InterfaceIP) {
t.Errorf("server identifier = %s, want %s", got, c.InterfaceIP)
}
if got := reply.DNS(); len(got) != 2 || !got[0].Equal(net.IPv4(1, 1, 1, 1)) || !got[1].Equal(net.IPv4(8, 8, 8, 8)) {
t.Errorf("dns = %v, want 1.1.1.1 and 8.8.8.8", got)
}
}
func TestBuildReply_NoRouterOptionWithoutDefaultRoute(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.Router(); len(got) != 0 {
t.Errorf("router option = %v, want none: the guest would use the server as its gateway", got)
}
}
func TestBuildReply_RouterOptionCarriesTheDefaultGateway(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
got := reply.Router()
if len(got) != 1 || !got[0].Equal(c.DefaultGateway) {
t.Errorf("router option = %v, want [%s]", got, c.DefaultGateway)
}
}
func TestBuildReply_SecondaryInterfaceGetsNoRouterOption(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
h.DefaultRoute = false
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.Router(); len(got) != 0 {
t.Errorf("router option = %v, want none on a secondary interface", got)
}
}
func TestBuildReply_ClasslessStaticRouteIsPresent(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.ClasslessStaticRoute(); len(got) == 0 {
t.Fatal("option 121 missing: cloud-init would have no route to the metadata server")
}
}
func TestBuildReply_WithoutInterfaceIPIsRejected(t *testing.T) {
c := testConfig(t)
c.InterfaceIP = nil
h := testHost(t)
if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoInterfaceIP) {
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
}
}
func TestBuildReply_WithoutNetworkIsRejected(t *testing.T) {
c := testConfig(t)
c.Network = nil
h := testHost(t)
if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoNetwork) {
t.Fatalf("error = %v, want ErrNoNetwork", err)
}
}
func TestBuildReply_WithoutHostIPIsRejected(t *testing.T) {
h := testHost(t)
h.IP = nil
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, testHost(t).MAC)); !errors.Is(err, ErrNoHostIP) {
t.Fatalf("error = %v, want ErrNoHostIP", err)
}
}
func TestBuildReply_NilRequestIsRejected(t *testing.T) {
if _, err := BuildReply(testConfig(t), testHost(t), nil); !errors.Is(err, ErrNoRequest) {
t.Fatalf("error = %v, want ErrNoRequest", err)
}
}