Files
nixos-router/pkgs/router-webui/wireguard_import.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

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