Files
nilsandClaude Sonnet 5.5 2eab735f59 Publish DHCP clients' names in DNS
router-webui leases-dns reads Kea's leases and publishes <name>.<domain>
with reverse records in the running Unbound (router.dns.dynamicLeases).
Names the configuration publishes are never taken over; a client's name
is cut to its first label and kept inside the local domain; of two
clients the newer lease wins. The DNS page switches it on and shows what
is published and what was skipped; router-setup enables it with a domain.

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

268 lines
7.5 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
Dynamic bool // router.dns.dynamicLeases
Dyn *dynStatus
}
func (s Settings) dynamicLeases() bool {
router, _ := s["router"].(map[string]any)
dns, _ := router["dns"].(map[string]any)
return boolean(dns, false, "dynamicLeases")
}
func (s Settings) setDynamicLeases(on bool) {
dns := obj(obj(s, "router"), "dns")
if on {
dns["dynamicLeases"] = true
} else {
delete(dns, "dynamicLeases")
}
}
// dnsView is the page's data for settings s.
func (a *app) dnsView(s Settings, records, zones []DNSRecord, errs FormErrors) dnsData {
d := dnsData{Records: records, Zones: zones, Errors: errs, Domain: s.domain(), Auto: s.dnsAuto(), Dynamic: s.dynamicLeases()}
if st, ok := readDynStatus(a.sys.leasesDNSDir()); ok {
d.Dyn = &st
}
return d
}
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 = a.dnsView(s, append(s.dnsRecords(), DNSRecord{}), append(s.forwardZones(), DNSRecord{}), nil)
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")
dynamic := r.PostFormValue("dynamic") == "on"
if err := s.applyDNS(records, zones); err != nil {
var fe FormErrors
errors.As(err, &fe)
p.Error = "Please correct the marked fields."
p.Data = a.dnsView(s, records, zones, fe)
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "dns", p)
return
}
s.setDynamicLeases(dynamic)
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.")
}