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