Files
nixos-router/pkgs/router-webui/dns.go
T
nilsandClaude Sonnet 5.5 84f6002d05 Add WireGuard, DNS and more to the web UI and router-setup
Web UI: a WireGuard page (tunnels, peers, keys made on the router, client
configs with a QR code, import of wg-quick configs, live status), a DNS page
(records, wildcards, forward zones), interface dropdowns with VLAN devices,
IPv6 options and a local domain. router-setup asks about VLANs, IPv6 and
the domain. The router publishes its own name in DNS and hands the local
domain to DHCP clients; wg and jq are on the router. Includes a script
that takes over antigua's tunnels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 23:53:34 +01:00

241 lines
6.8 KiB
Go

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