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:
Executable
+115
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+20
-2
@@ -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;
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{`<select name="wan.interface">`, `<option value="eth1" selected>`, `<select name="lan.0.interface">`, `<option value="eth2" selected>`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("network page lacks %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `list="`) {
|
||||
t.Error("an interface is still a free text field")
|
||||
}
|
||||
|
||||
add := func(parent, id string) int {
|
||||
return do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "add": {"1"}, "parent": {parent}, "id": {id}}, cookie).Code
|
||||
}
|
||||
if add("eth2", "20") != http.StatusSeeOther || add("eth2", "99999") != http.StatusSeeOther {
|
||||
t.Fatal("add")
|
||||
}
|
||||
if got := sys.loadPrefs().VLANs; len(got) != 1 || got[0] != "eth2.20" {
|
||||
t.Fatalf("defined VLANs = %v", got)
|
||||
}
|
||||
body = do(h, "GET", "/network", nil, cookie).Body.String()
|
||||
if strings.Count(body, `<option value="eth2.20"`) != 3 { // the WAN, the LAN and the add-a-LAN row
|
||||
t.Errorf("the VLAN is not offered in both dropdowns:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `name="remove" value="eth2.20"`) {
|
||||
t.Error("an unused VLAN cannot be removed")
|
||||
}
|
||||
|
||||
// The ping targets keep the VLANs.
|
||||
do(h, "POST", "/prefs", url.Values{"csrf": {token()}, "pingTargets": {"1.1.1.1"}}, cookie)
|
||||
if len(sys.loadPrefs().VLANs) != 1 {
|
||||
t.Error("saving the ping targets dropped the VLANs")
|
||||
}
|
||||
|
||||
// Use it for a LAN: it is then in use and cannot be removed.
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
f := s.form()
|
||||
f.LANs[0].Interface = "eth2.20"
|
||||
if _, err := s.apply(f, "/pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.save(sys.settingsPath())
|
||||
do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "remove": {"eth2.20"}}, cookie)
|
||||
if len(sys.loadPrefs().VLANs) != 1 {
|
||||
t.Error("a VLAN in use was removed")
|
||||
}
|
||||
f.LANs[0].Interface = "eth2"
|
||||
s.apply(f, "/pw")
|
||||
s.save(sys.settingsPath())
|
||||
do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "remove": {"eth2.20"}}, cookie)
|
||||
if len(sys.loadPrefs().VLANs) != 0 {
|
||||
t.Error("an unused VLAN was not removed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DNSRecord is a row of the DNS page: a name and the addresses it answers
|
||||
// with (A for IPv4, AAAA for IPv6, as the addresses are). A name that starts
|
||||
// with "*." is a wildcard: the name below it and everything under it.
|
||||
type DNSRecord struct {
|
||||
Name string
|
||||
Addresses string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
var dnsNameRe = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*$`)
|
||||
|
||||
func (s Settings) dnsMap(key string) map[string]any {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
dns, _ := router["dns"].(map[string]any)
|
||||
m, _ := dns[key].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
func rowsOf(m map[string]any, prefix string) []DNSRecord {
|
||||
var out []DNSRecord
|
||||
for name, v := range m {
|
||||
addrs, _ := v.([]any)
|
||||
var list []string
|
||||
for _, a := range addrs {
|
||||
if x, ok := a.(string); ok {
|
||||
list = append(list, x)
|
||||
}
|
||||
}
|
||||
out = append(out, DNSRecord{Name: prefix + name, Addresses: strings.Join(list, ", ")})
|
||||
}
|
||||
slices.SortFunc(out, func(a, b DNSRecord) int {
|
||||
return strings.Compare(strings.TrimPrefix(a.Name, "*."), strings.TrimPrefix(b.Name, "*."))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// dnsRecords are the hosts and the wildcards (dns.hosts and dns.overrides).
|
||||
func (s Settings) dnsRecords() []DNSRecord {
|
||||
return append(rowsOf(s.dnsMap("hosts"), ""), rowsOf(s.dnsMap("overrides"), "*.")...)
|
||||
}
|
||||
|
||||
func (s Settings) forwardZones() []DNSRecord { return rowsOf(s.dnsMap("forwardZones"), "") }
|
||||
|
||||
func (s Settings) domain() string {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
dns, _ := router["dns"].(map[string]any)
|
||||
return str(dns, "domain")
|
||||
}
|
||||
|
||||
// addressList parses a row's addresses: plain IPs, no prefix length.
|
||||
func addressList(v string) ([]any, error) {
|
||||
items := splitList(v)
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("at least one IPv4 or IPv6 address")
|
||||
}
|
||||
out := make([]any, len(items))
|
||||
for i, a := range items {
|
||||
if !ipOK(a) {
|
||||
return nil, fmt.Errorf("%q is not an IP address", a)
|
||||
}
|
||||
out[i] = a
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// applyDNS replaces the records and forward zones. Names are checked like the
|
||||
// module does; a record named like a DHCP reservation is refused (the
|
||||
// reservation publishes it already).
|
||||
func (s Settings) applyDNS(records, zones []DNSRecord) error {
|
||||
errs := FormErrors{}
|
||||
domain := s.domain()
|
||||
reserved := map[string]bool{}
|
||||
for _, r := range s.reservations() {
|
||||
reserved[r.Name] = true
|
||||
}
|
||||
hosts, overrides, forward := map[string]any{}, map[string]any{}, map[string]any{}
|
||||
seen := map[string]bool{}
|
||||
for i, r := range records {
|
||||
field := func(n string) string { return fmt.Sprintf("rec[%d].%s", i, n) }
|
||||
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(r.Name)), ".")
|
||||
if name == "" && strings.TrimSpace(r.Addresses) == "" {
|
||||
continue
|
||||
}
|
||||
if r.Remove {
|
||||
continue
|
||||
}
|
||||
wild := strings.HasPrefix(name, "*.")
|
||||
base := strings.TrimPrefix(name, "*.")
|
||||
if len(base) > 253 || !dnsNameRe.MatchString(base) {
|
||||
errs[field("name")] = "a DNS name like nas.example.org, or *.example.org for everything below it"
|
||||
continue
|
||||
}
|
||||
addrs, err := addressList(r.Addresses)
|
||||
if err != nil {
|
||||
errs[field("addresses")] = err.Error()
|
||||
}
|
||||
// The key the module sees: a host without a dot lives in the domain.
|
||||
key := base
|
||||
if !wild && !strings.Contains(base, ".") {
|
||||
if domain == "" {
|
||||
errs[field("name")] = "has no dot: set a local domain on the Network page first"
|
||||
} else {
|
||||
key = base + "." + domain
|
||||
}
|
||||
}
|
||||
if seen[strconv.FormatBool(wild)+key] {
|
||||
errs[field("name")] = "used twice"
|
||||
}
|
||||
seen[strconv.FormatBool(wild)+key] = true
|
||||
if wild {
|
||||
overrides[base] = addrs
|
||||
} else {
|
||||
if reserved[base] {
|
||||
errs[field("name")] = "a DHCP reservation has this name and publishes it already"
|
||||
}
|
||||
hosts[base] = addrs
|
||||
}
|
||||
}
|
||||
for i, z := range zones {
|
||||
field := func(n string) string { return fmt.Sprintf("zone[%d].%s", i, n) }
|
||||
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(z.Name)), ".")
|
||||
if name == "" && strings.TrimSpace(z.Addresses) == "" {
|
||||
continue
|
||||
}
|
||||
if z.Remove {
|
||||
continue
|
||||
}
|
||||
if len(name) > 253 || !dnsNameRe.MatchString(name) {
|
||||
errs[field("name")] = "a domain like branch.example.org"
|
||||
continue
|
||||
}
|
||||
addrs, err := addressList(z.Addresses)
|
||||
if err != nil {
|
||||
errs[field("addresses")] = err.Error()
|
||||
}
|
||||
if _, dup := forward[name]; dup {
|
||||
errs[field("name")] = "used twice"
|
||||
}
|
||||
forward[name] = addrs
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
dns := obj(obj(s, "router"), "dns")
|
||||
for key, m := range map[string]map[string]any{"hosts": hosts, "overrides": overrides, "forwardZones": forward} {
|
||||
if len(m) == 0 {
|
||||
delete(dns, key)
|
||||
} else {
|
||||
dns[key] = m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type dnsData struct {
|
||||
Records []DNSRecord
|
||||
Zones []DNSRecord
|
||||
Errors FormErrors
|
||||
Domain string
|
||||
Auto []string // published without a record here
|
||||
}
|
||||
|
||||
func (s Settings) dnsAuto() []string {
|
||||
var out []string
|
||||
domain := s.domain()
|
||||
name := func(n string) string {
|
||||
if domain != "" && !strings.Contains(n, ".") {
|
||||
return n + "." + domain
|
||||
}
|
||||
return n
|
||||
}
|
||||
if h := s.form().HostName; h != "" {
|
||||
out = append(out, name(h)+": this router")
|
||||
}
|
||||
for _, r := range s.reservations() {
|
||||
out = append(out, name(r.Name)+": "+r.Address+" (DHCP reservation)")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) dnsPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "DNS", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg")}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "dns", p)
|
||||
return
|
||||
}
|
||||
p.Data = dnsData{Records: append(s.dnsRecords(), DNSRecord{}), Zones: append(s.forwardZones(), DNSRecord{}),
|
||||
Domain: s.domain(), Auto: s.dnsAuto()}
|
||||
a.render(w, "dns", p)
|
||||
}
|
||||
|
||||
func dnsRows(r *http.Request, prefix string) []DNSRecord {
|
||||
n, _ := strconv.Atoi(r.PostFormValue(prefix + "s"))
|
||||
var out []DNSRecord
|
||||
for i := range min(n, 1024) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("%s.%d.%s", prefix, i, k)) }
|
||||
out = append(out, DNSRecord{Name: v("name"), Addresses: v("addresses"), Remove: v("remove") == "on"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) dns(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "DNS", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "dns", p)
|
||||
return
|
||||
}
|
||||
records, zones := dnsRows(r, "rec"), dnsRows(r, "zone")
|
||||
if err := s.applyDNS(records, zones); err != nil {
|
||||
var fe FormErrors
|
||||
errors.As(err, &fe)
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = dnsData{Records: records, Zones: zones, Errors: fe, Domain: s.domain(), Auto: s.dnsAuto()}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "dns", p)
|
||||
return
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
a.render(w, "dns", p)
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -221,6 +222,10 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
"chartW": func() float64 { return chartW },
|
||||
"chartH": func() float64 { return chartH },
|
||||
"join": strings.Join,
|
||||
"choices": choices,
|
||||
"tform": func(t WGTunnel, csrf string, isNew bool) map[string]any {
|
||||
return map[string]any{"T": t, "CSRF": csrf, "New": isNew}
|
||||
},
|
||||
}
|
||||
pages, err := a.parseTemplates()
|
||||
if err != nil {
|
||||
@@ -233,7 +238,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
||||
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, page := range []string{"login", "overview", "network", "dhcp", "advanced", "update"} {
|
||||
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "advanced", "update"} {
|
||||
t, err := template.New("layout.html").Funcs(a.funcs).
|
||||
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
||||
if err != nil {
|
||||
@@ -274,6 +279,14 @@ func (a *app) routes() http.Handler {
|
||||
mux.HandleFunc("POST /network", a.auth(a.network))
|
||||
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
|
||||
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
|
||||
mux.HandleFunc("POST /vlan", a.auth(a.vlan))
|
||||
mux.HandleFunc("GET /dns", a.auth(a.dnsPage))
|
||||
mux.HandleFunc("POST /dns", a.auth(a.dns))
|
||||
mux.HandleFunc("GET /wireguard", a.auth(a.wireguardPage))
|
||||
mux.HandleFunc("POST /wireguard/save", a.auth(a.wireguardSave))
|
||||
mux.HandleFunc("POST /wireguard/delete", a.auth(a.wireguardDelete))
|
||||
mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient))
|
||||
mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport))
|
||||
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
|
||||
mux.HandleFunc("POST /dhcp", a.auth(a.dhcp))
|
||||
mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
|
||||
@@ -450,9 +463,72 @@ type networkData struct {
|
||||
Devices []Device
|
||||
}
|
||||
|
||||
// devices lists what the interface dropdowns offer for the form f.
|
||||
func (a *app) devices(f Form) []Device {
|
||||
return f.devices(Interfaces(), a.sys.loadPrefs().VLANs)
|
||||
}
|
||||
|
||||
// vlan defines or removes a VLAN device for the dropdowns. It lives in the
|
||||
// UI's preferences: nothing is rebuilt until a WAN or LAN uses it.
|
||||
func (a *app) vlan(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
prefs := a.sys.loadPrefs()
|
||||
msg := ""
|
||||
switch {
|
||||
case r.PostFormValue("add") != "":
|
||||
parent := strings.TrimSpace(r.PostFormValue("parent"))
|
||||
id, ok, perr := optionalInt(r.PostFormValue("id"), 1, 4094)
|
||||
dev := fmt.Sprintf("%s.%d", parent, id)
|
||||
switch {
|
||||
case !ifaceRe.MatchString(parent) || strings.Contains(parent, "."):
|
||||
msg = "Choose the interface the VLAN is on."
|
||||
case perr != nil || !ok:
|
||||
msg = "The VLAN ID must be a number from 1 to 4094."
|
||||
case slices.Contains(prefs.VLANs, dev):
|
||||
msg = dev + " exists already."
|
||||
default:
|
||||
prefs.VLANs = append(prefs.VLANs, dev)
|
||||
slices.Sort(prefs.VLANs)
|
||||
msg = "Added " + dev + "."
|
||||
}
|
||||
case r.PostFormValue("remove") != "":
|
||||
dev := r.PostFormValue("remove")
|
||||
for _, d := range s.form().devices(Interfaces(), prefs.VLANs) {
|
||||
if d.Name == dev && !d.Removable {
|
||||
msg = dev + " is in use."
|
||||
}
|
||||
}
|
||||
if msg == "" {
|
||||
prefs.VLANs = slices.DeleteFunc(prefs.VLANs, func(v string) bool { return v == dev })
|
||||
msg = "Removed " + dev + "."
|
||||
}
|
||||
}
|
||||
if err := a.sys.savePrefs(prefs); err != nil {
|
||||
msg = "Saving: " + err.Error()
|
||||
}
|
||||
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// choices are the dropdown entries for a device: all known devices, plus the
|
||||
// current value if it is not among them (an interface this machine lacks).
|
||||
func choices(devices []Device, current string) []string {
|
||||
var out []string
|
||||
for _, d := range devices {
|
||||
out = append(out, d.Name)
|
||||
}
|
||||
if current != "" && !slices.Contains(out, current) {
|
||||
out = append(out, current)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
p := page{Title: "Network", CSRF: sess.csrf, LoggedIn: true}
|
||||
p := page{Title: "Network", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg")}
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "network", p)
|
||||
@@ -460,13 +536,14 @@ func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session)
|
||||
}
|
||||
f := s.form()
|
||||
f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN
|
||||
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: f.devices(Interfaces())}
|
||||
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(f)}
|
||||
a.render(w, "network", p)
|
||||
}
|
||||
|
||||
func formFromRequest(r *http.Request) Form {
|
||||
f := Form{
|
||||
HostName: r.PostFormValue("hostName"),
|
||||
Domain: r.PostFormValue("domain"),
|
||||
WAN: WANForm{
|
||||
Interface: strings.TrimSpace(r.PostFormValue("wan.interface")),
|
||||
Type: r.PostFormValue("wan.type"),
|
||||
@@ -481,7 +558,6 @@ func formFromRequest(r *http.Request) Form {
|
||||
f.LANs = append(f.LANs, LANForm{
|
||||
Name: v("name"),
|
||||
Interface: strings.TrimSpace(v("interface")),
|
||||
VLAN: v("vlan"),
|
||||
Address: v("address"),
|
||||
Prefix: v("prefix"),
|
||||
Internet: v("internet") == "on",
|
||||
@@ -511,7 +587,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
f.WAN.PPPoEPassword = ""
|
||||
f.WAN.PasswordSet = s.form().WAN.PasswordSet
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: f.devices(Interfaces())}
|
||||
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(f)}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "network", p)
|
||||
return
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -89,14 +89,14 @@ func TestApplyValidates(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
f := s.form()
|
||||
f.LANs[0].Address = "not-an-ip"
|
||||
f.LANs[0].VLAN = "5000"
|
||||
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].vlan", "lans[1].name"} {
|
||||
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)
|
||||
}
|
||||
@@ -106,12 +106,12 @@ func TestApplyValidates(t *testing.T) {
|
||||
func TestApplyAddsAndRemovesLANs(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
f := s.form()
|
||||
f.LANs = append(f.LANs, LANForm{Name: "guest", Interface: "eth2", VLAN: "20", Address: "192.168.30.1",
|
||||
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].VLAN != "20" {
|
||||
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()
|
||||
@@ -153,8 +153,8 @@ func TestVLANUplinkAndIPv6(t *testing.T) {
|
||||
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"})
|
||||
if len(devs) != 3 || devs[1].Name != "enp2s0.1001" || devs[1].Role != "WAN" {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -174,3 +174,29 @@ func TestVLANUplinkAndIPv6(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,3 +68,9 @@ code { font-size: .9em; overflow-wrap: anywhere; }
|
||||
.chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); }
|
||||
.s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); }
|
||||
.s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); }
|
||||
|
||||
/* WireGuard */
|
||||
.qr svg { width: 14rem; height: 14rem; background: #fff; }
|
||||
pre { overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
|
||||
form.inline { margin-top: 0.5rem; }
|
||||
details { margin: 0.5rem 0; }
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<form method="post" action="/dns">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Records</legend>
|
||||
<input type="hidden" name="recs" value="{{len .Records}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Addresses</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Records}}
|
||||
<tr>
|
||||
<td><input name="rec.{{$i}}.name" value="{{$r.Name}}" size="30" spellcheck="false" placeholder="nas or *.k8s.example.org"></td>
|
||||
<td><input name="rec.{{$i}}.addresses" value="{{$r.Addresses}}" size="40" spellcheck="false" placeholder="192.168.10.20, fd00::20"></td>
|
||||
<td>{{if $r.Name}}<input type="checkbox" name="rec.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "addresses"}}{{with index $e (printf "rec[%d].%s" $i $f)}}<tr><td colspan="3" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">An IPv4 address becomes an A record, an IPv6 address an AAAA record; several addresses give several records.
|
||||
A name without a dot lives in the local domain{{if .Domain}} (<code>{{.Domain}}</code>){{else}} (none is set: do that on the Network page){{end}}.
|
||||
A full name such as <code>photos.example.org</code> overrides public DNS for just that name.</p>
|
||||
<p class="hint"><code>*.k8s.example.org</code> is a wildcard: <code>k8s.example.org</code> itself and every name below it
|
||||
(<code>a.k8s.example.org</code>, <code>x.y.k8s.example.org</code>) answer with these addresses.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Forward zones</legend>
|
||||
<input type="hidden" name="zones" value="{{len .Zones}}">
|
||||
<table>
|
||||
<tr><th>Domain</th><th>Resolvers</th><th>Remove</th></tr>
|
||||
{{range $i, $z := .Zones}}
|
||||
<tr>
|
||||
<td><input name="zone.{{$i}}.name" value="{{$z.Name}}" size="30" spellcheck="false" placeholder="branch.example.org"></td>
|
||||
<td><input name="zone.{{$i}}.addresses" value="{{$z.Addresses}}" size="30" spellcheck="false" placeholder="10.1.0.254"></td>
|
||||
<td>{{if $z.Name}}<input type="checkbox" name="zone.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "addresses"}}{{with index $e (printf "zone[%d].%s" $i $f)}}<tr><td colspan="3" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="hint">Names below these domains are asked from these resolvers (for example across a tunnel), not from the internet.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
{{if .Auto}}
|
||||
<section>
|
||||
<h2>Published automatically</h2>
|
||||
<ul>{{range .Auto}}<li>{{.}}</li>{{end}}</ul>
|
||||
<p class="hint">The router's own name and the DHCP reservations (edit those on the DHCP page).</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -15,6 +15,8 @@
|
||||
<a href="/">Overview</a>
|
||||
<a href="/network">Network</a>
|
||||
<a href="/dhcp">DHCP</a>
|
||||
<a href="/dns">DNS</a>
|
||||
<a href="/wireguard">WireGuard</a>
|
||||
<a href="/update">Update</a>
|
||||
<a href="/advanced">Advanced</a>
|
||||
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<form method="post" action="/vlan">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Interfaces</legend>
|
||||
<table>
|
||||
<tr><th>Interface</th><th>Kind</th><th>Used as</th><th></th></tr>
|
||||
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td>
|
||||
<td>{{if .Removable}}<button name="remove" value="{{.Name}}" class="link">remove</button>{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
<div class="sub">
|
||||
<p class="hint">Add a VLAN (a tagged network on one of the interfaces); it is then offered below.</p>
|
||||
<label>On <select name="parent">{{range .Devices}}{{if eq .Kind "physical"}}<option value="{{.Name}}">{{.Name}}</option>{{end}}{{end}}</select></label>
|
||||
<label>VLAN ID <input name="id" size="5" inputmode="numeric"></label>
|
||||
<button name="add" value="1">Add VLAN</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/network">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<datalist id="ifaces">{{range .Interfaces}}<option value="{{.}}">{{end}}</datalist>
|
||||
<datalist id="devices">{{range .Devices}}<option value="{{.Name}}">{{end}}</datalist>
|
||||
|
||||
<fieldset>
|
||||
<legend>System</legend>
|
||||
<label>Host name <input name="hostName" value="{{.Form.HostName}}"></label>
|
||||
{{with index $e "hostName"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Interfaces</legend>
|
||||
<table>
|
||||
<tr><th>Interface</th><th>Kind</th><th>Used as</th></tr>
|
||||
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
<p class="hint">A VLAN is written <code>interface.id</code>, for example <code>enp2s0.1001</code>; naming one below creates it.</p>
|
||||
<label>Local domain <input name="domain" value="{{.Form.Domain}}" placeholder="home.example.org" spellcheck="false"></label>
|
||||
{{with index $e "domain"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<p class="hint">Optional. The router answers for it, publishes DHCP reservations in it, and clients get it as
|
||||
their search domain, so <code>nas</code> finds <code>nas.home.example.org</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Uplink (WAN)</legend>
|
||||
<label>Interface <input name="wan.interface" list="devices" value="{{.Form.WAN.Interface}}" required></label>
|
||||
<label>Interface
|
||||
<select name="wan.interface">{{$wan := .Form.WAN.Interface}}
|
||||
{{range choices $.Data.Devices $wan}}<option value="{{.}}" {{if eq . $wan}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Type
|
||||
<select name="wan.type">
|
||||
@@ -47,12 +62,14 @@
|
||||
{{with index $e "lans"}}<p class="field-error">{{.}}</p>{{end}}
|
||||
<input type="hidden" name="lans" value="{{len .Form.LANs}}">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Interface</th><th>VLAN</th><th>Address</th><th>Prefix</th><th>Internet access</th><th>IPv6</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
|
||||
<tr><th>Name</th><th>Interface</th><th>Address</th><th>Prefix</th><th>Internet access</th><th>IPv6</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
|
||||
{{range $i, $l := .Form.LANs}}
|
||||
<tr>
|
||||
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
|
||||
<td><input name="lan.{{$i}}.interface" list="ifaces" value="{{$l.Interface}}" size="8"></td>
|
||||
<td><input name="lan.{{$i}}.vlan" value="{{$l.VLAN}}" size="4" inputmode="numeric"></td>
|
||||
<td><select name="lan.{{$i}}.interface">{{$cur := $l.Interface}}
|
||||
{{if not $cur}}<option value="" selected></option>{{end}}
|
||||
{{range choices $.Data.Devices $cur}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td>
|
||||
<td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td>
|
||||
<td><input type="checkbox" name="lan.{{$i}}.internet" {{if $l.Internet}}checked{{end}}></td>
|
||||
@@ -62,7 +79,7 @@
|
||||
<td><input name="lan.{{$i}}.poolEnd" value="{{$l.PoolEnd}}" size="13"></td>
|
||||
<td>{{if $l.Name}}<input type="checkbox" name="lan.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{range $f := list "name" "interface" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="10" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{{define "tunnelform"}}
|
||||
{{$t := .T}}{{$e := $t.Errors}}{{$csrf := .CSRF}}
|
||||
<form method="post" action="/wireguard/save">
|
||||
<input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<fieldset>
|
||||
<legend>{{if .New}}New tunnel{{else}}{{$t.Name}}{{end}}</legend>
|
||||
<label>Name <input name="name" value="{{$t.Name}}" {{if .New}}placeholder="family"{{else}}readonly{{end}} size="14"></label>
|
||||
{{with index $e "name"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Listen port <input name="listenPort" value="{{$t.ListenPort}}" size="6" inputmode="numeric" placeholder="none (dial-out)"></label>
|
||||
{{with index $e "listenPort"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Addresses <input name="addresses" value="{{$t.Addresses}}" size="34" placeholder="10.100.0.1/24, fd42:100::1/64"></label>
|
||||
{{with index $e "addresses"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label><input type="checkbox" name="internet" {{if $t.Internet}}checked{{end}}> Peers may reach the internet</label>
|
||||
<label><input type="checkbox" name="allowManagement" {{if $t.AllowManagement}}checked{{end}}> Peers may reach SSH on the router</label>
|
||||
<label><input type="checkbox" name="dns" {{if $t.DNS}}checked{{end}}> The router answers DNS on the tunnel address</label>
|
||||
<label><input type="checkbox" name="masquerade" {{if $t.Masquerade}}checked{{end}}> Source-NAT IPv4 leaving through this tunnel</label>
|
||||
<label><input type="checkbox" name="killSwitch" {{if $t.KillSwitch}}checked{{end}}> Kill switch: drop the peers' networks instead of letting them leave via the uplink</label>
|
||||
<details>
|
||||
<summary>More</summary>
|
||||
<label>Interface <input name="interface" value="{{$t.Interface}}" size="12" placeholder="wg-{{if $t.Name}}{{$t.Name}}{{else}}name{{end}}"></label>
|
||||
{{with index $e "interface"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Firewall zone <input name="zone" value="{{$t.Zone}}" size="12" placeholder="{{if $t.Name}}{{$t.Name}}{{else}}name{{end}}"></label>
|
||||
{{with index $e "zone"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>MTU <input name="mtu" value="{{$t.MTU}}" size="5" inputmode="numeric" placeholder="1420"></label>
|
||||
{{with index $e "mtu"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Source addresses <input name="sourceAddresses" value="{{$t.SourceAddresses}}" size="24" placeholder="192.168.10.1"></label>
|
||||
{{with index $e "sourceAddresses"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
</details>
|
||||
|
||||
<p>
|
||||
{{if $t.KeyPresent}}Public key: <code>{{$t.PublicKey}}</code>{{else if not .New}}<span class="field-error">No private key.</span>{{else}}A private key is generated for it unless you paste one.{{end}}
|
||||
</p>
|
||||
<label>Private key <input type="password" name="privateKey" autocomplete="off" size="46"
|
||||
placeholder="{{if $t.KeyPresent}}stored; paste to replace{{else}}paste an existing key (optional){{end}}"></label>
|
||||
{{with index $e "privateKey"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
{{if not .New}}<label><input type="checkbox" name="newKey"> Generate a new private key (every peer must be given the new public key)</label>{{end}}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Peers of {{if .New}}the new tunnel{{else}}{{$t.Name}}{{end}}</legend>
|
||||
<input type="hidden" name="peers" value="{{len $t.Peers}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Public key</th><th>Endpoint</th><th>Allowed IPs</th><th>Keepalive</th><th>Preshared key</th><th>Now</th><th>Remove</th></tr>
|
||||
{{range $i, $p := $t.Peers}}
|
||||
{{$st := index $t.Live $p.PublicKey}}
|
||||
<tr>
|
||||
<td><input name="peer.{{$i}}.name" value="{{$p.Name}}" size="10" placeholder="new"></td>
|
||||
<td><input name="peer.{{$i}}.publicKey" value="{{$p.PublicKey}}" size="30" spellcheck="false"></td>
|
||||
<td><input name="peer.{{$i}}.endpoint" value="{{$p.Endpoint}}" size="18" placeholder="host:port"></td>
|
||||
<td><input name="peer.{{$i}}.allowed" value="{{$p.Allowed}}" size="22"></td>
|
||||
<td><input name="peer.{{$i}}.keepalive" value="{{$p.Keepalive}}" size="4" inputmode="numeric"></td>
|
||||
<td><input type="password" name="peer.{{$i}}.psk" autocomplete="off" size="12" placeholder="{{if $p.PSKSet}}stored{{end}}">
|
||||
<label><input type="checkbox" name="peer.{{$i}}.newPsk"> new</label></td>
|
||||
<td>{{if $p.PublicKey}}{{if not $st.Handshake.IsZero}}<span class="ok">{{ago $st.Handshake}} ago</span>
|
||||
<span class="muted">{{bytes $st.RX}} in, {{bytes $st.TX}} out</span>{{else if $st.RX}}<span class="muted">no handshake</span>{{else if $t.Live}}<span class="muted">never</span>{{end}}{{end}}</td>
|
||||
<td>{{if $p.Name}}<input type="checkbox" name="peer.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "name" "publicKey" "endpoint" "allowed" "keepalive" "psk"}}{{with index $e (printf "peers[%d].%s" $i $f)}}<tr><td colspan="8" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">Fill in the last row to add a peer. Allowed IPs are the destinations sent into the tunnel
|
||||
(and what the router accepts from the peer). Pasted preshared keys are stored on the router and not shown again.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
{{if not .New}}
|
||||
<form method="post" action="/wireguard/delete" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="name" value="{{$t.Name}}">
|
||||
<label><input type="checkbox" name="confirm"> delete {{$t.Name}}</label> <button>Delete tunnel</button>
|
||||
<span class="hint">The key file stays on the router.</span>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<p class="hint">Changes are saved here and take effect when applied. Keys are made on the router and stay on it.
|
||||
{{if not .Live}}Live status is not available.{{end}}</p>
|
||||
|
||||
{{range .Tunnels}}
|
||||
<section>
|
||||
<h2>{{.Name}} <span class="muted">{{.Device}}</span></h2>
|
||||
{{template "tunnelform" (tform . $.CSRF false)}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section>
|
||||
<h2>Add a phone or laptop</h2>
|
||||
{{if .Config}}
|
||||
<div class="flash">
|
||||
<p><strong>{{.Config.Name}}</strong>: scan this with the WireGuard app or save the text as a config file.
|
||||
It contains the client's private key and is shown only now; apply the settings so the router accepts it.</p>
|
||||
{{if .Config.QR}}<div class="qr">{{.Config.QR}}</div>{{end}}
|
||||
<pre>{{.Config.Text}}</pre>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Tunnels}}
|
||||
{{$c := .Client}}{{$e := $c.Errors}}
|
||||
<form method="post" action="/wireguard/client" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<label>Tunnel <select name="tunnel">{{range .Tunnels}}<option value="{{.Name}}" {{if eq .Name $c.Tunnel}}selected{{end}}>{{.Name}}</option>{{end}}</select></label>
|
||||
{{with index $e "tunnel"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Name <input name="name" value="{{$c.Name}}" placeholder="nils-phone"></label>
|
||||
{{with index $e "name"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Client address <input name="address" value="{{$c.Address}}" placeholder="10.100.0.2"></label>
|
||||
{{with index $e "address"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Router's public address <input name="endpoint" value="{{$c.Endpoint}}" placeholder="vpn.example.org:51820" spellcheck="false"></label>
|
||||
{{with index $e "endpoint"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>Send through the tunnel <input name="allowed" value="{{$c.Allowed}}"></label>
|
||||
{{with index $e "allowed"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<label>DNS server <input name="dns" value="{{$c.DNS}}" placeholder="optional"></label>
|
||||
{{with index $e "dns"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
<button>Add and show the configuration</button>
|
||||
</form>
|
||||
{{else}}<p class="muted">Add a tunnel first.</p>{{end}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Import a tunnel</h2>
|
||||
<p class="hint">Paste an existing WireGuard config (a <code>wg-quick</code> file or <code>wg showconf</code> output).
|
||||
Its keys are stored on the router as root-only files and not shown again. Options the router has no
|
||||
equivalent for (DNS, PostUp, ...) are skipped, and a peer that sends everything (<code>0.0.0.0/0</code>) has to be
|
||||
changed to the networks you want through the tunnel.</p>
|
||||
<form method="post" action="/wireguard/import">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<label>Tunnel name <input name="importName" value="{{.ImportName}}" placeholder="family" size="14"></label>
|
||||
<textarea name="importText" rows="12" spellcheck="false" autocomplete="off" placeholder="[Interface] PrivateKey = ... Address = 10.100.0.1/24 ListenPort = 51820 [Peer] PublicKey = ... AllowedIPs = 10.100.0.2/32">{{.ImportText}}</textarea>
|
||||
<button>Import</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>New tunnel</h2>
|
||||
{{template "tunnelform" (tform .New $.CSRF true)}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,430 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WireGuard keys are 32 bytes, base64 encoded.
|
||||
|
||||
// newPrivateKey returns a fresh private key and its public key.
|
||||
func newPrivateKey() (priv, pub string, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// As `wg genkey` does.
|
||||
raw[0] &= 248
|
||||
raw[31] = raw[31]&127 | 64
|
||||
return keyPair(raw)
|
||||
}
|
||||
|
||||
func keyPair(raw []byte) (priv, pub string, err error) {
|
||||
k, err := ecdh.X25519().NewPrivateKey(raw)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw), base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), nil
|
||||
}
|
||||
|
||||
// publicKey derives the public key of a base64 private key.
|
||||
func publicKey(priv string) (string, error) {
|
||||
raw, err := decodeKey(priv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, pub, err := keyPair(raw)
|
||||
return pub, err
|
||||
}
|
||||
|
||||
func decodeKey(s string) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("not a WireGuard key (44 characters of base64)")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func newPresharedKey() (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func (s System) wgDir() string { return filepath.Join(s.StateDir, "wireguard") }
|
||||
func (s System) wgKeyPath(tunnel string) string {
|
||||
return filepath.Join(s.wgDir(), tunnel+".key")
|
||||
}
|
||||
func (s System) wgPSKPath(tunnel, peer string) string {
|
||||
return filepath.Join(s.wgDir(), tunnel+"-"+peer+".psk")
|
||||
}
|
||||
|
||||
// A secretFile is written (0600) before the settings that refer to it.
|
||||
type secretFile struct {
|
||||
Path, Content string
|
||||
}
|
||||
|
||||
func writeSecrets(dir string, files []secretFile) error {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := writeFileAtomic(f.Path, []byte(f.Content+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readKeyFile(path string) (string, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(string(data)), true
|
||||
}
|
||||
|
||||
// WGPeer is a peer of a tunnel as the page edits it.
|
||||
type WGPeer struct {
|
||||
Name string
|
||||
PublicKey string
|
||||
Endpoint string
|
||||
Allowed string // comma or newline separated CIDRs
|
||||
Keepalive string
|
||||
PSKSet bool // a preshared key file is configured
|
||||
PSK string // write-only: pasted preshared key
|
||||
NewPSK bool // generate one
|
||||
Remove bool
|
||||
}
|
||||
|
||||
// WGTunnel is a tunnel as the page edits it.
|
||||
type WGTunnel struct {
|
||||
Name string
|
||||
Interface string
|
||||
ListenPort string
|
||||
MTU string
|
||||
Addresses string // CIDRs
|
||||
SourceAddresses string
|
||||
Zone string
|
||||
Internet bool
|
||||
AllowManagement bool
|
||||
DNS bool
|
||||
Masquerade bool
|
||||
KillSwitch bool
|
||||
Peers []WGPeer
|
||||
|
||||
PublicKey string // derived from the key file
|
||||
KeyPresent bool
|
||||
NewKey bool // generate a new private key
|
||||
PrivateKey string // write-only: pasted private key
|
||||
|
||||
Live map[string]PeerStatus // by public key; nil if unknown
|
||||
Errors FormErrors
|
||||
}
|
||||
|
||||
var (
|
||||
b64KeyRe = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`)
|
||||
hostPort = regexp.MustCompile(`^(\[[0-9a-fA-F:]+\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?):[0-9]{1,5}$`)
|
||||
)
|
||||
|
||||
// splitList splits "a, b\nc" into its items.
|
||||
func splitList(v string) []string {
|
||||
return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '\n' || r == ' ' || r == '\t' || r == '\r' })
|
||||
}
|
||||
|
||||
func stringList(m map[string]any, key string) []string {
|
||||
var out []string
|
||||
items, _ := m[key].([]any)
|
||||
for _, i := range items {
|
||||
if v, ok := i.(string); ok {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tunnels reads the WireGuard tunnels, sorted by name.
|
||||
func (s Settings) tunnels(sys System) []WGTunnel {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
wgs, _ := router["wireguard"].(map[string]any)
|
||||
var names []string
|
||||
for n := range wgs {
|
||||
names = append(names, n)
|
||||
}
|
||||
slices.Sort(names)
|
||||
var out []WGTunnel
|
||||
for _, name := range names {
|
||||
w, _ := wgs[name].(map[string]any)
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
t := WGTunnel{
|
||||
Name: name,
|
||||
Interface: str(w, "interface"),
|
||||
ListenPort: str(w, "listenPort"),
|
||||
MTU: str(w, "mtu"),
|
||||
Addresses: strings.Join(stringList(w, "addresses"), ", "),
|
||||
SourceAddresses: strings.Join(stringList(w, "sourceAddresses"), ", "),
|
||||
Zone: str(w, "zone"),
|
||||
Internet: boolean(w, false, "internet"),
|
||||
AllowManagement: boolean(w, false, "allowManagement"),
|
||||
DNS: boolean(w, false, "dns"),
|
||||
Masquerade: boolean(w, false, "masquerade"),
|
||||
KillSwitch: boolean(w, true, "killSwitch"),
|
||||
}
|
||||
if key, ok := readKeyFile(str(w, "privateKeyFile")); ok {
|
||||
if pub, err := publicKey(key); err == nil {
|
||||
t.PublicKey, t.KeyPresent = pub, true
|
||||
}
|
||||
}
|
||||
peers, _ := w["peers"].(map[string]any)
|
||||
var pnames []string
|
||||
for n := range peers {
|
||||
pnames = append(pnames, n)
|
||||
}
|
||||
slices.Sort(pnames)
|
||||
for _, pn := range pnames {
|
||||
p, _ := peers[pn].(map[string]any)
|
||||
t.Peers = append(t.Peers, WGPeer{
|
||||
Name: pn,
|
||||
PublicKey: str(p, "publicKey"),
|
||||
Endpoint: str(p, "endpoint"),
|
||||
Allowed: strings.Join(stringList(p, "allowedIPs"), ", "),
|
||||
Keepalive: str(p, "persistentKeepalive"),
|
||||
PSKSet: str(p, "presharedKeyFile") != "",
|
||||
})
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cidrOK(v string) bool {
|
||||
if p, err := netip.ParsePrefix(v); err == nil {
|
||||
return p.IsValid()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ipOK(v string) bool {
|
||||
_, err := netip.ParseAddr(v)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// applyTunnel writes t into the settings (creating the tunnel if it is new)
|
||||
// and returns the key files to store. Only basic checks happen here; the
|
||||
// build validates the rest. A new tunnel gets a generated key unless one was
|
||||
// pasted, and an existing one needs a key file.
|
||||
func (s Settings) applyTunnel(sys System, t WGTunnel) ([]secretFile, error) {
|
||||
errs := FormErrors{}
|
||||
router := obj(s, "router")
|
||||
wgs := obj(router, "wireguard")
|
||||
t.Name = strings.TrimSpace(t.Name)
|
||||
if !nameRe.MatchString(t.Name) {
|
||||
errs["name"] = "lowercase letters, digits and dashes, starting with a letter"
|
||||
return nil, errs
|
||||
}
|
||||
_, existed := wgs[t.Name].(map[string]any)
|
||||
w := obj(wgs, t.Name)
|
||||
var files []secretFile
|
||||
|
||||
if t.Interface = strings.TrimSpace(t.Interface); t.Interface != "" {
|
||||
if !ifaceRe.MatchString(t.Interface) {
|
||||
errs["interface"] = "an interface name of up to 15 characters"
|
||||
}
|
||||
w["interface"] = t.Interface
|
||||
} else {
|
||||
delete(w, "interface")
|
||||
}
|
||||
if port, ok, err := optionalInt(t.ListenPort, 1, 65535); err != nil {
|
||||
errs["listenPort"] = err.Error()
|
||||
} else if ok {
|
||||
w["listenPort"] = port
|
||||
} else {
|
||||
delete(w, "listenPort")
|
||||
}
|
||||
if mtu, ok, err := optionalInt(t.MTU, 576, 9000); err != nil {
|
||||
errs["mtu"] = err.Error()
|
||||
} else if ok {
|
||||
w["mtu"] = mtu
|
||||
} else {
|
||||
delete(w, "mtu")
|
||||
}
|
||||
setList := func(key, field string, raw string, valid func(string) bool, what string) {
|
||||
items := splitList(raw)
|
||||
for _, i := range items {
|
||||
if !valid(i) {
|
||||
errs[field] = fmt.Sprintf("%q is not %s", i, what)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
delete(w, key)
|
||||
return
|
||||
}
|
||||
list := make([]any, len(items))
|
||||
for i, v := range items {
|
||||
list[i] = v
|
||||
}
|
||||
w[key] = list
|
||||
}
|
||||
setList("addresses", "addresses", t.Addresses, cidrOK, "an address like 10.100.0.1/24")
|
||||
setList("sourceAddresses", "sourceAddresses", t.SourceAddresses, ipOK, "an IP address")
|
||||
if t.Zone = strings.TrimSpace(t.Zone); t.Zone != "" && t.Zone != t.Name {
|
||||
if !nameRe.MatchString(t.Zone) {
|
||||
errs["zone"] = "lowercase letters, digits and dashes, starting with a letter"
|
||||
}
|
||||
w["zone"] = t.Zone
|
||||
} else {
|
||||
delete(w, "zone")
|
||||
}
|
||||
w["internet"] = t.Internet
|
||||
w["allowManagement"] = t.AllowManagement
|
||||
w["dns"] = t.DNS
|
||||
w["masquerade"] = t.Masquerade
|
||||
w["killSwitch"] = t.KillSwitch
|
||||
|
||||
// The private key.
|
||||
keyPath := sys.wgKeyPath(t.Name)
|
||||
switch {
|
||||
case strings.TrimSpace(t.PrivateKey) != "":
|
||||
if _, err := decodeKey(t.PrivateKey); err != nil {
|
||||
errs["privateKey"] = err.Error()
|
||||
} else {
|
||||
files = append(files, secretFile{keyPath, strings.TrimSpace(t.PrivateKey)})
|
||||
}
|
||||
case t.NewKey || !existed:
|
||||
priv, _, err := newPrivateKey()
|
||||
if err != nil {
|
||||
errs["privateKey"] = err.Error()
|
||||
}
|
||||
files = append(files, secretFile{keyPath, priv})
|
||||
default:
|
||||
if _, ok := readKeyFile(str(w, "privateKeyFile")); !ok {
|
||||
errs["privateKey"] = "this tunnel has no private key: paste one or generate a new one"
|
||||
}
|
||||
}
|
||||
if len(files) > 0 && errs["privateKey"] == "" {
|
||||
w["privateKeyFile"] = keyPath
|
||||
}
|
||||
|
||||
// Peers.
|
||||
peers := obj(w, "peers")
|
||||
keep := map[string]bool{}
|
||||
keys := map[string]bool{}
|
||||
for i, p := range t.Peers {
|
||||
field := func(n string) string { return fmt.Sprintf("peers[%d].%s", i, n) }
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" && p.PublicKey == "" && p.Endpoint == "" && p.Allowed == "" {
|
||||
continue // the "add a peer" row
|
||||
}
|
||||
if p.Remove {
|
||||
continue
|
||||
}
|
||||
if !nameRe.MatchString(p.Name) {
|
||||
errs[field("name")] = "lowercase letters, digits and dashes, starting with a letter"
|
||||
continue
|
||||
}
|
||||
if keep[p.Name] {
|
||||
errs[field("name")] = "used twice"
|
||||
continue
|
||||
}
|
||||
keep[p.Name] = true
|
||||
pm := obj(peers, p.Name)
|
||||
if p.PublicKey = strings.TrimSpace(p.PublicKey); !b64KeyRe.MatchString(p.PublicKey) {
|
||||
errs[field("publicKey")] = "a public key (44 characters of base64)"
|
||||
} else if keys[p.PublicKey] {
|
||||
errs[field("publicKey")] = "used twice"
|
||||
}
|
||||
keys[p.PublicKey] = true
|
||||
pm["publicKey"] = p.PublicKey
|
||||
if p.Endpoint = strings.TrimSpace(p.Endpoint); p.Endpoint == "" {
|
||||
delete(pm, "endpoint")
|
||||
} else if m := hostPort.MatchString(p.Endpoint); !m || portOf(p.Endpoint) > 65535 || portOf(p.Endpoint) < 1 {
|
||||
errs[field("endpoint")] = "host:port, or [IPv6]:port"
|
||||
} else {
|
||||
pm["endpoint"] = p.Endpoint
|
||||
}
|
||||
allowed := splitList(p.Allowed)
|
||||
for _, a := range allowed {
|
||||
if !cidrOK(a) {
|
||||
errs[field("allowed")] = fmt.Sprintf("%q is not an address with a prefix length like 10.100.0.2/32", a)
|
||||
}
|
||||
}
|
||||
list := make([]any, len(allowed))
|
||||
for i, v := range allowed {
|
||||
list[i] = v
|
||||
}
|
||||
pm["allowedIPs"] = list
|
||||
if ka, ok, err := optionalInt(p.Keepalive, 1, 65535); err != nil {
|
||||
errs[field("keepalive")] = err.Error()
|
||||
} else if ok {
|
||||
pm["persistentKeepalive"] = ka
|
||||
} else {
|
||||
delete(pm, "persistentKeepalive")
|
||||
}
|
||||
pskPath := sys.wgPSKPath(t.Name, p.Name)
|
||||
switch {
|
||||
case strings.TrimSpace(p.PSK) != "":
|
||||
if _, err := decodeKey(p.PSK); err != nil {
|
||||
errs[field("psk")] = err.Error()
|
||||
} else {
|
||||
files = append(files, secretFile{pskPath, strings.TrimSpace(p.PSK)})
|
||||
pm["presharedKeyFile"] = pskPath
|
||||
}
|
||||
case p.NewPSK:
|
||||
psk, err := newPresharedKey()
|
||||
if err != nil {
|
||||
errs[field("psk")] = err.Error()
|
||||
}
|
||||
files = append(files, secretFile{pskPath, psk})
|
||||
pm["presharedKeyFile"] = pskPath
|
||||
}
|
||||
}
|
||||
for n := range peers {
|
||||
if !keep[n] {
|
||||
delete(peers, n)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return nil, errs
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func portOf(endpoint string) int {
|
||||
i := strings.LastIndex(endpoint, ":")
|
||||
n, err := strconv.Atoi(endpoint[i+1:])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// deleteTunnel removes a tunnel from the settings. Its key files stay: they
|
||||
// may be a key a remote side still expects.
|
||||
func (s Settings) deleteTunnel(name string) bool {
|
||||
router := obj(s, "router")
|
||||
wgs := obj(router, "wireguard")
|
||||
if _, ok := wgs[name]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(wgs, name)
|
||||
if len(wgs) == 0 {
|
||||
delete(router, "wireguard")
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// An imported wg-quick config (or `wg showconf` output): the tunnel it
|
||||
// describes, and the options the router does not take over.
|
||||
type wgImport struct {
|
||||
Tunnel WGTunnel
|
||||
Ignored []string
|
||||
}
|
||||
|
||||
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// parseWGQuick reads a wg-quick style config. The tunnel gets name; peers are
|
||||
// named after the comment above their [Peer] line, else peer1, peer2, ...
|
||||
func parseWGQuick(name, text string) (wgImport, error) {
|
||||
imp := wgImport{Tunnel: WGTunnel{Name: name, KillSwitch: true}}
|
||||
t := &imp.Tunnel
|
||||
var section string
|
||||
var peer *WGPeer
|
||||
comment := ""
|
||||
ignored := map[string]bool{}
|
||||
flush := func() {
|
||||
if peer != nil {
|
||||
t.Peers = append(t.Peers, *peer)
|
||||
peer = nil
|
||||
}
|
||||
}
|
||||
for n, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case line == "":
|
||||
continue
|
||||
case strings.HasPrefix(line, "#"):
|
||||
comment = strings.TrimSpace(strings.TrimLeft(line, "# "))
|
||||
comment = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(comment, "Name ="), "name ="))
|
||||
continue
|
||||
case strings.EqualFold(line, "[Interface]"):
|
||||
flush()
|
||||
section = "interface"
|
||||
comment = ""
|
||||
continue
|
||||
case strings.EqualFold(line, "[Peer]"):
|
||||
flush()
|
||||
section = "peer"
|
||||
peer = &WGPeer{}
|
||||
if slug := strings.Trim(slugRe.ReplaceAllString(strings.ToLower(comment), "-"), "-"); nameRe.MatchString(slug) {
|
||||
peer.Name = slug
|
||||
}
|
||||
comment = ""
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return imp, fmt.Errorf("line %d is not \"key = value\"", n+1)
|
||||
}
|
||||
key, value = strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value)
|
||||
comment = ""
|
||||
switch section {
|
||||
case "interface":
|
||||
switch key {
|
||||
case "privatekey":
|
||||
t.PrivateKey = value
|
||||
case "address":
|
||||
for _, a := range splitList(value) {
|
||||
if !strings.Contains(a, "/") {
|
||||
if strings.Contains(a, ":") {
|
||||
a += "/128"
|
||||
} else {
|
||||
a += "/32"
|
||||
}
|
||||
}
|
||||
t.Addresses += a + ", "
|
||||
}
|
||||
case "listenport":
|
||||
t.ListenPort = value
|
||||
case "mtu":
|
||||
t.MTU = value
|
||||
default: // DNS, Table, FwMark, PreUp, PostUp, ...
|
||||
ignored[key] = true
|
||||
}
|
||||
case "peer":
|
||||
switch key {
|
||||
case "publickey":
|
||||
peer.PublicKey = value
|
||||
case "presharedkey":
|
||||
peer.PSK = value
|
||||
case "endpoint":
|
||||
peer.Endpoint = value
|
||||
case "allowedips":
|
||||
peer.Allowed += value + ", "
|
||||
case "persistentkeepalive":
|
||||
if value != "off" {
|
||||
peer.Keepalive = value
|
||||
}
|
||||
default:
|
||||
ignored[key] = true
|
||||
}
|
||||
default:
|
||||
return imp, fmt.Errorf("line %d is outside [Interface] and [Peer]", n+1)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
t.Addresses = strings.TrimSuffix(t.Addresses, ", ")
|
||||
if t.PrivateKey == "" {
|
||||
return imp, fmt.Errorf("the config has no [Interface] PrivateKey")
|
||||
}
|
||||
// Peers without a comment get numbered names.
|
||||
used := map[string]bool{}
|
||||
for _, p := range t.Peers {
|
||||
used[p.Name] = true
|
||||
}
|
||||
next := 1
|
||||
for i := range t.Peers {
|
||||
p := &t.Peers[i]
|
||||
p.Allowed = strings.TrimSuffix(p.Allowed, ", ")
|
||||
if p.Name == "" || (used[p.Name] && countName(t.Peers, p.Name) > 1 && firstIndex(t.Peers, p.Name) != i) {
|
||||
for {
|
||||
p.Name = fmt.Sprintf("peer%d", next)
|
||||
next++
|
||||
if !used[p.Name] {
|
||||
used[p.Name] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, a := range splitList(p.Allowed) {
|
||||
if pfx, err := netip.ParsePrefix(a); err == nil && pfx.Bits() == 0 {
|
||||
return imp, fmt.Errorf("peer %s sends everything (%s) through the tunnel; this router routes by destination only, so list the networks you want through it instead", p.Name, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
for k := range ignored {
|
||||
imp.Ignored = append(imp.Ignored, k)
|
||||
}
|
||||
return imp, nil
|
||||
}
|
||||
|
||||
func countName(ps []WGPeer, name string) int {
|
||||
n := 0
|
||||
for _, p := range ps {
|
||||
if p.Name == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func firstIndex(ps []WGPeer, name string) int {
|
||||
for i, p := range ps {
|
||||
if p.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
var secretLine = regexp.MustCompile(`(?im)^(\s*(?:PrivateKey|PresharedKey)\s*=).*$`)
|
||||
|
||||
// redact keeps secrets out of a page that is shown again after an error.
|
||||
func redact(text string) string {
|
||||
return secretLine.ReplaceAllString(text, "$1 (paste again)")
|
||||
}
|
||||
|
||||
func (a *app) wireguardImport(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "WireGuard", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "wireguard", p)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.PostFormValue("importName"))
|
||||
text := r.PostFormValue("importText")
|
||||
fail := func(msg string, status int) {
|
||||
p.Error = msg
|
||||
p.Data = a.wgView(s, func(d *wgData) { d.ImportName, d.ImportText = name, redact(text) })
|
||||
w.WriteHeader(status)
|
||||
a.render(w, "wireguard", p)
|
||||
}
|
||||
if !nameRe.MatchString(name) {
|
||||
fail("Give the tunnel a name: lowercase letters, digits and dashes, starting with a letter.", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
for _, t := range s.tunnels(a.sys) {
|
||||
if t.Name == name {
|
||||
fail("A tunnel called "+name+" exists already.", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
}
|
||||
imp, err := parseWGQuick(name, text)
|
||||
if err != nil {
|
||||
fail("The config cannot be imported: "+err.Error()+".", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
files, err := s.applyTunnel(a.sys, imp.Tunnel)
|
||||
if err == nil {
|
||||
if err = writeSecrets(a.sys.wgDir(), files); err == nil {
|
||||
err = s.save(a.sys.settingsPath())
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
msg := "Saving: " + err.Error()
|
||||
if fe, ok := err.(FormErrors); ok {
|
||||
msg = "The config has a problem: " + fe.Error() + "."
|
||||
}
|
||||
fail(msg, http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Imported %s with %d peer(s). Check its options below (internet, DNS, masquerade), then apply.", name, len(imp.Tunnel.Peers))
|
||||
if len(imp.Ignored) > 0 {
|
||||
msg += " Not taken over: " + strings.Join(imp.Ignored, ", ") + "."
|
||||
}
|
||||
http.Redirect(w, r, "/wireguard?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKeys(t *testing.T) {
|
||||
// RFC 7748's X25519 test vector: what a WireGuard public key is.
|
||||
pub, err := publicKey("dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=")
|
||||
if err != nil || pub != "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=" {
|
||||
t.Fatalf("public key = %q %v", pub, err)
|
||||
}
|
||||
priv, pub, err := newPrivateKey()
|
||||
if err != nil || !b64KeyRe.MatchString(priv) || !b64KeyRe.MatchString(pub) {
|
||||
t.Fatalf("new key: %q %q %v", priv, pub, err)
|
||||
}
|
||||
if again, _ := publicKey(priv); again != pub {
|
||||
t.Error("the public key does not belong to the private key")
|
||||
}
|
||||
if _, err := publicKey("short"); err == nil {
|
||||
t.Error("a bad key was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWGDump(t *testing.T) {
|
||||
dump := "wg-a\tPRIV\tPUB\t51820\toff\n" +
|
||||
"wg-a\tPEERKEY=\t(none)\t203.0.113.7:51820\t10.0.0.2/32\t1700000000\t1234\t5678\t25\n" +
|
||||
"wg-a\tOTHER=\t(none)\t(none)\t10.0.0.3/32\t0\t0\t0\toff\n"
|
||||
got := parseWGDump(dump)["wg-a"]
|
||||
if p := got["PEERKEY="]; p.RX != 1234 || p.TX != 5678 || p.Endpoint != "203.0.113.7:51820" || !p.Handshake.Equal(time.Unix(1700000000, 0)) {
|
||||
t.Errorf("peer = %+v", p)
|
||||
}
|
||||
if p := got["OTHER="]; !p.Handshake.IsZero() || p.Endpoint != "" {
|
||||
t.Errorf("idle peer = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
const peerKey = "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo="
|
||||
|
||||
func TestApplyTunnel(t *testing.T) {
|
||||
sys := System{StateDir: t.TempDir()}
|
||||
s := mustParse(t)
|
||||
files, err := s.applyTunnel(sys, WGTunnel{
|
||||
Name: "family", ListenPort: "51820", Addresses: "10.100.0.1/24, fd42:100::1/64", DNS: true, KillSwitch: true,
|
||||
Peers: []WGPeer{
|
||||
{Name: "gran", PublicKey: peerKey, Allowed: "10.100.0.2/32", Keepalive: "25", NewPSK: true},
|
||||
{}, // the empty add row
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 2 || files[0].Path != sys.wgKeyPath("family") || files[1].Path != sys.wgPSKPath("family", "gran") {
|
||||
t.Fatalf("files = %+v", files)
|
||||
}
|
||||
if err := writeSecrets(sys.wgDir(), files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st, err := os.Stat(sys.wgKeyPath("family")); err != nil || st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("key file: %v %v", st, err)
|
||||
}
|
||||
tunnels := s.tunnels(sys)
|
||||
if len(tunnels) != 2 || tunnels[0].Name != "family" || !tunnels[0].KeyPresent || tunnels[0].PublicKey == "" || !tunnels[0].DNS ||
|
||||
len(tunnels[0].Peers) != 1 || !tunnels[0].Peers[0].PSKSet {
|
||||
t.Fatalf("tunnels = %+v", tunnels)
|
||||
}
|
||||
// What else is in the settings survives (the sample has a wireguard "vpn" too).
|
||||
if got := str(s, "router", "wireguard", "vpn", "listenPort"); got != "51820" {
|
||||
t.Errorf("the other tunnel changed: %q", got)
|
||||
}
|
||||
out, _ := s.marshal()
|
||||
if strings.Contains(string(out), tunnels[0].PublicKey+"x") || strings.Contains(string(out), "PRIVATE") {
|
||||
t.Error("secrets in the settings")
|
||||
}
|
||||
|
||||
// Editing keeps the key; removing the peer row removes the peer.
|
||||
before, _ := os.ReadFile(sys.wgKeyPath("family"))
|
||||
edit := tunnels[0]
|
||||
edit.Peers[0].Remove = true
|
||||
files, err = s.applyTunnel(sys, edit)
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatalf("edit: %v %+v", err, files)
|
||||
}
|
||||
if after, _ := os.ReadFile(sys.wgKeyPath("family")); string(after) != string(before) {
|
||||
t.Error("the key changed")
|
||||
}
|
||||
if len(s.tunnels(sys)[0].Peers) != 0 {
|
||||
t.Error("peer not removed")
|
||||
}
|
||||
// A new key on request.
|
||||
edit.NewKey = true
|
||||
edit.Peers = nil
|
||||
files, _ = s.applyTunnel(sys, edit)
|
||||
if len(files) != 1 || files[0].Content == strings.TrimSpace(string(before)) {
|
||||
t.Errorf("no new key: %+v", files)
|
||||
}
|
||||
if !s.deleteTunnel("family") || s.deleteTunnel("family") {
|
||||
t.Error("delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTunnelValidates(t *testing.T) {
|
||||
sys := System{StateDir: t.TempDir()}
|
||||
for name, tun := range map[string]WGTunnel{
|
||||
"name": {Name: "Bad Name"},
|
||||
"listenPort": {Name: "a", ListenPort: "70000"},
|
||||
"addresses": {Name: "a", Addresses: "10.0.0.1"},
|
||||
"privateKey": {Name: "a", PrivateKey: "nope"},
|
||||
"peers[0].publicKey": {Name: "a", Peers: []WGPeer{{Name: "p", PublicKey: "x", Allowed: "10.0.0.2/32"}}},
|
||||
"peers[0].allowed": {Name: "a", Peers: []WGPeer{{Name: "p", PublicKey: peerKey, Allowed: "10.0.0.2"}}},
|
||||
"peers[0].endpoint": {Name: "a", Peers: []WGPeer{{Name: "p", PublicKey: peerKey, Endpoint: "host", Allowed: "10.0.0.2/32"}}},
|
||||
"peers[1].publicKey": {Name: "a", Peers: []WGPeer{{Name: "p", PublicKey: peerKey}, {Name: "q", PublicKey: peerKey}}},
|
||||
} {
|
||||
s := mustParse(t)
|
||||
_, err := s.applyTunnel(sys, tun)
|
||||
fe, ok := err.(FormErrors)
|
||||
if !ok || fe[name] == "" {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// A pasted key is stored; an existing tunnel without a key file is refused.
|
||||
s := mustParse(t)
|
||||
files, err := s.applyTunnel(sys, WGTunnel{Name: "pasted", PrivateKey: "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo="})
|
||||
if err != nil || len(files) != 1 || files[0].Content != "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=" {
|
||||
t.Fatalf("pasted: %v %+v", err, files)
|
||||
}
|
||||
if _, err := mustParse(t).applyTunnel(sys, WGTunnel{Name: "vpn"}); err == nil {
|
||||
t.Error("an existing tunnel without a key file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextClientAddress(t *testing.T) {
|
||||
tun := WGTunnel{Addresses: "10.100.0.1/24", Peers: []WGPeer{{Allowed: "10.100.0.2/32"}, {Allowed: "10.100.0.3/32"}}}
|
||||
if got := nextClientAddress(tun); got != "10.100.0.4" {
|
||||
t.Errorf("next = %q", got)
|
||||
}
|
||||
if got := nextClientAddress(WGTunnel{Addresses: "fd42::1/64"}); got != "" {
|
||||
t.Errorf("no IPv4 network: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireGuardPage(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(path string) string {
|
||||
body := do(h, "GET", path, nil, cookie).Body.String()
|
||||
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
}
|
||||
if body := do(h, "GET", "/wireguard", nil, cookie).Body.String(); !strings.Contains(body, "New tunnel") {
|
||||
t.Fatalf("no page:\n%s", body)
|
||||
}
|
||||
|
||||
// A new tunnel gets its key; a bad one is refused with the form kept.
|
||||
form := url.Values{"csrf": {token("/wireguard")}, "name": {"family"}, "listenPort": {"51820"}, "addresses": {"10.100.0.1/24"},
|
||||
"killSwitch": {"on"}, "peers": {"1"}, "peer.0.name": {"gran"}, "peer.0.publicKey": {"bad"}, "peer.0.allowed": {"10.100.0.2/32"}}
|
||||
rec := do(h, "POST", "/wireguard/save", form, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `value="gran"`) {
|
||||
t.Fatalf("bad peer: %d", rec.Code)
|
||||
}
|
||||
if _, err := os.Stat(sys.wgKeyPath("family")); err == nil {
|
||||
t.Error("a key was written for a refused form")
|
||||
}
|
||||
form.Set("peer.0.publicKey", peerKey)
|
||||
if rec := do(h, "POST", "/wireguard/save", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, err := os.Stat(sys.wgKeyPath("family")); err != nil {
|
||||
t.Fatal("no key file")
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
if str(s, "router", "wireguard", "family", "privateKeyFile") != sys.wgKeyPath("family") {
|
||||
t.Fatalf("settings: %v", s)
|
||||
}
|
||||
body := html.UnescapeString(do(h, "GET", "/wireguard", nil, cookie).Body.String())
|
||||
pub := s.tunnels(sys)[0].PublicKey
|
||||
priv, _ := os.ReadFile(sys.wgKeyPath("family"))
|
||||
if !strings.Contains(body, pub) || strings.Contains(body, strings.TrimSpace(string(priv))) {
|
||||
t.Error("the page lacks the public key (or shows the private one)")
|
||||
}
|
||||
|
||||
// A phone: the router makes its keys; the config is shown once.
|
||||
client := url.Values{"csrf": {token("/wireguard")}, "tunnel": {"family"}, "name": {"phone"}, "address": {"10.100.0.3"},
|
||||
"endpoint": {"vpn.example.org:51820"}, "allowed": {"10.100.0.0/24, 192.168.10.0/24"}, "dns": {"10.100.0.1"}}
|
||||
rec = do(h, "POST", "/wireguard/client", client, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("client: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
shown := html.UnescapeString(rec.Body.String())
|
||||
for _, want := range []string{"[Interface]", "PrivateKey = ", "Address = 10.100.0.3/32", "DNS = 10.100.0.1", "PublicKey = " + pub, "PresharedKey = ", "Endpoint = vpn.example.org:51820"} {
|
||||
if !strings.Contains(shown, want) {
|
||||
t.Errorf("config lacks %q", want)
|
||||
}
|
||||
}
|
||||
s, _ = loadSettings(sys.settingsPath())
|
||||
peer := s.tunnels(sys)[0].Peers
|
||||
if len(peer) != 2 || peer[1].Name != "phone" || !peer[1].PSKSet || peer[1].Allowed != "10.100.0.3/32" {
|
||||
t.Fatalf("peers = %+v", peer)
|
||||
}
|
||||
psk, _ := os.ReadFile(sys.wgPSKPath("family", "phone"))
|
||||
if !strings.Contains(html.UnescapeString(rec.Body.String()), strings.TrimSpace(string(psk))) {
|
||||
t.Error("the config's preshared key is not the stored one")
|
||||
}
|
||||
if st, _ := os.Stat(filepath.Join(sys.wgDir(), "family-phone.psk")); st.Mode().Perm() != 0o600 {
|
||||
t.Error("psk permissions")
|
||||
}
|
||||
if _, err := exec.LookPath("qrencode"); err == nil && !strings.Contains(rec.Body.String(), "<svg") {
|
||||
t.Error("no QR code although qrencode is available")
|
||||
}
|
||||
// Not shown again, and the address is taken now.
|
||||
if body := do(h, "GET", "/wireguard", nil, cookie).Body.String(); strings.Contains(body, "Address = 10.100.0.3/32") {
|
||||
t.Error("the client config is shown again")
|
||||
}
|
||||
client.Set("csrf", token("/wireguard"))
|
||||
client.Set("name", "tablet")
|
||||
if rec := do(h, "POST", "/wireguard/client", client, cookie); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("a used address was accepted: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Delete needs the box ticked.
|
||||
do(h, "POST", "/wireguard/delete", url.Values{"csrf": {token("/wireguard")}, "name": {"family"}}, cookie)
|
||||
if s, _ := loadSettings(sys.settingsPath()); len(s.tunnels(sys)) != 2 { // vpn and family
|
||||
t.Error("deleted without confirmation")
|
||||
}
|
||||
do(h, "POST", "/wireguard/delete", url.Values{"csrf": {token("/wireguard")}, "name": {"family"}, "confirm": {"on"}}, cookie)
|
||||
if s, _ := loadSettings(sys.settingsPath()); len(s.tunnels(sys)) != 1 {
|
||||
t.Error("not deleted")
|
||||
}
|
||||
if _, err := os.Stat(sys.wgKeyPath("family")); err != nil {
|
||||
t.Error("the key file went with the tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
// With wg available: the keys made here are the keys WireGuard derives.
|
||||
func TestKeysMatchWG(t *testing.T) {
|
||||
wg, err := exec.LookPath("wg")
|
||||
if err != nil {
|
||||
t.Skip("wg is not installed")
|
||||
}
|
||||
priv, pub, _ := newPrivateKey()
|
||||
cmd := exec.Command(wg, "pubkey")
|
||||
cmd.Stdin = strings.NewReader(priv + "\n")
|
||||
out, err := cmd.Output()
|
||||
if err != nil || strings.TrimSpace(string(out)) != pub {
|
||||
t.Fatalf("wg pubkey = %q %v, want %q", out, err, pub)
|
||||
}
|
||||
}
|
||||
|
||||
const sampleConf = `# a comment before the interface
|
||||
[Interface]
|
||||
PrivateKey = dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=
|
||||
Address = 10.100.0.1/24, fd42:100::1/64
|
||||
ListenPort = 51820
|
||||
MTU = 1380
|
||||
DNS = 10.100.0.1
|
||||
PostUp = iptables -A FORWARD -i %i -j ACCEPT
|
||||
|
||||
# Gran's laptop
|
||||
[Peer]
|
||||
PublicKey = hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=
|
||||
PresharedKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
AllowedIPs = 10.100.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = 78NK8vKabw7TOKjZ4hjW/GgDLPFI03EcKksGUGuiC0I=
|
||||
Endpoint = vpn.example.org:51820
|
||||
AllowedIPs = 10.100.0.3/32, 192.168.50.0/24
|
||||
PersistentKeepalive = 25
|
||||
`
|
||||
|
||||
func TestParseWGQuick(t *testing.T) {
|
||||
imp, err := parseWGQuick("family", sampleConf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tun := imp.Tunnel
|
||||
if tun.PrivateKey == "" || tun.ListenPort != "51820" || tun.MTU != "1380" || tun.Addresses != "10.100.0.1/24, fd42:100::1/64" {
|
||||
t.Errorf("interface = %+v", tun)
|
||||
}
|
||||
if len(tun.Peers) != 2 || tun.Peers[0].Name != "gran-s-laptop" || tun.Peers[1].Name != "peer1" {
|
||||
t.Fatalf("peers = %+v", tun.Peers)
|
||||
}
|
||||
if p := tun.Peers[1]; p.Endpoint != "vpn.example.org:51820" || p.Keepalive != "25" || p.Allowed != "10.100.0.3/32, 192.168.50.0/24" {
|
||||
t.Errorf("peer = %+v", p)
|
||||
}
|
||||
if tun.Peers[0].PSK == "" {
|
||||
t.Error("the preshared key was lost")
|
||||
}
|
||||
if strings.Join(imp.Ignored, ",") != "dns,postup" && strings.Join(imp.Ignored, ",") != "postup,dns" {
|
||||
t.Errorf("ignored = %v", imp.Ignored)
|
||||
}
|
||||
// An address without a prefix length gets one; showconf has no Address at all.
|
||||
imp, err = parseWGQuick("x", "[Interface]\nPrivateKey = k\nAddress = 10.0.0.2\n")
|
||||
if err != nil || imp.Tunnel.Addresses != "10.0.0.2/32" {
|
||||
t.Errorf("address: %q %v", imp.Tunnel.Addresses, err)
|
||||
}
|
||||
|
||||
for name, conf := range map[string]string{
|
||||
"no key": "[Interface]\nAddress = 10.0.0.1/24\n",
|
||||
"full": "[Interface]\nPrivateKey = k\n[Peer]\nPublicKey = p\nAllowedIPs = 0.0.0.0/0, ::/0\n",
|
||||
"garbage": "[Interface]\nPrivateKey = k\nnonsense\n",
|
||||
"no section": "PrivateKey = k\n",
|
||||
} {
|
||||
if _, err := parseWGQuick("x", conf); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportThroughThePage(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", "/wireguard", nil, cookie).Body.String()
|
||||
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
}
|
||||
|
||||
// A full-tunnel peer is refused, and the key is not echoed back.
|
||||
bad := strings.Replace(sampleConf, "AllowedIPs = 10.100.0.2/32", "AllowedIPs = 0.0.0.0/0", 1)
|
||||
rec := do(h, "POST", "/wireguard/import", url.Values{"csrf": {token()}, "importName": {"family"}, "importText": {bad}}, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "routes by destination only") {
|
||||
t.Fatalf("full tunnel: %d", rec.Code)
|
||||
}
|
||||
for _, secret := range []string{"dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo", "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"} {
|
||||
if strings.Contains(html.UnescapeString(rec.Body.String()), secret) {
|
||||
t.Error("a key was echoed back")
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(sys.wgKeyPath("family")); err == nil {
|
||||
t.Error("a key was stored for a refused import")
|
||||
}
|
||||
|
||||
rec = do(h, "POST", "/wireguard/import", url.Values{"csrf": {token()}, "importName": {"family"}, "importText": {sampleConf}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("import: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
if key, _ := os.ReadFile(sys.wgKeyPath("family")); strings.TrimSpace(string(key)) != "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=" {
|
||||
t.Errorf("the imported key was not stored as it was: %q", key)
|
||||
}
|
||||
if st, _ := os.Stat(sys.wgPSKPath("family", "gran-s-laptop")); st == nil || st.Mode().Perm() != 0o600 {
|
||||
t.Error("the preshared key was not stored")
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
tun := s.tunnels(sys)[0]
|
||||
if tun.Name != "family" || tun.PublicKey != "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=" || len(tun.Peers) != 2 || tun.Peers[1].Allowed != "10.100.0.3/32, 192.168.50.0/24" {
|
||||
t.Errorf("tunnel = %+v", tun)
|
||||
}
|
||||
if out, _ := s.marshal(); strings.Contains(string(out), "dwdtCnMY") || strings.Contains(string(out), "AAECAwQF") {
|
||||
t.Error("a secret is in settings.json")
|
||||
}
|
||||
if rec := do(h, "POST", "/wireguard/import", url.Values{"csrf": {token()}, "importName": {"family"}, "importText": {sampleConf}}, cookie); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("a second tunnel of the same name: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PeerStatus is what the kernel knows about a peer.
|
||||
type PeerStatus struct {
|
||||
Endpoint string
|
||||
Handshake time.Time // zero: never
|
||||
RX, TX int64
|
||||
}
|
||||
|
||||
// parseWGDump parses `wg show all dump`: per interface a line with its keys
|
||||
// and port, then one line per peer (public key, preshared key, endpoint,
|
||||
// allowed IPs, latest handshake, received, sent, keepalive), tab separated.
|
||||
func parseWGDump(dump string) map[string]map[string]PeerStatus {
|
||||
out := map[string]map[string]PeerStatus{}
|
||||
for _, line := range strings.Split(dump, "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) != 9 { // interface lines have 5 fields
|
||||
continue
|
||||
}
|
||||
hs, _ := strconv.ParseInt(f[5], 10, 64)
|
||||
rx, _ := strconv.ParseInt(f[6], 10, 64)
|
||||
tx, _ := strconv.ParseInt(f[7], 10, 64)
|
||||
p := PeerStatus{RX: rx, TX: tx}
|
||||
if f[3] != "(none)" {
|
||||
p.Endpoint = f[3]
|
||||
}
|
||||
if hs > 0 {
|
||||
p.Handshake = time.Unix(hs, 0)
|
||||
}
|
||||
if out[f[0]] == nil {
|
||||
out[f[0]] = map[string]PeerStatus{}
|
||||
}
|
||||
out[f[0]][f[1]] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wgStatus asks the kernel; without wg or the permission there is no status.
|
||||
func wgStatus() map[string]map[string]PeerStatus {
|
||||
out, err := command("wg", "show", "all", "dump")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseWGDump(out)
|
||||
}
|
||||
|
||||
// Device is the tunnel's interface name.
|
||||
func (t WGTunnel) Device() string {
|
||||
if t.Interface != "" {
|
||||
return t.Interface
|
||||
}
|
||||
return "wg-" + t.Name
|
||||
}
|
||||
|
||||
// ClientConfig is a phone's or laptop's config, shown once after it was made.
|
||||
type ClientConfig struct {
|
||||
Name string
|
||||
Text string
|
||||
QR template.HTML // an SVG, empty if qrencode is unavailable
|
||||
Tunnel string
|
||||
}
|
||||
|
||||
// ClientForm is the "add a phone or laptop" form.
|
||||
type ClientForm struct {
|
||||
Tunnel string
|
||||
Name string
|
||||
Address string
|
||||
Endpoint string
|
||||
Allowed string
|
||||
DNS string
|
||||
Errors FormErrors
|
||||
}
|
||||
|
||||
type wgData struct {
|
||||
Tunnels []WGTunnel
|
||||
New WGTunnel
|
||||
Client ClientForm
|
||||
Config *ClientConfig
|
||||
Live bool // status is available
|
||||
|
||||
ImportName, ImportText string // the import form, shown again after an error
|
||||
}
|
||||
|
||||
func renderQR(text string) template.HTML {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "qrencode", "-t", "SVG", "-m", "2", "-o", "-")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if cmd.Run() != nil {
|
||||
return ""
|
||||
}
|
||||
svg := out.String()
|
||||
if i := strings.Index(svg, "<svg"); i >= 0 {
|
||||
return template.HTML(svg[i:]) // our own tool's output for our own text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lanSubnets are the networks of the LANs, for a client's default routes.
|
||||
func (s Settings) lanSubnets() []string {
|
||||
var out []string
|
||||
for _, l := range s.form().LANs {
|
||||
a, ok := ipv4(l.Address)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bits := 24
|
||||
if n, ok, _ := optionalInt(l.Prefix, 8, 30); ok {
|
||||
bits = n
|
||||
}
|
||||
out = append(out, netip.PrefixFrom(a, bits).Masked().String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nextClientAddress suggests an unused address in the tunnel's first IPv4 network.
|
||||
func nextClientAddress(t WGTunnel) string {
|
||||
var prefix netip.Prefix
|
||||
used := map[netip.Addr]bool{}
|
||||
for _, a := range splitList(t.Addresses) {
|
||||
if p, err := netip.ParsePrefix(a); err == nil && p.Addr().Is4() {
|
||||
if !prefix.IsValid() {
|
||||
prefix = p.Masked()
|
||||
}
|
||||
used[p.Addr()] = true
|
||||
}
|
||||
}
|
||||
if !prefix.IsValid() {
|
||||
return ""
|
||||
}
|
||||
for _, p := range t.Peers {
|
||||
for _, a := range splitList(p.Allowed) {
|
||||
if pp, err := netip.ParsePrefix(a); err == nil {
|
||||
used[pp.Addr()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for a := prefix.Addr().Next(); prefix.Contains(a); a = a.Next() {
|
||||
if !used[a] && a != prefix.Addr() {
|
||||
if next := a.Next(); !prefix.Contains(next) {
|
||||
break // the broadcast address
|
||||
}
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *app) wgView(s Settings, mutate func(*wgData)) wgData {
|
||||
d := wgData{Tunnels: s.tunnels(a.sys), New: WGTunnel{KillSwitch: true, Peers: []WGPeer{{}}}}
|
||||
status := wgStatus()
|
||||
d.Live = status != nil
|
||||
for i := range d.Tunnels {
|
||||
t := &d.Tunnels[i]
|
||||
t.Peers = append(t.Peers, WGPeer{}) // the "add a peer" row
|
||||
t.Live = status[t.Device()]
|
||||
}
|
||||
if len(d.Tunnels) > 0 {
|
||||
t := d.Tunnels[0]
|
||||
d.Client = ClientForm{Tunnel: t.Name, Address: nextClientAddress(t),
|
||||
Allowed: strings.Join(append(addrNetworks(t.Addresses), s.lanSubnets()...), ", ")}
|
||||
if ip := firstV4(t.Addresses); t.DNS && ip != "" {
|
||||
d.Client.DNS = ip
|
||||
}
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&d)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func addrNetworks(addrs string) []string {
|
||||
var out []string
|
||||
for _, a := range splitList(addrs) {
|
||||
if p, err := netip.ParsePrefix(a); err == nil && p.Addr().Is4() {
|
||||
out = append(out, p.Masked().String())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstV4(addrs string) string {
|
||||
for _, a := range splitList(addrs) {
|
||||
if p, err := netip.ParsePrefix(a); err == nil && p.Addr().Is4() {
|
||||
return p.Addr().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *app) wireguardPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "WireGuard", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg")}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "wireguard", p)
|
||||
return
|
||||
}
|
||||
p.Data = a.wgView(s, nil)
|
||||
a.render(w, "wireguard", p)
|
||||
}
|
||||
|
||||
func tunnelFromRequest(r *http.Request) WGTunnel {
|
||||
v := r.PostFormValue
|
||||
t := WGTunnel{
|
||||
Name: v("name"), Interface: v("interface"), ListenPort: v("listenPort"), MTU: v("mtu"),
|
||||
Addresses: v("addresses"), SourceAddresses: v("sourceAddresses"), Zone: v("zone"),
|
||||
Internet: v("internet") == "on", AllowManagement: v("allowManagement") == "on",
|
||||
DNS: v("dns") == "on", Masquerade: v("masquerade") == "on", KillSwitch: v("killSwitch") == "on",
|
||||
NewKey: v("newKey") == "on", PrivateKey: v("privateKey"),
|
||||
}
|
||||
n, _ := strconv.Atoi(v("peers"))
|
||||
for i := range min(n, 256) {
|
||||
f := func(k string) string { return r.PostFormValue(fmt.Sprintf("peer.%d.%s", i, k)) }
|
||||
t.Peers = append(t.Peers, WGPeer{
|
||||
Name: f("name"), PublicKey: f("publicKey"), Endpoint: f("endpoint"), Allowed: f("allowed"),
|
||||
Keepalive: f("keepalive"), PSK: f("psk"), NewPSK: f("newPsk") == "on", Remove: f("remove") == "on",
|
||||
})
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (a *app) wireguardSave(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "WireGuard", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "wireguard", p)
|
||||
return
|
||||
}
|
||||
t := tunnelFromRequest(r)
|
||||
files, err := s.applyTunnel(a.sys, t)
|
||||
if err == nil {
|
||||
if err = writeSecrets(a.sys.wgDir(), files); err == nil {
|
||||
err = s.save(a.sys.settingsPath())
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
var fe FormErrors
|
||||
if !errors.As(err, &fe) {
|
||||
p.Error = "Saving: " + err.Error()
|
||||
p.Data = a.wgView(mustLoad(a.sys), nil)
|
||||
a.render(w, "wireguard", p)
|
||||
return
|
||||
}
|
||||
t.Errors, t.PrivateKey = fe, ""
|
||||
for i := range t.Peers {
|
||||
t.Peers[i].PSK = ""
|
||||
}
|
||||
old, _ := loadSettings(a.sys.settingsPath())
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = a.wgView(old, func(d *wgData) {
|
||||
for i := range d.Tunnels {
|
||||
if d.Tunnels[i].Name == strings.TrimSpace(t.Name) {
|
||||
t.PublicKey, t.KeyPresent, t.Live = d.Tunnels[i].PublicKey, d.Tunnels[i].KeyPresent, d.Tunnels[i].Live
|
||||
d.Tunnels[i] = t
|
||||
return
|
||||
}
|
||||
}
|
||||
d.New = t
|
||||
})
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "wireguard", p)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/wireguard?msg="+url.QueryEscape("Saved "+strings.TrimSpace(t.Name)+". Apply to make the changes take effect."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func mustLoad(sys System) Settings {
|
||||
s, err := loadSettings(sys.settingsPath())
|
||||
if err != nil {
|
||||
return Settings{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (a *app) wireguardDelete(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
msg := ""
|
||||
switch {
|
||||
case err != nil:
|
||||
msg = err.Error()
|
||||
case r.PostFormValue("confirm") != "on":
|
||||
msg = "Tick the box to delete the tunnel."
|
||||
case !s.deleteTunnel(r.PostFormValue("name")):
|
||||
msg = "No such tunnel."
|
||||
default:
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
msg = "Saving: " + err.Error()
|
||||
} else {
|
||||
msg = "Deleted " + r.PostFormValue("name") + " (its key file stays). Apply to make it take effect."
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/wireguard?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// wireguardClient adds a peer for a phone or laptop: the router makes the
|
||||
// client's keys and shows its configuration once.
|
||||
func (a *app) wireguardClient(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
p := page{Title: "WireGuard", CSRF: sess.csrf, LoggedIn: true}
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
a.render(w, "wireguard", p)
|
||||
return
|
||||
}
|
||||
f := ClientForm{Tunnel: r.PostFormValue("tunnel"), Name: strings.TrimSpace(r.PostFormValue("name")),
|
||||
Address: strings.TrimSpace(r.PostFormValue("address")), Endpoint: strings.TrimSpace(r.PostFormValue("endpoint")),
|
||||
Allowed: r.PostFormValue("allowed"), DNS: strings.TrimSpace(r.PostFormValue("dns")), Errors: FormErrors{}}
|
||||
|
||||
var tunnel *WGTunnel
|
||||
for _, t := range s.tunnels(a.sys) {
|
||||
if t.Name == f.Tunnel {
|
||||
tunnel = &t
|
||||
}
|
||||
}
|
||||
if tunnel == nil {
|
||||
f.Errors["tunnel"] = "choose a tunnel"
|
||||
} else if !tunnel.KeyPresent {
|
||||
f.Errors["tunnel"] = "this tunnel has no private key yet"
|
||||
}
|
||||
if !nameRe.MatchString(f.Name) {
|
||||
f.Errors["name"] = "lowercase letters, digits and dashes, starting with a letter"
|
||||
}
|
||||
addr, aerr := netip.ParseAddr(strings.SplitN(f.Address, "/", 2)[0])
|
||||
if aerr != nil {
|
||||
f.Errors["address"] = "an IP address inside the tunnel's network"
|
||||
}
|
||||
if f.Endpoint == "" || !hostPort.MatchString(f.Endpoint) || portOf(f.Endpoint) < 1 || portOf(f.Endpoint) > 65535 {
|
||||
f.Errors["endpoint"] = "the router's public name or address and port, like vpn.example.org:51820"
|
||||
}
|
||||
allowed := splitList(f.Allowed)
|
||||
if len(allowed) == 0 {
|
||||
f.Errors["allowed"] = "what the client sends through the tunnel"
|
||||
}
|
||||
for _, c := range allowed {
|
||||
if !cidrOK(c) {
|
||||
f.Errors["allowed"] = fmt.Sprintf("%q is not a network like 192.168.10.0/24", c)
|
||||
}
|
||||
}
|
||||
if f.DNS != "" && !ipOK(f.DNS) {
|
||||
f.Errors["dns"] = "an IP address"
|
||||
}
|
||||
if tunnel != nil && len(f.Errors) == 0 {
|
||||
for _, pr := range tunnel.Peers {
|
||||
if pr.Name == f.Name {
|
||||
f.Errors["name"] = "a peer of this name exists"
|
||||
}
|
||||
for _, c := range splitList(pr.Allowed) {
|
||||
if pp, err := netip.ParsePrefix(c); err == nil && pp.Contains(addr) {
|
||||
f.Errors["address"] = "already used by peer " + pr.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fail := func(msg string, status int) {
|
||||
p.Error = msg
|
||||
p.Data = a.wgView(s, func(d *wgData) { d.Client = f })
|
||||
w.WriteHeader(status)
|
||||
a.render(w, "wireguard", p)
|
||||
}
|
||||
if len(f.Errors) > 0 {
|
||||
fail("Please correct the marked fields.", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
clientPriv, clientPub, err := newPrivateKey()
|
||||
psk, err2 := newPresharedKey()
|
||||
if err != nil || err2 != nil {
|
||||
fail("Making keys failed.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prefixBits := 32
|
||||
if addr.Is6() {
|
||||
prefixBits = 128
|
||||
}
|
||||
clientNet := netip.PrefixFrom(addr, prefixBits).String()
|
||||
pskPath := a.sys.wgPSKPath(tunnel.Name, f.Name)
|
||||
router := obj(s, "router")
|
||||
wg := obj(obj(router, "wireguard"), tunnel.Name)
|
||||
peers := obj(wg, "peers")
|
||||
peers[f.Name] = map[string]any{
|
||||
"publicKey": clientPub,
|
||||
"presharedKeyFile": pskPath,
|
||||
"allowedIPs": []any{clientNet},
|
||||
}
|
||||
if err := writeSecrets(a.sys.wgDir(), []secretFile{{pskPath, psk}}); err != nil {
|
||||
fail("Storing the preshared key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
fail("Saving: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var cfg strings.Builder
|
||||
fmt.Fprintf(&cfg, "[Interface]\nPrivateKey = %s\nAddress = %s\n", clientPriv, clientNet)
|
||||
if f.DNS != "" {
|
||||
fmt.Fprintf(&cfg, "DNS = %s\n", f.DNS)
|
||||
}
|
||||
fmt.Fprintf(&cfg, "\n[Peer]\nPublicKey = %s\nPresharedKey = %s\nEndpoint = %s\nAllowedIPs = %s\nPersistentKeepalive = 25\n",
|
||||
tunnel.PublicKey, psk, f.Endpoint, strings.Join(allowed, ", "))
|
||||
p.Flash = "Added " + f.Name + ". Apply to make the tunnel accept it. Its configuration is shown here once."
|
||||
p.Data = a.wgView(s, func(d *wgData) {
|
||||
d.Config = &ClientConfig{Name: f.Name, Text: cfg.String(), QR: renderQR(cfg.String()), Tunnel: tunnel.Name}
|
||||
})
|
||||
a.render(w, "wireguard", p)
|
||||
}
|
||||
@@ -143,6 +143,11 @@ pkgs.testers.runNixOSTest {
|
||||
assert q("printer.lan.test") == "192.168.10.51"
|
||||
assert q("20.10.168.192.in-addr.arpa", "PTR") == "nas.lan.test."
|
||||
|
||||
with subtest("the router's own name resolves, with and without the domain"):
|
||||
assert q("router.lan.test") == "192.168.10.1", q("router.lan.test")
|
||||
assert q("router") == "192.168.10.1", q("router")
|
||||
assert q("1.10.168.192.in-addr.arpa", "PTR") == "router.lan.test."
|
||||
|
||||
with subtest("the local domain is authoritative: unknown names are not forwarded"):
|
||||
# Positive control: resolving through the router works at all.
|
||||
assert q("other.example.test") == "203.0.113.8"
|
||||
|
||||
+5
-1
@@ -37,6 +37,7 @@ let
|
||||
dhcp
|
||||
n
|
||||
gw
|
||||
|
||||
192.168.10.1
|
||||
hunter2hunter2
|
||||
hunter2hunter2
|
||||
@@ -58,6 +59,8 @@ let
|
||||
pppoe-secret
|
||||
|
||||
gw
|
||||
Home.test
|
||||
home.test
|
||||
192.168.10.1
|
||||
hunter2hunter2
|
||||
hunter2hunter2
|
||||
@@ -128,7 +131,7 @@ pkgs.testers.runNixOSTest {
|
||||
installer.wait_for_unit("multi-user.target")
|
||||
|
||||
with subtest("router-setup refuses to install without the disk name"):
|
||||
installer.fail("printf '/dev/vda\\nn\\neth1\\neth2\\ndhcp\\nn\\n\\n\\nhunter2hunter2\\nhunter2hunter2\\n\\nno\\n' | router-setup")
|
||||
installer.fail("printf '/dev/vda\\nn\\neth1\\neth2\\ndhcp\\nn\\n\\n\\n\\nhunter2hunter2\\nhunter2hunter2\\n\\nno\\n' | router-setup")
|
||||
installer.fail("blkid /dev/vda1")
|
||||
|
||||
with subtest("router-setup writes a PPPoE uplink on a VLAN, shared with the LAN, with IPv6"):
|
||||
@@ -142,6 +145,7 @@ pkgs.testers.runNixOSTest {
|
||||
assert r["wan"]["ipv6"] == {"enable": True, "transport": "ppp"}, s
|
||||
assert r["lans"]["lan"]["interface"] == "eth1" and r["lans"]["lan"]["ipv6"] == {"pd": True}, s
|
||||
assert "vlan" not in r["lans"]["lan"], s
|
||||
assert r["dns"]["domain"] == "home.test", s
|
||||
assert "pppoe-secret" not in s
|
||||
installer.succeed("grep -qx pppoe-secret /tmp/vlan-config/pppoe-password")
|
||||
installer.fail("blkid /dev/vda1")
|
||||
|
||||
Reference in New Issue
Block a user