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

+19
View File
@@ -118,6 +118,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.
if in.NotAuthoritative != nil {
set.NotAuthoritative = *in.NotAuthoritative
}
if in.AddressOrder != nil {
set.AddressOrder = orderInput(*in.AddressOrder)
}
return nil
}, func(st *model.State) any { return st.Settings })
}
@@ -194,6 +197,16 @@ func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request, p *auth.Princ
return nil
}
// orderInput reads an address or pool order: "default" is "" (inherited,
// or the default); validation checks the rest.
func orderInput(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "default" {
return ""
}
return s
}
// applySubnetInput sets the given fields.
func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.Name != nil {
@@ -219,6 +232,12 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
sn.Pools = append(sn.Pools, pool)
}
}
if in.PoolOrder != nil {
sn.PoolOrder = orderInput(*in.PoolOrder)
}
if in.AddressOrder != nil {
sn.AddressOrder = orderInput(*in.AddressOrder)
}
if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime
}
+29
View File
@@ -405,3 +405,32 @@ func TestInterfacesExclude(t *testing.T) {
t.Errorf("interfaces: %+v", ifs)
}
}
func TestAddressOrderAPI(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
in := api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), PoolOrder: ptr("random"), AddressOrder: ptr("Reverse"),
Pools: &[]string{"10.0.20.100-10.0.20.110:random", "10.0.20.10-10.0.20.20"}}
if code := e.call(e.token, "POST", "/subnets", in, &mr); code != 201 {
t.Fatalf("create: %d %+v", code, mr)
}
sn := e.eng.State().Subnets[0]
// The pools keep the order given.
if sn.PoolOrder != "random" || sn.AddressOrder != "reverse" || sn.Pools[0].String() != "10.0.20.100-10.0.20.110:random" || sn.Pools[1].Order != "" {
t.Errorf("subnet %+v", sn)
}
// "listed" and "default" are the defaults, stored as "".
if code := e.call(e.token, "PATCH", "/subnets/"+itoa(sn.ID), api.SubnetInput{PoolOrder: ptr("listed"), AddressOrder: ptr("default")}, nil); code != 200 {
t.Errorf("patch: %d", code)
}
if sn = e.eng.State().Subnets[0]; sn.PoolOrder != "" || sn.AddressOrder != "" {
t.Errorf("defaults %+v", sn)
}
var eb api.ErrorBody
if code := e.call(e.token, "PATCH", "/settings", api.SettingsInput{AddressOrder: ptr("lowest")}, &eb); code != 422 || eb.Error.Problems[0].Code != "address_order" {
t.Errorf("bad order: %d %+v", code, eb)
}
if code := e.call(e.token, "PATCH", "/subnets/"+itoa(sn.ID), api.SubnetInput{Pools: &[]string{"10.0.20.10-10.0.20.20:lowest"}}, nil); code != 400 {
t.Errorf("bad pool order: %d", code)
}
}