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
203 lines
5.6 KiB
Go
203 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const sample = `{
|
|
"system": { "hostName": "router", "stateVersion": "26.11" },
|
|
"router": {
|
|
"wan": { "interface": "eth1" },
|
|
"lans": {
|
|
"main": {
|
|
"interface": "eth2", "internet": true,
|
|
"ipv4": { "address": "192.168.10.1" },
|
|
"dhcp": { "pool": { "start": "192.168.10.100", "end": "192.168.10.199" },
|
|
"reservations": { "nas": { "mac": "aa:bb:cc:dd:ee:ff", "address": "192.168.10.5" } } },
|
|
"ipv6": { "address": "fd00::1/64" }
|
|
}
|
|
},
|
|
"wireguard": { "vpn": { "listenPort": 51820 } }
|
|
}
|
|
}`
|
|
|
|
func mustParse(t *testing.T) Settings {
|
|
t.Helper()
|
|
s, err := parseSettings([]byte(sample))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestFormReadsSettings(t *testing.T) {
|
|
f := mustParse(t).form()
|
|
if f.HostName != "router" || f.WAN.Interface != "eth1" || f.WAN.Type != "dhcp" {
|
|
t.Fatalf("unexpected form: %+v", f)
|
|
}
|
|
if len(f.LANs) != 1 || f.LANs[0].Name != "main" || !f.LANs[0].DHCP || !f.LANs[0].Internet ||
|
|
f.LANs[0].PoolEnd != "192.168.10.199" {
|
|
t.Fatalf("unexpected LANs: %+v", f.LANs)
|
|
}
|
|
}
|
|
|
|
func TestApplyKeepsUnknownKeys(t *testing.T) {
|
|
s := mustParse(t)
|
|
f := s.form()
|
|
f.LANs[0].PoolEnd = "192.168.10.150"
|
|
if _, err := s.apply(f, "/var/lib/router/pppoe-password"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, _ := s.marshal()
|
|
for _, want := range []string{`"192.168.10.150"`, `"reservations"`, `"fd00::1/64"`, `"wireguard"`, `"stateVersion"`} {
|
|
if !strings.Contains(string(out), want) {
|
|
t.Errorf("%s missing after apply:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyPPPoE(t *testing.T) {
|
|
s := mustParse(t)
|
|
f := s.form()
|
|
f.WAN.Type = "pppoe"
|
|
f.WAN.PPPoEUser = "user@isp"
|
|
f.WAN.Interface += ".7"
|
|
if _, err := s.apply(f, "/pw"); err == nil {
|
|
t.Fatal("PPPoE without any password was accepted")
|
|
}
|
|
s = mustParse(t)
|
|
f.WAN.PPPoEPassword = "secret"
|
|
pw, err := s.apply(f, "/pw")
|
|
if err != nil || pw != "secret" {
|
|
t.Fatalf("apply: %q %v", pw, err)
|
|
}
|
|
if got := str(s, "router", "wan", "ipv4", "pppoe", "passwordFile"); got != "/pw" {
|
|
t.Errorf("passwordFile = %q", got)
|
|
}
|
|
out, _ := s.marshal()
|
|
if strings.Contains(string(out), "secret") {
|
|
t.Error("the password ended up in the settings")
|
|
}
|
|
if !strings.Contains(string(out), `"vlan": 7`) {
|
|
t.Errorf("vlan is not a number:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestApplyValidates(t *testing.T) {
|
|
s := mustParse(t)
|
|
f := s.form()
|
|
f.LANs[0].Address = "not-an-ip"
|
|
f.LANs[0].Interface = "eth2.5000"
|
|
f.LANs = append(f.LANs, LANForm{Name: "Bad Name", Interface: "eth3", Address: "10.0.0.1"})
|
|
_, err := s.apply(f, "/pw")
|
|
var fe FormErrors
|
|
if !errors.As(err, &fe) {
|
|
t.Fatalf("expected form errors, got %v", err)
|
|
}
|
|
for _, k := range []string{"lans[0].address", "lans[0].interface", "lans[1].name"} {
|
|
if fe[k] == "" {
|
|
t.Errorf("no error for %s: %v", k, fe)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyAddsAndRemovesLANs(t *testing.T) {
|
|
s := mustParse(t)
|
|
f := s.form()
|
|
f.LANs = append(f.LANs, LANForm{Name: "guest", Interface: "eth2.20", Address: "192.168.30.1",
|
|
DHCP: true, PoolStart: "192.168.30.100", PoolEnd: "192.168.30.199"})
|
|
if _, err := s.apply(f, "/pw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := s.form().LANs; len(got) != 2 || got[0].Name != "guest" || got[0].Interface != "eth2.20" {
|
|
t.Fatalf("after adding: %+v", got)
|
|
}
|
|
f = s.form()
|
|
f.LANs[0].Remove = true
|
|
if _, err := s.apply(f, "/pw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := s.form().LANs; len(got) != 1 || got[0].Name != "main" {
|
|
t.Fatalf("after removing: %+v", got)
|
|
}
|
|
f = s.form()
|
|
f.LANs[0].Remove = true
|
|
if _, err := s.apply(f, "/pw"); err == nil {
|
|
t.Fatal("removing the last LAN was accepted")
|
|
}
|
|
}
|
|
|
|
func TestVLANUplinkAndIPv6(t *testing.T) {
|
|
s := mustParse(t)
|
|
f := s.form()
|
|
f.WAN.Type = "pppoe"
|
|
f.WAN.PPPoEUser = "user@isp"
|
|
f.WAN.PPPoEPassword = "secret"
|
|
f.WAN.Interface = "enp2s0.1001"
|
|
f.WAN.IPv6 = true
|
|
f.LANs[0].IPv6PD = true
|
|
if _, err := s.apply(f, "/pw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := str(s, "router", "wan", "interface"); got != "enp2s0" {
|
|
t.Errorf("interface = %q", got)
|
|
}
|
|
if got := str(s, "router", "wan", "ipv4", "pppoe", "vlan"); got != "1001" {
|
|
t.Errorf("vlan = %q", got)
|
|
}
|
|
if got := str(s, "router", "wan", "ipv6", "transport"); got != "ppp" {
|
|
t.Errorf("transport = %q", got)
|
|
}
|
|
if !s.form().LANs[0].IPv6PD || s.form().WAN.Interface != "enp2s0.1001" {
|
|
t.Errorf("form does not round-trip: %+v", s.form())
|
|
}
|
|
devs := s.form().devices([]string{"enp2s0", "wlp3s0"}, []string{"enp2s0.77"})
|
|
if len(devs) != 5 || !devs[2].Removable || devs[1].Name != "enp2s0.1001" || devs[1].Role != "WAN" {
|
|
t.Errorf("devices = %+v", devs)
|
|
}
|
|
|
|
// Without PPPoE a VLAN uplink is not possible; turning IPv6 off keeps the rest.
|
|
g := s.form()
|
|
g.WAN.Type = "dhcp"
|
|
if _, err := s.apply(g, "/pw"); err == nil {
|
|
t.Error("a DHCP uplink on a VLAN was accepted")
|
|
}
|
|
g = s.form()
|
|
g.WAN.IPv6 = false
|
|
g.LANs[0].IPv6PD = false
|
|
if _, err := s.apply(g, "/pw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if boolean(s, true, "router", "wan", "ipv6", "enable") || s.form().LANs[0].IPv6PD {
|
|
t.Error("IPv6 still on")
|
|
}
|
|
}
|
|
|
|
func TestLocalDomain(t *testing.T) {
|
|
s := mustParse(t)
|
|
f := s.form()
|
|
f.Domain = " Home.Example.org "
|
|
if _, err := s.apply(f, "/pw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := str(s, "router", "dns", "domain"); got != "home.example.org" {
|
|
t.Errorf("domain = %q", got)
|
|
}
|
|
if s.form().Domain != "home.example.org" {
|
|
t.Error("the form does not show it")
|
|
}
|
|
f.Domain = "bad domain"
|
|
if _, err := s.apply(f, "/pw"); err == nil {
|
|
t.Error("a domain with a space was accepted")
|
|
}
|
|
f.Domain = ""
|
|
if _, err := s.apply(f, "/pw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := str(s, "router", "dns", "domain"); got != "" {
|
|
t.Errorf("domain not removed: %q", got)
|
|
}
|
|
}
|