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:
1 parent
29fc991ce1
commit
8ee430276c
29 files changed
+653
-79
No files matched your search
+11
-16
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user