Files
nixos-router/pkgs/router-webui/dhcp_test.go
T
nilsandClaude Sonnet 5.5 84f6002d05 Add WireGuard, DNS and more to the web UI and router-setup
Web UI: a WireGuard page (tunnels, peers, keys made on the router, client
configs with a QR code, import of wg-quick configs, live status), a DNS page
(records, wildcards, forward zones), interface dropdowns with VLAN devices,
IPv6 options and a local domain. router-setup asks about VLANs, IPv6 and
the domain. The router publishes its own name in DNS and hands the local
domain to DHCP clients; wg and jq are on the router. Includes a script
that takes over antigua's tunnels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 23:53:34 +01:00

182 lines
6.6 KiB
Go

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)
}
}
func TestInterfaceDropdownsAndVLANs(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
token := func() string {
body := do(h, "GET", "/network", nil, cookie).Body.String()
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
}
body := do(h, "GET", "/network", nil, cookie).Body.String()
for _, want := range []string{`<select name="wan.interface">`, `<option value="eth1" selected>`, `<select name="lan.0.interface">`, `<option value="eth2" selected>`} {
if !strings.Contains(body, want) {
t.Errorf("network page lacks %s", want)
}
}
if strings.Contains(body, `list="`) {
t.Error("an interface is still a free text field")
}
add := func(parent, id string) int {
return do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "add": {"1"}, "parent": {parent}, "id": {id}}, cookie).Code
}
if add("eth2", "20") != http.StatusSeeOther || add("eth2", "99999") != http.StatusSeeOther {
t.Fatal("add")
}
if got := sys.loadPrefs().VLANs; len(got) != 1 || got[0] != "eth2.20" {
t.Fatalf("defined VLANs = %v", got)
}
body = do(h, "GET", "/network", nil, cookie).Body.String()
if strings.Count(body, `<option value="eth2.20"`) != 3 { // the WAN, the LAN and the add-a-LAN row
t.Errorf("the VLAN is not offered in both dropdowns:\n%s", body)
}
if !strings.Contains(body, `name="remove" value="eth2.20"`) {
t.Error("an unused VLAN cannot be removed")
}
// The ping targets keep the VLANs.
do(h, "POST", "/prefs", url.Values{"csrf": {token()}, "pingTargets": {"1.1.1.1"}}, cookie)
if len(sys.loadPrefs().VLANs) != 1 {
t.Error("saving the ping targets dropped the VLANs")
}
// Use it for a LAN: it is then in use and cannot be removed.
s, _ := loadSettings(sys.settingsPath())
f := s.form()
f.LANs[0].Interface = "eth2.20"
if _, err := s.apply(f, "/pw"); err != nil {
t.Fatal(err)
}
s.save(sys.settingsPath())
do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "remove": {"eth2.20"}}, cookie)
if len(sys.loadPrefs().VLANs) != 1 {
t.Error("a VLAN in use was removed")
}
f.LANs[0].Interface = "eth2"
s.apply(f, "/pw")
s.save(sys.settingsPath())
do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "remove": {"eth2.20"}}, cookie)
if len(sys.loadPrefs().VLANs) != 0 {
t.Error("an unused VLAN was not removed")
}
}