Files
steward/internal/kea/kea_test.go
T
abrown38andClaude Opus 5.5 8ee430276c 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>
2026-10-18 18:13:25 +01:00

192 lines
7.3 KiB
Go

package kea
import (
"net/netip"
"strings"
"testing"
"time"
"steward/internal/model"
)
const sample = `
# Kea for the house
{
"Dhcp4": {
"interfaces-config": { "interfaces": [ "eth0", "eth0.20/10.0.20.1" ] },
"lease-database": { "type": "memfile", "persist": true, "name": "/var/lib/kea/kea-leases4.csv" },
"valid-lifetime": 43200,
"renew-timer": 21600,
"authoritative": true,
"option-data": [
{ "name": "domain-name-servers", "data": "10.0.10.2, 10.0.10.3" },
{ "name": "domain-name", "data": "lan" }, // our domain
{ "name": "domain-search", "data": "lan, home.arpa" }
],
"hooks-libraries": [ { "library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so" } ],
/* the office */
"subnet4": [
{
"id": 1,
"subnet": "10.0.10.0/24",
"pools": [ { "pool": "10.0.10.100 - 10.0.10.199" } ],
"option-data": [
{ "name": "routers", "data": "10.0.10.1" },
{ "code": 42, "data": "0a000a02", "csv-format": false }
],
"reservations": [
{ "hw-address": "AA-BB-CC-00-00-01", "ip-address": "10.0.10.20", "hostname": "nas" },
{ "client-id": "01:aa:bb:cc:00:00:02", "ip-address": "10.0.10.21", "hostname": "printer",
"option-data": [ { "name": "domain-name-servers", "data": "1.1.1.1" } ] },
{ "duid": "00:01:02", "ip-address": "10.0.10.22" }
]
}
],
"shared-networks": [
{
"name": "iot",
"interface": "eth0.20",
"valid-lifetime": 3600,
"option-data": [ { "name": "routers", "data": "10.0.20.1" } ],
"subnet4": [
{ "id": 2, "subnet": "10.0.20.0/24", "pools": [ { "pool": "10.0.20.0/25" } ], "relay": { "ip-addresses": [ "10.0.20.254" ] } },
{ "id": 3, "subnet": "10.0.21.0/24", "pools": [ { "pool": "10.0.21.10-10.0.21.20" } ],
"option-data": [ { "name": "routers", "data": "10.0.21.1" } ] }
]
}
]
},
"Logging": { "loggers": [] }
}
`
func TestParseConfig(t *testing.T) {
res, err := ParseConfig([]byte(sample), Options{HostInterfaces: map[string][]netip.Prefix{"eth0": {netip.MustParsePrefix("10.0.10.2/24")}}})
if err != nil {
t.Fatal(err)
}
st := res.State
if st.Settings.LeaseTime != 43200 || len(st.Settings.Options) != 3 {
t.Errorf("settings %+v", st.Settings)
}
if len(st.Subnets) != 3 {
t.Fatalf("subnets %+v", st.Subnets)
}
office := st.Subnets[0]
if office.Interface != "eth0" || office.Pools[0].String() != "10.0.10.100-10.0.10.199" || office.LeaseTime != 0 {
t.Errorf("office %+v", office)
}
if len(office.Options) != 2 || office.Options[1].Code != 42 || office.Options[1].Value != "10.0.10.2" {
t.Errorf("office options %+v", office.Options)
}
iot, iot2 := st.Subnets[1], st.Subnets[2]
if iot.Interface != "eth0.20" || iot.LeaseTime != 3600 || iot.Pools[0].String() != "10.0.20.0-10.0.20.127" || iot.Options[0].Value != "10.0.20.1" {
t.Errorf("iot %+v", iot)
}
if iot2.Interface != "eth0.20" || len(iot2.Options) != 1 || iot2.Options[0].Value != "10.0.21.1" {
t.Errorf("iot2 %+v %+v", iot2, iot2.Options)
}
if len(st.Reservations) != 2 || st.Reservations[0].MAC != "aa:bb:cc:00:00:01" || st.Reservations[1].ClientID != "01aabbcc000002" ||
st.Reservations[1].Options[0].Value != "1.1.1.1" {
t.Errorf("reservations %+v %+v", st.Reservations[0], st.Reservations[1])
}
skipped := strings.Join(res.Skipped, "\n")
for _, want := range []string{"hooks-libraries", "Logging", "duid", "relay"} {
if !strings.Contains(skipped, want) {
t.Errorf("skipped list lacks %s:\n%s", want, skipped)
}
}
notes := strings.Join(res.Notes, "\n")
for _, want := range []string{"renew-timer", "shared network iot"} {
if !strings.Contains(notes, want) {
t.Errorf("notes lack %s:\n%s", want, notes)
}
}
}
func TestInterfaceOverride(t *testing.T) {
cfg := `{"Dhcp4": {"interfaces-config": {"interfaces": ["*"]}, "subnet4": [{"subnet": "192.168.5.0/24", "pools": [{"pool": "192.168.5.10-192.168.5.20"}]}]}}`
res, err := ParseConfig([]byte(cfg), Options{})
if err != nil {
t.Fatal(err)
}
if res.State.Subnets[0].Interface != "" {
t.Errorf("guessed %q", res.State.Subnets[0].Interface)
}
res, _ = ParseConfig([]byte(cfg), Options{Interfaces: map[string]string{"192.168.5.0/24": "br0"}})
if res.State.Subnets[0].Interface != "br0" {
t.Errorf("override: %q", res.State.Subnets[0].Interface)
}
}
func TestConfigErrors(t *testing.T) {
for in, want := range map[string]string{
`{"Dhcp6": {}}`: "no Dhcp4",
"{\n\"Dhcp4\": {\n\"subnet4\": [\n}}": "line 4",
`{"Dhcp4": {}} <?include "/etc/kea/more.json"?>`: "include",
`{"Dhcp4": {"subnet4": [{"subnet": "2001:db8::/64"}]}}`: "IPv4",
`{"Dhcp4": {"option-data": [{"name": "routers", "data": "x"}]}}`: "routers",
} {
if _, err := ParseConfig([]byte(in), Options{}); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%q: %v", in, err)
}
}
}
func TestStripComments(t *testing.T) {
in := "{\"a\": \"x // not a comment # nor this\", # comment\n \"b\": /* gone */ 1 // tail\n}"
got := string(StripComments([]byte(in)))
if !strings.Contains(got, `"x // not a comment # nor this"`) || strings.Contains(got, "gone") || strings.Contains(got, "tail") {
t.Errorf("got %q", got)
}
}
func TestParseLeases(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
csv := `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
10.0.10.100,aa:bb:cc:00:00:05,01:aa:bb:cc:00:00:05,43200,1800010000,1,0,0,laptop,0,,0
10.0.10.101,aa:bb:cc:00:00:06,,43200,1799990000,1,0,0,old,0,,0
10.0.10.100,aa:bb:cc:00:00:05,01:aa:bb:cc:00:00:05,43200,1800040000,1,0,0,laptop&#x2cwork,0,,0
10.0.10.102,,,86400,1800050000,1,0,0,,1,,0
10.0.10.103,aa:bb:cc:00:00:07,,43200,1799000000,1,0,0,gone,3,,0
`
ls, err := ParseLeases(strings.NewReader(csv), now)
if err != nil {
t.Fatal(err)
}
if len(ls) != 3 {
t.Fatalf("leases %+v", ls)
}
l := ls[0]
if l.IP.String() != "10.0.10.100" || l.State != model.LeaseActive || l.End.Unix() != 1800040000 || l.Hostname != "laptop,work" || l.ClientID != "01aabbcc000005" {
t.Errorf("lease %+v", l)
}
if ls[1].State != model.LeaseDeclined || ls[2].State != model.LeaseReleased {
t.Errorf("states %s %s", ls[1].State, ls[2].State)
}
if _, err := ParseLeases(strings.NewReader("a,b\n1,2\n"), now); err == nil {
t.Error("a file without Kea's columns was accepted")
}
}
func TestAllocator(t *testing.T) {
cfg := `{"Dhcp4": {"allocator": "random", "subnet4": [
{"subnet": "192.168.5.0/24", "interface": "eth0", "pools": [{"pool": "192.168.5.10-192.168.5.20"}]},
{"subnet": "192.168.6.0/24", "interface": "eth1", "allocator": "iterative", "pools": [{"pool": "192.168.6.10-192.168.6.20"}]}]}}`
res, err := ParseConfig([]byte(cfg), Options{})
if err != nil {
t.Fatal(err)
}
if s := res.State.Subnets[0]; s.PoolOrder != model.PoolsRandom || s.AddressOrder != model.OrderRandom {
t.Errorf("random: %+v", s)
}
if s := res.State.Subnets[1]; s.PoolOrder != "" || s.AddressOrder != "" {
t.Errorf("iterative: %+v", s)
}
for _, sk := range res.Skipped {
if strings.Contains(sk, "allocator") {
t.Errorf("skipped %q", sk)
}
}
}