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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-29 21:41:43 +01:00
co-authored by Claude Sonnet 5.5
parent ca05f3ab36
commit f839e7fffb
11 changed files with 557 additions and 28 deletions
+224
View File
@@ -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.")
}
+118
View File
@@ -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)
}
}
+3 -1
View File
@@ -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))
+44
View File
@@ -0,0 +1,44 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<form method="post" action="/dhcp">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>Reservations</legend>
{{if not .LANs}}<p class="field-error">No LAN serves DHCP; enable it on the Network page first.</p>{{end}}
<input type="hidden" name="rows" value="{{len .Rows}}">
<table>
<tr><th>LAN</th><th>Name</th><th>MAC</th><th>Address</th><th>Remove</th></tr>
{{range $i, $r := .Rows}}
<tr>
<td><select name="res.{{$i}}.lan">
{{range $.Data.LANs}}<option value="{{.}}" {{if eq . $r.LAN}}selected{{end}}>{{.}}</option>{{end}}
</select></td>
<td><input name="res.{{$i}}.name" value="{{$r.Name}}" size="14" placeholder="new"></td>
<td><input name="res.{{$i}}.mac" value="{{$r.MAC}}" size="17" spellcheck="false" placeholder="aa:bb:cc:dd:ee:ff"></td>
<td><input name="res.{{$i}}.address" value="{{$r.Address}}" size="13"></td>
<td>{{if $r.Name}}<input type="checkbox" name="res.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "lan" "name" "mac" "address"}}{{with index $e (printf "res[%d].%s" $i $f)}}<tr><td colspan="5" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
<p class="hint">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.</p>
</fieldset>
<button>Save</button>
</form>
<section>
<h2>Clients without a reservation</h2>
{{if .Clients}}
<div class="scroll">
<table class="leases">
<tr><th>Name</th><th>Address</th><th>MAC</th><th>LAN</th><th></th></tr>
{{range .Clients}}<tr><td>{{or .Hostname "–"}}</td><td>{{.Address}}</td><td><code>{{.MAC}}</code></td><td>{{.LAN}}</td>
<td><a href="/dhcp?lan={{.LAN | urlquery}}&amp;name={{.Hostname | urlquery}}&amp;mac={{.MAC | urlquery}}&amp;address={{.Address | urlquery}}">Reserve</a></td></tr>{{end}}
</table>
</div>
{{else}}<p class="muted">No active leases.</p>{{end}}
</section>
{{end}}
{{end}}
+1
View File
@@ -14,6 +14,7 @@
<nav>
<a href="/">Overview</a>
<a href="/network">Network</a>
<a href="/dhcp">DHCP</a>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
+1 -1
View File
@@ -65,7 +65,7 @@
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (reservations, zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
</fieldset>
<button>Save</button>