diff --git a/modules/installer/setup.nix b/modules/installer/setup.nix index 2da5c3d..a8542d5 100644 --- a/modules/installer/setup.nix +++ b/modules/installer/setup.nix @@ -391,7 +391,7 @@ let + (if $v6 == "y" then { ipv6: { pd: true } } else {} end)) } } - + (if $domain == "" then {} else { dns: { domain: $domain } } end)) + + (if $domain == "" then {} else { dns: { domain: $domain, dynamicLeases: true } } end)) }' > "$config/settings.json" if [ "$wan_type" = pppoe ]; then diff --git a/modules/router/dns.nix b/modules/router/dns.nix index 88bdec3..d44efb8 100644 --- a/modules/router/dns.nix +++ b/modules/router/dns.nix @@ -43,12 +43,23 @@ let lib.nameValuePair selfName selfAddrs ); + hostRecords = lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts; + reservationRecords = map (r: lib.nameValuePair (fqdn r.name) r.value) reservations; records = selfRecord - ++ lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts - ++ map (r: lib.nameValuePair (fqdn r.name) r.value) reservations + ++ hostRecords + ++ reservationRecords ++ lib.mapAttrsToList (n: a: lib.nameValuePair n a) dns.overrides; + # What the configuration publishes itself: dynamic names (the clients' host + # names, see dns.dynamicLeases) must never take one of these over. + staticNames = builtins.toJSON { + inherit domain; + names = map (r: r.name) (selfRecord ++ hostRecords ++ reservationRecords) ++ map (r: r.name) selfBare; + overrides = lib.attrNames dns.overrides; + forwardZones = lib.attrNames dns.forwardZones; + }; + # Unbound reads these as one quoted argument. localData = lib.concatMap (r: map (a: ''"${r.name}. ${rrOf a} ${a}"'') r.value) (records ++ selfBare); # Reverse records for hosts and reservations (not for wildcard overrides). @@ -67,8 +78,15 @@ let in { config = lib.mkIf cfg.enable { + environment.etc."router/dns-static.json".text = staticNames; + warnings = lib.optional (dns.dynamicLeases && domain == null) '' + router.dns.dynamicLeases has no effect without router.dns.domain: the clients' names need a domain to live in. + ''; + services.unbound = { enable = true; + # What router-webui leases-dns publishes the clients' names through. + localControlSocketPath = lib.mkIf dns.dynamicLeases "/run/unbound/unbound.ctl"; settings = { server = { interface = [ diff --git a/modules/router/options.nix b/modules/router/options.nix index cf252e7..4162949 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -528,6 +528,21 @@ in description = "Like `dns.hosts`, but the name and everything below it answer with these addresses (wildcard)."; }; + dns.dynamicLeases = mkOption { + type = types.bool; + default = false; + description = '' + Publish the host names of DHCP clients as `.` (with + reverse records), updated as leases come and go. Needs `dns.domain`. + + A client picks its own name, so only its first label is used, only + under the local domain, and a name that is configured here (`dns.hosts`, + a reservation, the router itself, a wildcard or a forward zone it falls + under) is never taken over. Of two clients claiming one name the newer + lease wins. Run by `router-webui leases-dns` (the settings module). + ''; + }; + dns.forwardZones = mkOption { type = types.attrsOf (types.listOf types.str); default = { }; diff --git a/modules/settings/default.nix b/modules/settings/default.nix index 4257f8a..8dcdff3 100644 --- a/modules/settings/default.nix +++ b/modules/settings/default.nix @@ -190,6 +190,37 @@ in }; }; + # The DHCP clients' names in DNS (router.dns.dynamicLeases): reads Kea's + # leases and publishes them in the running Unbound. + systemd.services.router-leases-dns = lib.mkIf (config.router.enable && config.router.dns.dynamicLeases) { + description = "Publish DHCP clients' names in DNS"; + wantedBy = [ "multi-user.target" ]; + after = [ + "unbound.service" + "kea-dhcp4-server.service" + ]; + path = [ config.services.unbound.package ]; + serviceConfig = { + ExecStart = "${lib.getExe webui} leases-dns -leases /var/lib/kea/dhcp4.leases -static /etc/router/dns-static.json -socket ${config.services.unbound.localControlSocketPath} -run /run/router-leases-dns"; + RuntimeDirectory = "router-leases-dns"; + Restart = "always"; + RestartSec = 5; + # It reads leases and talks to Unbound's socket; nothing else. + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateNetwork = true; + PrivateDevices = true; + NoNewPrivileges = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_UNIX" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + }; + }; + # Rebuilding on the device must work without internet (the uplink may be # what is being configured). What a changed configuration rebuilds is all # generated by NixOS, but those builds need these packages; found by diff --git a/pkgs/router-webui/collect.go b/pkgs/router-webui/collect.go index a914079..4f85d71 100644 --- a/pkgs/router-webui/collect.go +++ b/pkgs/router-webui/collect.go @@ -446,6 +446,7 @@ type Lease struct { Address string MAC string Hostname string + Start time.Time // when it was handed out or last renewed Expires time.Time LAN string } @@ -514,6 +515,7 @@ func parseLeases(r io.Reader, latest map[string]Lease, now time.Time) { MAC: get(rec, "hwaddr"), Hostname: strings.ReplaceAll(get(rec, "hostname"), ",", ","), Expires: time.Unix(expire, 0), + Start: time.Unix(expire-lifetime, 0), } if lifetime == 0 || (state != "" && state != "0") { l.Expires = time.Time{} // released, declined or reclaimed diff --git a/pkgs/router-webui/dns.go b/pkgs/router-webui/dns.go index 67044c9..610b3ab 100644 --- a/pkgs/router-webui/dns.go +++ b/pkgs/router-webui/dns.go @@ -170,6 +170,32 @@ type dnsData struct { Errors FormErrors Domain string Auto []string // published without a record here + Dynamic bool // router.dns.dynamicLeases + Dyn *dynStatus +} + +func (s Settings) dynamicLeases() bool { + router, _ := s["router"].(map[string]any) + dns, _ := router["dns"].(map[string]any) + return boolean(dns, false, "dynamicLeases") +} + +func (s Settings) setDynamicLeases(on bool) { + dns := obj(obj(s, "router"), "dns") + if on { + dns["dynamicLeases"] = true + } else { + delete(dns, "dynamicLeases") + } +} + +// dnsView is the page's data for settings s. +func (a *app) dnsView(s Settings, records, zones []DNSRecord, errs FormErrors) dnsData { + d := dnsData{Records: records, Zones: zones, Errors: errs, Domain: s.domain(), Auto: s.dnsAuto(), Dynamic: s.dynamicLeases()} + if st, ok := readDynStatus(a.sys.leasesDNSDir()); ok { + d.Dyn = &st + } + return d } func (s Settings) dnsAuto() []string { @@ -198,8 +224,7 @@ func (a *app) dnsPage(w http.ResponseWriter, r *http.Request, sess session) { a.render(w, "dns", p) return } - p.Data = dnsData{Records: append(s.dnsRecords(), DNSRecord{}), Zones: append(s.forwardZones(), DNSRecord{}), - Domain: s.domain(), Auto: s.dnsAuto()} + p.Data = a.dnsView(s, append(s.dnsRecords(), DNSRecord{}), append(s.forwardZones(), DNSRecord{}), nil) a.render(w, "dns", p) } @@ -222,15 +247,17 @@ func (a *app) dns(w http.ResponseWriter, r *http.Request, sess session) { return } records, zones := dnsRows(r, "rec"), dnsRows(r, "zone") + dynamic := r.PostFormValue("dynamic") == "on" if err := s.applyDNS(records, zones); err != nil { var fe FormErrors errors.As(err, &fe) p.Error = "Please correct the marked fields." - p.Data = dnsData{Records: records, Zones: zones, Errors: fe, Domain: s.domain(), Auto: s.dnsAuto()} + p.Data = a.dnsView(s, records, zones, fe) w.WriteHeader(http.StatusUnprocessableEntity) a.render(w, "dns", p) return } + s.setDynamicLeases(dynamic) if err := s.save(a.sys.settingsPath()); err != nil { p.Error = "Saving: " + err.Error() a.render(w, "dns", p) diff --git a/pkgs/router-webui/dns_test.go b/pkgs/router-webui/dns_test.go index 5967b2d..e61ba66 100644 --- a/pkgs/router-webui/dns_test.go +++ b/pkgs/router-webui/dns_test.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" "testing" + "time" ) func withDomain(t *testing.T) Settings { @@ -145,3 +146,53 @@ func TestDNSPage(t *testing.T) { t.Error("the network form dropped the DNS records") } } + +func TestDynamicLeasesOnThePage(t *testing.T) { + a, sys := newTestApp(t) + a.sys.LeasesDNSDir = t.TempDir() + h := a.routes() + cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0] + token := func() string { + body := do(h, "GET", "/dns", nil, cookie).Body.String() + return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1] + } + save := func(extra ...string) { + form := url.Values{"csrf": {token()}, "recs": {"0"}, "zones": {"0"}} + for i := 0; i < len(extra); i += 2 { + form.Set(extra[i], extra[i+1]) + } + if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusSeeOther { + t.Fatalf("save: %d\n%s", rec.Code, rec.Body) + } + } + + save("dynamic", "on") + if s, _ := loadSettings(sys.settingsPath()); !s.dynamicLeases() { + t.Fatal("not switched on") + } + body := do(h, "GET", "/dns", nil, cookie).Body.String() + if !strings.Contains(body, `name="dynamic" checked`) || !strings.Contains(body, "apply the settings so the service starts") { + t.Errorf("the page after switching on:\n%s", body) + } + + // What the running service reports is shown. + writeDynStatus(a.sys.LeasesDNSDir, dynStatus{ + Published: []DynRecord{{Name: "phone.home.example.org", Address: "192.168.1.101"}}, + Skipped: []DynSkip{{Hostname: "nas", Address: "192.168.1.102", Reason: "the configuration has a record with this name"}}, + Updated: time.Now(), + }) + body = do(h, "GET", "/dns", nil, cookie).Body.String() + for _, want := range []string{"phone.home.example.org", "192.168.1.101", "the configuration has a record with this name"} { + if !strings.Contains(body, want) { + t.Errorf("status lacks %q", want) + } + } + + save() // unticked + if s, _ := loadSettings(sys.settingsPath()); s.dynamicLeases() { + t.Error("not switched off") + } + if _, ok := obj(obj(mustLoad(sys), "router"), "dns")["dynamicLeases"]; ok { + t.Error("the key stays after switching off") + } +} diff --git a/pkgs/router-webui/leasesdns.go b/pkgs/router-webui/leasesdns.go new file mode 100644 index 0000000..58afb58 --- /dev/null +++ b/pkgs/router-webui/leasesdns.go @@ -0,0 +1,342 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/netip" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "slices" + "strings" + "syscall" + "time" +) + +// The DHCP clients' names in DNS. Kea hands out the leases and Unbound +// answers, and nothing connects them, so this does: `router-webui leases-dns` +// reads Kea's lease file and publishes `.` (and the reverse +// record) in the running Unbound through unbound-control. +// +// A client chooses its own host name, so it is not trusted: only the first +// label is used, only under the router's local domain, and a name that the +// configuration publishes (hosts, DHCP reservations, the router itself, a +// wildcard or a forward zone it falls under) is never taken over. Of two +// clients claiming one name the newer lease wins. + +// staticNames is what the running configuration publishes itself +// (/etc/router/dns-static.json, written by the router module). +type staticNames struct { + Domain string `json:"domain"` + Names []string `json:"names"` // full names with records + Overrides []string `json:"overrides"` // wildcard bases: the name and everything below + ForwardZones []string `json:"forwardZones"` // answered by other resolvers +} + +// DynRecord is a published client name. +type DynRecord struct { + Name string `json:"name"` // without the trailing dot + Address string `json:"address"` +} + +// DynSkip is a client that was not published, and why. +type DynSkip struct { + Hostname string `json:"hostname"` + Address string `json:"address"` + Reason string `json:"reason"` +} + +var labelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +func under(name, zone string) bool { + return name == zone || strings.HasSuffix(name, "."+zone) +} + +// planDynamic decides which leases are published. +func planDynamic(leases []Lease, st staticNames, now time.Time) (map[string]DynRecord, []DynSkip) { + want := map[string]DynRecord{} + var skipped []DynSkip + if st.Domain == "" { + return want, skipped + } + domain := strings.ToLower(strings.TrimSuffix(st.Domain, ".")) + static := map[string]bool{} + for _, n := range st.Names { + static[strings.ToLower(strings.TrimSuffix(n, "."))] = true + } + var active []Lease + for _, l := range leases { + if l.Hostname != "" && l.Expires.After(now) { + active = append(active, l) + } + } + // Newest lease first: it wins a name. + slices.SortStableFunc(active, func(a, b Lease) int { + if c := b.Start.Compare(a.Start); c != 0 { + return c + } + return strings.Compare(a.Address, b.Address) + }) + for _, l := range active { + label, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(l.Hostname)), ".") + skip := func(reason string) { + skipped = append(skipped, DynSkip{Hostname: l.Hostname, Address: l.Address, Reason: reason}) + } + if !labelRe.MatchString(label) { + skip("not a usable host name") + continue + } + if a, err := netip.ParseAddr(l.Address); err != nil || !a.Is4() { + skip("not an IPv4 address") + continue + } + name := label + "." + domain + switch { + case static[name]: + skip("the configuration has a record with this name") + case slices.ContainsFunc(st.Overrides, func(z string) bool { return under(name, strings.ToLower(strings.TrimSuffix(z, "."))) }): + skip("below a wildcard record") + case slices.ContainsFunc(st.ForwardZones, func(z string) bool { return under(name, strings.ToLower(strings.TrimSuffix(z, "."))) }): + skip("below a forwarded zone") + default: + if other, ok := want[name]; ok { + skip("a newer lease (" + other.Address + ") has this name") + continue + } + want[name] = DynRecord{Name: name, Address: l.Address} + } + } + return want, skipped +} + +// reverseName is the in-addr.arpa name of an IPv4 address. +func reverseName(addr string) string { + a, err := netip.ParseAddr(addr) + if err != nil || !a.Is4() { + return "" + } + b := a.As4() + return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa", b[3], b[2], b[1], b[0]) +} + +// controller runs unbound-control commands. +type controller interface { + run(args ...string) (string, error) +} + +type unboundControl struct{ socket, config string } + +func (u unboundControl) run(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + pre := []string{"-s", u.socket} + if u.config != "" { // else unbound-control reads /etc/unbound/unbound.conf + pre = append(pre, "-c", u.config) + } + out, err := exec.CommandContext(ctx, "unbound-control", append(pre, args...)...).CombinedOutput() + if err != nil { + err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), err +} + +const dynTTL = "300" + +func publish(ctl controller, r DynRecord) error { + if _, err := ctl.run("local_data", r.Name+".", dynTTL, "IN", "A", r.Address); err != nil { + return err + } + if rev := reverseName(r.Address); rev != "" { + _, err := ctl.run("local_data", rev+".", dynTTL, "IN", "PTR", r.Name+".") + return err + } + return nil +} + +func unpublish(ctl controller, r DynRecord) error { + var first error + for _, n := range []string{r.Name, reverseName(r.Address)} { + if n == "" { + continue + } + if _, err := ctl.run("local_data_remove", n+"."); err != nil && first == nil { + first = err + } + } + return first +} + +// syncDynamic brings Unbound from what was published to what is wanted. With +// fresh set (a new Unbound, or a restarted daemon) everything published +// before is removed first and the wanted records are all added. +func syncDynamic(ctl controller, published, want map[string]DynRecord, fresh bool) (map[string]DynRecord, error) { + now := map[string]DynRecord{} + var firstErr error + note := func(err error) { + if err != nil && firstErr == nil { + firstErr = err + } + } + for name, r := range published { + if w, ok := want[name]; fresh || !ok || w.Address != r.Address { + note(unpublish(ctl, r)) + } else { + now[name] = r + } + } + for name, w := range want { + if _, ok := now[name]; ok { + continue + } + if err := publish(ctl, w); err != nil { + note(err) + continue + } + now[name] = w + } + return now, firstErr +} + +// dynStatus is what the daemon leaves for the web UI. +type dynStatus struct { + Published []DynRecord `json:"published"` + Skipped []DynSkip `json:"skipped"` + Updated time.Time `json:"updated"` + Error string `json:"error,omitempty"` +} + +func readDynStatus(dir string) (dynStatus, bool) { + var st dynStatus + data, err := os.ReadFile(filepath.Join(dir, "status.json")) + if err != nil || json.Unmarshal(data, &st) != nil { + return st, false + } + return st, true +} + +func writeDynStatus(dir string, st dynStatus) { + data, _ := json.Marshal(st) + if err := writeFileAtomic(filepath.Join(dir, "status.json"), data, 0o644); err != nil { + log.Printf("status: %v", err) + } +} + +func recordsOf(m map[string]DynRecord) []DynRecord { + out := make([]DynRecord, 0, len(m)) + for _, r := range m { + out = append(out, r) + } + slices.SortFunc(out, func(a, b DynRecord) int { return strings.Compare(a.Name, b.Name) }) + return out +} + +func loadStatic(path string) staticNames { + var st staticNames + if data, err := os.ReadFile(path); err == nil { + json.Unmarshal(data, &st) + } + return st +} + +// unboundPID tells a restarted Unbound (which lost what was published) from +// the same one. +func unboundPID(ctl controller) (string, error) { + out, err := ctl.run("status") + if err != nil { + return "", err + } + return parseUnboundPID(out) +} + +var pidRe = regexp.MustCompile(`\(pid ([0-9]+)\)`) + +// parseUnboundPID reads the last line of `unbound-control status`: +// "unbound (pid 1234) is running...". +func parseUnboundPID(status string) (string, error) { + if m := pidRe.FindStringSubmatch(status); m != nil { + return m[1], nil + } + return "", fmt.Errorf("no pid in unbound-control status") +} + +// leasesDNS is `router-webui leases-dns`. +func leasesDNS(args []string) { + fs := flag.NewFlagSet("leases-dns", flag.ExitOnError) + leases := fs.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file") + staticPath := fs.String("static", "/etc/router/dns-static.json", "the names the configuration publishes") + socket := fs.String("socket", "/run/unbound/unbound.ctl", "Unbound's control socket") + config := fs.String("unbound-config", "", "Unbound's config file (default: unbound-control's own)") + run := fs.String("run", "/run/router-leases-dns", "directory for the status the web UI shows") + interval := fs.Duration("interval", 5*time.Second, "how often the leases are read") + once := fs.Bool("once", false, "do one round and exit") + fs.Parse(args) + + ctl := unboundControl{socket: *socket, config: *config} + if err := os.MkdirAll(*run, 0o755); err != nil { + log.Fatal(err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + + // What a previous run published is removed first: the daemon may have + // been restarted while Unbound kept its data. + published := map[string]DynRecord{} + if st, ok := readDynStatus(*run); ok { + for _, r := range st.Published { + published[r.Name] = r + } + } + pid, fresh := "", true + var lastErr string + for { + st := loadStatic(*staticPath) + want, skipped := planDynamic(readLeasesAt(*leases, time.Now()), st, time.Now()) + var err error + if cur, perr := unboundPID(ctl); perr != nil { + err = perr + } else { + if cur != pid { + fresh = true // a new Unbound has none of it + } + var now map[string]DynRecord + if now, err = syncDynamic(ctl, published, want, fresh); err == nil { + fresh, pid = false, cur + } + published = now + } + status := dynStatus{Published: recordsOf(published), Skipped: skipped, Updated: time.Now()} + if err != nil { + status.Error = err.Error() + if status.Error != lastErr { + log.Printf("unbound-control: %v", err) + } + } + lastErr = status.Error + writeDynStatus(*run, status) + if *once { + if err != nil { + os.Exit(1) + } + return + } + select { + case <-ctx.Done(): + // Leave nothing behind: the names would outlive the daemon. + syncDynamic(ctl, published, nil, false) + writeDynStatus(*run, dynStatus{Updated: time.Now()}) + return + case <-time.After(*interval): + } + } +} + +// readLeasesAt reads the active leases of the lease file (without the LAN +// names the dashboard adds). +func readLeasesAt(path string, now time.Time) []Lease { + return readLeases(path, nil, now) +} diff --git a/pkgs/router-webui/leasesdns_test.go b/pkgs/router-webui/leasesdns_test.go new file mode 100644 index 0000000..ea8cca5 --- /dev/null +++ b/pkgs/router-webui/leasesdns_test.go @@ -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) + } +} diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index 119fde5..8b28623 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -7,6 +7,7 @@ // router-webui [-listen :443] [-config DIR] [-state DIR] [-leases FILE] // router-webui passwd FILE (reads the new password from stdin) // router-webui unbundle FILE DIR (stages an update bundle; FILE - is stdin) +// router-webui leases-dns (publishes DHCP clients' names in Unbound) package main import ( @@ -51,6 +52,10 @@ var embedded embed.FS var assets fs.FS = embedded func main() { + if len(os.Args) > 1 && os.Args[1] == "leases-dns" { + leasesDNS(os.Args[2:]) + return + } if len(os.Args) == 3 && os.Args[1] == "passwd" { if err := setPassword(os.Args[2]); err != nil { log.Fatal(err) diff --git a/pkgs/router-webui/system.go b/pkgs/router-webui/system.go index be9d1d9..bd49736 100644 --- a/pkgs/router-webui/system.go +++ b/pkgs/router-webui/system.go @@ -20,6 +20,15 @@ type System struct { StateDir string // /var/lib/router: secrets, UI state // ReleaseFile describes the installed nixos-router; /etc/router-release if empty. ReleaseFile string + // LeasesDNSDir is where leases-dns leaves its status; /run/router-leases-dns if empty. + LeasesDNSDir string +} + +func (s System) leasesDNSDir() string { + if s.LeasesDNSDir != "" { + return s.LeasesDNSDir + } + return "/run/router-leases-dns" } func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") } diff --git a/pkgs/router-webui/templates/dns.html b/pkgs/router-webui/templates/dns.html index 47ee4d9..2a2c782 100644 --- a/pkgs/router-webui/templates/dns.html +++ b/pkgs/router-webui/templates/dns.html @@ -42,9 +42,41 @@

