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", `{"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) } } }