Files
abrown38andClaude Opus 5.5 3c0808591f Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three
levels, leases in SQLite saved before the ACK, ping and decline conflict
checks, validated changes with history and undo, users and tokens, an
audit log, a Kea importer, a sandboxed systemd unit and Debian packages.
Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:07 +01:00

447 lines
15 KiB
Go

package dhcpd
import (
"context"
"errors"
"net"
"net/netip"
"strings"
"sync"
"testing"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
"steward/internal/store"
)
type fakeIfaces map[string][]netip.Prefix
func (f fakeIfaces) Addrs(name string) ([]netip.Prefix, error) {
a, ok := f[name]
if !ok {
return nil, errors.New("no interface " + name)
}
return a, nil
}
type fakePinger struct {
mu sync.Mutex
answers map[netip.Addr]bool
pinged []netip.Addr
}
func (f *fakePinger) Ping(_ context.Context, a netip.Addr, _ time.Duration) bool {
f.mu.Lock()
defer f.mu.Unlock()
f.pinged = append(f.pinged, a)
return f.answers[a]
}
type harness struct {
t *testing.T
s *Server
st *store.Store
ping *fakePinger
clock time.Time
}
func pool(s string) model.Pool {
p, err := model.ParsePool(s)
if err != nil {
panic(err)
}
return p
}
func newHarness(t *testing.T, edit func(st *model.State)) *harness {
t.Helper()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
ifs := fakeIfaces{
"eth0": {netip.MustParsePrefix("10.0.10.2/24")},
"eth0.20": {netip.MustParsePrefix("10.0.20.1/24")},
"eth1": {netip.MustParsePrefix("192.168.1.1/24")},
}
h := &harness{t: t, st: db, ping: &fakePinger{answers: map[netip.Addr]bool{}}, clock: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)}
h.s = New(nil, db, ifs, h.ping)
h.s.now = func() time.Time { return h.clock }
st := &model.State{Settings: model.DefaultSettings(),
Subnets: []*model.Subnet{
{ID: 1, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), Pools: []model.Pool{pool("10.0.10.100-10.0.10.103")},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}, {Code: 6, Value: "10.0.10.2"}, {Code: 15, Value: "lan"}, {Code: 42, Value: "10.0.10.2"}}},
{ID: 2, Name: "iot", Interface: "eth0.20", Prefix: netip.MustParsePrefix("10.0.20.0/24"), Pools: []model.Pool{pool("10.0.20.100-10.0.20.200")}, LeaseTime: 3600},
},
Reservations: []*model.Reservation{
{ID: 3, MAC: "aa:00:00:00:00:99", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas", Options: []model.OptionValue{{Code: 6, Value: "1.1.1.1"}}},
{ID: 4, MAC: "aa:00:00:00:00:98", IP: netip.MustParseAddr("10.0.10.101"), Hostname: "printer"},
}}
st.Settings.Options = []model.OptionValue{{Code: 6, Value: "9.9.9.9"}}
if edit != nil {
edit(st)
}
h.s.SetConfig(st)
return h
}
func mac(n byte) net.HardwareAddr { return net.HardwareAddr{0xaa, 0, 0, 0, 0, n} }
func (h *harness) send(iface string, p *dhcp4.Packet) *Outbound {
h.t.Helper()
// Through the wire format, as for real.
q, err := dhcp4.Parse(p.Marshal())
if err != nil {
h.t.Fatal(err)
}
out := h.s.Handle(context.Background(), iface, q)
if out != nil {
r, err := dhcp4.Parse(out.Packet.Marshal())
if err != nil {
h.t.Fatal(err)
}
out.Packet = r
}
return out
}
func (h *harness) discover(iface string, m net.HardwareAddr, opts ...dhcp4.Option) *Outbound {
p := dhcp4.NewRequest(dhcp4.Discover, m, 1)
for _, o := range opts {
p.Set(o.Code, o.Data)
}
return h.send(iface, p)
}
// lease runs DISCOVER and REQUEST and returns the ACK.
func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet {
h.t.Helper()
off := h.discover(iface, m)
if off == nil || off.Packet.Type() != dhcp4.Offer {
h.t.Fatalf("no offer for %s on %s", m, iface)
}
sid, _ := off.Packet.IPOption(dhcp4.OptServerID)
req := dhcp4.NewRequest(dhcp4.Request, m, 2)
req.Set(dhcp4.OptServerID, dhcp4.IP(sid))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:]))
ack := h.send(iface, req)
if ack == nil || ack.Packet.Type() != dhcp4.Ack {
h.t.Fatalf("no ACK for %s: %+v", m, ack)
}
if ack.Packet.YIAddr != off.Packet.YIAddr {
h.t.Fatalf("ACK %s differs from offer %s", ack.Packet.YIAddr, off.Packet.YIAddr)
}
return ack.Packet
}
func ipOpt(p *dhcp4.Packet, code byte) string {
return dhcp4.Decode(dhcp4.TypeIPs, p.Get(code))
}
func TestOfferAndAck(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1))
if off == nil {
t.Fatal("no offer")
}
p := off.Packet
if p.YIAddr.String() != "10.0.10.100" || off.Dst.IsValid() {
t.Errorf("offered %s to %v", p.YIAddr, off.Dst)
}
if ipOpt(p, dhcp4.OptServerID) != "10.0.10.2" || ipOpt(p, dhcp4.OptSubnetMask) != "255.255.255.0" || ipOpt(p, dhcp4.OptRouters) != "10.0.10.1" {
t.Errorf("options: sid %s mask %s routers %s", ipOpt(p, 54), ipOpt(p, 1), ipOpt(p, 3))
}
// The subnet's DNS server wins over the settings'.
if ipOpt(p, dhcp4.OptDNSServers) != "10.0.10.2" || string(p.Get(dhcp4.OptDomainName)) != "lan" {
t.Errorf("dns %s domain %q", ipOpt(p, 6), p.Get(15))
}
if dhcp4.Decode(dhcp4.TypeU32, p.Get(dhcp4.OptLeaseTime)) != "43200" || dhcp4.Decode(dhcp4.TypeU32, p.Get(dhcp4.OptRenewalTime)) != "21600" {
t.Errorf("lease time %v", p.Get(51))
}
ack := h.lease("eth0", mac(1))
l := h.s.Lease(ack.YIAddr)
if l == nil || l.State != model.LeaseActive || l.MAC != mac(1).String() || l.Interface != "eth0" || l.SubnetID != 1 || l.Hostname != "client01" {
t.Fatalf("lease %+v", l)
}
// Saved before the ACK.
saved, _ := h.st.LoadLeases(context.Background())
if len(saved) != 1 || saved[0].IP != ack.YIAddr {
t.Errorf("saved %+v", saved)
}
}
func TestSubnetsPerInterface(t *testing.T) {
h := newHarness(t, nil)
if ip := h.lease("eth0.20", mac(1)).YIAddr; !strings.HasPrefix(ip.String(), "10.0.20.") {
t.Errorf("a client on eth0.20 got %s", ip)
}
// The same MAC on the office VLAN gets an office address.
if ip := h.lease("eth0", mac(1)).YIAddr; !strings.HasPrefix(ip.String(), "10.0.10.") {
t.Errorf("a client on eth0 got %s", ip)
}
// No subnet on eth1: no answer at all.
if out := h.discover("eth1", mac(2)); out != nil {
t.Errorf("answered on eth1: %+v", out)
}
// Asking eth0.20 for an office address: NAK.
req := dhcp4.NewRequest(dhcp4.Request, mac(3), 5)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.100")))
out := h.send("eth0.20", req)
if out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "does not belong on eth0.20") {
t.Errorf("init-reboot on the wrong VLAN: %+v", out)
}
}
func TestReservations(t *testing.T) {
h := newHarness(t, nil)
ack := h.lease("eth0", mac(0x99))
if ack.YIAddr.String() != "10.0.10.20" || string(ack.Get(dhcp4.OptHostName)) != "nas" || ipOpt(ack, 6) != "1.1.1.1" {
t.Errorf("reserved client got %s host %q dns %s", ack.YIAddr, ack.Get(12), ipOpt(ack, 6))
}
// 10.0.10.101 is reserved for the printer: nobody else gets it, even
// though it is in the pool.
got := map[string]bool{}
for i := byte(1); i <= 3; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if got["10.0.10.101"] || !got["10.0.10.100"] || !got["10.0.10.102"] || !got["10.0.10.103"] {
t.Errorf("pool addresses handed out: %v", got)
}
// The pool is now full (the reserved address doesn't count).
if out := h.discover("eth0", mac(4)); out != nil {
t.Errorf("offered %s from a full pool", out.Packet.YIAddr)
}
if h.lease("eth0", mac(0x98)).YIAddr.String() != "10.0.10.101" {
t.Error("the printer didn't get its reservation")
}
// A client with a reservation asking for another address gets a NAK
// that says why.
req := dhcp4.NewRequest(dhcp4.Request, mac(0x99), 7)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.102")))
out := h.send("eth0", req)
if out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "reservation for 10.0.10.20") {
t.Errorf("got %+v", out)
}
}
func TestReturningClientKeepsAddress(t *testing.T) {
h := newHarness(t, nil)
first := h.lease("eth0", mac(1)).YIAddr
h.lease("eth0", mac(2))
rel := dhcp4.NewRequest(dhcp4.Release, mac(1), 9)
rel.CIAddr = first
if out := h.send("eth0", rel); out != nil {
t.Error("a release was answered")
}
if l := h.s.Lease(first); l.State != model.LeaseReleased {
t.Errorf("after release: %+v", l)
}
h.clock = h.clock.Add(time.Hour)
if again := h.lease("eth0", mac(1)).YIAddr; again != first {
t.Errorf("came back to %s, had %s", again, first)
}
// Expired leases too.
h.clock = h.clock.Add(48 * time.Hour)
if again := h.lease("eth0", mac(1)).YIAddr; again != first {
t.Errorf("after expiry got %s, had %s", again, first)
}
// A new client gets a never-used address before a released one.
if ip := h.lease("eth0", mac(3)).YIAddr; ip == first || ip.String() == "10.0.10.101" {
t.Errorf("new client got %s", ip)
}
}
func TestRenewAndRebind(t *testing.T) {
h := newHarness(t, nil)
ack := h.lease("eth0.20", mac(1))
start := h.s.Lease(ack.YIAddr).Start
h.clock = h.clock.Add(30 * time.Minute)
ren := dhcp4.NewRequest(dhcp4.Request, mac(1), 11)
ren.CIAddr = ack.YIAddr
out := h.send("eth0.20", ren)
if out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst != ack.YIAddr || out.Packet.CIAddr != ack.YIAddr {
t.Fatalf("renewal: %+v", out)
}
l := h.s.Lease(ack.YIAddr)
if l.Hostname != "client01" {
t.Errorf("a renewal without a host name lost it: %q", l.Hostname)
}
if !l.Start.Equal(start) || !l.End.Equal(h.clock.Add(time.Hour)) {
t.Errorf("renewed lease %v - %v", l.Start, l.End)
}
// Another client renewing that address: NAK.
ren2 := dhcp4.NewRequest(dhcp4.Request, mac(2), 12)
ren2.CIAddr = ack.YIAddr
if out := h.send("eth0.20", ren2); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("someone else's renewal: %+v", out)
}
}
func TestInitReboot(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
req := dhcp4.NewRequest(dhcp4.Request, mac(1), 20)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst.IsValid() {
t.Errorf("own address after reboot: %+v", out)
}
// Someone else's address: NAK.
req = dhcp4.NewRequest(dhcp4.Request, mac(2), 21)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("another's address: %+v", out)
}
// A free pool address from a client Steward hasn't seen (say, after
// switching from another server): ACK.
req = dhcp4.NewRequest(dhcp4.Request, mac(3), 22)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.103")))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Ack {
t.Errorf("free address: %+v", out)
}
// An address outside the pools: NAK.
req = dhcp4.NewRequest(dhcp4.Request, mac(4), 23)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.50")))
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Nak {
t.Errorf("address outside the pools: %+v", out)
}
}
func TestOtherServerChosen(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1))
req := dhcp4.NewRequest(dhcp4.Request, mac(1), 2)
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.10.254")))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.77")))
if out := h.send("eth0", req); out != nil {
t.Errorf("answered a request to another server: %+v", out)
}
// The offer was given up: another client can have the address.
if out := h.discover("eth0", mac(2)); out == nil || out.Packet.YIAddr != off.Packet.YIAddr {
t.Errorf("offer not released: %+v", out)
}
}
func TestDeclineAndPingConflict(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
dec := dhcp4.NewRequest(dhcp4.Decline, mac(1), 30)
dec.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
h.send("eth0", dec)
if l := h.s.Lease(ip); l.State != model.LeaseDeclined || !l.End.Equal(h.clock.Add(24*time.Hour)) {
t.Errorf("declined lease %+v", l)
}
// Not offered again, not even to the client that declined it.
if out := h.discover("eth0", mac(1)); out == nil || out.Packet.YIAddr == ip {
t.Errorf("declined address offered again: %+v", out)
}
// An address that answers a ping is skipped and held back.
h2 := newHarness(t, nil)
h2.ping.answers[netip.MustParseAddr("10.0.10.100")] = true
off := h2.discover("eth0", mac(1))
if off == nil || off.Packet.YIAddr.String() != "10.0.10.102" {
t.Fatalf("offer after a conflict: %+v", off)
}
if l := h2.s.Lease(netip.MustParseAddr("10.0.10.100")); l == nil || l.State != model.LeaseDeclined {
t.Errorf("conflict not recorded: %+v", l)
}
// Reservations aren't pinged (the device may still have its address).
h2.ping.pinged = nil
h2.lease("eth0", mac(0x99))
for _, a := range h2.ping.pinged {
if a.String() == "10.0.10.20" {
t.Error("pinged a reserved address")
}
}
}
func TestSharedNetwork(t *testing.T) {
h := newHarness(t, func(st *model.State) {
st.Subnets = append(st.Subnets, &model.Subnet{ID: 5, Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.11.0/24"), Pools: []model.Pool{pool("10.0.11.10-10.0.11.11")}})
})
got := map[string]bool{}
for i := byte(1); i <= 5; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if !got["10.0.11.10"] || !got["10.0.11.11"] {
t.Errorf("second subnet not used: %v", got)
}
// Its clients are answered from the interface's address (10.0.10.2),
// with the second subnet's mask.
l := h.s.Lease(netip.MustParseAddr("10.0.11.10"))
ren := dhcp4.NewRequest(dhcp4.Request, net.HardwareAddr(mustMAC(l.MAC)), 3)
ren.CIAddr = l.IP
out := h.send("eth0", ren)
if out == nil || ipOpt(out.Packet, 54) != "10.0.10.2" || ipOpt(out.Packet, 1) != "255.255.255.0" {
t.Errorf("renewal in the second subnet: %+v", out)
}
}
func mustMAC(s string) net.HardwareAddr {
m, err := net.ParseMAC(s)
if err != nil {
panic(err)
}
return m
}
func TestParameterRequestList(t *testing.T) {
h := newHarness(t, nil)
off := h.discover("eth0", mac(1), dhcp4.Option{Code: dhcp4.OptParamRequest, Data: []byte{42, 1}})
var codes []byte
for _, o := range off.Packet.Options {
codes = append(codes, o.Code)
}
// Type, server ID and lease times; then NTP and mask as asked; then
// the essentials the client didn't ask for.
want := []byte{53, 54, 51, 58, 59, 42, 1, 3, 6, 15}
if string(codes) != string(want) {
t.Errorf("options %v, want %v", codes, want)
}
}
func TestInformAndRelay(t *testing.T) {
h := newHarness(t, nil)
inf := dhcp4.NewRequest(dhcp4.Inform, mac(1), 40)
inf.CIAddr = netip.MustParseAddr("10.0.10.50")
out := h.send("eth0", inf)
if out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst != inf.CIAddr || out.Packet.Has(dhcp4.OptLeaseTime) || ipOpt(out.Packet, 3) != "10.0.10.1" {
t.Errorf("inform: %+v", out)
}
rel := dhcp4.NewRequest(dhcp4.Discover, mac(2), 41)
rel.GIAddr = netip.MustParseAddr("10.0.30.1")
if out := h.send("eth0", rel); out != nil {
t.Error("answered a relayed request")
}
}
func TestNotAuthoritative(t *testing.T) {
h := newHarness(t, func(st *model.State) { st.Settings.NotAuthoritative = true })
req := dhcp4.NewRequest(dhcp4.Request, mac(3), 5)
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("192.168.7.7")))
if out := h.send("eth0", req); out != nil {
t.Errorf("NAK without authority: %+v", out)
}
}
func TestDisabledSubnet(t *testing.T) {
h := newHarness(t, func(st *model.State) { st.Subnets[1].Disabled = true })
if out := h.discover("eth0.20", mac(1)); out != nil {
t.Error("a disabled subnet answered")
}
}
func TestPrune(t *testing.T) {
h := newHarness(t, nil)
ip := h.lease("eth0", mac(1)).YIAddr
h.clock = h.clock.Add(40 * 24 * time.Hour)
h.s.Prune(context.Background())
if h.s.Lease(ip) != nil {
t.Error("old lease kept")
}
if ls, _ := h.st.LoadLeases(context.Background()); len(ls) != 0 {
t.Errorf("old lease still saved: %+v", ls)
}
}