Names below these domains are asked from these resolvers (for example across a tunnel), not from the internet.

+ +
+ DHCP clients + + {{if and .Dynamic (not .Domain)}}

This needs a local domain (Network page).

{{end}} +

A client chooses its own name, so only its first label is used, only in your local domain, and a name + that is configured here (a record above, a reservation, the router itself, a wildcard) is never taken over. + Of two clients with one name the newer lease wins. Takes effect when applied.

+
+{{if .Dyn}} +
+

DHCP clients in DNS

+ {{if .Dyn.Error}}

The last update failed: {{.Dyn.Error}}

{{end}} + {{if .Dyn.Published}} + + + {{range .Dyn.Published}}{{end}} +
NameAddress
{{.Name}}{{.Address}}
+ {{else}}

No client names are published at the moment.

{{end}} + {{if .Dyn.Skipped}} +

Not published:

+ + + {{range .Dyn.Skipped}}{{end}} +
Client nameAddressWhy
{{.Hostname}}{{.Address}}{{.Reason}}
+ {{end}} +

As of {{ago .Dyn.Updated}} ago, from the running configuration.

+
+{{else if .Dynamic}} +

Client names are not published yet: apply the settings so the service starts.

+{{end}} + {{if .Auto}}

Published automatically

diff --git a/tests/setup.nix b/tests/setup.nix index 406a67b..e496bce 100644 --- a/tests/setup.nix +++ b/tests/setup.nix @@ -145,7 +145,7 @@ pkgs.testers.runNixOSTest { assert r["wan"]["ipv6"] == {"enable": True, "transport": "ppp"}, s assert r["lans"]["lan"]["interface"] == "eth1" and r["lans"]["lan"]["ipv6"] == {"pd": True}, s assert "vlan" not in r["lans"]["lan"], s - assert r["dns"]["domain"] == "home.test", s + assert r["dns"] == {"domain": "home.test", "dynamicLeases": True}, s assert "pppoe-secret" not in s installer.succeed("grep -qx pppoe-secret /tmp/vlan-config/pppoe-password") installer.fail("blkid /dev/vda1")