Files
steward/internal/dhcpd/handle_test.go
T
abrown38andClaude Opus 5.5 3ad2f981d4 Allow and deny lists per subnet; every option of a DHCP message in words
- A subnet is a deny-list subnet (the default: everyone not denied) or
  an allow-list subnet (only clients allowed or with a reservation);
  the deny list counts in both. Entries are MAC addresses, prefixes
  (aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
  in the activity log, with an Allow button) or a NAK on renewal.
  steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
  decoded into words (dhcp4.Describe): the parameter request list by
  name, client IDs, lease times. ▸ on an Activity row, steward
  activity show N / --details, GET /events/{id}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:27:51 +01:00

663 lines
24 KiB
Go

package dhcpd
import (
"context"
"encoding/binary"
"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, opts ...dhcp4.Option) *dhcp4.Packet {
h.t.Helper()
off := h.discover(iface, m, opts...)
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:]))
for _, o := range opts {
req.Set(o.Code, o.Data)
}
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)
}
}
func TestDisabledReservation(t *testing.T) {
// The printer's reservation (10.0.10.101, inside the pool) is
// disabled: the printer gets a pool address like anyone, and its
// reserved address can go to others.
h := newHarness(t, func(st *model.State) { st.Reservations[1].Disabled = true })
got := map[string]bool{}
for i := byte(1); i <= 4; i++ {
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
}
if !got["10.0.10.101"] {
t.Errorf("the disabled reservation's address wasn't handed out: %v", got)
}
if out := h.discover("eth0", mac(0x98)); out != nil {
t.Errorf("the printer got %s from a full pool", out.Packet.YIAddr)
}
}
func TestLastSeen(t *testing.T) {
h := newHarness(t, nil)
up := h.lease("eth0", mac(1)).YIAddr
quiet := h.lease("eth0", mac(2)).YIAddr
h.ping.answers[up] = true
h.s.CheckSeen(context.Background())
if l := h.s.Lease(up); l.LastSeen == nil || !l.LastSeen.Equal(h.clock) {
t.Errorf("answering client: %+v", l)
}
// One that never answers has no last-seen time, and stays active.
if l := h.s.Lease(quiet); l.LastSeen != nil || !l.ActiveAt(h.clock) {
t.Errorf("quiet client: %+v", l)
}
// Later, the time stays when it stops answering, and survives a
// renewal and a restart.
seen := h.clock
h.clock = h.clock.Add(10 * time.Minute)
h.ping.answers[up] = false
h.s.CheckSeen(context.Background())
h.lease("eth0", mac(1))
if l := h.s.Lease(up); l.LastSeen == nil || !l.LastSeen.Equal(seen) {
t.Errorf("after a renewal: %+v", l)
}
ls, _ := h.st.LoadLeases(context.Background())
for _, l := range ls {
if l.IP == up && (l.LastSeen == nil || !l.LastSeen.Equal(seen)) {
t.Errorf("stored: %+v", l)
}
}
// Expired leases aren't pinged.
h.clock = h.clock.Add(48 * time.Hour)
h.ping.pinged = nil
h.s.CheckSeen(context.Background())
if len(h.ping.pinged) != 0 {
t.Errorf("pinged %v", h.ping.pinged)
}
}
func TestAddressOrder(t *testing.T) {
ip := func(o *dhcp4.Packet) string { return o.YIAddr.String() }
twoPools := func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.10-10.0.20.19"), pool("10.0.20.100-10.0.20.109")}
}
// Sequential (the default): the lowest of the first pool listed.
h := newHarness(t, twoPools)
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.10" {
t.Errorf("sequential: %s", got)
}
// Never-used addresses first: one that came free isn't the next.
h.clock = h.clock.Add(2 * time.Hour)
if got := ip(h.lease("eth0.20", mac(2))); got != "10.0.20.11" {
t.Errorf("after an expiry: %s", got)
}
// But the client that had it gets it back, whatever the order.
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.10" {
t.Errorf("returning client: %s", got)
}
// Reverse from the settings.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Settings.AddressOrder = model.OrderReverse })
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.19" {
t.Errorf("reverse: %s", got)
}
// The pools in the order listed; a pool's own order wins over the
// subnet's, which wins over the settings'.
h = newHarness(t, func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{{Start: netip.MustParseAddr("10.0.20.100"), End: netip.MustParseAddr("10.0.20.109"), Order: model.OrderSequential}, pool("10.0.20.10-10.0.20.19")}
st.Subnets[1].AddressOrder = model.OrderReverse
st.Settings.AddressOrder = model.OrderRandom
})
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.100" {
t.Errorf("pool's own order: %s", got)
}
// Full first pool: the next one, in the subnet's order (reverse).
h = newHarness(t, func(st *model.State) {
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.10-10.0.20.11"), pool("10.0.20.100-10.0.20.109")}
st.Subnets[1].AddressOrder = model.OrderReverse
})
got := []string{ip(h.lease("eth0.20", mac(1))), ip(h.lease("eth0.20", mac(2))), ip(h.lease("eth0.20", mac(3)))}
if strings.Join(got, " ") != "10.0.20.11 10.0.20.10 10.0.20.109" {
t.Errorf("pool by pool: %v", got)
}
// Random inside a pool: the n-th never-used address.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Subnets[1].AddressOrder = model.OrderRandom })
h.s.randN = func(n int) int { return 3 }
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.13" {
t.Errorf("random: %s", got)
}
if got := ip(h.lease("eth0.20", mac(2))); got != "10.0.20.14" {
t.Errorf("random, skipping the used one: %s", got)
}
// Random pools, weighted by never-used addresses: 10 + 10, so 12 is
// in the second pool, which then uses its own order.
h = newHarness(t, func(st *model.State) { twoPools(st); st.Subnets[1].PoolOrder = model.PoolsRandom })
var asked []int
h.s.randN = func(n int) int { asked = append(asked, n); return 12 }
if got := ip(h.lease("eth0.20", mac(1))); got != "10.0.20.100" || asked[0] != 20 {
t.Errorf("random pools: %s %v", got, asked)
}
// Real randomness stays inside the pools and never repeats.
h = newHarness(t, func(st *model.State) {
twoPools(st)
st.Subnets[1].PoolOrder = model.PoolsRandom
st.Subnets[1].AddressOrder = model.OrderRandom
})
seen := map[string]bool{}
for i := byte(1); i <= 20; i++ {
a := ip(h.lease("eth0.20", mac(i)))
if seen[a] || !(strings.HasPrefix(a, "10.0.20.1") && len(a) >= 10) {
t.Fatalf("random address %s (have %v)", a, seen)
}
seen[a] = true
}
if out := h.discover("eth0.20", mac(21)); out != nil {
t.Errorf("a full subnet offered %s", out.Packet.YIAddr)
}
}
func TestReservationLeaseTimeAndClientID(t *testing.T) {
h := newHarness(t, func(st *model.State) {
st.Reservations[0].LeaseTime = 600
st.Reservations[0].ClientID = "ff0102"
})
u32 := func(p *dhcp4.Packet) uint32 { return binary.BigEndian.Uint32(p.Get(dhcp4.OptLeaseTime)) }
id := func(hex ...byte) dhcp4.Option { return dhcp4.Option{Code: dhcp4.OptClientID, Data: hex} }
// By its client ID, from any MAC address, with its own lease time.
ack := h.lease("eth0", mac(0x50), id(0xff, 1, 2))
if ack.YIAddr.String() != "10.0.10.20" || u32(ack) != 600 {
t.Errorf("by client id: %s %d", ack.YIAddr, u32(ack))
}
if l := h.s.Lease(ack.YIAddr); !l.End.Equal(h.clock.Add(10 * time.Minute)) {
t.Errorf("lease end %v", l.End)
}
// By its MAC address with another client ID (the machine's other OS):
// the same address, though the first identity's lease is still on.
ack = h.lease("eth0", mac(0x99), id(1, 0xaa, 0, 0, 0, 0, 0x99))
if ack.YIAddr.String() != "10.0.10.20" {
t.Errorf("by MAC: %s", ack.YIAddr)
}
// Anyone else gets a pool address, with the subnet's lease time.
ack = h.lease("eth0", mac(0x51), id(0xff, 9, 9))
if ack.YIAddr.String() == "10.0.10.20" || u32(ack) != 43200 {
t.Errorf("stranger: %s %d", ack.YIAddr, u32(ack))
}
}
func TestAccessLists(t *testing.T) {
// A deny-list subnet: everyone but the denied.
h := newHarness(t, func(st *model.State) {
st.Subnets[0].Deny = []model.ClientMatch{{Match: "aa:00:00:00:00:02"}, {Match: "aa:00:00:00:00:9*"}}
})
if h.discover("eth0", mac(1)) == nil {
t.Error("an unlisted client was refused")
}
for _, m := range []byte{2, 0x95} {
if out := h.discover("eth0", mac(m)); out != nil {
t.Errorf("denied %s got %s", mac(m), out.Packet.YIAddr)
}
}
if evs := h.s.Events(1, nil); evs[0].Type != "DENIED" || !strings.Contains(evs[0].Detail, "deny list of office") {
t.Errorf("event %+v", evs[0])
}
// The deny list wins over a reservation (the printer, aa:...:98).
if out := h.discover("eth0", mac(0x98)); out != nil {
t.Errorf("a denied reserved client got %s", out.Packet.YIAddr)
}
// An allow-list subnet: the allowed, by MAC, prefix or client ID, and
// clients with a reservation.
h = newHarness(t, func(st *model.State) {
st.Subnets[0].Access = model.AccessAllowList
st.Subnets[0].Allow = []model.ClientMatch{{Match: "aa:00:00:00:00:01"}, {Match: "id ff07"}}
})
ip := h.lease("eth0", mac(1)).YIAddr
if h.discover("eth0", mac(3)) != nil {
t.Error("an unlisted client got an address")
}
if h.discover("eth0", mac(7), dhcp4.Option{Code: dhcp4.OptClientID, Data: []byte{0xff, 7}}) == nil {
t.Error("a client allowed by client ID was refused")
}
if h.discover("eth0", mac(0x99)) == nil {
t.Error("a reserved client was refused")
}
// Taken off the list: its renewal gets a NAK.
st := h.s.cfg.Load().state
st.Subnets[0].Allow = st.Subnets[0].Allow[1:]
h.s.SetConfig(st)
ren := dhcp4.NewRequest(dhcp4.Request, mac(1), 9)
ren.CIAddr = ip
if out := h.send("eth0", ren); out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "allow list") {
t.Errorf("renewal of a refused client: %+v", out)
}
}