Address order: sequential, reverse or random, per subnet and pool

- Which never-used pool address a new client gets: sequential (the
  lowest, as before), reverse or random, in the settings, a subnet or
  a single pool (FIRST-LAST:ORDER). When no never-used address is left,
  the one free the longest, whatever the order; returning clients keep
  theirs.
- A subnet's pools are used in the order listed, or one at random
  weighted by its never-used addresses. Pools now keep the order they
  are given in (no more sorting by address).
- CLI --address-order and --pool-order; --add-pool of an existing range
  changes its order in place. Web UI: per-pool order and a move-up
  button, pool and address order on the subnet and settings pages.
- Kea's allocator: random is imported. Schema migration 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-18 18:13:25 +01:00
1 parent 29fc991ce1
commit 8ee430276c
29 files changed
+653 -79

No files matched your search

+81
View File
@@ -499,3 +499,84 @@ func TestLastSeen(t *testing.T) {
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)
}
}