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 @@