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) } }