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
426 lines
12 KiB
Go
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)
|
|
}
|