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
+6 -4
View File
@@ -23,6 +23,9 @@ import (
// not in settings.json: changing them needs no rebuild.
type Prefs struct {
PingTargets []string `json:"pingTargets"`
// VLANs defined in the network page ("enp2s0.1001") that may not be used
// yet; the interface dropdowns offer them.
VLANs []string `json:"vlans,omitempty"`
}
var defaultPrefs = Prefs{PingTargets: []string{"gateway", "1.1.1.1", "9.9.9.9"}}
@@ -342,9 +345,6 @@ func (a *app) dashboardData(rangeName string) dashboard {
d.Charts = append(d.Charts, traffic("Internet ("+up+")", up))
for _, l := range f.LANs {
dev := l.Interface
if l.VLAN != "" {
dev += "." + l.VLAN
}
c := traffic("LAN "+l.Name+" ("+dev+")", dev)
// From the LAN's side: what the router sends is what the LAN downloads.
c.Lines[0].Label, c.Lines[1].Label = "to router", "from router"
@@ -395,7 +395,9 @@ func (a *app) dashboardFragment(w http.ResponseWriter, r *http.Request, sess ses
func (a *app) savePrefs(w http.ResponseWriter, r *http.Request, sess session) {
targets, err := parsePingTargets(r.PostFormValue("pingTargets"))
if err == nil {
err = a.sys.savePrefs(Prefs{PingTargets: targets})
prefs := a.sys.loadPrefs()
prefs.PingTargets = targets
err = a.sys.savePrefs(prefs)
}
if err != nil {
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets not saved: "+err.Error()), http.StatusSeeOther)
+63
View File
@@ -116,3 +116,66 @@ func TestDHCPPage(t *testing.T) {
t.Fatalf("not saved: %v", s)
}
}
func TestInterfaceDropdownsAndVLANs(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
token := func() string {
body := do(h, "GET", "/network", nil, cookie).Body.String()
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
}
body := do(h, "GET", "/network", nil, cookie).Body.String()
for _, want := range []string{`<select name="wan.interface">`, `<option value="eth1" selected>`, `<select name="lan.0.interface">`, `<option value="eth2" selected>`} {
if !strings.Contains(body, want) {
t.Errorf("network page lacks %s", want)
}
}
if strings.Contains(body, `list="`) {
t.Error("an interface is still a free text field")
}
add := func(parent, id string) int {
return do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "add": {"1"}, "parent": {parent}, "id": {id}}, cookie).Code
}
if add("eth2", "20") != http.StatusSeeOther || add("eth2", "99999") != http.StatusSeeOther {
t.Fatal("add")
}
if got := sys.loadPrefs().VLANs; len(got) != 1 || got[0] != "eth2.20" {
t.Fatalf("defined VLANs = %v", got)
}
body = do(h, "GET", "/network", nil, cookie).Body.String()
if strings.Count(body, `<option value="eth2.20"`) != 3 { // the WAN, the LAN and the add-a-LAN row
t.Errorf("the VLAN is not offered in both dropdowns:\n%s", body)
}
if !strings.Contains(body, `name="remove" value="eth2.20"`) {
t.Error("an unused VLAN cannot be removed")
}
// The ping targets keep the VLANs.
do(h, "POST", "/prefs", url.Values{"csrf": {token()}, "pingTargets": {"1.1.1.1"}}, cookie)
if len(sys.loadPrefs().VLANs) != 1 {
t.Error("saving the ping targets dropped the VLANs")
}
// Use it for a LAN: it is then in use and cannot be removed.
s, _ := loadSettings(sys.settingsPath())
f := s.form()
f.LANs[0].Interface = "eth2.20"
if _, err := s.apply(f, "/pw"); err != nil {
t.Fatal(err)
}
s.save(sys.settingsPath())
do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "remove": {"eth2.20"}}, cookie)
if len(sys.loadPrefs().VLANs) != 1 {
t.Error("a VLAN in use was removed")
}
f.LANs[0].Interface = "eth2"
s.apply(f, "/pw")
s.save(sys.settingsPath())
do(h, "POST", "/vlan", url.Values{"csrf": {token()}, "remove": {"eth2.20"}}, cookie)
if len(sys.loadPrefs().VLANs) != 0 {
t.Error("an unused VLAN was not removed")
}
}
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"errors"
"fmt"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
)
// DNSRecord is a row of the DNS page: a name and the addresses it answers
// with (A for IPv4, AAAA for IPv6, as the addresses are). A name that starts
// with "*." is a wildcard: the name below it and everything under it.
type DNSRecord struct {
Name string
Addresses string
Remove bool
}
var dnsNameRe = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*$`)
func (s Settings) dnsMap(key string) map[string]any {
router, _ := s["router"].(map[string]any)
dns, _ := router["dns"].(map[string]any)
m, _ := dns[key].(map[string]any)
return m
}
func rowsOf(m map[string]any, prefix string) []DNSRecord {
var out []DNSRecord
for name, v := range m {
addrs, _ := v.([]any)
var list []string
for _, a := range addrs {
if x, ok := a.(string); ok {
list = append(list, x)
}
}
out = append(out, DNSRecord{Name: prefix + name, Addresses: strings.Join(list, ", ")})
}
slices.SortFunc(out, func(a, b DNSRecord) int {
return strings.Compare(strings.TrimPrefix(a.Name, "*."), strings.TrimPrefix(b.Name, "*."))
})
return out
}
// dnsRecords are the hosts and the wildcards (dns.hosts and dns.overrides).
func (s Settings) dnsRecords() []DNSRecord {
return append(rowsOf(s.dnsMap("hosts"), ""), rowsOf(s.dnsMap("overrides"), "*.")...)
}
func (s Settings) forwardZones() []DNSRecord { return rowsOf(s.dnsMap("forwardZones"), "") }
func (s Settings) domain() string {
router, _ := s["router"].(map[string]any)
dns, _ := router["dns"].(map[string]any)
return str(dns, "domain")
}
// addressList parses a row's addresses: plain IPs, no prefix length.
func addressList(v string) ([]any, error) {
items := splitList(v)
if len(items) == 0 {
return nil, fmt.Errorf("at least one IPv4 or IPv6 address")
}
out := make([]any, len(items))
for i, a := range items {
if !ipOK(a) {
return nil, fmt.Errorf("%q is not an IP address", a)
}
out[i] = a
}
return out, nil
}
// applyDNS replaces the records and forward zones. Names are checked like the
// module does; a record named like a DHCP reservation is refused (the
// reservation publishes it already).
func (s Settings) applyDNS(records, zones []DNSRecord) error {
errs := FormErrors{}
domain := s.domain()
reserved := map[string]bool{}
for _, r := range s.reservations() {
reserved[r.Name] = true
}
hosts, overrides, forward := map[string]any{}, map[string]any{}, map[string]any{}
seen := map[string]bool{}
for i, r := range records {
field := func(n string) string { return fmt.Sprintf("rec[%d].%s", i, n) }
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(r.Name)), ".")
if name == "" && strings.TrimSpace(r.Addresses) == "" {
continue
}
if r.Remove {
continue
}
wild := strings.HasPrefix(name, "*.")
base := strings.TrimPrefix(name, "*.")
if len(base) > 253 || !dnsNameRe.MatchString(base) {
errs[field("name")] = "a DNS name like nas.example.org, or *.example.org for everything below it"
continue
}
addrs, err := addressList(r.Addresses)
if err != nil {
errs[field("addresses")] = err.Error()
}
// The key the module sees: a host without a dot lives in the domain.
key := base
if !wild && !strings.Contains(base, ".") {
if domain == "" {
errs[field("name")] = "has no dot: set a local domain on the Network page first"
} else {
key = base + "." + domain
}
}
if seen[strconv.FormatBool(wild)+key] {
errs[field("name")] = "used twice"
}
seen[strconv.FormatBool(wild)+key] = true
if wild {
overrides[base] = addrs
} else {
if reserved[base] {
errs[field("name")] = "a DHCP reservation has this name and publishes it already"
}
hosts[base] = addrs
}
}
for i, z := range zones {
field := func(n string) string { return fmt.Sprintf("zone[%d].%s", i, n) }
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(z.Name)), ".")
if name == "" && strings.TrimSpace(z.Addresses) == "" {
continue
}
if z.Remove {
continue
}
if len(name) > 253 || !dnsNameRe.MatchString(name) {
errs[field("name")] = "a domain like branch.example.org"
continue
}
addrs, err := addressList(z.Addresses)
if err != nil {
errs[field("addresses")] = err.Error()
}
if _, dup := forward[name]; dup {
errs[field("name")] = "used twice"
}
forward[name] = addrs
}
if len(errs) > 0 {
return errs
}
dns := obj(obj(s, "router"), "dns")
for key, m := range map[string]map[string]any{"hosts": hosts, "overrides": overrides, "forwardZones": forward} {
if len(m) == 0 {
delete(dns, key)
} else {
dns[key] = m
}
}
return nil
}
type dnsData struct {
Records []DNSRecord
Zones []DNSRecord
Errors FormErrors
Domain string
Auto []string // published without a record here
}
func (s Settings) dnsAuto() []string {
var out []string
domain := s.domain()
name := func(n string) string {
if domain != "" && !strings.Contains(n, ".") {
return n + "." + domain
}
return n
}
if h := s.form().HostName; h != "" {
out = append(out, name(h)+": this router")
}
for _, r := range s.reservations() {
out = append(out, name(r.Name)+": "+r.Address+" (DHCP reservation)")
}
return out
}
func (a *app) dnsPage(w http.ResponseWriter, r *http.Request, sess session) {
p := page{Title: "DNS", 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, "dns", p)
return
}
p.Data = dnsData{Records: append(s.dnsRecords(), DNSRecord{}), Zones: append(s.forwardZones(), DNSRecord{}),
Domain: s.domain(), Auto: s.dnsAuto()}
a.render(w, "dns", p)
}
func dnsRows(r *http.Request, prefix string) []DNSRecord {
n, _ := strconv.Atoi(r.PostFormValue(prefix + "s"))
var out []DNSRecord
for i := range min(n, 1024) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("%s.%d.%s", prefix, i, k)) }
out = append(out, DNSRecord{Name: v("name"), Addresses: v("addresses"), Remove: v("remove") == "on"})
}
return out
}
func (a *app) dns(w http.ResponseWriter, r *http.Request, sess session) {
p := page{Title: "DNS", CSRF: sess.csrf, LoggedIn: true}
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
p.Error = err.Error()
a.render(w, "dns", p)
return
}
records, zones := dnsRows(r, "rec"), dnsRows(r, "zone")
if err := s.applyDNS(records, zones); err != nil {
var fe FormErrors
errors.As(err, &fe)
p.Error = "Please correct the marked fields."
p.Data = dnsData{Records: records, Zones: zones, Errors: fe, Domain: s.domain(), Auto: s.dnsAuto()}
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "dns", p)
return
}
if err := s.save(a.sys.settingsPath()); err != nil {
p.Error = "Saving: " + err.Error()
a.render(w, "dns", p)
return
}
back(w, r, "Saved. Apply to make the changes take effect.")
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"net/http"
"net/url"
"regexp"
"strings"
"testing"
)
func withDomain(t *testing.T) Settings {
t.Helper()
s := mustParse(t)
obj(obj(s, "router"), "dns")["domain"] = "home.example.org"
obj(obj(s, "router"), "dns")["forwarders"] = []any{"1.1.1.1"}
return s
}
func TestDNSRecordsRoundTrip(t *testing.T) {
s := withDomain(t)
err := s.applyDNS([]DNSRecord{
{Name: "files", Addresses: "192.168.168.20, fd00::20"},
{Name: "photos.example.org", Addresses: "192.168.168.50"},
{Name: "*.k8s.stinnesbeck.com", Addresses: "192.168.168.254"},
{Name: "git.stinnesbeck.com.", Addresses: "10.43.162.50"}, // a trailing dot is fine
{}, // the add row
}, []DNSRecord{{Name: "tdf.stinnesbeck.com", Addresses: "10.1.0.254"}})
if err != nil {
t.Fatal(err)
}
// The wildcard is an override of the name; hosts stay hosts.
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
t.Fatalf("overrides = %v", s.dnsMap("overrides"))
}
if _, ok := s.dnsMap("hosts")["files"]; !ok || len(s.dnsMap("hosts")) != 3 {
t.Errorf("hosts = %v", s.dnsMap("hosts"))
}
rows := s.dnsRecords()
var names []string
for _, r := range rows {
names = append(names, r.Name)
}
if strings.Join(names, " ") != "files git.stinnesbeck.com photos.example.org *.k8s.stinnesbeck.com" {
t.Errorf("rows = %v", names)
}
if r := rows[0]; r.Addresses != "192.168.168.20, fd00::20" {
t.Errorf("files = %+v", r)
}
if z := s.forwardZones(); len(z) != 1 || z[0].Name != "tdf.stinnesbeck.com" {
t.Errorf("zones = %+v", z)
}
// The rest of dns survives, and removing everything removes the keys.
if s.domain() != "home.example.org" || str(s, "router", "dns", "forwarders") == "" && s["router"].(map[string]any)["dns"].(map[string]any)["forwarders"] == nil {
t.Error("domain or forwarders lost")
}
for i := range rows {
rows[i].Remove = true
}
if err := s.applyDNS(rows, nil); err != nil {
t.Fatal(err)
}
if len(s.dnsRecords()) != 0 || len(s.forwardZones()) != 0 || s.domain() == "" {
t.Errorf("after removing: %v %v %q", s.dnsRecords(), s.forwardZones(), s.domain())
}
}
func TestDNSRecordsAreChecked(t *testing.T) {
for name, c := range map[string]struct {
rec DNSRecord
want string
}{
"bad name": {DNSRecord{Name: "no good", Addresses: "10.0.0.1"}, "rec[0].name"},
"wildcard mid": {DNSRecord{Name: "a.*.example.org", Addresses: "10.0.0.1"}, "rec[0].name"},
"no address": {DNSRecord{Name: "files", Addresses: ""}, "rec[0].addresses"},
"bad address": {DNSRecord{Name: "files", Addresses: "10.0.0.999"}, "rec[0].addresses"},
"prefix": {DNSRecord{Name: "files", Addresses: "10.0.0.1/24"}, "rec[0].addresses"},
// The sample has a DHCP reservation called nas, which publishes it.
"reservation": {DNSRecord{Name: "nas", Addresses: "10.0.0.1"}, "rec[0].name"},
} {
s := withDomain(t)
err := s.applyDNS([]DNSRecord{c.rec}, nil)
if fe, ok := err.(FormErrors); !ok || fe[c.want] == "" {
t.Errorf("%s: %v", name, err)
}
}
// Two names that are the same once placed in the domain.
s := withDomain(t)
if err := s.applyDNS([]DNSRecord{{Name: "web", Addresses: "10.0.0.1"}, {Name: "web.home.example.org", Addresses: "10.0.0.2"}}, nil); err == nil {
t.Error("web and web.home.example.org were both accepted")
}
// A short name needs a domain; a name cannot be there twice.
s = mustParse(t)
err := s.applyDNS([]DNSRecord{{Name: "web", Addresses: "10.0.0.1"}}, nil)
if fe, ok := err.(FormErrors); !ok || !strings.Contains(fe["rec[0].name"], "local domain") {
t.Errorf("short name without a domain: %v", err)
}
if err := s.applyDNS([]DNSRecord{{Name: "a.example.org", Addresses: "10.0.0.1"}, {Name: "a.example.org", Addresses: "10.0.0.2"}}, nil); err == nil {
t.Error("a name twice was accepted")
}
// A wildcard and a plain name of the same name are two different things.
if err := s.applyDNS([]DNSRecord{{Name: "a.example.org", Addresses: "10.0.0.1"}, {Name: "*.a.example.org", Addresses: "10.0.0.2"}}, nil); err != nil {
t.Errorf("a name and its wildcard: %v", err)
}
if err := s.applyDNS(nil, []DNSRecord{{Name: "*.branch.test", Addresses: "10.0.0.1"}}); err == nil {
t.Error("a wildcard forward zone was accepted")
}
}
func TestDNSPage(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/dns", nil, cookie).Body.String()
if !strings.Contains(body, "Forward zones") || !strings.Contains(body, "DHCP reservation") {
t.Fatalf("page:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "recs": {"2"}, "zones": {"0"},
"rec.0.name": {"*.k8s.stinnesbeck.com"}, "rec.0.addresses": {"192.168.168.254"},
"rec.1.name": {"web"}, "rec.1.addresses": {"192.168.168.9"}}
// No local domain in the sample: the short name is refused, nothing is saved.
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("short name without domain: %d", rec.Code)
}
form.Set("rec.1.name", "web.stinnesbeck.com")
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ := loadSettings(sys.settingsPath())
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
t.Errorf("settings: %v", s["router"])
}
if body := do(h, "GET", "/dns", nil, cookie).Body.String(); !strings.Contains(body, `value="*.k8s.stinnesbeck.com"`) {
t.Error("the wildcard is not shown again")
}
// Saving the network form must keep them.
net := do(h, "GET", "/network", nil, cookie).Body.String()
nt := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(net)[1]
nf := url.Values{"csrf": {nt}, "hostName": {"gw"}, "wan.interface": {"eth1"}, "wan.type": {"dhcp"}, "lans": {"1"},
"lan.0.name": {"main"}, "lan.0.interface": {"eth2"}, "lan.0.address": {"192.168.10.1"}, "lan.0.dhcp": {"on"},
"lan.0.poolStart": {"192.168.10.100"}, "lan.0.poolEnd": {"192.168.10.199"}}
do(h, "POST", "/network", nf, cookie)
s, _ = loadSettings(sys.settingsPath())
if _, ok := s.dnsMap("overrides")["k8s.stinnesbeck.com"]; !ok {
t.Error("the network form dropped the DNS records")
}
}
+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
+65 -25
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"maps"
"net/netip"
"os"
"path/filepath"
@@ -126,6 +127,7 @@ func boolean(m map[string]any, def bool, keys ...string) bool {
// Form is what the network page edits.
type Form struct {
HostName string
Domain string // the local domain (router.dns.domain)
WAN WANForm
LANs []LANForm
}
@@ -141,8 +143,7 @@ type WANForm struct {
type LANForm struct {
Name string
Interface string
VLAN string
Interface string // a device: "eth2", or "eth2.20" for a VLAN
Address string
Prefix string
Internet bool
@@ -160,6 +161,7 @@ func (s Settings) form() Form {
}
f := Form{
HostName: str(s, "system", "hostName"),
Domain: str(router, "dns", "domain"),
WAN: WANForm{
Interface: str(router, "wan", "interface"),
Type: str(router, "wan", "ipv4", "type"),
@@ -185,10 +187,13 @@ func (s Settings) form() Form {
if l == nil {
continue
}
dev := str(l, "interface")
if vlan := str(l, "vlan"); vlan != "" {
dev += "." + vlan
}
f.LANs = append(f.LANs, LANForm{
Name: name,
Interface: str(l, "interface"),
VLAN: str(l, "vlan"),
Interface: dev,
Address: str(l, "ipv4", "address"),
Prefix: str(l, "ipv4", "prefixLength"),
Internet: boolean(l, false, "internet"),
@@ -202,8 +207,10 @@ func (s Settings) form() Form {
}
var (
nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`)
ifaceRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,15}$`)
nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`)
// A DNS name of up to 253 characters.
domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
ifaceRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,15}$`)
)
// FormErrors maps a field to what is wrong with it.
@@ -249,16 +256,31 @@ func splitVLAN(dev string) (string, int) {
return dev[:i], id
}
// badVLAN reports a device name that looks like a VLAN (interface.digits) but
// whose ID is not from 1 to 4094.
func badVLAN(dev string) bool {
i := strings.LastIndex(dev, ".")
if i <= 0 {
return false
}
if _, err := strconv.Atoi(dev[i+1:]); err != nil {
return false
}
_, id := splitVLAN(dev)
return id == 0
}
// Device is a row of the interface list: a physical NIC or a VLAN on one.
type Device struct {
Name string
Kind string // "physical" or "VLAN"
Role string // "WAN", "LAN lan", or ""
Name string
Kind string // "physical" or "VLAN"
Role string // "WAN", "LAN lan", or ""
Removable bool // a VLAN defined in the UI that nothing uses
}
// devices lists the physical interfaces plus the VLAN devices the settings
// use, each with the role it plays.
func (f Form) devices(physical []string) []Device {
// use or the UI defined (defined), each with the role it plays.
func (f Form) devices(physical, defined []string) []Device {
roles := map[string][]string{}
add := func(dev, role string) { roles[dev] = append(roles[dev], role) }
add(f.WAN.Interface, "WAN")
@@ -266,26 +288,31 @@ func (f Form) devices(physical []string) []Device {
if l.Name == "" || l.Remove {
continue
}
dev := l.Interface
if l.VLAN != "" {
dev += "." + l.VLAN
}
add(dev, "LAN "+l.Name)
add(l.Interface, "LAN "+l.Name)
}
// Interfaces the settings or the defined VLANs refer to are listed even
// when this machine lacks them (other hardware).
names := slices.Clone(physical)
var vlans []string
for dev := range roles {
if _, id := splitVLAN(dev); id != 0 && !slices.Contains(names, dev) {
var vlans, extra []string
for _, dev := range append(slices.Collect(maps.Keys(roles)), defined...) {
base, id := splitVLAN(dev)
if id != 0 && !slices.Contains(vlans, dev) {
vlans = append(vlans, dev)
}
if !slices.Contains(names, base) && !slices.Contains(extra, base) {
extra = append(extra, base)
}
}
slices.Sort(vlans)
slices.Sort(extra)
names = append(names, extra...)
var out []Device
for _, n := range names {
out = append(out, Device{Name: n, Kind: "physical", Role: strings.Join(roles[n], ", ")})
for _, v := range vlans {
if parent, _ := splitVLAN(v); parent == n {
out = append(out, Device{Name: v, Kind: "VLAN", Role: strings.Join(roles[v], ", ")})
out = append(out, Device{Name: v, Kind: "VLAN", Role: strings.Join(roles[v], ", "),
Removable: len(roles[v]) == 0 && slices.Contains(defined, v)})
}
}
}
@@ -313,11 +340,23 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
system["hostName"] = f.HostName
}
if f.Domain = strings.ToLower(strings.TrimSpace(f.Domain)); f.Domain == "" {
if dns, ok := router["dns"].(map[string]any); ok {
delete(dns, "domain")
}
} else if !domainRe.MatchString(f.Domain) {
errs["domain"] = "letters, digits and dashes, in labels separated by dots (home.example.org)"
} else {
obj(router, "dns")["domain"] = f.Domain
}
// A VLAN device (enp2s0.1001) is the physical interface plus the
// PPPoE session's VLAN.
wanDev, wanVLAN := splitVLAN(f.WAN.Interface)
if !ifaceRe.MatchString(f.WAN.Interface) {
errs["wan.interface"] = "an interface name is required"
} else if badVLAN(f.WAN.Interface) {
errs["wan.interface"] = "the VLAN ID must be from 1 to 4094"
}
wan["interface"] = wanDev
ipv4cfg := obj(wan, "ipv4")
@@ -383,12 +422,13 @@ func (s Settings) apply(f Form, passwordFile string) (string, error) {
l := obj(lans, lf.Name)
if !ifaceRe.MatchString(lf.Interface) {
errs[field("interface")] = "an interface name is required"
} else if badVLAN(lf.Interface) {
errs[field("interface")] = "the VLAN ID must be from 1 to 4094"
}
l["interface"] = lf.Interface
if vlan, ok, err := optionalInt(lf.VLAN, 1, 4094); err != nil {
errs[field("vlan")] = err.Error()
} else if ok {
l["vlan"] = vlan
lanDev, lanVLAN := splitVLAN(lf.Interface)
l["interface"] = lanDev
if lanVLAN != 0 {
l["vlan"] = lanVLAN
} else {
delete(l, "vlan")
}
+32 -6
View File
@@ -89,14 +89,14 @@ func TestApplyValidates(t *testing.T) {
s := mustParse(t)
f := s.form()
f.LANs[0].Address = "not-an-ip"
f.LANs[0].VLAN = "5000"
f.LANs[0].Interface = "eth2.5000"
f.LANs = append(f.LANs, LANForm{Name: "Bad Name", Interface: "eth3", Address: "10.0.0.1"})
_, err := s.apply(f, "/pw")
var fe FormErrors
if !errors.As(err, &fe) {
t.Fatalf("expected form errors, got %v", err)
}
for _, k := range []string{"lans[0].address", "lans[0].vlan", "lans[1].name"} {
for _, k := range []string{"lans[0].address", "lans[0].interface", "lans[1].name"} {
if fe[k] == "" {
t.Errorf("no error for %s: %v", k, fe)
}
@@ -106,12 +106,12 @@ func TestApplyValidates(t *testing.T) {
func TestApplyAddsAndRemovesLANs(t *testing.T) {
s := mustParse(t)
f := s.form()
f.LANs = append(f.LANs, LANForm{Name: "guest", Interface: "eth2", VLAN: "20", Address: "192.168.30.1",
f.LANs = append(f.LANs, LANForm{Name: "guest", Interface: "eth2.20", Address: "192.168.30.1",
DHCP: true, PoolStart: "192.168.30.100", PoolEnd: "192.168.30.199"})
if _, err := s.apply(f, "/pw"); err != nil {
t.Fatal(err)
}
if got := s.form().LANs; len(got) != 2 || got[0].Name != "guest" || got[0].VLAN != "20" {
if got := s.form().LANs; len(got) != 2 || got[0].Name != "guest" || got[0].Interface != "eth2.20" {
t.Fatalf("after adding: %+v", got)
}
f = s.form()
@@ -153,8 +153,8 @@ func TestVLANUplinkAndIPv6(t *testing.T) {
if !s.form().LANs[0].IPv6PD || s.form().WAN.Interface != "enp2s0.1001" {
t.Errorf("form does not round-trip: %+v", s.form())
}
devs := s.form().devices([]string{"enp2s0", "wlp3s0"})
if len(devs) != 3 || devs[1].Name != "enp2s0.1001" || devs[1].Role != "WAN" {
devs := s.form().devices([]string{"enp2s0", "wlp3s0"}, []string{"enp2s0.77"})
if len(devs) != 5 || !devs[2].Removable || devs[1].Name != "enp2s0.1001" || devs[1].Role != "WAN" {
t.Errorf("devices = %+v", devs)
}
@@ -174,3 +174,29 @@ func TestVLANUplinkAndIPv6(t *testing.T) {
t.Error("IPv6 still on")
}
}
func TestLocalDomain(t *testing.T) {
s := mustParse(t)
f := s.form()
f.Domain = " Home.Example.org "
if _, err := s.apply(f, "/pw"); err != nil {
t.Fatal(err)
}
if got := str(s, "router", "dns", "domain"); got != "home.example.org" {
t.Errorf("domain = %q", got)
}
if s.form().Domain != "home.example.org" {
t.Error("the form does not show it")
}
f.Domain = "bad domain"
if _, err := s.apply(f, "/pw"); err == nil {
t.Error("a domain with a space was accepted")
}
f.Domain = ""
if _, err := s.apply(f, "/pw"); err != nil {
t.Fatal(err)
}
if got := str(s, "router", "dns", "domain"); got != "" {
t.Errorf("domain not removed: %q", got)
}
}
+6
View File
@@ -68,3 +68,9 @@ code { font-size: .9em; overflow-wrap: anywhere; }
.chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); }
.s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); }
.s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); }
/* WireGuard */
.qr svg { width: 14rem; height: 14rem; background: #fff; }
pre { overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
form.inline { margin-top: 0.5rem; }
details { margin: 0.5rem 0; }
+56
View File
@@ -0,0 +1,56 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<form method="post" action="/dns">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>Records</legend>
<input type="hidden" name="recs" value="{{len .Records}}">
<div class="scroll">
<table>
<tr><th>Name</th><th>Addresses</th><th>Remove</th></tr>
{{range $i, $r := .Records}}
<tr>
<td><input name="rec.{{$i}}.name" value="{{$r.Name}}" size="30" spellcheck="false" placeholder="nas or *.k8s.example.org"></td>
<td><input name="rec.{{$i}}.addresses" value="{{$r.Addresses}}" size="40" spellcheck="false" placeholder="192.168.10.20, fd00::20"></td>
<td>{{if $r.Name}}<input type="checkbox" name="rec.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "addresses"}}{{with index $e (printf "rec[%d].%s" $i $f)}}<tr><td colspan="3" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
</div>
<p class="hint">An IPv4 address becomes an A record, an IPv6 address an AAAA record; several addresses give several records.
A name without a dot lives in the local domain{{if .Domain}} (<code>{{.Domain}}</code>){{else}} (none is set: do that on the Network page){{end}}.
A full name such as <code>photos.example.org</code> overrides public DNS for just that name.</p>
<p class="hint"><code>*.k8s.example.org</code> is a wildcard: <code>k8s.example.org</code> itself and every name below it
(<code>a.k8s.example.org</code>, <code>x.y.k8s.example.org</code>) answer with these addresses.</p>
</fieldset>
<fieldset>
<legend>Forward zones</legend>
<input type="hidden" name="zones" value="{{len .Zones}}">
<table>
<tr><th>Domain</th><th>Resolvers</th><th>Remove</th></tr>
{{range $i, $z := .Zones}}
<tr>
<td><input name="zone.{{$i}}.name" value="{{$z.Name}}" size="30" spellcheck="false" placeholder="branch.example.org"></td>
<td><input name="zone.{{$i}}.addresses" value="{{$z.Addresses}}" size="30" spellcheck="false" placeholder="10.1.0.254"></td>
<td>{{if $z.Name}}<input type="checkbox" name="zone.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "addresses"}}{{with index $e (printf "zone[%d].%s" $i $f)}}<tr><td colspan="3" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
<p class="hint">Names below these domains are asked from these resolvers (for example across a tunnel), not from the internet.</p>
</fieldset>
<button>Save</button>
</form>
{{if .Auto}}
<section>
<h2>Published automatically</h2>
<ul>{{range .Auto}}<li>{{.}}</li>{{end}}</ul>
<p class="hint">The router's own name and the DHCP reservations (edit those on the DHCP page).</p>
</section>
{{end}}
{{end}}
{{end}}
+2
View File
@@ -15,6 +15,8 @@
<a href="/">Overview</a>
<a href="/network">Network</a>
<a href="/dhcp">DHCP</a>
<a href="/dns">DNS</a>
<a href="/wireguard">WireGuard</a>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
+33 -16
View File
@@ -1,29 +1,44 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<form method="post" action="/vlan">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>Interfaces</legend>
<table>
<tr><th>Interface</th><th>Kind</th><th>Used as</th><th></th></tr>
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td>
<td>{{if .Removable}}<button name="remove" value="{{.Name}}" class="link">remove</button>{{end}}</td></tr>{{end}}
</table>
<div class="sub">
<p class="hint">Add a VLAN (a tagged network on one of the interfaces); it is then offered below.</p>
<label>On <select name="parent">{{range .Devices}}{{if eq .Kind "physical"}}<option value="{{.Name}}">{{.Name}}</option>{{end}}{{end}}</select></label>
<label>VLAN ID <input name="id" size="5" inputmode="numeric"></label>
<button name="add" value="1">Add VLAN</button>
</div>
</fieldset>
</form>
<form method="post" action="/network">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<datalist id="ifaces">{{range .Interfaces}}<option value="{{.}}">{{end}}</datalist>
<datalist id="devices">{{range .Devices}}<option value="{{.Name}}">{{end}}</datalist>
<fieldset>
<legend>System</legend>
<label>Host name <input name="hostName" value="{{.Form.HostName}}"></label>
{{with index $e "hostName"}}<span class="field-error">{{.}}</span>{{end}}
</fieldset>
<fieldset>
<legend>Interfaces</legend>
<table>
<tr><th>Interface</th><th>Kind</th><th>Used as</th></tr>
{{range .Devices}}<tr><td>{{.Name}}</td><td>{{.Kind}}</td><td>{{if .Role}}{{.Role}}{{else}}<span class="hint">unused</span>{{end}}</td></tr>{{end}}
</table>
<p class="hint">A VLAN is written <code>interface.id</code>, for example <code>enp2s0.1001</code>; naming one below creates it.</p>
<label>Local domain <input name="domain" value="{{.Form.Domain}}" placeholder="home.example.org" spellcheck="false"></label>
{{with index $e "domain"}}<span class="field-error">{{.}}</span>{{end}}
<p class="hint">Optional. The router answers for it, publishes DHCP reservations in it, and clients get it as
their search domain, so <code>nas</code> finds <code>nas.home.example.org</code>.</p>
</fieldset>
<fieldset>
<legend>Uplink (WAN)</legend>
<label>Interface <input name="wan.interface" list="devices" value="{{.Form.WAN.Interface}}" required></label>
<label>Interface
<select name="wan.interface">{{$wan := .Form.WAN.Interface}}
{{range choices $.Data.Devices $wan}}<option value="{{.}}" {{if eq . $wan}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
<label>Type
<select name="wan.type">
@@ -47,12 +62,14 @@
{{with index $e "lans"}}<p class="field-error">{{.}}</p>{{end}}
<input type="hidden" name="lans" value="{{len .Form.LANs}}">
<table>
<tr><th>Name</th><th>Interface</th><th>VLAN</th><th>Address</th><th>Prefix</th><th>Internet access</th><th>IPv6</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
<tr><th>Name</th><th>Interface</th><th>Address</th><th>Prefix</th><th>Internet access</th><th>IPv6</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
{{range $i, $l := .Form.LANs}}
<tr>
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
<td><input name="lan.{{$i}}.interface" list="ifaces" value="{{$l.Interface}}" size="8"></td>
<td><input name="lan.{{$i}}.vlan" value="{{$l.VLAN}}" size="4" inputmode="numeric"></td>
<td><select name="lan.{{$i}}.interface">{{$cur := $l.Interface}}
{{if not $cur}}<option value="" selected></option>{{end}}
{{range choices $.Data.Devices $cur}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{.}}</option>{{end}}
</select></td>
<td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td>
<td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td>
<td><input type="checkbox" name="lan.{{$i}}.internet" {{if $l.Internet}}checked{{end}}></td>
@@ -62,7 +79,7 @@
<td><input name="lan.{{$i}}.poolEnd" value="{{$l.PoolEnd}}" size="13"></td>
<td>{{if $l.Name}}<input type="checkbox" name="lan.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{range $f := list "name" "interface" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="10" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
+139
View File
@@ -0,0 +1,139 @@
{{define "tunnelform"}}
{{$t := .T}}{{$e := $t.Errors}}{{$csrf := .CSRF}}
<form method="post" action="/wireguard/save">
<input type="hidden" name="csrf" value="{{$csrf}}">
<fieldset>
<legend>{{if .New}}New tunnel{{else}}{{$t.Name}}{{end}}</legend>
<label>Name <input name="name" value="{{$t.Name}}" {{if .New}}placeholder="family"{{else}}readonly{{end}} size="14"></label>
{{with index $e "name"}}<span class="field-error">{{.}}</span>{{end}}
<label>Listen port <input name="listenPort" value="{{$t.ListenPort}}" size="6" inputmode="numeric" placeholder="none (dial-out)"></label>
{{with index $e "listenPort"}}<span class="field-error">{{.}}</span>{{end}}
<label>Addresses <input name="addresses" value="{{$t.Addresses}}" size="34" placeholder="10.100.0.1/24, fd42:100::1/64"></label>
{{with index $e "addresses"}}<span class="field-error">{{.}}</span>{{end}}
<label><input type="checkbox" name="internet" {{if $t.Internet}}checked{{end}}> Peers may reach the internet</label>
<label><input type="checkbox" name="allowManagement" {{if $t.AllowManagement}}checked{{end}}> Peers may reach SSH on the router</label>
<label><input type="checkbox" name="dns" {{if $t.DNS}}checked{{end}}> The router answers DNS on the tunnel address</label>
<label><input type="checkbox" name="masquerade" {{if $t.Masquerade}}checked{{end}}> Source-NAT IPv4 leaving through this tunnel</label>
<label><input type="checkbox" name="killSwitch" {{if $t.KillSwitch}}checked{{end}}> Kill switch: drop the peers' networks instead of letting them leave via the uplink</label>
<details>
<summary>More</summary>
<label>Interface <input name="interface" value="{{$t.Interface}}" size="12" placeholder="wg-{{if $t.Name}}{{$t.Name}}{{else}}name{{end}}"></label>
{{with index $e "interface"}}<span class="field-error">{{.}}</span>{{end}}
<label>Firewall zone <input name="zone" value="{{$t.Zone}}" size="12" placeholder="{{if $t.Name}}{{$t.Name}}{{else}}name{{end}}"></label>
{{with index $e "zone"}}<span class="field-error">{{.}}</span>{{end}}
<label>MTU <input name="mtu" value="{{$t.MTU}}" size="5" inputmode="numeric" placeholder="1420"></label>
{{with index $e "mtu"}}<span class="field-error">{{.}}</span>{{end}}
<label>Source addresses <input name="sourceAddresses" value="{{$t.SourceAddresses}}" size="24" placeholder="192.168.10.1"></label>
{{with index $e "sourceAddresses"}}<span class="field-error">{{.}}</span>{{end}}
</details>
<p>
{{if $t.KeyPresent}}Public key: <code>{{$t.PublicKey}}</code>{{else if not .New}}<span class="field-error">No private key.</span>{{else}}A private key is generated for it unless you paste one.{{end}}
</p>
<label>Private key <input type="password" name="privateKey" autocomplete="off" size="46"
placeholder="{{if $t.KeyPresent}}stored; paste to replace{{else}}paste an existing key (optional){{end}}"></label>
{{with index $e "privateKey"}}<span class="field-error">{{.}}</span>{{end}}
{{if not .New}}<label><input type="checkbox" name="newKey"> Generate a new private key (every peer must be given the new public key)</label>{{end}}
</fieldset>
<fieldset>
<legend>Peers of {{if .New}}the new tunnel{{else}}{{$t.Name}}{{end}}</legend>
<input type="hidden" name="peers" value="{{len $t.Peers}}">
<div class="scroll">
<table>
<tr><th>Name</th><th>Public key</th><th>Endpoint</th><th>Allowed IPs</th><th>Keepalive</th><th>Preshared key</th><th>Now</th><th>Remove</th></tr>
{{range $i, $p := $t.Peers}}
{{$st := index $t.Live $p.PublicKey}}
<tr>
<td><input name="peer.{{$i}}.name" value="{{$p.Name}}" size="10" placeholder="new"></td>
<td><input name="peer.{{$i}}.publicKey" value="{{$p.PublicKey}}" size="30" spellcheck="false"></td>
<td><input name="peer.{{$i}}.endpoint" value="{{$p.Endpoint}}" size="18" placeholder="host:port"></td>
<td><input name="peer.{{$i}}.allowed" value="{{$p.Allowed}}" size="22"></td>
<td><input name="peer.{{$i}}.keepalive" value="{{$p.Keepalive}}" size="4" inputmode="numeric"></td>
<td><input type="password" name="peer.{{$i}}.psk" autocomplete="off" size="12" placeholder="{{if $p.PSKSet}}stored{{end}}">
<label><input type="checkbox" name="peer.{{$i}}.newPsk"> new</label></td>
<td>{{if $p.PublicKey}}{{if not $st.Handshake.IsZero}}<span class="ok">{{ago $st.Handshake}} ago</span>
<span class="muted">{{bytes $st.RX}} in, {{bytes $st.TX}} out</span>{{else if $st.RX}}<span class="muted">no handshake</span>{{else if $t.Live}}<span class="muted">never</span>{{end}}{{end}}</td>
<td>{{if $p.Name}}<input type="checkbox" name="peer.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "publicKey" "endpoint" "allowed" "keepalive" "psk"}}{{with index $e (printf "peers[%d].%s" $i $f)}}<tr><td colspan="8" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
</div>
<p class="hint">Fill in the last row to add a peer. Allowed IPs are the destinations sent into the tunnel
(and what the router accepts from the peer). Pasted preshared keys are stored on the router and not shown again.</p>
</fieldset>
<button>Save</button>
</form>
{{if not .New}}
<form method="post" action="/wireguard/delete" class="inline">
<input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="name" value="{{$t.Name}}">
<label><input type="checkbox" name="confirm"> delete {{$t.Name}}</label> <button>Delete tunnel</button>
<span class="hint">The key file stays on the router.</span>
</form>
{{end}}
{{end}}
{{define "content"}}
{{with .Data}}
<p class="hint">Changes are saved here and take effect when applied. Keys are made on the router and stay on it.
{{if not .Live}}Live status is not available.{{end}}</p>
{{range .Tunnels}}
<section>
<h2>{{.Name}} <span class="muted">{{.Device}}</span></h2>
{{template "tunnelform" (tform . $.CSRF false)}}
</section>
{{end}}
<section>
<h2>Add a phone or laptop</h2>
{{if .Config}}
<div class="flash">
<p><strong>{{.Config.Name}}</strong>: scan this with the WireGuard app or save the text as a config file.
It contains the client's private key and is shown only now; apply the settings so the router accepts it.</p>
{{if .Config.QR}}<div class="qr">{{.Config.QR}}</div>{{end}}
<pre>{{.Config.Text}}</pre>
</div>
{{end}}
{{if .Tunnels}}
{{$c := .Client}}{{$e := $c.Errors}}
<form method="post" action="/wireguard/client" class="narrow">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>Tunnel <select name="tunnel">{{range .Tunnels}}<option value="{{.Name}}" {{if eq .Name $c.Tunnel}}selected{{end}}>{{.Name}}</option>{{end}}</select></label>
{{with index $e "tunnel"}}<span class="field-error">{{.}}</span>{{end}}
<label>Name <input name="name" value="{{$c.Name}}" placeholder="nils-phone"></label>
{{with index $e "name"}}<span class="field-error">{{.}}</span>{{end}}
<label>Client address <input name="address" value="{{$c.Address}}" placeholder="10.100.0.2"></label>
{{with index $e "address"}}<span class="field-error">{{.}}</span>{{end}}
<label>Router's public address <input name="endpoint" value="{{$c.Endpoint}}" placeholder="vpn.example.org:51820" spellcheck="false"></label>
{{with index $e "endpoint"}}<span class="field-error">{{.}}</span>{{end}}
<label>Send through the tunnel <input name="allowed" value="{{$c.Allowed}}"></label>
{{with index $e "allowed"}}<span class="field-error">{{.}}</span>{{end}}
<label>DNS server <input name="dns" value="{{$c.DNS}}" placeholder="optional"></label>
{{with index $e "dns"}}<span class="field-error">{{.}}</span>{{end}}
<button>Add and show the configuration</button>
</form>
{{else}}<p class="muted">Add a tunnel first.</p>{{end}}
</section>
<section>
<h2>Import a tunnel</h2>
<p class="hint">Paste an existing WireGuard config (a <code>wg-quick</code> file or <code>wg showconf</code> output).
Its keys are stored on the router as root-only files and not shown again. Options the router has no
equivalent for (DNS, PostUp, ...) are skipped, and a peer that sends everything (<code>0.0.0.0/0</code>) has to be
changed to the networks you want through the tunnel.</p>
<form method="post" action="/wireguard/import">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>Tunnel name <input name="importName" value="{{.ImportName}}" placeholder="family" size="14"></label>
<textarea name="importText" rows="12" spellcheck="false" autocomplete="off" placeholder="[Interface]&#10;PrivateKey = ...&#10;Address = 10.100.0.1/24&#10;ListenPort = 51820&#10;&#10;[Peer]&#10;PublicKey = ...&#10;AllowedIPs = 10.100.0.2/32">{{.ImportText}}</textarea>
<button>Import</button>
</form>
</section>
<section>
<h2>New tunnel</h2>
{{template "tunnelform" (tform .New $.CSRF true)}}
</section>
{{end}}
{{end}}
+430
View File
@@ -0,0 +1,430 @@
package main
import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"fmt"
"net/netip"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// WireGuard keys are 32 bytes, base64 encoded.
// newPrivateKey returns a fresh private key and its public key.
func newPrivateKey() (priv, pub string, err error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
// As `wg genkey` does.
raw[0] &= 248
raw[31] = raw[31]&127 | 64
return keyPair(raw)
}
func keyPair(raw []byte) (priv, pub string, err error) {
k, err := ecdh.X25519().NewPrivateKey(raw)
if err != nil {
return "", "", err
}
return base64.StdEncoding.EncodeToString(raw), base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), nil
}
// publicKey derives the public key of a base64 private key.
func publicKey(priv string) (string, error) {
raw, err := decodeKey(priv)
if err != nil {
return "", err
}
_, pub, err := keyPair(raw)
return pub, err
}
func decodeKey(s string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s))
if err != nil || len(raw) != 32 {
return nil, fmt.Errorf("not a WireGuard key (44 characters of base64)")
}
return raw, nil
}
func newPresharedKey() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(raw), nil
}
func (s System) wgDir() string { return filepath.Join(s.StateDir, "wireguard") }
func (s System) wgKeyPath(tunnel string) string {
return filepath.Join(s.wgDir(), tunnel+".key")
}
func (s System) wgPSKPath(tunnel, peer string) string {
return filepath.Join(s.wgDir(), tunnel+"-"+peer+".psk")
}
// A secretFile is written (0600) before the settings that refer to it.
type secretFile struct {
Path, Content string
}
func writeSecrets(dir string, files []secretFile) error {
if len(files) == 0 {
return nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
for _, f := range files {
if err := writeFileAtomic(f.Path, []byte(f.Content+"\n"), 0o600); err != nil {
return err
}
}
return nil
}
func readKeyFile(path string) (string, bool) {
data, err := os.ReadFile(path)
if err != nil {
return "", false
}
return strings.TrimSpace(string(data)), true
}
// WGPeer is a peer of a tunnel as the page edits it.
type WGPeer struct {
Name string
PublicKey string
Endpoint string
Allowed string // comma or newline separated CIDRs
Keepalive string
PSKSet bool // a preshared key file is configured
PSK string // write-only: pasted preshared key
NewPSK bool // generate one
Remove bool
}
// WGTunnel is a tunnel as the page edits it.
type WGTunnel struct {
Name string
Interface string
ListenPort string
MTU string
Addresses string // CIDRs
SourceAddresses string
Zone string
Internet bool
AllowManagement bool
DNS bool
Masquerade bool
KillSwitch bool
Peers []WGPeer
PublicKey string // derived from the key file
KeyPresent bool
NewKey bool // generate a new private key
PrivateKey string // write-only: pasted private key
Live map[string]PeerStatus // by public key; nil if unknown
Errors FormErrors
}
var (
b64KeyRe = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`)
hostPort = regexp.MustCompile(`^(\[[0-9a-fA-F:]+\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?):[0-9]{1,5}$`)
)
// splitList splits "a, b\nc" into its items.
func splitList(v string) []string {
return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '\n' || r == ' ' || r == '\t' || r == '\r' })
}
func stringList(m map[string]any, key string) []string {
var out []string
items, _ := m[key].([]any)
for _, i := range items {
if v, ok := i.(string); ok {
out = append(out, v)
}
}
return out
}
// tunnels reads the WireGuard tunnels, sorted by name.
func (s Settings) tunnels(sys System) []WGTunnel {
router, _ := s["router"].(map[string]any)
wgs, _ := router["wireguard"].(map[string]any)
var names []string
for n := range wgs {
names = append(names, n)
}
slices.Sort(names)
var out []WGTunnel
for _, name := range names {
w, _ := wgs[name].(map[string]any)
if w == nil {
continue
}
t := WGTunnel{
Name: name,
Interface: str(w, "interface"),
ListenPort: str(w, "listenPort"),
MTU: str(w, "mtu"),
Addresses: strings.Join(stringList(w, "addresses"), ", "),
SourceAddresses: strings.Join(stringList(w, "sourceAddresses"), ", "),
Zone: str(w, "zone"),
Internet: boolean(w, false, "internet"),
AllowManagement: boolean(w, false, "allowManagement"),
DNS: boolean(w, false, "dns"),
Masquerade: boolean(w, false, "masquerade"),
KillSwitch: boolean(w, true, "killSwitch"),
}
if key, ok := readKeyFile(str(w, "privateKeyFile")); ok {
if pub, err := publicKey(key); err == nil {
t.PublicKey, t.KeyPresent = pub, true
}
}
peers, _ := w["peers"].(map[string]any)
var pnames []string
for n := range peers {
pnames = append(pnames, n)
}
slices.Sort(pnames)
for _, pn := range pnames {
p, _ := peers[pn].(map[string]any)
t.Peers = append(t.Peers, WGPeer{
Name: pn,
PublicKey: str(p, "publicKey"),
Endpoint: str(p, "endpoint"),
Allowed: strings.Join(stringList(p, "allowedIPs"), ", "),
Keepalive: str(p, "persistentKeepalive"),
PSKSet: str(p, "presharedKeyFile") != "",
})
}
out = append(out, t)
}
return out
}
func cidrOK(v string) bool {
if p, err := netip.ParsePrefix(v); err == nil {
return p.IsValid()
}
return false
}
func ipOK(v string) bool {
_, err := netip.ParseAddr(v)
return err == nil
}
// applyTunnel writes t into the settings (creating the tunnel if it is new)
// and returns the key files to store. Only basic checks happen here; the
// build validates the rest. A new tunnel gets a generated key unless one was
// pasted, and an existing one needs a key file.
func (s Settings) applyTunnel(sys System, t WGTunnel) ([]secretFile, error) {
errs := FormErrors{}
router := obj(s, "router")
wgs := obj(router, "wireguard")
t.Name = strings.TrimSpace(t.Name)
if !nameRe.MatchString(t.Name) {
errs["name"] = "lowercase letters, digits and dashes, starting with a letter"
return nil, errs
}
_, existed := wgs[t.Name].(map[string]any)
w := obj(wgs, t.Name)
var files []secretFile
if t.Interface = strings.TrimSpace(t.Interface); t.Interface != "" {
if !ifaceRe.MatchString(t.Interface) {
errs["interface"] = "an interface name of up to 15 characters"
}
w["interface"] = t.Interface
} else {
delete(w, "interface")
}
if port, ok, err := optionalInt(t.ListenPort, 1, 65535); err != nil {
errs["listenPort"] = err.Error()
} else if ok {
w["listenPort"] = port
} else {
delete(w, "listenPort")
}
if mtu, ok, err := optionalInt(t.MTU, 576, 9000); err != nil {
errs["mtu"] = err.Error()
} else if ok {
w["mtu"] = mtu
} else {
delete(w, "mtu")
}
setList := func(key, field string, raw string, valid func(string) bool, what string) {
items := splitList(raw)
for _, i := range items {
if !valid(i) {
errs[field] = fmt.Sprintf("%q is not %s", i, what)
}
}
if len(items) == 0 {
delete(w, key)
return
}
list := make([]any, len(items))
for i, v := range items {
list[i] = v
}
w[key] = list
}
setList("addresses", "addresses", t.Addresses, cidrOK, "an address like 10.100.0.1/24")
setList("sourceAddresses", "sourceAddresses", t.SourceAddresses, ipOK, "an IP address")
if t.Zone = strings.TrimSpace(t.Zone); t.Zone != "" && t.Zone != t.Name {
if !nameRe.MatchString(t.Zone) {
errs["zone"] = "lowercase letters, digits and dashes, starting with a letter"
}
w["zone"] = t.Zone
} else {
delete(w, "zone")
}
w["internet"] = t.Internet
w["allowManagement"] = t.AllowManagement
w["dns"] = t.DNS
w["masquerade"] = t.Masquerade
w["killSwitch"] = t.KillSwitch
// The private key.
keyPath := sys.wgKeyPath(t.Name)
switch {
case strings.TrimSpace(t.PrivateKey) != "":
if _, err := decodeKey(t.PrivateKey); err != nil {
errs["privateKey"] = err.Error()
} else {
files = append(files, secretFile{keyPath, strings.TrimSpace(t.PrivateKey)})
}
case t.NewKey || !existed:
priv, _, err := newPrivateKey()
if err != nil {
errs["privateKey"] = err.Error()
}
files = append(files, secretFile{keyPath, priv})
default:
if _, ok := readKeyFile(str(w, "privateKeyFile")); !ok {
errs["privateKey"] = "this tunnel has no private key: paste one or generate a new one"
}
}
if len(files) > 0 && errs["privateKey"] == "" {
w["privateKeyFile"] = keyPath
}
// Peers.
peers := obj(w, "peers")
keep := map[string]bool{}
keys := map[string]bool{}
for i, p := range t.Peers {
field := func(n string) string { return fmt.Sprintf("peers[%d].%s", i, n) }
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" && p.PublicKey == "" && p.Endpoint == "" && p.Allowed == "" {
continue // the "add a peer" row
}
if p.Remove {
continue
}
if !nameRe.MatchString(p.Name) {
errs[field("name")] = "lowercase letters, digits and dashes, starting with a letter"
continue
}
if keep[p.Name] {
errs[field("name")] = "used twice"
continue
}
keep[p.Name] = true
pm := obj(peers, p.Name)
if p.PublicKey = strings.TrimSpace(p.PublicKey); !b64KeyRe.MatchString(p.PublicKey) {
errs[field("publicKey")] = "a public key (44 characters of base64)"
} else if keys[p.PublicKey] {
errs[field("publicKey")] = "used twice"
}
keys[p.PublicKey] = true
pm["publicKey"] = p.PublicKey
if p.Endpoint = strings.TrimSpace(p.Endpoint); p.Endpoint == "" {
delete(pm, "endpoint")
} else if m := hostPort.MatchString(p.Endpoint); !m || portOf(p.Endpoint) > 65535 || portOf(p.Endpoint) < 1 {
errs[field("endpoint")] = "host:port, or [IPv6]:port"
} else {
pm["endpoint"] = p.Endpoint
}
allowed := splitList(p.Allowed)
for _, a := range allowed {
if !cidrOK(a) {
errs[field("allowed")] = fmt.Sprintf("%q is not an address with a prefix length like 10.100.0.2/32", a)
}
}
list := make([]any, len(allowed))
for i, v := range allowed {
list[i] = v
}
pm["allowedIPs"] = list
if ka, ok, err := optionalInt(p.Keepalive, 1, 65535); err != nil {
errs[field("keepalive")] = err.Error()
} else if ok {
pm["persistentKeepalive"] = ka
} else {
delete(pm, "persistentKeepalive")
}
pskPath := sys.wgPSKPath(t.Name, p.Name)
switch {
case strings.TrimSpace(p.PSK) != "":
if _, err := decodeKey(p.PSK); err != nil {
errs[field("psk")] = err.Error()
} else {
files = append(files, secretFile{pskPath, strings.TrimSpace(p.PSK)})
pm["presharedKeyFile"] = pskPath
}
case p.NewPSK:
psk, err := newPresharedKey()
if err != nil {
errs[field("psk")] = err.Error()
}
files = append(files, secretFile{pskPath, psk})
pm["presharedKeyFile"] = pskPath
}
}
for n := range peers {
if !keep[n] {
delete(peers, n)
}
}
if len(errs) > 0 {
return nil, errs
}
return files, nil
}
func portOf(endpoint string) int {
i := strings.LastIndex(endpoint, ":")
n, err := strconv.Atoi(endpoint[i+1:])
if err != nil {
return 0
}
return n
}
// deleteTunnel removes a tunnel from the settings. Its key files stay: they
// may be a key a remote side still expects.
func (s Settings) deleteTunnel(name string) bool {
router := obj(s, "router")
wgs := obj(router, "wireguard")
if _, ok := wgs[name]; !ok {
return false
}
delete(wgs, name)
if len(wgs) == 0 {
delete(router, "wireguard")
}
return true
}
+222
View File
@@ -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)
}
+364
View File
@@ -0,0 +1,364 @@
package main
import (
"html"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestKeys(t *testing.T) {
// RFC 7748's X25519 test vector: what a WireGuard public key is.
pub, err := publicKey("dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=")
if err != nil || pub != "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=" {
t.Fatalf("public key = %q %v", pub, err)
}
priv, pub, err := newPrivateKey()
if err != nil || !b64KeyRe.MatchString(priv) || !b64KeyRe.MatchString(pub) {
t.Fatalf("new key: %q %q %v", priv, pub, err)
}
if again, _ := publicKey(priv); again != pub {
t.Error("the public key does not belong to the private key")
}
if _, err := publicKey("short"); err == nil {
t.Error("a bad key was accepted")
}
}
func TestParseWGDump(t *testing.T) {
dump := "wg-a\tPRIV\tPUB\t51820\toff\n" +
"wg-a\tPEERKEY=\t(none)\t203.0.113.7:51820\t10.0.0.2/32\t1700000000\t1234\t5678\t25\n" +
"wg-a\tOTHER=\t(none)\t(none)\t10.0.0.3/32\t0\t0\t0\toff\n"
got := parseWGDump(dump)["wg-a"]
if p := got["PEERKEY="]; p.RX != 1234 || p.TX != 5678 || p.Endpoint != "203.0.113.7:51820" || !p.Handshake.Equal(time.Unix(1700000000, 0)) {
t.Errorf("peer = %+v", p)
}
if p := got["OTHER="]; !p.Handshake.IsZero() || p.Endpoint != "" {
t.Errorf("idle peer = %+v", p)
}
}
const peerKey = "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo="
func TestApplyTunnel(t *testing.T) {
sys := System{StateDir: t.TempDir()}
s := mustParse(t)
files, err := s.applyTunnel(sys, WGTunnel{
Name: "family", ListenPort: "51820", Addresses: "10.100.0.1/24, fd42:100::1/64", DNS: true, KillSwitch: true,
Peers: []WGPeer{
{Name: "gran", PublicKey: peerKey, Allowed: "10.100.0.2/32", Keepalive: "25", NewPSK: true},
{}, // the empty add row
},
})
if err != nil {
t.Fatal(err)
}
if len(files) != 2 || files[0].Path != sys.wgKeyPath("family") || files[1].Path != sys.wgPSKPath("family", "gran") {
t.Fatalf("files = %+v", files)
}
if err := writeSecrets(sys.wgDir(), files); err != nil {
t.Fatal(err)
}
if st, err := os.Stat(sys.wgKeyPath("family")); err != nil || st.Mode().Perm() != 0o600 {
t.Errorf("key file: %v %v", st, err)
}
tunnels := s.tunnels(sys)
if len(tunnels) != 2 || tunnels[0].Name != "family" || !tunnels[0].KeyPresent || tunnels[0].PublicKey == "" || !tunnels[0].DNS ||
len(tunnels[0].Peers) != 1 || !tunnels[0].Peers[0].PSKSet {
t.Fatalf("tunnels = %+v", tunnels)
}
// What else is in the settings survives (the sample has a wireguard "vpn" too).
if got := str(s, "router", "wireguard", "vpn", "listenPort"); got != "51820" {
t.Errorf("the other tunnel changed: %q", got)
}
out, _ := s.marshal()
if strings.Contains(string(out), tunnels[0].PublicKey+"x") || strings.Contains(string(out), "PRIVATE") {
t.Error("secrets in the settings")
}
// Editing keeps the key; removing the peer row removes the peer.
before, _ := os.ReadFile(sys.wgKeyPath("family"))
edit := tunnels[0]
edit.Peers[0].Remove = true
files, err = s.applyTunnel(sys, edit)
if err != nil || len(files) != 0 {
t.Fatalf("edit: %v %+v", err, files)
}
if after, _ := os.ReadFile(sys.wgKeyPath("family")); string(after) != string(before) {
t.Error("the key changed")
}
if len(s.tunnels(sys)[0].Peers) != 0 {
t.Error("peer not removed")
}
// A new key on request.
edit.NewKey = true
edit.Peers = nil
files, _ = s.applyTunnel(sys, edit)
if len(files) != 1 || files[0].Content == strings.TrimSpace(string(before)) {
t.Errorf("no new key: %+v", files)
}
if !s.deleteTunnel("family") || s.deleteTunnel("family") {
t.Error("delete")
}
}
func TestApplyTunnelValidates(t *testing.T) {
sys := System{StateDir: t.TempDir()}
for name, tun := range map[string]WGTunnel{
"name": {Name: "Bad Name"},
"listenPort": {Name: "a", ListenPort: "70000"},
"addresses": {Name: "a", Addresses: "10.0.0.1"},
"privateKey": {Name: "a", PrivateKey: "nope"},
"peers[0].publicKey": {Name: "a", Peers: []WGPeer{{Name: "p", PublicKey: "x", Allowed: "10.0.0.2/32"}}},
"peers[0].allowed": {Name: "a", Peers: []WGPeer{{Name: "p", PublicKey: peerKey, Allowed: "10.0.0.2"}}},
"peers[0].endpoint": {Name: "a", Peers: []WGPeer{{Name: "p", PublicKey: peerKey, Endpoint: "host", Allowed: "10.0.0.2/32"}}},
"peers[1].publicKey": {Name: "a", Peers: []WGPeer{{Name: "p", PublicKey: peerKey}, {Name: "q", PublicKey: peerKey}}},
} {
s := mustParse(t)
_, err := s.applyTunnel(sys, tun)
fe, ok := err.(FormErrors)
if !ok || fe[name] == "" {
t.Errorf("%s: %v", name, err)
}
}
// A pasted key is stored; an existing tunnel without a key file is refused.
s := mustParse(t)
files, err := s.applyTunnel(sys, WGTunnel{Name: "pasted", PrivateKey: "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo="})
if err != nil || len(files) != 1 || files[0].Content != "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=" {
t.Fatalf("pasted: %v %+v", err, files)
}
if _, err := mustParse(t).applyTunnel(sys, WGTunnel{Name: "vpn"}); err == nil {
t.Error("an existing tunnel without a key file was accepted")
}
}
func TestNextClientAddress(t *testing.T) {
tun := WGTunnel{Addresses: "10.100.0.1/24", Peers: []WGPeer{{Allowed: "10.100.0.2/32"}, {Allowed: "10.100.0.3/32"}}}
if got := nextClientAddress(tun); got != "10.100.0.4" {
t.Errorf("next = %q", got)
}
if got := nextClientAddress(WGTunnel{Addresses: "fd42::1/64"}); got != "" {
t.Errorf("no IPv4 network: %q", got)
}
}
func TestWireGuardPage(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
token := func(path string) string {
body := do(h, "GET", path, nil, cookie).Body.String()
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
}
if body := do(h, "GET", "/wireguard", nil, cookie).Body.String(); !strings.Contains(body, "New tunnel") {
t.Fatalf("no page:\n%s", body)
}
// A new tunnel gets its key; a bad one is refused with the form kept.
form := url.Values{"csrf": {token("/wireguard")}, "name": {"family"}, "listenPort": {"51820"}, "addresses": {"10.100.0.1/24"},
"killSwitch": {"on"}, "peers": {"1"}, "peer.0.name": {"gran"}, "peer.0.publicKey": {"bad"}, "peer.0.allowed": {"10.100.0.2/32"}}
rec := do(h, "POST", "/wireguard/save", form, cookie)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `value="gran"`) {
t.Fatalf("bad peer: %d", rec.Code)
}
if _, err := os.Stat(sys.wgKeyPath("family")); err == nil {
t.Error("a key was written for a refused form")
}
form.Set("peer.0.publicKey", peerKey)
if rec := do(h, "POST", "/wireguard/save", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
if _, err := os.Stat(sys.wgKeyPath("family")); err != nil {
t.Fatal("no key file")
}
s, _ := loadSettings(sys.settingsPath())
if str(s, "router", "wireguard", "family", "privateKeyFile") != sys.wgKeyPath("family") {
t.Fatalf("settings: %v", s)
}
body := html.UnescapeString(do(h, "GET", "/wireguard", nil, cookie).Body.String())
pub := s.tunnels(sys)[0].PublicKey
priv, _ := os.ReadFile(sys.wgKeyPath("family"))
if !strings.Contains(body, pub) || strings.Contains(body, strings.TrimSpace(string(priv))) {
t.Error("the page lacks the public key (or shows the private one)")
}
// A phone: the router makes its keys; the config is shown once.
client := url.Values{"csrf": {token("/wireguard")}, "tunnel": {"family"}, "name": {"phone"}, "address": {"10.100.0.3"},
"endpoint": {"vpn.example.org:51820"}, "allowed": {"10.100.0.0/24, 192.168.10.0/24"}, "dns": {"10.100.0.1"}}
rec = do(h, "POST", "/wireguard/client", client, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("client: %d\n%s", rec.Code, rec.Body)
}
shown := html.UnescapeString(rec.Body.String())
for _, want := range []string{"[Interface]", "PrivateKey = ", "Address = 10.100.0.3/32", "DNS = 10.100.0.1", "PublicKey = " + pub, "PresharedKey = ", "Endpoint = vpn.example.org:51820"} {
if !strings.Contains(shown, want) {
t.Errorf("config lacks %q", want)
}
}
s, _ = loadSettings(sys.settingsPath())
peer := s.tunnels(sys)[0].Peers
if len(peer) != 2 || peer[1].Name != "phone" || !peer[1].PSKSet || peer[1].Allowed != "10.100.0.3/32" {
t.Fatalf("peers = %+v", peer)
}
psk, _ := os.ReadFile(sys.wgPSKPath("family", "phone"))
if !strings.Contains(html.UnescapeString(rec.Body.String()), strings.TrimSpace(string(psk))) {
t.Error("the config's preshared key is not the stored one")
}
if st, _ := os.Stat(filepath.Join(sys.wgDir(), "family-phone.psk")); st.Mode().Perm() != 0o600 {
t.Error("psk permissions")
}
if _, err := exec.LookPath("qrencode"); err == nil && !strings.Contains(rec.Body.String(), "<svg") {
t.Error("no QR code although qrencode is available")
}
// Not shown again, and the address is taken now.
if body := do(h, "GET", "/wireguard", nil, cookie).Body.String(); strings.Contains(body, "Address = 10.100.0.3/32") {
t.Error("the client config is shown again")
}
client.Set("csrf", token("/wireguard"))
client.Set("name", "tablet")
if rec := do(h, "POST", "/wireguard/client", client, cookie); rec.Code != http.StatusUnprocessableEntity {
t.Errorf("a used address was accepted: %d", rec.Code)
}
// Delete needs the box ticked.
do(h, "POST", "/wireguard/delete", url.Values{"csrf": {token("/wireguard")}, "name": {"family"}}, cookie)
if s, _ := loadSettings(sys.settingsPath()); len(s.tunnels(sys)) != 2 { // vpn and family
t.Error("deleted without confirmation")
}
do(h, "POST", "/wireguard/delete", url.Values{"csrf": {token("/wireguard")}, "name": {"family"}, "confirm": {"on"}}, cookie)
if s, _ := loadSettings(sys.settingsPath()); len(s.tunnels(sys)) != 1 {
t.Error("not deleted")
}
if _, err := os.Stat(sys.wgKeyPath("family")); err != nil {
t.Error("the key file went with the tunnel")
}
}
// With wg available: the keys made here are the keys WireGuard derives.
func TestKeysMatchWG(t *testing.T) {
wg, err := exec.LookPath("wg")
if err != nil {
t.Skip("wg is not installed")
}
priv, pub, _ := newPrivateKey()
cmd := exec.Command(wg, "pubkey")
cmd.Stdin = strings.NewReader(priv + "\n")
out, err := cmd.Output()
if err != nil || strings.TrimSpace(string(out)) != pub {
t.Fatalf("wg pubkey = %q %v, want %q", out, err, pub)
}
}
const sampleConf = `# a comment before the interface
[Interface]
PrivateKey = dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=
Address = 10.100.0.1/24, fd42:100::1/64
ListenPort = 51820
MTU = 1380
DNS = 10.100.0.1
PostUp = iptables -A FORWARD -i %i -j ACCEPT
# Gran's laptop
[Peer]
PublicKey = hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=
PresharedKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
AllowedIPs = 10.100.0.2/32
[Peer]
PublicKey = 78NK8vKabw7TOKjZ4hjW/GgDLPFI03EcKksGUGuiC0I=
Endpoint = vpn.example.org:51820
AllowedIPs = 10.100.0.3/32, 192.168.50.0/24
PersistentKeepalive = 25
`
func TestParseWGQuick(t *testing.T) {
imp, err := parseWGQuick("family", sampleConf)
if err != nil {
t.Fatal(err)
}
tun := imp.Tunnel
if tun.PrivateKey == "" || tun.ListenPort != "51820" || tun.MTU != "1380" || tun.Addresses != "10.100.0.1/24, fd42:100::1/64" {
t.Errorf("interface = %+v", tun)
}
if len(tun.Peers) != 2 || tun.Peers[0].Name != "gran-s-laptop" || tun.Peers[1].Name != "peer1" {
t.Fatalf("peers = %+v", tun.Peers)
}
if p := tun.Peers[1]; p.Endpoint != "vpn.example.org:51820" || p.Keepalive != "25" || p.Allowed != "10.100.0.3/32, 192.168.50.0/24" {
t.Errorf("peer = %+v", p)
}
if tun.Peers[0].PSK == "" {
t.Error("the preshared key was lost")
}
if strings.Join(imp.Ignored, ",") != "dns,postup" && strings.Join(imp.Ignored, ",") != "postup,dns" {
t.Errorf("ignored = %v", imp.Ignored)
}
// An address without a prefix length gets one; showconf has no Address at all.
imp, err = parseWGQuick("x", "[Interface]\nPrivateKey = k\nAddress = 10.0.0.2\n")
if err != nil || imp.Tunnel.Addresses != "10.0.0.2/32" {
t.Errorf("address: %q %v", imp.Tunnel.Addresses, err)
}
for name, conf := range map[string]string{
"no key": "[Interface]\nAddress = 10.0.0.1/24\n",
"full": "[Interface]\nPrivateKey = k\n[Peer]\nPublicKey = p\nAllowedIPs = 0.0.0.0/0, ::/0\n",
"garbage": "[Interface]\nPrivateKey = k\nnonsense\n",
"no section": "PrivateKey = k\n",
} {
if _, err := parseWGQuick("x", conf); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestImportThroughThePage(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
token := func() string {
body := do(h, "GET", "/wireguard", nil, cookie).Body.String()
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
}
// A full-tunnel peer is refused, and the key is not echoed back.
bad := strings.Replace(sampleConf, "AllowedIPs = 10.100.0.2/32", "AllowedIPs = 0.0.0.0/0", 1)
rec := do(h, "POST", "/wireguard/import", url.Values{"csrf": {token()}, "importName": {"family"}, "importText": {bad}}, cookie)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "routes by destination only") {
t.Fatalf("full tunnel: %d", rec.Code)
}
for _, secret := range []string{"dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo", "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"} {
if strings.Contains(html.UnescapeString(rec.Body.String()), secret) {
t.Error("a key was echoed back")
}
}
if _, err := os.Stat(sys.wgKeyPath("family")); err == nil {
t.Error("a key was stored for a refused import")
}
rec = do(h, "POST", "/wireguard/import", url.Values{"csrf": {token()}, "importName": {"family"}, "importText": {sampleConf}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("import: %d\n%s", rec.Code, rec.Body)
}
if key, _ := os.ReadFile(sys.wgKeyPath("family")); strings.TrimSpace(string(key)) != "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=" {
t.Errorf("the imported key was not stored as it was: %q", key)
}
if st, _ := os.Stat(sys.wgPSKPath("family", "gran-s-laptop")); st == nil || st.Mode().Perm() != 0o600 {
t.Error("the preshared key was not stored")
}
s, _ := loadSettings(sys.settingsPath())
tun := s.tunnels(sys)[0]
if tun.Name != "family" || tun.PublicKey != "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=" || len(tun.Peers) != 2 || tun.Peers[1].Allowed != "10.100.0.3/32, 192.168.50.0/24" {
t.Errorf("tunnel = %+v", tun)
}
if out, _ := s.marshal(); strings.Contains(string(out), "dwdtCnMY") || strings.Contains(string(out), "AAECAwQF") {
t.Error("a secret is in settings.json")
}
if rec := do(h, "POST", "/wireguard/import", url.Values{"csrf": {token()}, "importName": {"family"}, "importText": {sampleConf}}, cookie); rec.Code != http.StatusUnprocessableEntity {
t.Errorf("a second tunnel of the same name: %d", rec.Code)
}
}
+425
View File
@@ -0,0 +1,425 @@
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)
}