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