Files
nixos-router/pkgs/router-webui/dns_test.go
T
nilsandClaude Sonnet 5.5 2eab735f59 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
2026-10-30 00:18:01 +01:00

199 lines
8.1 KiB
Go

package main
import (
"net/http"
"net/url"
"regexp"
"strings"
"testing"
"time"
)
func withDomain(t *testing.T) Settings {
t.Helper()
s := mustParse(t)
obj(obj(s, "router"), "dns")["domain"] = "home.example.org"
obj(obj(s, "router"), "dns")["forwarders"] = []any{"1.1.1.1"}
return s
}
func TestDNSRecordsRoundTrip(t *testing.T) {
s := withDomain(t)
err := s.applyDNS([]DNSRecord{
{Name: "files", Addresses: "192.168.168.20, fd00::20"},
{Name: "photos.example.org", Addresses: "192.168.168.50"},
{Name: "*.k8s.stinnesbeck.com", Addresses: "192.168.168.254"},
{Name: "git.stinnesbeck.com.", Addresses: "10.43.162.50"}, // a trailing dot is fine
{}, // the add row
}, []DNSRecord{{Name: "tdf.stinnesbeck.com", Addresses: "10.1.0.254"}})
if err != nil {
t.Fatal(err)
}
// The wildcard is an override of the name; hosts stay hosts.
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
t.Fatalf("overrides = %v", s.dnsMap("overrides"))
}
if _, ok := s.dnsMap("hosts")["files"]; !ok || len(s.dnsMap("hosts")) != 3 {
t.Errorf("hosts = %v", s.dnsMap("hosts"))
}
rows := s.dnsRecords()
var names []string
for _, r := range rows {
names = append(names, r.Name)
}
if strings.Join(names, " ") != "files git.stinnesbeck.com photos.example.org *.k8s.stinnesbeck.com" {
t.Errorf("rows = %v", names)
}
if r := rows[0]; r.Addresses != "192.168.168.20, fd00::20" {
t.Errorf("files = %+v", r)
}
if z := s.forwardZones(); len(z) != 1 || z[0].Name != "tdf.stinnesbeck.com" {
t.Errorf("zones = %+v", z)
}
// The rest of dns survives, and removing everything removes the keys.
if s.domain() != "home.example.org" || str(s, "router", "dns", "forwarders") == "" && s["router"].(map[string]any)["dns"].(map[string]any)["forwarders"] == nil {
t.Error("domain or forwarders lost")
}
for i := range rows {
rows[i].Remove = true
}
if err := s.applyDNS(rows, nil); err != nil {
t.Fatal(err)
}
if len(s.dnsRecords()) != 0 || len(s.forwardZones()) != 0 || s.domain() == "" {
t.Errorf("after removing: %v %v %q", s.dnsRecords(), s.forwardZones(), s.domain())
}
}
func TestDNSRecordsAreChecked(t *testing.T) {
for name, c := range map[string]struct {
rec DNSRecord
want string
}{
"bad name": {DNSRecord{Name: "no good", Addresses: "10.0.0.1"}, "rec[0].name"},
"wildcard mid": {DNSRecord{Name: "a.*.example.org", Addresses: "10.0.0.1"}, "rec[0].name"},
"no address": {DNSRecord{Name: "files", Addresses: ""}, "rec[0].addresses"},
"bad address": {DNSRecord{Name: "files", Addresses: "10.0.0.999"}, "rec[0].addresses"},
"prefix": {DNSRecord{Name: "files", Addresses: "10.0.0.1/24"}, "rec[0].addresses"},
// The sample has a DHCP reservation called nas, which publishes it.
"reservation": {DNSRecord{Name: "nas", Addresses: "10.0.0.1"}, "rec[0].name"},
} {
s := withDomain(t)
err := s.applyDNS([]DNSRecord{c.rec}, nil)
if fe, ok := err.(FormErrors); !ok || fe[c.want] == "" {
t.Errorf("%s: %v", name, err)
}
}
// Two names that are the same once placed in the domain.
s := withDomain(t)
if err := s.applyDNS([]DNSRecord{{Name: "web", Addresses: "10.0.0.1"}, {Name: "web.home.example.org", Addresses: "10.0.0.2"}}, nil); err == nil {
t.Error("web and web.home.example.org were both accepted")
}
// A short name needs a domain; a name cannot be there twice.
s = mustParse(t)
err := s.applyDNS([]DNSRecord{{Name: "web", Addresses: "10.0.0.1"}}, nil)
if fe, ok := err.(FormErrors); !ok || !strings.Contains(fe["rec[0].name"], "local domain") {
t.Errorf("short name without a domain: %v", err)
}
if err := s.applyDNS([]DNSRecord{{Name: "a.example.org", Addresses: "10.0.0.1"}, {Name: "a.example.org", Addresses: "10.0.0.2"}}, nil); err == nil {
t.Error("a name twice was accepted")
}
// A wildcard and a plain name of the same name are two different things.
if err := s.applyDNS([]DNSRecord{{Name: "a.example.org", Addresses: "10.0.0.1"}, {Name: "*.a.example.org", Addresses: "10.0.0.2"}}, nil); err != nil {
t.Errorf("a name and its wildcard: %v", err)
}
if err := s.applyDNS(nil, []DNSRecord{{Name: "*.branch.test", Addresses: "10.0.0.1"}}); err == nil {
t.Error("a wildcard forward zone was accepted")
}
}
func TestDNSPage(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/dns", nil, cookie).Body.String()
if !strings.Contains(body, "Forward zones") || !strings.Contains(body, "DHCP reservation") {
t.Fatalf("page:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "recs": {"2"}, "zones": {"0"},
"rec.0.name": {"*.k8s.stinnesbeck.com"}, "rec.0.addresses": {"192.168.168.254"},
"rec.1.name": {"web"}, "rec.1.addresses": {"192.168.168.9"}}
// No local domain in the sample: the short name is refused, nothing is saved.
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("short name without domain: %d", rec.Code)
}
form.Set("rec.1.name", "web.stinnesbeck.com")
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ := loadSettings(sys.settingsPath())
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
t.Errorf("settings: %v", s["router"])
}
if body := do(h, "GET", "/dns", nil, cookie).Body.String(); !strings.Contains(body, `value="*.k8s.stinnesbeck.com"`) {
t.Error("the wildcard is not shown again")
}
// Saving the network form must keep them.
net := do(h, "GET", "/network", nil, cookie).Body.String()
nt := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(net)[1]
nf := url.Values{"csrf": {nt}, "hostName": {"gw"}, "wan.interface": {"eth1"}, "wan.type": {"dhcp"}, "lans": {"1"},
"lan.0.name": {"main"}, "lan.0.interface": {"eth2"}, "lan.0.address": {"192.168.10.1"}, "lan.0.dhcp": {"on"},
"lan.0.poolStart": {"192.168.10.100"}, "lan.0.poolEnd": {"192.168.10.199"}}
do(h, "POST", "/network", nf, cookie)
s, _ = loadSettings(sys.settingsPath())
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
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")
}
}