Files
nixos-router/pkgs/router-webui/wireguard_ui.go
T
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

426 lines
12 KiB
Go

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)
}