From 84f6002d05084e065e5e433ad6caf341f8550ca3 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Thu, 29 Oct 2026 23:53:34 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- examples/home/import-antigua-wireguard.sh | 115 ++++++ modules/installer/setup.nix | 15 +- modules/router/dhcp.nix | 11 + modules/router/dns.nix | 22 +- modules/settings/default.nix | 7 +- pkgs/router-webui/dashboard.go | 10 +- pkgs/router-webui/dhcp_test.go | 63 +++ pkgs/router-webui/dns.go | 240 ++++++++++++ pkgs/router-webui/dns_test.go | 147 +++++++ pkgs/router-webui/main.go | 86 ++++- pkgs/router-webui/settings.go | 90 +++-- pkgs/router-webui/settings_test.go | 38 +- pkgs/router-webui/static/style.css | 6 + pkgs/router-webui/templates/dns.html | 56 +++ pkgs/router-webui/templates/layout.html | 2 + pkgs/router-webui/templates/network.html | 49 ++- pkgs/router-webui/templates/wireguard.html | 139 +++++++ pkgs/router-webui/wireguard.go | 430 +++++++++++++++++++++ pkgs/router-webui/wireguard_import.go | 222 +++++++++++ pkgs/router-webui/wireguard_test.go | 364 +++++++++++++++++ pkgs/router-webui/wireguard_ui.go | 425 ++++++++++++++++++++ tests/dns.nix | 5 + tests/setup.nix | 6 +- 23 files changed, 2484 insertions(+), 64 deletions(-) create mode 100755 examples/home/import-antigua-wireguard.sh create mode 100644 pkgs/router-webui/dns.go create mode 100644 pkgs/router-webui/dns_test.go create mode 100644 pkgs/router-webui/templates/dns.html create mode 100644 pkgs/router-webui/templates/wireguard.html create mode 100644 pkgs/router-webui/wireguard.go create mode 100644 pkgs/router-webui/wireguard_import.go create mode 100644 pkgs/router-webui/wireguard_test.go create mode 100644 pkgs/router-webui/wireguard_ui.go diff --git a/examples/home/import-antigua-wireguard.sh b/examples/home/import-antigua-wireguard.sh new file mode 100755 index 0000000..4f1ac6c --- /dev/null +++ b/examples/home/import-antigua-wireguard.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Takes over antigua's three WireGuard tunnels (freifunk, family, mobile-clients) +# on a router installed from the generic installer. Run as root on the router. +# +# import-antigua-wireguard.sh KEYDIR +# +# KEYDIR holds the secret files copied from antigua's /opt/wireguard: +# freifunk.priv family.priv mobile-clients.priv troisdorf.psk mobile-clients.psk +# e.g. from antigua: +# tar -C /opt/wireguard -czf - freifunk.priv family.priv mobile-clients.priv \ +# troisdorf.psk mobile-clients.psk | ssh root@ROUTER 'mkdir -p /root/wg && tar -xzf - -C /root/wg' +# +# The keys are installed as 0600 files in /var/lib/router/wireguard; the tunnels +# (public settings only), the firewall rules between them and the LAN are +# merged into settings.json. Nothing is applied: look at the WireGuard page, +# then `router-apply` and `router-confirm`. Never run both boxes with the same +# keys at once: stop the tunnels on antigua first. +# +# The tunnels are those of examples/home, translated from antigua's +# wireguard.nix: freifunk accepts 0.0.0.0/0 there but routes just the three +# treasure-maps.com hosts (masqueraded); the road-warrior tunnel is called +# `mobile` (the interface name wg-mobile-clients would be too long). +set -euo pipefail + +state=${STATE_DIR:-/var/lib/router} +settings=${SETTINGS:-$state/config/settings.json} +keys=${1:-} +[ -d "$keys" ] || { echo "usage: $0 KEYDIR" >&2; exit 2; } +[ "$(id -u)" -eq 0 ] || [ -n "${STATE_DIR:-}" ] || { echo "must run as root" >&2; exit 1; } +command -v jq >/dev/null || { echo "jq is needed" >&2; exit 1; } +[ -f "$settings" ] || { echo "no $settings" >&2; exit 1; } + +# antigua's file -> the name it gets here. +declare -A install=( + [freifunk.priv]=freifunk.key + [family.priv]=family.key + [mobile-clients.priv]=mobile.key + [troisdorf.psk]=family-troisdorf.psk + [mobile-clients.psk]=mobile-laptop.psk +) +for f in "${!install[@]}"; do + [ -s "$keys/$f" ] || { echo "missing $keys/$f" >&2; exit 1; } + # A WireGuard key is 32 bytes in base64: 44 characters. + [[ $(tr -d '[:space:]' < "$keys/$f") =~ ^[A-Za-z0-9+/]{43}=$ ]] || { echo "$f is not a WireGuard key" >&2; exit 1; } +done +if jq -e '.router.wireguard // {} | has("freifunk") or has("family") or has("mobile")' "$settings" >/dev/null; then + echo "settings.json has a freifunk, family or mobile tunnel already; not touching it" >&2 + exit 1 +fi +lan=$(jq -r '[.router.lans[]?.ipv4.address][0] // empty' "$settings") +lan_zone=$(jq -r '[.router.lans // {} | to_entries[] | (.value.zone // .key)][0] // empty' "$settings") +[ -n "$lan" ] && [ -n "$lan_zone" ] || { echo "settings.json has no LAN" >&2; exit 1; } + +dest=$state/wireguard +install -d -m 0700 "$dest" +for f in "${!install[@]}"; do + (umask 077; tr -d '[:space:]' < "$keys/$f" > "$dest/${install[$f]}"; echo >> "$dest/${install[$f]}") +done + +cp -a "$settings" "$settings.before-wireguard-import" +jq --arg dest "$dest" --arg lan "$lan" --arg lanzone "$lan_zone" ' + .router.wireguard = ((.router.wireguard // {}) + { + freifunk: { + privateKeyFile: "\($dest)/freifunk.key", + masquerade: true, + addresses: ["10.255.1.8/32", "fd80:3ea2:e399:203a::8/128"], + peers: { supernode: { + publicKey: "5B/YTaDPVWVApUyHshJp899iXXlBy8rBqJUpYvKo+1s=", + endpoint: "5.9.220.114:42001", + persistentKeepalive: 15, + allowedIPs: ["185.90.196.65/32", "185.90.196.97/32", "185.90.196.129/32"] + } } + }, + family: { + privateKeyFile: "\($dest)/family.key", + listenPort: 36432, + allowManagement: true, + dns: true, + sourceAddresses: [$lan], + peers: { troisdorf: { + publicKey: "BsQODhcHnEN4tXn60t72iejZvgYvVNt5BDVK+69+RgM=", + endpoint: "fw-tdf.stinnesbeck.com:36432", + persistentKeepalive: 25, + presharedKeyFile: "\($dest)/family-troisdorf.psk", + allowedIPs: ["10.1.0.0/24", "fd73:f65e:6b64::/48"] + } } + }, + mobile: { + privateKeyFile: "\($dest)/mobile.key", + listenPort: 36433, + allowManagement: true, + dns: true, + peers: { laptop: { + publicKey: "Jts5u8zIs28v6RIEYCCufDDiWXf+gbElT63wUFWQxX4=", + persistentKeepalive: 25, + presharedKeyFile: "\($dest)/mobile-laptop.psk", + allowedIPs: ["10.2.0.2/32"] + } } + } + }) + # antigua trusted these interfaces completely; spelled out: the LAN and + # Troisdorf reach each other, the LAN reaches the freifunk hosts, road + # warriors reach the LAN. Nothing initiates from freifunk. + | .router.firewall.rules = ((.router.firewall.rules // []) + [ + { from: $lanzone, to: "family" }, + { from: "family", to: $lanzone }, + { from: $lanzone, to: "freifunk" }, + { from: "mobile", to: $lanzone } + ])' "$settings.before-wireguard-import" > "$settings.new" +chmod 600 "$settings.new" +mv "$settings.new" "$settings" + +echo "Imported freifunk, family and mobile (keys in $dest)." +echo "Look at the WireGuard page, then: router-apply, and router-confirm when the LAN still works." +echo "The previous settings are in $settings.before-wireguard-import. Remove $keys when done." diff --git a/modules/installer/setup.nix b/modules/installer/setup.nix index da4f1ab..2da5c3d 100644 --- a/modules/installer/setup.nix +++ b/modules/installer/setup.nix @@ -113,7 +113,7 @@ let } # Set by ask/choose (printf -v). - disk="" wan="" lan="" wan_type="" want_vlans="" want_v6="" vlan_parent="" vlan_id="" host="" lan_address="" ssh_key="" answer="" + disk="" domain="" wan="" lan="" wan_type="" want_vlans="" want_v6="" vlan_parent="" vlan_id="" host="" lan_address="" ssh_key="" answer="" webui_password="" webui_password2="" reuse="" reuse_label="" new="" work=$(mktemp -d /run/router-setup.XXXXXX) @@ -326,6 +326,12 @@ let yes_no want_v6 "Enable IPv6 (the provider delegates a prefix to the LAN)?" y echo ask host "Host name" "router" + while :; do + ask domain "Local domain for DNS and DHCP, like home.example.org (optional)" "" + [ -z "$domain" ] && break + [[ $domain =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] && break + echo " lower case letters, digits, dashes and dots" >&2 + done while :; do ask lan_address "Router address on the LAN (a /24 network)" "192.168.1.1" ipv4_ok "$lan_address" && break @@ -348,7 +354,7 @@ let echo " disk: $disk (will be erased)" echo " WAN: $wan, $wan_type''${pppoe_user:+ as $pppoe_user}, IPv6 $([ "$want_v6" = y ] && echo on || echo off)" echo " LAN: $lan, $lan_address/24, DHCP $net.100 - $net.200" - echo " host name: $host" + echo " host name: $host''${domain:+ (domain $domain)}" echo " web UI: https://$lan_address" ask answer "Type the disk name ($disk) to install" [ "$answer" = "$disk" ] || { echo "aborted" >&2; exit 1; } @@ -362,7 +368,7 @@ let keys=$( [ -n "$ssh_key" ] && jq -n --arg k "$ssh_key" '[$k]' || echo '[]') jq -n \ - --arg host "$host" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \ + --arg host "$host" --arg domain "$domain" --arg state "${cfg.stateVersion}" --argjson keys "$keys" \ --argjson schema ${toString cfg.schemaVersion} \ --arg wan "$wan_dev" --arg type "$wan_type" --arg user "$pppoe_user" --arg vlan "$wan_vlan" \ --arg lan "$lan_dev" --arg lanvlan "$lan_vlan" --arg v6 "$want_v6" \ @@ -370,7 +376,7 @@ let { version: $schema, system: { hostName: $host, stateVersion: $state, sshKeys: $keys }, - router: { + router: ({ wan: ({ interface: $wan, ipv4: { type: $type } } | if $type == "pppoe" then .ipv4.pppoe = ({ username: $user, passwordFile: "/var/lib/router/pppoe-password" } + (if $vlan == "" then {} else { vlan: ($vlan | tonumber) } end)) else . end @@ -385,6 +391,7 @@ let + (if $v6 == "y" then { ipv6: { pd: true } } else {} end)) } } + + (if $domain == "" then {} else { dns: { domain: $domain } } end)) }' > "$config/settings.json" if [ "$wan_type" = pppoe ]; then diff --git a/modules/router/dhcp.nix b/modules/router/dhcp.nix index 8e487ec..5815f33 100644 --- a/modules/router/dhcp.nix +++ b/modules/router/dhcp.nix @@ -71,6 +71,17 @@ in name = "domain-name-servers"; data = l.ipv4.address; } + ] + # The local domain: clients use it for short names (nas -> nas.home...). + ++ lib.optionals (cfg.dns.domain != null) [ + { + name = "domain-name"; + data = cfg.dns.domain; + } + { + name = "domain-search"; + data = cfg.dns.domain; + } ]; }) dhcpLans; }; diff --git a/modules/router/dns.nix b/modules/router/dns.nix index 9999cea..88bdec3 100644 --- a/modules/router/dns.nix +++ b/modules/router/dns.nix @@ -28,13 +28,29 @@ let reservations = lib.concatMap ( l: lib.mapAttrsToList (n: r: lib.nameValuePair n [ r.address ]) l.dhcp.reservations ) lans; + # The router's own name resolves to its addresses on the LANs (and their + # ULAs), so clients can reach it by name; with a domain also bare, for + # clients that have no search domain. + selfName = config.networking.hostName; + selfAddrs = + map (l: l.ipv4.address) lans + ++ map (l: addrOf l.ipv6.address) (lib.filter (l: l.ipv6.address != null) lans); + # Unless dns.hosts names the router itself. + selfDefined = lib.hasAttr selfName dns.hosts || lib.hasAttr (fqdn selfName) dns.hosts; + selfRecord = lib.optional (!selfDefined) (lib.nameValuePair (fqdn selfName) selfAddrs); + # (No reverse record for the bare name: the address has one already.) + selfBare = lib.optional (!selfDefined && fqdn selfName != selfName) ( + lib.nameValuePair selfName selfAddrs + ); + records = - lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts + selfRecord + ++ lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts ++ map (r: lib.nameValuePair (fqdn r.name) r.value) reservations ++ lib.mapAttrsToList (n: a: lib.nameValuePair n a) dns.overrides; # Unbound reads these as one quoted argument. - localData = lib.concatMap (r: map (a: ''"${r.name}. ${rrOf a} ${a}"'') r.value) records; + localData = lib.concatMap (r: map (a: ''"${r.name}. ${rrOf a} ${a}"'') r.value) (records ++ selfBare); # Reverse records for hosts and reservations (not for wildcard overrides). ptrs = lib.concatMap (r: map (a: ''"${a} ${r.name}."'') r.value) ( lib.filter (r: !(lib.hasAttr r.name dns.overrides)) records @@ -72,6 +88,8 @@ in ++ map (n: "${n} allow") dnsPeerNets; local-zone = lib.optional (domain != null) "${domain}. static" + # The router's bare name is a zone of its own (not under the domain). + ++ lib.optional (!lib.hasInfix "." selfName) "${selfName}. transparent" ++ map (n: "${n}. redirect") (lib.attrNames dns.overrides); local-data = localData; local-data-ptr = ptrs; diff --git a/modules/settings/default.nix b/modules/settings/default.nix index 69b7576..4257f8a 100644 --- a/modules/settings/default.nix +++ b/modules/settings/default.nix @@ -125,6 +125,8 @@ in environment.systemPackages = [ apply webui + pkgs.jq # for looking at and editing settings.json by hand + pkgs.wireguard-tools # wg: the tunnels' state, from a shell ]; # The web UI starts these rather than running anything itself, so a switch @@ -156,10 +158,13 @@ in description = "Router web UI"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; - # iproute2: the dashboard reads the default route and the PPP peer. + # iproute2: the dashboard reads the default route and the PPP peer; + # wireguard-tools: the tunnels' status; qrencode: client configs. path = [ config.systemd.package pkgs.iproute2 + pkgs.wireguard-tools + pkgs.qrencode ]; serviceConfig = { ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port} -config ${cfg.directory} -state ${cfg.stateDirectory}"; diff --git a/pkgs/router-webui/dashboard.go b/pkgs/router-webui/dashboard.go index 49274c7..1660b25 100644 --- a/pkgs/router-webui/dashboard.go +++ b/pkgs/router-webui/dashboard.go @@ -23,6 +23,9 @@ import ( // not in settings.json: changing them needs no rebuild. type Prefs struct { PingTargets []string `json:"pingTargets"` + // VLANs defined in the network page ("enp2s0.1001") that may not be used + // yet; the interface dropdowns offer them. + VLANs []string `json:"vlans,omitempty"` } var defaultPrefs = Prefs{PingTargets: []string{"gateway", "1.1.1.1", "9.9.9.9"}} @@ -342,9 +345,6 @@ func (a *app) dashboardData(rangeName string) dashboard { d.Charts = append(d.Charts, traffic("Internet ("+up+")", up)) for _, l := range f.LANs { dev := l.Interface - if l.VLAN != "" { - dev += "." + l.VLAN - } c := traffic("LAN "+l.Name+" ("+dev+")", dev) // From the LAN's side: what the router sends is what the LAN downloads. c.Lines[0].Label, c.Lines[1].Label = "to router", "from router" @@ -395,7 +395,9 @@ func (a *app) dashboardFragment(w http.ResponseWriter, r *http.Request, sess ses func (a *app) savePrefs(w http.ResponseWriter, r *http.Request, sess session) { targets, err := parsePingTargets(r.PostFormValue("pingTargets")) if err == nil { - err = a.sys.savePrefs(Prefs{PingTargets: targets}) + prefs := a.sys.loadPrefs() + prefs.PingTargets = targets + err = a.sys.savePrefs(prefs) } if err != nil { http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets not saved: "+err.Error()), http.StatusSeeOther) diff --git a/pkgs/router-webui/dhcp_test.go b/pkgs/router-webui/dhcp_test.go index dc9b4c0..1aa20a6 100644 --- a/pkgs/router-webui/dhcp_test.go +++ b/pkgs/router-webui/dhcp_test.go @@ -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{``, `