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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user