Publish DHCP clients' names in DNS
router-webui leases-dns reads Kea's leases and publishes <name>.<domain> with reverse records in the running Unbound (router.dns.dynamicLeases). Names the configuration publishes are never taken over; a client's name is cut to its first label and kept inside the local domain; of two clients the newer lease wins. The DNS page switches it on and shows what is published and what was skipped; router-setup enables it with a domain. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var t0 = time.Unix(1_800_000_000, 0)
|
||||
|
||||
func lease(host, addr string, startAgo time.Duration) Lease {
|
||||
return Lease{Hostname: host, Address: addr, Start: t0.Add(-startAgo), Expires: t0.Add(time.Hour - startAgo)}
|
||||
}
|
||||
|
||||
var testStatic = staticNames{
|
||||
Domain: "home.example.org",
|
||||
Names: []string{"router.home.example.org", "nas.home.example.org"},
|
||||
Overrides: []string{"apps.home.example.org"},
|
||||
ForwardZones: []string{"branch.home.example.org"},
|
||||
}
|
||||
|
||||
func TestPlanDynamic(t *testing.T) {
|
||||
leases := []Lease{
|
||||
lease("Phone", "192.168.1.101", time.Minute),
|
||||
lease("laptop.evil.example", "192.168.1.102", time.Minute), // only the first label
|
||||
lease("nas", "192.168.1.103", time.Minute), // a static name
|
||||
lease("router", "192.168.1.104", time.Minute), // the router itself
|
||||
lease("web.apps", "192.168.1.105", time.Minute), // first label "web": fine
|
||||
lease("x_y", "192.168.1.106", time.Minute), // not a host name
|
||||
lease("", "192.168.1.107", time.Minute), // none sent
|
||||
lease("tv", "192.168.1.108", 2*time.Hour), // expired (started 2h ago)
|
||||
lease("printer", "192.168.1.110", 10*time.Minute),
|
||||
lease("printer", "192.168.1.111", time.Minute), // newer: wins
|
||||
}
|
||||
want, skipped := planDynamic(leases, testStatic, t0)
|
||||
var names []string
|
||||
for n := range want {
|
||||
names = append(names, n)
|
||||
}
|
||||
slices.Sort(names)
|
||||
if strings.Join(names, " ") != "laptop.home.example.org phone.home.example.org printer.home.example.org web.home.example.org" {
|
||||
t.Errorf("published = %v", names)
|
||||
}
|
||||
if want["printer.home.example.org"].Address != "192.168.1.111" {
|
||||
t.Errorf("the newer lease did not win: %+v", want["printer.home.example.org"])
|
||||
}
|
||||
reasons := map[string]string{}
|
||||
for _, s := range skipped {
|
||||
reasons[s.Address] = s.Reason
|
||||
}
|
||||
for addr, part := range map[string]string{"192.168.1.103": "configuration", "192.168.1.104": "configuration",
|
||||
"192.168.1.106": "not a usable", "192.168.1.110": "newer lease"} {
|
||||
if !strings.Contains(reasons[addr], part) {
|
||||
t.Errorf("%s: skipped for %q, want %q", addr, reasons[addr], part)
|
||||
}
|
||||
}
|
||||
// Names below a wildcard or a forward zone are not taken.
|
||||
_, skipped = planDynamic([]Lease{lease("a", "10.0.0.1", 0)}, staticNames{Domain: "apps.home.example.org", Overrides: []string{"apps.home.example.org"}}, t0)
|
||||
if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "wildcard") {
|
||||
t.Errorf("wildcard: %+v", skipped)
|
||||
}
|
||||
_, skipped = planDynamic([]Lease{lease("a", "10.0.0.1", 0)}, staticNames{Domain: "b.example.org", ForwardZones: []string{"b.example.org"}}, t0)
|
||||
if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "forwarded") {
|
||||
t.Errorf("forward zone: %+v", skipped)
|
||||
}
|
||||
// Without a domain nothing is published.
|
||||
if w, _ := planDynamic(leases, staticNames{}, t0); len(w) != 0 {
|
||||
t.Error("published without a domain")
|
||||
}
|
||||
// Not IPv4.
|
||||
if w, s := planDynamic([]Lease{lease("v6", "fd00::1", 0)}, testStatic, t0); len(w) != 0 || len(s) != 1 {
|
||||
t.Errorf("IPv6 lease: %v %v", w, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseName(t *testing.T) {
|
||||
if got := reverseName("192.168.1.101"); got != "101.1.168.192.in-addr.arpa" {
|
||||
t.Errorf("reverse = %q", got)
|
||||
}
|
||||
if reverseName("fd00::1") != "" || reverseName("x") != "" {
|
||||
t.Error("a non-IPv4 address has no reverse name here")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCtl struct {
|
||||
cmds []string
|
||||
fail string
|
||||
}
|
||||
|
||||
func (f *fakeCtl) run(args ...string) (string, error) {
|
||||
cmd := strings.Join(args, " ")
|
||||
f.cmds = append(f.cmds, cmd)
|
||||
if f.fail != "" && strings.Contains(cmd, f.fail) {
|
||||
return "", errors.New("boom")
|
||||
}
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
func TestSyncDynamic(t *testing.T) {
|
||||
a := DynRecord{Name: "a.home.example.org", Address: "192.168.1.101"}
|
||||
b := DynRecord{Name: "b.home.example.org", Address: "192.168.1.102"}
|
||||
ctl := &fakeCtl{}
|
||||
got, err := syncDynamic(ctl, nil, map[string]DynRecord{a.Name: a, b.Name: b}, true)
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("first sync: %v %v", got, err)
|
||||
}
|
||||
if !slices.Contains(ctl.cmds, "local_data a.home.example.org. 300 IN A 192.168.1.101") ||
|
||||
!slices.Contains(ctl.cmds, "local_data 101.1.168.192.in-addr.arpa. 300 IN PTR a.home.example.org.") {
|
||||
t.Errorf("commands = %v", ctl.cmds)
|
||||
}
|
||||
|
||||
// Nothing changed: nothing is sent.
|
||||
ctl.cmds = nil
|
||||
if _, err := syncDynamic(ctl, got, map[string]DynRecord{a.Name: a, b.Name: b}, false); err != nil || len(ctl.cmds) != 0 {
|
||||
t.Errorf("no change sent %v (%v)", ctl.cmds, err)
|
||||
}
|
||||
|
||||
// b went away, a moved: b is removed, a replaced.
|
||||
a2 := DynRecord{Name: a.Name, Address: "192.168.1.150"}
|
||||
got, err = syncDynamic(ctl, got, map[string]DynRecord{a.Name: a2}, false)
|
||||
if err != nil || len(got) != 1 || got[a.Name].Address != "192.168.1.150" {
|
||||
t.Fatalf("second sync: %v %v", got, err)
|
||||
}
|
||||
for _, want := range []string{"local_data_remove b.home.example.org.", "local_data_remove 102.1.168.192.in-addr.arpa.",
|
||||
"local_data_remove a.home.example.org.", "local_data a.home.example.org. 300 IN A 192.168.1.150"} {
|
||||
if !slices.Contains(ctl.cmds, want) {
|
||||
t.Errorf("missing %q in %v", want, ctl.cmds)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh Unbound (or daemon): everything is removed, then added again.
|
||||
ctl.cmds = nil
|
||||
if _, err := syncDynamic(ctl, got, got, true); err != nil || len(ctl.cmds) != 4 {
|
||||
t.Errorf("fresh sync: %v %v", ctl.cmds, err)
|
||||
}
|
||||
|
||||
// A failing add is not recorded as published, so it is retried.
|
||||
ctl = &fakeCtl{fail: "local_data a."}
|
||||
got, err = syncDynamic(ctl, nil, map[string]DynRecord{a.Name: a, b.Name: b}, true)
|
||||
if err == nil || len(got) != 1 || got[b.Name].Name == "" {
|
||||
t.Errorf("failing add: %v %v", got, err)
|
||||
}
|
||||
|
||||
// Stopping removes everything.
|
||||
ctl = &fakeCtl{}
|
||||
if got, _ := syncDynamic(ctl, map[string]DynRecord{a.Name: a, b.Name: b}, nil, false); len(got) != 0 || len(ctl.cmds) != 4 {
|
||||
t.Errorf("stop: %v %v", got, ctl.cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnboundPID(t *testing.T) {
|
||||
out := "version: 1.26.1\nverbosity: 1\nthreads: 1\nuptime: 0 seconds\nunbound (pid 1458081) is running...\n"
|
||||
if pid, err := parseUnboundPID(out); err != nil || pid != "1458081" {
|
||||
t.Errorf("pid = %q %v", pid, err)
|
||||
}
|
||||
if _, err := parseUnboundPID("error: connection refused"); err == nil {
|
||||
t.Error("a pid out of nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseStartIsRead(t *testing.T) {
|
||||
csv := "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id\n" +
|
||||
"192.168.1.101,aa:bb:cc:00:00:01,,3600,4102444800,1,0,0,phone,0,,0\n"
|
||||
latest := map[string]Lease{}
|
||||
parseLeases(strings.NewReader(csv), latest, time.Unix(1_800_000_000, 0))
|
||||
l := latest["192.168.1.101"]
|
||||
if l.Expires.Unix() != 4102444800 || l.Start.Unix() != 4102444800-3600 {
|
||||
t.Errorf("lease = %+v", l)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user