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

+11 -16
View File
@@ -156,29 +156,24 @@ func (s *Server) choose(cfg *config, iface string, subs []*subnet, cl client, re
return hold(requested, sub, true)
}
}
// 4. A free address: one never handed out, else the one free the
// longest.
// 4. A free address: one never handed out, in the subnet's order
// (order.go), else the one free the longest.
for _, sub := range subs {
if ip, ok := s.neverUsed(cfg, own, sub, cl, now); ok {
return hold(ip, sub, true)
}
}
var oldest netip.Addr
var oldestSub *subnet
var oldestEnd time.Time
for _, sub := range subs {
for _, pool := range sub.Pools {
for v := model.U32(pool.Start); v <= model.U32(pool.End) && v != 0; v++ {
ip := model.FromU32(v)
if !s.usable(cfg, own, sub, ip, cl, now) {
continue
}
l := s.leases[ip]
if l == nil {
return hold(ip, sub, true)
}
if !oldest.IsValid() || l.End.Before(oldestEnd) {
eachIn(pool, false, func(ip netip.Addr) bool {
if l := s.leases[ip]; l != nil && s.usable(cfg, own, sub, ip, cl, now) && (!oldest.IsValid() || l.End.Before(oldestEnd)) {
oldest, oldestSub, oldestEnd = ip, sub, l.End
}
if v == model.U32(pool.End) {
break
}
}
return false
})
}
}
if oldest.IsValid() {
+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)
}
}
+117
View File
@@ -0,0 +1,117 @@
package dhcpd
import (
"net/netip"
"time"
"steward/internal/model"
)
// neverUsed picks a pool address that was never handed out (or whose
// lease was deleted), in the subnet's order (s.mu held): the pools listed
// or at random, and inside a pool sequential, reverse or random
// (docs/subnets.md, "Address order").
func (s *Server) neverUsed(cfg *config, own map[netip.Addr]bool, sub *subnet, cl client, now time.Time) (netip.Addr, bool) {
ok := func(ip netip.Addr) bool { return s.leases[ip] == nil && s.usable(cfg, own, sub, ip, cl, now) }
order := func(p model.Pool) string {
switch {
case p.Order != "":
return p.Order
case sub.AddressOrder != "":
return sub.AddressOrder
}
return cfg.settings.EffectiveAddressOrder()
}
if sub.PoolOrder != model.PoolsRandom {
for _, p := range sub.Pools {
if ip, found := s.pickIn(p, order(p), ok); found {
return ip, true
}
}
return netip.Addr{}, false
}
// A pool at random, weighted by its never-used addresses.
counts := make([]int, len(sub.Pools))
total := 0
for i, p := range sub.Pools {
counts[i] = countIn(p, ok)
total += counts[i]
}
if total == 0 {
return netip.Addr{}, false
}
n := s.randN(total)
for i, p := range sub.Pools {
if n < counts[i] {
return s.pickIn(p, order(p), ok)
}
n -= counts[i]
}
return netip.Addr{}, false
}
// pickIn picks an address of the pool that ok accepts, in an order.
func (s *Server) pickIn(p model.Pool, order string, ok func(netip.Addr) bool) (netip.Addr, bool) {
var found netip.Addr
switch order {
case model.OrderRandom:
n := countIn(p, ok)
if n == 0 {
return netip.Addr{}, false
}
k := s.randN(n)
eachIn(p, false, func(ip netip.Addr) bool {
if !ok(ip) {
return false
}
if k == 0 {
found = ip
return true
}
k--
return false
})
default:
eachIn(p, order == model.OrderReverse, func(ip netip.Addr) bool {
if ok(ip) {
found = ip
return true
}
return false
})
}
return found, found.IsValid()
}
// countIn counts the pool's addresses that ok accepts.
func countIn(p model.Pool, ok func(netip.Addr) bool) int {
n := 0
eachIn(p, false, func(ip netip.Addr) bool {
if ok(ip) {
n++
}
return false
})
return n
}
// eachIn calls fn for the pool's addresses, lowest first (highest first
// when reverse), until it returns true.
func eachIn(p model.Pool, reverse bool, fn func(netip.Addr) bool) {
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
return
}
lo, hi := model.U32(p.Start), model.U32(p.End)
if reverse {
for v := hi; ; v-- {
if fn(model.FromU32(v)) || v == lo {
return
}
}
}
for v := lo; ; v++ {
if fn(model.FromU32(v)) || v == hi {
return
}
}
}
+4 -1
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"log/slog"
"math/rand/v2"
"net"
"net/netip"
"sort"
@@ -52,6 +53,8 @@ type Server struct {
ifaces Interfaces
pinger Pinger
now func() time.Time
// randN returns a random number in [0, n) (address order random).
randN func(n int) int
// ServerPort and ClientPort are 67 and 68 (tests use others).
ServerPort, ClientPort int
@@ -82,7 +85,7 @@ func New(log *slog.Logger, st LeaseStore, ifaces Interfaces, pinger Pinger) *Ser
if ifaces == nil {
ifaces = &HostInterfaces{}
}
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, ServerPort: 67, ClientPort: 68,
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, randN: rand.IntN, ServerPort: 67, ClientPort: 68,
leases: map[netip.Addr]*model.Lease{}, offers: map[netip.Addr]offer{}, changed: make(chan struct{}, 1)}
s.cfg.Store(compile(&model.State{Settings: model.DefaultSettings()}))
s.events.max = 1000