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
This commit is contained in:
2026-10-29 23:53:34 +01:00
co-authored by Claude Sonnet 5.5
parent 54040a1402
commit 84f6002d05
23 changed files with 2484 additions and 64 deletions
+63
View File
@@ -116,3 +116,66 @@ func TestDHCPPage(t *testing.T) {
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")
}
}