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
223 lines
5.9 KiB
Go
223 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// An imported wg-quick config (or `wg showconf` output): the tunnel it
|
|
// describes, and the options the router does not take over.
|
|
type wgImport struct {
|
|
Tunnel WGTunnel
|
|
Ignored []string
|
|
}
|
|
|
|
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
// parseWGQuick reads a wg-quick style config. The tunnel gets name; peers are
|
|
// named after the comment above their [Peer] line, else peer1, peer2, ...
|
|
func parseWGQuick(name, text string) (wgImport, error) {
|
|
imp := wgImport{Tunnel: WGTunnel{Name: name, KillSwitch: true}}
|
|
t := &imp.Tunnel
|
|
var section string
|
|
var peer *WGPeer
|
|
comment := ""
|
|
ignored := map[string]bool{}
|
|
flush := func() {
|
|
if peer != nil {
|
|
t.Peers = append(t.Peers, *peer)
|
|
peer = nil
|
|
}
|
|
}
|
|
for n, raw := range strings.Split(text, "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
switch {
|
|
case line == "":
|
|
continue
|
|
case strings.HasPrefix(line, "#"):
|
|
comment = strings.TrimSpace(strings.TrimLeft(line, "# "))
|
|
comment = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(comment, "Name ="), "name ="))
|
|
continue
|
|
case strings.EqualFold(line, "[Interface]"):
|
|
flush()
|
|
section = "interface"
|
|
comment = ""
|
|
continue
|
|
case strings.EqualFold(line, "[Peer]"):
|
|
flush()
|
|
section = "peer"
|
|
peer = &WGPeer{}
|
|
if slug := strings.Trim(slugRe.ReplaceAllString(strings.ToLower(comment), "-"), "-"); nameRe.MatchString(slug) {
|
|
peer.Name = slug
|
|
}
|
|
comment = ""
|
|
continue
|
|
}
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
return imp, fmt.Errorf("line %d is not \"key = value\"", n+1)
|
|
}
|
|
key, value = strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value)
|
|
comment = ""
|
|
switch section {
|
|
case "interface":
|
|
switch key {
|
|
case "privatekey":
|
|
t.PrivateKey = value
|
|
case "address":
|
|
for _, a := range splitList(value) {
|
|
if !strings.Contains(a, "/") {
|
|
if strings.Contains(a, ":") {
|
|
a += "/128"
|
|
} else {
|
|
a += "/32"
|
|
}
|
|
}
|
|
t.Addresses += a + ", "
|
|
}
|
|
case "listenport":
|
|
t.ListenPort = value
|
|
case "mtu":
|
|
t.MTU = value
|
|
default: // DNS, Table, FwMark, PreUp, PostUp, ...
|
|
ignored[key] = true
|
|
}
|
|
case "peer":
|
|
switch key {
|
|
case "publickey":
|
|
peer.PublicKey = value
|
|
case "presharedkey":
|
|
peer.PSK = value
|
|
case "endpoint":
|
|
peer.Endpoint = value
|
|
case "allowedips":
|
|
peer.Allowed += value + ", "
|
|
case "persistentkeepalive":
|
|
if value != "off" {
|
|
peer.Keepalive = value
|
|
}
|
|
default:
|
|
ignored[key] = true
|
|
}
|
|
default:
|
|
return imp, fmt.Errorf("line %d is outside [Interface] and [Peer]", n+1)
|
|
}
|
|
}
|
|
flush()
|
|
t.Addresses = strings.TrimSuffix(t.Addresses, ", ")
|
|
if t.PrivateKey == "" {
|
|
return imp, fmt.Errorf("the config has no [Interface] PrivateKey")
|
|
}
|
|
// Peers without a comment get numbered names.
|
|
used := map[string]bool{}
|
|
for _, p := range t.Peers {
|
|
used[p.Name] = true
|
|
}
|
|
next := 1
|
|
for i := range t.Peers {
|
|
p := &t.Peers[i]
|
|
p.Allowed = strings.TrimSuffix(p.Allowed, ", ")
|
|
if p.Name == "" || (used[p.Name] && countName(t.Peers, p.Name) > 1 && firstIndex(t.Peers, p.Name) != i) {
|
|
for {
|
|
p.Name = fmt.Sprintf("peer%d", next)
|
|
next++
|
|
if !used[p.Name] {
|
|
used[p.Name] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
for _, a := range splitList(p.Allowed) {
|
|
if pfx, err := netip.ParsePrefix(a); err == nil && pfx.Bits() == 0 {
|
|
return imp, fmt.Errorf("peer %s sends everything (%s) through the tunnel; this router routes by destination only, so list the networks you want through it instead", p.Name, a)
|
|
}
|
|
}
|
|
}
|
|
for k := range ignored {
|
|
imp.Ignored = append(imp.Ignored, k)
|
|
}
|
|
return imp, nil
|
|
}
|
|
|
|
func countName(ps []WGPeer, name string) int {
|
|
n := 0
|
|
for _, p := range ps {
|
|
if p.Name == name {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func firstIndex(ps []WGPeer, name string) int {
|
|
for i, p := range ps {
|
|
if p.Name == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
var secretLine = regexp.MustCompile(`(?im)^(\s*(?:PrivateKey|PresharedKey)\s*=).*$`)
|
|
|
|
// redact keeps secrets out of a page that is shown again after an error.
|
|
func redact(text string) string {
|
|
return secretLine.ReplaceAllString(text, "$1 (paste again)")
|
|
}
|
|
|
|
func (a *app) wireguardImport(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
|
|
}
|
|
name := strings.TrimSpace(r.PostFormValue("importName"))
|
|
text := r.PostFormValue("importText")
|
|
fail := func(msg string, status int) {
|
|
p.Error = msg
|
|
p.Data = a.wgView(s, func(d *wgData) { d.ImportName, d.ImportText = name, redact(text) })
|
|
w.WriteHeader(status)
|
|
a.render(w, "wireguard", p)
|
|
}
|
|
if !nameRe.MatchString(name) {
|
|
fail("Give the tunnel a name: lowercase letters, digits and dashes, starting with a letter.", http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
for _, t := range s.tunnels(a.sys) {
|
|
if t.Name == name {
|
|
fail("A tunnel called "+name+" exists already.", http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
}
|
|
imp, err := parseWGQuick(name, text)
|
|
if err != nil {
|
|
fail("The config cannot be imported: "+err.Error()+".", http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
files, err := s.applyTunnel(a.sys, imp.Tunnel)
|
|
if err == nil {
|
|
if err = writeSecrets(a.sys.wgDir(), files); err == nil {
|
|
err = s.save(a.sys.settingsPath())
|
|
}
|
|
}
|
|
if err != nil {
|
|
msg := "Saving: " + err.Error()
|
|
if fe, ok := err.(FormErrors); ok {
|
|
msg = "The config has a problem: " + fe.Error() + "."
|
|
}
|
|
fail(msg, http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
msg := fmt.Sprintf("Imported %s with %d peer(s). Check its options below (internet, DNS, masquerade), then apply.", name, len(imp.Tunnel.Peers))
|
|
if len(imp.Ignored) > 0 {
|
|
msg += " Not taken over: " + strings.Join(imp.Ignored, ", ") + "."
|
|
}
|
|
http.Redirect(w, r, "/wireguard?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
|
}
|