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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user