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
+65 -25
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"maps"
"net/netip"
"os"
"path/filepath"
@@ -126,6 +127,7 @@ func boolean(m map[string]any, def bool, keys ...string) bool {
// Form is what the network page edits.
type Form struct {
HostName string
Domain string // the local domain (router.dns.domain)
WAN WANForm
LANs []LANForm
}
@@ -141,8 +143,7 @@ type WANForm struct {
type LANForm struct {
Name string
Interface string
VLAN string
Interface string // a device: "eth2", or "eth2.20" for a VLAN
Address string
Prefix string
Internet bool
@@ -160,6 +161,7 @@ func (s Settings) form() Form {
}
f := Form{
HostName: str(s, "system", "hostName"),
Domain: str(router, "dns", "domain"),
WAN: WANForm{
Interface: str(router, "wan", "interface"),
Type: str(router, "wan", "ipv4", "type"),
@@ -185,10 +187,13 @@ func (s Settings) form() Form {
if l == nil {
continue
}
dev := str(l, "interface")
if vlan := str(l, "vlan"); vlan != "" {
dev += "." + vlan
}
f.LANs = append(f.LANs, LANForm{
Name: name,
Interface: str(l, "interface"),
VLAN: str(l, "vlan"),
Interface: dev,
Address: str(l, "ipv4", "address"),
Prefix: str(l, "ipv4", "prefixLength"),
Internet: boolean(l, false, "internet"),
@@ -202,8 +207,10 @@ func (s Settings) form() Form {
}
var (
nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`)
ifaceRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,15}$`)
nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`)
// A DNS name of up to 253 characters.
domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
ifaceRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,15}$`)
)
// FormErrors maps a field to what is wrong with it.
@@ -249,16 +256,31 @@ func splitVLAN(dev string) (string, int) {
return dev[:i], id
}
// badVLAN reports a device name that looks like a VLAN (interface.digits) but
// whose ID is not from 1 to 4094.
func badVLAN(dev string) bool {
i := strings.LastIndex(dev, ".")
if i <= 0 {
return false
}
if _, err := strconv.Atoi(dev[i+1:]); err != nil {
return false
}
_, id := splitVLAN(dev)
return id == 0
}
// Device is a row of the interface list: a physical NIC or a VLAN on one.
type Device struct {
Name string
Kind string // "physical" or "VLAN"
Role string // "WAN", "LAN lan", or ""
Name string
Kind string // "physical" or "VLAN"
Role string // "WAN", "LAN lan", or ""
Removable bool // a VLAN defined in the UI that nothing uses
}
// devices lists the physical interfaces plus the VLAN devices the settings
// use, each with the role it plays.
func (f Form) devices(physical []string) []Device {
// use or the UI defined (defined), each with the role it plays.
func (f Form) devices(physical, defined []string) []Device {
roles := map[string][]string{}
add := func(dev, role string) { roles[dev] = append(roles[dev], role) }
add(f.WAN.Interface, "WAN")
@@ -266,26 +288,31 @@ func (f Form) devices(physical []string) []Device {
if l.Name == "" || l.Remove {
continue
}
dev := l.Interface
if l.VLAN != "" {
dev += "." + l.VLAN
}
add(dev, "LAN "+l.Name)
add(l.Interface, "LAN "+l.Name)
}
// Interfaces the settings or the defined VLANs refer to are listed even
// when this machine lacks them (other hardware).
names := slices.Clone(physical)
var vlans []string
for dev := range roles {
if _, id := splitVLAN(dev); id != 0 && !slices.Contains(names, dev) {
var vlans, extra []string
for _, dev := range append(slices.Collect(maps.Keys(roles)), defined...) {
base, id := splitVLAN(dev)
if id != 0 && !slices.Contains(vlans, dev) {
vlans = append(vlans, dev)
}
if !slices.Contains(names, base) && !slices.Contains(extra, base) {
extra = append(extra, base)
}
}
slices.Sort(vlans)
slices.Sort(extra)
names = append(names, extra...)
var out []Device
for _, n := range names {
out = append(out, Device{Name: n, Kind: "physical", Role: strings.Join(roles[n], ", ")})
for _, v := range vlans {
if parent, _ := splitVLAN(v); parent == n {
out = append(out, Device{Name: v, Kind: "VLAN", Role: strings.Join(roles[v], ", ")})
out = append(out, Device{Name: v, Kind: "VLAN", Role: strings.Join(roles[v], ", "),
Removable: len(roles[v]) == 0 && slices.Contains(defined, v)})
}
}
}
@@ -313,11 +340,23 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
system["hostName"] = f.HostName
}
if f.Domain = strings.ToLower(strings.TrimSpace(f.Domain)); f.Domain == "" {
if dns, ok := router["dns"].(map[string]any); ok {
delete(dns, "domain")
}
} else if !domainRe.MatchString(f.Domain) {
errs["domain"] = "letters, digits and dashes, in labels separated by dots (home.example.org)"
} else {
obj(router, "dns")["domain"] = f.Domain
}
// A VLAN device (enp2s0.1001) is the physical interface plus the
// PPPoE session's VLAN.
wanDev, wanVLAN := splitVLAN(f.WAN.Interface)
if !ifaceRe.MatchString(f.WAN.Interface) {
errs["wan.interface"] = "an interface name is required"
} else if badVLAN(f.WAN.Interface) {
errs["wan.interface"] = "the VLAN ID must be from 1 to 4094"
}
wan["interface"] = wanDev
ipv4cfg := obj(wan, "ipv4")
@@ -383,12 +422,13 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
l := obj(lans, lf.Name)
if !ifaceRe.MatchString(lf.Interface) {
errs[field("interface")] = "an interface name is required"
} else if badVLAN(lf.Interface) {
errs[field("interface")] = "the VLAN ID must be from 1 to 4094"
}
l["interface"] = lf.Interface
if vlan, ok, err := optionalInt(lf.VLAN, 1, 4094); err != nil {
errs[field("vlan")] = err.Error()
} else if ok {
l["vlan"] = vlan
lanDev, lanVLAN := splitVLAN(lf.Interface)
l["interface"] = lanDev
if lanVLAN != 0 {
l["vlan"] = lanVLAN
} else {
delete(l, "vlan")
}