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
268 lines
7.5 KiB
Go
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.")
|
|
}
|