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