Files
nilsandClaude Sonnet 5.5 84f6002d05 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
2026-10-29 23:53:34 +01:00

431 lines
11 KiB
Go

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
}