From f839e7fffbc28be79f72705fbdfb0a66013defa8 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Thu, 29 Oct 2026 21:41:43 +0100 Subject: [PATCH] Add DHCP reservations to the web UI, split out a fast webui test The DHCP page edits the reservations per LAN (checked like the module's assertions) and offers active clients for reservation. The web UI checks that need no installed system moved from apply.nix into webui.nix (one node, under a minute); apply.nix shares one apply for the pool change and a reservation. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- DEVELOPING.md | 15 +- pkgs/router-webui/dhcp.go | 224 +++++++++++++++++++++++ pkgs/router-webui/dhcp_test.go | 118 ++++++++++++ pkgs/router-webui/main.go | 4 +- pkgs/router-webui/templates/dhcp.html | 44 +++++ pkgs/router-webui/templates/layout.html | 1 + pkgs/router-webui/templates/network.html | 2 +- scripts/test.sh | 2 +- tests/apply.nix | 34 ++-- tests/default.nix | 1 + tests/webui.nix | 140 ++++++++++++++ 11 files changed, 557 insertions(+), 28 deletions(-) create mode 100644 pkgs/router-webui/dhcp.go create mode 100644 pkgs/router-webui/dhcp_test.go create mode 100644 pkgs/router-webui/templates/dhcp.html create mode 100644 tests/webui.nix diff --git a/DEVELOPING.md b/DEVELOPING.md index e912614..4fae098 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -45,8 +45,17 @@ runs `router-apply` on the router (building a NixOS system there takes about a minute) or waits for a rollback timer. A source change rebuilds the systems again on the host. So: +Measured (one at a time): `webui` 45 s, `basic` 107 s, `onestick` 77 s; the +ones that install and rebuild on the device are the slow ones: `apply` and +`update` several minutes, `setup` 11 minutes (three installs). + - Try a change with `webui-dev` or `fast` first; run a VM test only to confirm. -- Run only the test for what you changed: `update` for `router-update` and - the Update page, `setup` for `router-setup`, `apply` for the settings and - the web UI, `basic` for the firewall and DHCP. +- A change to the web UI only needs `webui` (a single node, no install). + `apply`, `setup` and `update` are for what an installed router does: + on-device builds, rollback, `router-setup`, `router-update`. Run them + before a release or when you touched those, not after every UI edit. +- Nix does not rerun a test whose inputs did not change, so `--all` after a + small change is cheaper than it looks, except that the web UI is part of + every installed system: any change to it invalidates `apply`, `setup` + and `update`. - Use `-i` to iterate on a test script. diff --git a/pkgs/router-webui/dhcp.go b/pkgs/router-webui/dhcp.go new file mode 100644 index 0000000..e5d2cfb --- /dev/null +++ b/pkgs/router-webui/dhcp.go @@ -0,0 +1,224 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "net/netip" + "regexp" + "slices" + "strconv" + "strings" + "time" +) + +// Reservation is a fixed DHCP lease of a LAN, keyed by host name in the +// settings (the name is also published in DNS). +type Reservation struct { + LAN string + Name string + MAC string + Address string + Remove bool +} + +var ( + hostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) + macRe = regexp.MustCompile(`^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$`) +) + +// dhcpLANs returns the LANs that serve DHCP (reservations need it), sorted. +func (s Settings) dhcpLANs() []string { + var out []string + for _, l := range s.form().LANs { + if l.DHCP { + out = append(out, l.Name) + } + } + return out +} + +func (s Settings) reservations() []Reservation { + router, _ := s["router"].(map[string]any) + lans, _ := router["lans"].(map[string]any) + var out []Reservation + for lan, v := range lans { + l, _ := v.(map[string]any) + dhcp, _ := l["dhcp"].(map[string]any) + res, _ := dhcp["reservations"].(map[string]any) + for name, rv := range res { + r, _ := rv.(map[string]any) + out = append(out, Reservation{LAN: lan, Name: name, MAC: str(r, "mac"), Address: str(r, "address")}) + } + } + slices.SortFunc(out, func(a, b Reservation) int { + if c := strings.Compare(a.LAN, b.LAN); c != 0 { + return c + } + return strings.Compare(a.Name, b.Name) + }) + return out +} + +// applyReservations replaces the reservations with rows (an empty row, the +// "add" row, is skipped). Everything else of the LANs stays as it is. +func (s Settings) applyReservations(rows []Reservation) error { + errs := FormErrors{} + router := obj(s, "router") + lans := obj(router, "lans") + + type lanInfo struct { + prefix netip.Prefix + router netip.Addr + dhcp bool + } + info := map[string]lanInfo{} + for _, l := range s.form().LANs { + a, ok := ipv4(l.Address) + if !ok { + continue + } + bits := 24 + if n, ok, _ := optionalInt(l.Prefix, 8, 30); ok { + bits = n + } + info[l.Name] = lanInfo{prefix: netip.PrefixFrom(a, bits).Masked(), router: a, dhcp: l.DHCP} + } + + names, macs, addrs := map[string]bool{}, map[string]bool{}, map[string]bool{} + next := map[string]map[string]any{} + for i, r := range rows { + field := func(n string) string { return fmt.Sprintf("res[%d].%s", i, n) } + r.Name = strings.TrimSpace(r.Name) + r.MAC = strings.ToLower(strings.TrimSpace(r.MAC)) + r.Address = strings.TrimSpace(r.Address) + if r.Name == "" && r.MAC == "" && r.Address == "" { + continue + } + if r.Remove { + continue + } + li, ok := info[r.LAN] + switch { + case !ok: + errs[field("lan")] = "choose a LAN" + case !li.dhcp: + errs[field("lan")] = "this LAN does not serve DHCP" + } + if !hostRe.MatchString(r.Name) { + errs[field("name")] = "letters, digits and dashes only" + } else if names[strings.ToLower(r.Name)] { + errs[field("name")] = "used twice" + } + names[strings.ToLower(r.Name)] = true + if !macRe.MatchString(r.MAC) { + errs[field("mac")] = "like aa:bb:cc:dd:ee:ff" + } else if macs[r.MAC] { + errs[field("mac")] = "used twice" + } + macs[r.MAC] = true + a, aok := ipv4(r.Address) + switch { + case !aok: + errs[field("address")] = "an IPv4 address is required" + case ok && !li.prefix.Contains(a): + errs[field("address")] = "not inside " + li.prefix.String() + case ok && a == li.router: + errs[field("address")] = "the router's own address" + case addrs[r.Address]: + errs[field("address")] = "used twice" + } + addrs[r.Address] = true + if next[r.LAN] == nil { + next[r.LAN] = map[string]any{} + } + next[r.LAN][r.Name] = map[string]any{"mac": r.MAC, "address": a.String()} + } + if len(errs) > 0 { + return errs + } + for name, v := range lans { + l, _ := v.(map[string]any) + if l == nil { + continue + } + if res := next[name]; len(res) > 0 { + obj(l, "dhcp")["reservations"] = res + } else if dhcp, ok := l["dhcp"].(map[string]any); ok { + delete(dhcp, "reservations") + } + } + return nil +} + +type dhcpData struct { + Rows []Reservation + Errors FormErrors + LANs []string + Clients []Lease +} + +// dhcpClients are the active leases that have no reservation yet. +func (a *app) dhcpClients(s Settings) []Lease { + if a.metrics == nil { + return nil + } + reserved := map[string]bool{} + for _, r := range s.reservations() { + reserved[strings.ToLower(r.MAC)] = true + } + var out []Lease + for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) { + if !reserved[strings.ToLower(l.MAC)] { + out = append(out, l) + } + } + return out +} + +func (a *app) dhcpPage(w http.ResponseWriter, r *http.Request, sess session) { + p := page{Title: "DHCP", CSRF: sess.csrf, LoggedIn: true} + s, err := loadSettings(a.sys.settingsPath()) + if err != nil { + p.Error = err.Error() + a.render(w, "dhcp", p) + return + } + rows := s.reservations() + // "Reserve" on a client prefills the row to add. + add := Reservation{LAN: r.URL.Query().Get("lan"), Name: r.URL.Query().Get("name"), + MAC: r.URL.Query().Get("mac"), Address: r.URL.Query().Get("address")} + p.Data = dhcpData{Rows: append(rows, add), LANs: s.dhcpLANs(), Clients: a.dhcpClients(s)} + a.render(w, "dhcp", p) +} + +func (a *app) dhcp(w http.ResponseWriter, r *http.Request, sess session) { + p := page{Title: "DHCP", CSRF: sess.csrf, LoggedIn: true} + s, err := loadSettings(a.sys.settingsPath()) + if err != nil { + p.Error = err.Error() + a.render(w, "dhcp", p) + return + } + n, _ := strconv.Atoi(r.PostFormValue("rows")) + var rows []Reservation + for i := range min(n, 512) { + v := func(k string) string { return r.PostFormValue(fmt.Sprintf("res.%d.%s", i, k)) } + rows = append(rows, Reservation{LAN: v("lan"), Name: v("name"), MAC: v("mac"), Address: v("address"), Remove: v("remove") == "on"}) + } + if err := s.applyReservations(rows); err != nil { + var fe FormErrors + errors.As(err, &fe) + p.Error = "Please correct the marked fields." + p.Data = dhcpData{Rows: rows, Errors: fe, LANs: s.dhcpLANs(), Clients: a.dhcpClients(s)} + w.WriteHeader(http.StatusUnprocessableEntity) + a.render(w, "dhcp", p) + return + } + if err := s.save(a.sys.settingsPath()); err != nil { + p.Error = "Saving: " + err.Error() + a.render(w, "dhcp", p) + return + } + back(w, r, "Saved. Apply to make the changes take effect.") +} diff --git a/pkgs/router-webui/dhcp_test.go b/pkgs/router-webui/dhcp_test.go new file mode 100644 index 0000000..dc9b4c0 --- /dev/null +++ b/pkgs/router-webui/dhcp_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestReservationsRoundTrip(t *testing.T) { + s := mustParse(t) + rows := s.reservations() + if len(rows) != 1 || rows[0] != (Reservation{LAN: "main", Name: "nas", MAC: "aa:bb:cc:dd:ee:ff", Address: "192.168.10.5"}) { + t.Fatalf("reservations = %+v", rows) + } + rows = append(rows, Reservation{LAN: "main", Name: "printer", MAC: "AA:BB:CC:00:11:22", Address: "192.168.10.6"}, + Reservation{LAN: "main"}) // the empty add row + if err := s.applyReservations(rows); err != nil { + t.Fatal(err) + } + if got := str(s, "router", "lans", "main", "dhcp", "reservations", "printer", "mac"); got != "aa:bb:cc:00:11:22" { + t.Errorf("mac = %q (should be lower case)", got) + } + // What else the LAN has survives. + out, _ := s.marshal() + for _, want := range []string{`"fd00::1/64"`, `"pool"`, `"wireguard"`} { + if !strings.Contains(string(out), want) { + t.Errorf("%s lost:\n%s", want, out) + } + } + rows[0].Remove = true + rows[1].Remove = true + if err := s.applyReservations(rows); err != nil { + t.Fatal(err) + } + if got := s.reservations(); len(got) != 0 { + t.Errorf("not removed: %+v", got) + } + if !strings.Contains(string(mustMarshal(t, s)), `"pool"`) { + t.Error("the pool went with the reservations") + } +} + +func mustMarshal(t *testing.T, s Settings) []byte { + t.Helper() + out, err := s.marshal() + if err != nil { + t.Fatal(err) + } + return out +} + +func TestReservationsAreChecked(t *testing.T) { + good := Reservation{LAN: "main", Name: "tv", MAC: "aa:bb:cc:dd:ee:01", Address: "192.168.10.7"} + for name, mod := range map[string]func(*Reservation){ + "name": func(r *Reservation) { r.Name = "tv room" }, + "mac": func(r *Reservation) { r.MAC = "aa:bb:cc" }, + "address": func(r *Reservation) { r.Address = "192.168.11.7" }, + "router": func(r *Reservation) { r.Address = "192.168.10.1" }, + "lan": func(r *Reservation) { r.LAN = "nope" }, + "dupmac": func(r *Reservation) { r.MAC = "AA:BB:CC:DD:EE:FF" }, + "dupaddr": func(r *Reservation) { r.Address = "192.168.10.5" }, + "dupname": func(r *Reservation) { r.Name = "NAS" }, + } { + s := mustParse(t) + r := good + mod(&r) + rows := append(s.reservations(), r) + if err := s.applyReservations(rows); err == nil { + t.Errorf("%s: accepted %+v", name, r) + } + } + s := mustParse(t) + if err := s.applyReservations(append(s.reservations(), good)); err != nil { + t.Errorf("a good reservation was refused: %v", err) + } +} + +func TestDHCPPage(t *testing.T) { + a, sys := newTestApp(t) + h := a.routes() + rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil) + cookie := rec.Result().Cookies()[0] + + body := do(h, "GET", "/dhcp?lan=main&mac=aa:bb:cc:00:00:09&address=192.168.10.9&name=cam", nil, cookie).Body.String() + for _, want := range []string{`value="nas"`, `value="aa:bb:cc:00:00:09"`, `value="cam"`} { + if !strings.Contains(body, want) { + t.Errorf("page lacks %s:\n%s", want, body) + } + } + token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1] + + // A lease of an unreserved client is offered for reservation. + os.WriteFile(filepath.Join(sys.StateDir, "leases"), []byte( + "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id\n"+ + "192.168.10.120,aa:bb:cc:00:00:20,,3600,4102444800,1,0,0,phone,0,,0\n"), 0o600) + if body := do(h, "GET", "/dhcp", nil, cookie).Body.String(); !strings.Contains(body, "aa:bb:cc:00:00:20") { + t.Errorf("the active client is not listed:\n%s", body) + } + + form := url.Values{"csrf": {token}, "rows": {"2"}, + "res.0.lan": {"main"}, "res.0.name": {"nas"}, "res.0.mac": {"aa:bb:cc:dd:ee:ff"}, "res.0.address": {"192.168.10.5"}, + "res.1.lan": {"main"}, "res.1.name": {"cam"}, "res.1.mac": {"aa:bb:cc:00:00:09"}, "res.1.address": {"192.168.10.300"}} + if rec := do(h, "POST", "/dhcp", form, cookie); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("bad address: %d", rec.Code) + } + form.Set("res.1.address", "192.168.10.9") + if rec := do(h, "POST", "/dhcp", form, cookie); rec.Code != http.StatusSeeOther { + t.Fatalf("save: %d\n%s", rec.Code, rec.Body) + } + s, _ := loadSettings(sys.settingsPath()) + if str(s, "router", "lans", "main", "dhcp", "reservations", "cam", "address") != "192.168.10.9" { + t.Fatalf("not saved: %v", s) + } +} diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index 72ce1cb..ac170d2 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -233,7 +233,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app { func (a *app) parseTemplates() (map[string]*template.Template, error) { files := map[string][]string{"overview": {"templates/dashboard.html"}} pages := map[string]*template.Template{} - for _, page := range []string{"login", "overview", "network", "advanced", "update"} { + for _, page := range []string{"login", "overview", "network", "dhcp", "advanced", "update"} { t, err := template.New("layout.html").Funcs(a.funcs). ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...) if err != nil { @@ -274,6 +274,8 @@ func (a *app) routes() http.Handler { mux.HandleFunc("POST /network", a.auth(a.network)) mux.HandleFunc("GET /advanced", a.auth(a.advancedPage)) mux.HandleFunc("POST /advanced", a.auth(a.advanced)) + mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage)) + mux.HandleFunc("POST /dhcp", a.auth(a.dhcp)) mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment)) mux.HandleFunc("POST /prefs", a.auth(a.savePrefs)) mux.HandleFunc("POST /backup", a.auth(a.backup)) diff --git a/pkgs/router-webui/templates/dhcp.html b/pkgs/router-webui/templates/dhcp.html new file mode 100644 index 0000000..deb197d --- /dev/null +++ b/pkgs/router-webui/templates/dhcp.html @@ -0,0 +1,44 @@ +{{define "content"}} +{{with .Data}} +{{$e := .Errors}} +
+ +
+ Reservations + {{if not .LANs}}

No LAN serves DHCP; enable it on the Network page first.

{{end}} + + + + {{range $i, $r := .Rows}} + + + + + + + + {{range $f := list "lan" "name" "mac" "address"}}{{with index $e (printf "res[%d].%s" $i $f)}}{{end}}{{end}} + {{end}} +
LANNameMACAddressRemove
{{if $r.Name}}{{end}}
{{$f}}: {{.}}
+

Fill in the last row to add one. The name is also sent to the client and published in DNS. + The address should be outside the pool.

+
+ +
+ +
+

Clients without a reservation

+ {{if .Clients}} +
+ + + {{range .Clients}} + {{end}} +
NameAddressMACLAN
{{or .Hostname "–"}}{{.Address}}{{.MAC}}{{.LAN}}Reserve
+
+ {{else}}

No active leases.

{{end}} +
+{{end}} +{{end}} diff --git a/pkgs/router-webui/templates/layout.html b/pkgs/router-webui/templates/layout.html index 4f5c682..722f478 100644 --- a/pkgs/router-webui/templates/layout.html +++ b/pkgs/router-webui/templates/layout.html @@ -14,6 +14,7 @@