- 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>
192 lines
7.3 KiB
Go
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,work,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)
|
|
}
|
|
}
|
|
}
|