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
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"net/http"
"net/url"
"regexp"
"strings"
"testing"
)
func withDomain(t *testing.T) Settings {
t.Helper()
s := mustParse(t)
obj(obj(s, "router"), "dns")["domain"] = "home.example.org"
obj(obj(s, "router"), "dns")["forwarders"] = []any{"1.1.1.1"}
return s
}
func TestDNSRecordsRoundTrip(t *testing.T) {
s := withDomain(t)
err := s.applyDNS([]DNSRecord{
{Name: "files", Addresses: "192.168.168.20, fd00::20"},
{Name: "photos.example.org", Addresses: "192.168.168.50"},
{Name: "*.k8s.stinnesbeck.com", Addresses: "192.168.168.254"},
{Name: "git.stinnesbeck.com.", Addresses: "10.43.162.50"}, // a trailing dot is fine
{}, // the add row
}, []DNSRecord{{Name: "tdf.stinnesbeck.com", Addresses: "10.1.0.254"}})
if err != nil {
t.Fatal(err)
}
// The wildcard is an override of the name; hosts stay hosts.
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
t.Fatalf("overrides = %v", s.dnsMap("overrides"))
}
if _, ok := s.dnsMap("hosts")["files"]; !ok || len(s.dnsMap("hosts")) != 3 {
t.Errorf("hosts = %v", s.dnsMap("hosts"))
}
rows := s.dnsRecords()
var names []string
for _, r := range rows {
names = append(names, r.Name)
}
if strings.Join(names, " ") != "files git.stinnesbeck.com photos.example.org *.k8s.stinnesbeck.com" {
t.Errorf("rows = %v", names)
}
if r := rows[0]; r.Addresses != "192.168.168.20, fd00::20" {
t.Errorf("files = %+v", r)
}
if z := s.forwardZones(); len(z) != 1 || z[0].Name != "tdf.stinnesbeck.com" {
t.Errorf("zones = %+v", z)
}
// The rest of dns survives, and removing everything removes the keys.
if s.domain() != "home.example.org" || str(s, "router", "dns", "forwarders") == "" && s["router"].(map[string]any)["dns"].(map[string]any)["forwarders"] == nil {
t.Error("domain or forwarders lost")
}
for i := range rows {
rows[i].Remove = true
}
if err := s.applyDNS(rows, nil); err != nil {
t.Fatal(err)
}
if len(s.dnsRecords()) != 0 || len(s.forwardZones()) != 0 || s.domain() == "" {
t.Errorf("after removing: %v %v %q", s.dnsRecords(), s.forwardZones(), s.domain())
}
}
func TestDNSRecordsAreChecked(t *testing.T) {
for name, c := range map[string]struct {
rec DNSRecord
want string
}{
"bad name": {DNSRecord{Name: "no good", Addresses: "10.0.0.1"}, "rec[0].name"},
"wildcard mid": {DNSRecord{Name: "a.*.example.org", Addresses: "10.0.0.1"}, "rec[0].name"},
"no address": {DNSRecord{Name: "files", Addresses: ""}, "rec[0].addresses"},
"bad address": {DNSRecord{Name: "files", Addresses: "10.0.0.999"}, "rec[0].addresses"},
"prefix": {DNSRecord{Name: "files", Addresses: "10.0.0.1/24"}, "rec[0].addresses"},
// The sample has a DHCP reservation called nas, which publishes it.
"reservation": {DNSRecord{Name: "nas", Addresses: "10.0.0.1"}, "rec[0].name"},
} {
s := withDomain(t)
err := s.applyDNS([]DNSRecord{c.rec}, nil)
if fe, ok := err.(FormErrors); !ok || fe[c.want] == "" {
t.Errorf("%s: %v", name, err)
}
}
// Two names that are the same once placed in the domain.
s := withDomain(t)
if err := s.applyDNS([]DNSRecord{{Name: "web", Addresses: "10.0.0.1"}, {Name: "web.home.example.org", Addresses: "10.0.0.2"}}, nil); err == nil {
t.Error("web and web.home.example.org were both accepted")
}
// A short name needs a domain; a name cannot be there twice.
s = mustParse(t)
err := s.applyDNS([]DNSRecord{{Name: "web", Addresses: "10.0.0.1"}}, nil)
if fe, ok := err.(FormErrors); !ok || !strings.Contains(fe["rec[0].name"], "local domain") {
t.Errorf("short name without a domain: %v", err)
}
if err := s.applyDNS([]DNSRecord{{Name: "a.example.org", Addresses: "10.0.0.1"}, {Name: "a.example.org", Addresses: "10.0.0.2"}}, nil); err == nil {
t.Error("a name twice was accepted")
}
// A wildcard and a plain name of the same name are two different things.
if err := s.applyDNS([]DNSRecord{{Name: "a.example.org", Addresses: "10.0.0.1"}, {Name: "*.a.example.org", Addresses: "10.0.0.2"}}, nil); err != nil {
t.Errorf("a name and its wildcard: %v", err)
}
if err := s.applyDNS(nil, []DNSRecord{{Name: "*.branch.test", Addresses: "10.0.0.1"}}); err == nil {
t.Error("a wildcard forward zone was accepted")
}
}
func TestDNSPage(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/dns", nil, cookie).Body.String()
if !strings.Contains(body, "Forward zones") || !strings.Contains(body, "DHCP reservation") {
t.Fatalf("page:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "recs": {"2"}, "zones": {"0"},
"rec.0.name": {"*.k8s.stinnesbeck.com"}, "rec.0.addresses": {"192.168.168.254"},
"rec.1.name": {"web"}, "rec.1.addresses": {"192.168.168.9"}}
// No local domain in the sample: the short name is refused, nothing is saved.
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("short name without domain: %d", rec.Code)
}
form.Set("rec.1.name", "web.stinnesbeck.com")
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ := loadSettings(sys.settingsPath())
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
t.Errorf("settings: %v", s["router"])
}
if body := do(h, "GET", "/dns", nil, cookie).Body.String(); !strings.Contains(body, `value="*.k8s.stinnesbeck.com"`) {
t.Error("the wildcard is not shown again")
}
// Saving the network form must keep them.
net := do(h, "GET", "/network", nil, cookie).Body.String()
nt := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(net)[1]
nf := url.Values{"csrf": {nt}, "hostName": {"gw"}, "wan.interface": {"eth1"}, "wan.type": {"dhcp"}, "lans": {"1"},
"lan.0.name": {"main"}, "lan.0.interface": {"eth2"}, "lan.0.address": {"192.168.10.1"}, "lan.0.dhcp": {"on"},
"lan.0.poolStart": {"192.168.10.100"}, "lan.0.poolEnd": {"192.168.10.199"}}
do(h, "POST", "/network", nf, cookie)
s, _ = loadSettings(sys.settingsPath())
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
t.Error("the network form dropped the DNS records")
}
}