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
431 lines
11 KiB
Go
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
|
|
}
|