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:
2026-10-29 23:53:34 +01:00
co-authored by Claude Sonnet 5.5
parent 54040a1402
commit 84f6002d05
23 changed files with 2484 additions and 64 deletions
+81 -5
View File
@@ -35,6 +35,7 @@ import (
"os"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
@@ -221,6 +222,10 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
"chartW": func() float64 { return chartW },
"chartH": func() float64 { return chartH },
"join": strings.Join,
"choices": choices,
"tform": func(t WGTunnel, csrf string, isNew bool) map[string]any {
return map[string]any{"T": t, "CSRF": csrf, "New": isNew}
},
}
pages, err := a.parseTemplates()
if err != nil {
@@ -233,7 +238,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
func (a *app) parseTemplates() (map[string]*template.Template, error) {
files := map[string][]string{"overview": {"templates/dashboard.html"}}
pages := map[string]*template.Template{}
for _, page := range []string{"login", "overview", "network", "dhcp", "advanced", "update"} {
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "advanced", "update"} {
t, err := template.New("layout.html").Funcs(a.funcs).
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
if err != nil {
@@ -274,6 +279,14 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /network", a.auth(a.network))
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
mux.HandleFunc("POST /vlan", a.auth(a.vlan))
mux.HandleFunc("GET /dns", a.auth(a.dnsPage))
mux.HandleFunc("POST /dns", a.auth(a.dns))
mux.HandleFunc("GET /wireguard", a.auth(a.wireguardPage))
mux.HandleFunc("POST /wireguard/save", a.auth(a.wireguardSave))
mux.HandleFunc("POST /wireguard/delete", a.auth(a.wireguardDelete))
mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient))
mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport))
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
mux.HandleFunc("POST /dhcp", a.auth(a.dhcp))
mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
@@ -450,9 +463,72 @@ type networkData struct {
Devices []Device
}
// devices lists what the interface dropdowns offer for the form f.
func (a *app) devices(f Form) []Device {
return f.devices(Interfaces(), a.sys.loadPrefs().VLANs)
}
// vlan defines or removes a VLAN device for the dropdowns. It lives in the
// UI's preferences: nothing is rebuilt until a WAN or LAN uses it.
func (a *app) vlan(w http.ResponseWriter, r *http.Request, _ session) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
prefs := a.sys.loadPrefs()
msg := ""
switch {
case r.PostFormValue("add") != "":
parent := strings.TrimSpace(r.PostFormValue("parent"))
id, ok, perr := optionalInt(r.PostFormValue("id"), 1, 4094)
dev := fmt.Sprintf("%s.%d", parent, id)
switch {
case !ifaceRe.MatchString(parent) || strings.Contains(parent, "."):
msg = "Choose the interface the VLAN is on."
case perr != nil || !ok:
msg = "The VLAN ID must be a number from 1 to 4094."
case slices.Contains(prefs.VLANs, dev):
msg = dev + " exists already."
default:
prefs.VLANs = append(prefs.VLANs, dev)
slices.Sort(prefs.VLANs)
msg = "Added " + dev + "."
}
case r.PostFormValue("remove") != "":
dev := r.PostFormValue("remove")
for _, d := range s.form().devices(Interfaces(), prefs.VLANs) {
if d.Name == dev && !d.Removable {
msg = dev + " is in use."
}
}
if msg == "" {
prefs.VLANs = slices.DeleteFunc(prefs.VLANs, func(v string) bool { return v == dev })
msg = "Removed " + dev + "."
}
}
if err := a.sys.savePrefs(prefs); err != nil {
msg = "Saving: " + err.Error()
}
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
}
// choices are the dropdown entries for a device: all known devices, plus the
// current value if it is not among them (an interface this machine lacks).
func choices(devices []Device, current string) []string {
var out []string
for _, d := range devices {
out = append(out, d.Name)
}
if current != "" && !slices.Contains(out, current) {
out = append(out, current)
}
return out
}
func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session) {
s, err := loadSettings(a.sys.settingsPath())
p := page{Title: "Network", CSRF: sess.csrf, LoggedIn: true}
p := page{Title: "Network", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg")}
if err != nil {
p.Error = err.Error()
a.render(w, "network", p)
@@ -460,13 +536,14 @@ func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session)
}
f := s.form()
f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: f.devices(Interfaces())}
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(f)}
a.render(w, "network", p)
}
func formFromRequest(r *http.Request) Form {
f := Form{
HostName: r.PostFormValue("hostName"),
Domain: r.PostFormValue("domain"),
WAN: WANForm{
Interface: strings.TrimSpace(r.PostFormValue("wan.interface")),
Type: r.PostFormValue("wan.type"),
@@ -481,7 +558,6 @@ func formFromRequest(r *http.Request) Form {
f.LANs = append(f.LANs, LANForm{
Name: v("name"),
Interface: strings.TrimSpace(v("interface")),
VLAN: v("vlan"),
Address: v("address"),
Prefix: v("prefix"),
Internet: v("internet") == "on",
@@ -511,7 +587,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
f.WAN.PPPoEPassword = ""
f.WAN.PasswordSet = s.form().WAN.PasswordSet
p.Error = "Please correct the marked fields."
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: f.devices(Interfaces())}
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(f)}
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "network", p)
return