From 194080511c9b29ad2cb0da5bb534b0c8960eb7e9 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sat, 18 Jul 2026 01:59:29 +1000 Subject: [PATCH] records: add domain/IP-range allow+deny filtering Adds a Filter (allow/deny CIDRs + allow/deny domain suffixes) applied to the desired set before reconcile. Range rules match A/AAAA by value and PTR by the address encoded in the reverse-DNS owner, so both the junk A record and its reverse PTR are dropped together. Configured via -deny-ranges/-allow-ranges/ -deny-domains/-allow-domains (comma-separated) or DNS_UPDATER_* env. Purpose: keep k8s pod/service CIDRs, LB VIP ranges and other internal addresses out of the authoritative zones, and stop NOTAUTH updates for reverse zones the server does not host (10.42.x, 198.18.200.x, etc.). --- README.md | 16 ++++ cmd/dns-updater/main.go | 12 +++ internal/config/config.go | 41 ++++++++++ internal/records/filter.go | 140 ++++++++++++++++++++++++++++++++ internal/records/filter_test.go | 95 ++++++++++++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 internal/records/filter.go create mode 100644 internal/records/filter_test.go diff --git a/README.md b/README.md index 7d4687f..866b6ba 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,25 @@ Flags or env (see `packaging/env.sample`); env wins via the systemd | `-api` | `DNS_UPDATER_API` | `/run/dns-updater/api.sock` | | `-log-level` | `DNS_UPDATER_LOG_LEVEL` | `info` | | `-oneshot` | `DNS_UPDATER_ONESHOT` | `false` | +| `-deny-ranges` | `DNS_UPDATER_DENY_RANGES` | (none) | +| `-allow-ranges` | `DNS_UPDATER_ALLOW_RANGES` | (none) | +| `-deny-domains` | `DNS_UPDATER_DENY_DOMAINS` | (none) | +| `-allow-domains` | `DNS_UPDATER_ALLOW_DOMAINS` | (none) | The TSIG key file is BIND format (`key "name" { algorithm ...; secret "..."; };`). +### Filtering + +`*-ranges` are comma-separated CIDRs; `*-domains` are comma-separated FQDN +suffixes. Range rules apply to records that carry an address — A/AAAA by value, +PTR by the address encoded in the reverse-DNS owner — so both +`prodnxsr01-kube-lb0 A 198.18.200.2` and the matching `…200.18.198.in-addr.arpa` +PTR are dropped by `-deny-ranges=198.18.200.0/24`. Domain rules apply to every +record by owner name. Deny wins; a non-empty allow list means "only these". +This keeps k8s/LB/internal addresses (pod/service CIDRs, LB VIP ranges) out of +the authoritative zones and stops NOTAUTH updates for zones the server does not +host. + ## Status API - `GET /status` → JSON: health, managed-record count, last reconcile/change diff --git a/cmd/dns-updater/main.go b/cmd/dns-updater/main.go index 04fee02..3f88154 100644 --- a/cmd/dns-updater/main.go +++ b/cmd/dns-updater/main.go @@ -48,6 +48,11 @@ func run(args []string) error { return fmt.Errorf("load key: %w", err) } log.Info("loaded tsig key", "name", strings.TrimSuffix(key.Name, "."), "algorithm", strings.TrimSuffix(key.Algorithm, ".")) + if !cfg.Filter.Empty() { + log.Info("record filter active", + "deny_ranges", len(cfg.Filter.DenyRanges), "allow_ranges", len(cfg.Filter.AllowRanges), + "deny_domains", len(cfg.Filter.DenyDomains), "allow_domains", len(cfg.Filter.AllowDomains)) + } app := updater.New(cfg.Server, key, cfg.Timeout) store := api.NewStore(version, cfg.Server, cfg.RecordsFile) @@ -162,6 +167,13 @@ func (d *daemon) reconcile(trigger string) error { if err != nil { d.log.Warn("some records skipped", "trigger", trigger, "err", err) } + if !d.cfg.Filter.Empty() { + var dropped int + desired, dropped = d.cfg.Filter.Apply(desired) + if dropped > 0 { + d.log.Debug("filtered records", "trigger", trigger, "dropped", dropped, "kept", desired.Len()) + } + } applied, aerr := records.LoadOrEmpty(d.cfg.StateFile) if aerr != nil { d.log.Warn("could not read applied state; assuming empty", "err", aerr) diff --git a/internal/config/config.go b/internal/config/config.go index cf1e663..1b729e3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,8 +6,12 @@ package config import ( "flag" "fmt" + "net" "os" + "strings" "time" + + "git.unkin.net/unkin/dns-updater/internal/records" ) // Config is the daemon configuration. @@ -23,6 +27,7 @@ type Config struct { Oneshot bool // reconcile once and exit (no watching) APIAddr string // status API address (unix socket path or host:port; empty disables) LogLevel string // debug|info|warn|error + Filter records.Filter } const defaultPort = "53" @@ -42,6 +47,10 @@ func Parse(args []string) (*Config, error) { fs.BoolVar(&c.Oneshot, "oneshot", envBool("DNS_UPDATER_ONESHOT", false), "reconcile once and exit") fs.StringVar(&c.APIAddr, "api", env("DNS_UPDATER_API", "/run/dns-updater/api.sock"), "status API address (unix path or host:port; empty disables)") fs.StringVar(&c.LogLevel, "log-level", env("DNS_UPDATER_LOG_LEVEL", "info"), "log level: debug|info|warn|error") + denyRanges := fs.String("deny-ranges", env("DNS_UPDATER_DENY_RANGES", ""), "comma-separated CIDRs to never publish (A value / PTR address in range is dropped)") + allowRanges := fs.String("allow-ranges", env("DNS_UPDATER_ALLOW_RANGES", ""), "comma-separated CIDRs; if set, only addresses in these are published") + denyDomains := fs.String("deny-domains", env("DNS_UPDATER_DENY_DOMAINS", ""), "comma-separated domain suffixes to never publish") + allowDomains := fs.String("allow-domains", env("DNS_UPDATER_ALLOW_DOMAINS", ""), "comma-separated domain suffixes; if set, only these are published") if err := fs.Parse(args); err != nil { return nil, err } @@ -49,9 +58,41 @@ func Parse(args []string) (*Config, error) { return nil, fmt.Errorf("server is required (-server or DNS_UPDATER_SERVER)") } c.Server = withPort(c.Server) + + var err error + if c.Filter.DenyRanges, err = parseCIDRs(*denyRanges); err != nil { + return nil, fmt.Errorf("deny-ranges: %w", err) + } + if c.Filter.AllowRanges, err = parseCIDRs(*allowRanges); err != nil { + return nil, fmt.Errorf("allow-ranges: %w", err) + } + c.Filter.DenyDomains = splitList(*denyDomains) + c.Filter.AllowDomains = splitList(*allowDomains) return c, nil } +func splitList(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func parseCIDRs(s string) ([]*net.IPNet, error) { + var out []*net.IPNet + for _, p := range splitList(s) { + _, n, err := net.ParseCIDR(p) + if err != nil { + return nil, fmt.Errorf("%q: %w", p, err) + } + out = append(out, n) + } + return out, nil +} + func withPort(s string) string { for i := len(s) - 1; i >= 0; i-- { if s[i] == ':' { diff --git a/internal/records/filter.go b/internal/records/filter.go new file mode 100644 index 0000000..3b18b9f --- /dev/null +++ b/internal/records/filter.go @@ -0,0 +1,140 @@ +package records + +import ( + "net" + "strings" + + "github.com/miekg/dns" +) + +// Filter decides which records to publish. IP-range rules apply to records that +// carry an address (A/AAAA by value, PTR by the address encoded in the owner +// name); domain rules apply to every record by owner name. Deny always wins; a +// non-empty allow list means "only these". +type Filter struct { + AllowRanges []*net.IPNet + DenyRanges []*net.IPNet + AllowDomains []string // fqdn suffixes + DenyDomains []string // fqdn suffixes +} + +// Empty reports whether the filter has no rules (so it can be skipped). +func (f *Filter) Empty() bool { + return f == nil || (len(f.AllowRanges) == 0 && len(f.DenyRanges) == 0 && + len(f.AllowDomains) == 0 && len(f.DenyDomains) == 0) +} + +// Allowed reports whether a record passes the filter. +func (f *Filter) Allowed(r Record) bool { + if f.Empty() { + return true + } + owner := r.Owner() + if domainMatch(owner, f.DenyDomains) { + return false + } + if len(f.AllowDomains) > 0 && !domainMatch(owner, f.AllowDomains) { + return false + } + if ip, ok := recordIP(r); ok { + if inAny(ip, f.DenyRanges) { + return false + } + if len(f.AllowRanges) > 0 && !inAny(ip, f.AllowRanges) { + return false + } + } + return true +} + +// Apply returns a Set containing only the allowed records, and the number +// dropped. +func (f *Filter) Apply(s *Set) (*Set, int) { + if f.Empty() { + return s, 0 + } + out := NewSet() + dropped := 0 + for _, r := range s.Records() { + if f.Allowed(r) { + _ = out.Add(r) + } else { + dropped++ + } + } + return out, dropped +} + +// recordIP returns the address a record concerns: the value for A/AAAA, or the +// address encoded in a PTR owner. ok is false for records with no address +// (CNAME/TXT/SRV/...), which range rules do not touch. +func recordIP(r Record) (net.IP, bool) { + switch strings.ToUpper(r.Type) { + case "A", "AAAA": + ip := net.ParseIP(strings.TrimSpace(r.Value)) + return ip, ip != nil + case "PTR": + ip := ptrToIP(r.Owner()) + return ip, ip != nil + } + return nil, false +} + +// ptrToIP converts a reverse-DNS owner (…in-addr.arpa / …ip6.arpa) to an IP. +func ptrToIP(owner string) net.IP { + name := strings.TrimSuffix(strings.ToLower(owner), ".") + if s := strings.TrimSuffix(name, ".in-addr.arpa"); s != name { + labels := strings.Split(s, ".") + if len(labels) != 4 { + return nil + } + reverse(labels) + return net.ParseIP(strings.Join(labels, ".")) + } + if s := strings.TrimSuffix(name, ".ip6.arpa"); s != name { + labels := strings.Split(s, ".") + if len(labels) != 32 { + return nil + } + reverse(labels) + var b strings.Builder + for i, l := range labels { + if i > 0 && i%4 == 0 { + b.WriteByte(':') + } + b.WriteString(l) + } + return net.ParseIP(b.String()) + } + return nil +} + +func reverse(s []string) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} + +func inAny(ip net.IP, nets []*net.IPNet) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + +// domainMatch reports whether name equals or is a subdomain of any suffix. +func domainMatch(name string, suffixes []string) bool { + n := strings.TrimSuffix(strings.ToLower(dns.Fqdn(name)), ".") + for _, suf := range suffixes { + s := strings.TrimSuffix(strings.ToLower(dns.Fqdn(suf)), ".") + if s == "" { + continue + } + if n == s || strings.HasSuffix(n, "."+s) { + return true + } + } + return false +} diff --git a/internal/records/filter_test.go b/internal/records/filter_test.go new file mode 100644 index 0000000..27f0178 --- /dev/null +++ b/internal/records/filter_test.go @@ -0,0 +1,95 @@ +package records + +import ( + "net" + "testing" +) + +func cidrs(t *testing.T, ss ...string) []*net.IPNet { + t.Helper() + var out []*net.IPNet + for _, s := range ss { + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("bad cidr %q: %v", s, err) + } + out = append(out, n) + } + return out +} + +func TestFilterDenyRanges(t *testing.T) { + f := &Filter{DenyRanges: cidrs(t, "198.18.199.0/24", "198.18.200.0/24", "10.42.0.0/16", "10.43.0.0/16")} + cases := []struct { + rec Record + keep bool + }{ + // real host A records — kept + {Record{Zone: "main.unkin.net", Name: "ausyd1nxvm2071", Type: "A", Value: "198.18.24.41"}, true}, + {Record{Zone: "main.unkin.net", Name: "prodnxsr0001", Type: "A", Value: "198.18.19.1"}, true}, + // k8s/LB junk A records — dropped by value + {Record{Zone: "main.unkin.net", Name: "prodnxsr0001-flannel.1", Type: "A", Value: "10.42.0.0"}, false}, + {Record{Zone: "main.unkin.net", Name: "prodnxsr0001-kube-lb0", Type: "A", Value: "198.18.200.2"}, false}, + // PTRs in the excluded reverse zones — dropped by owner-derived address + {Record{Zone: "200.18.198.in-addr.arpa", Name: "2", Type: "PTR", Value: "prodnxsr0001-kube-lb0.main.unkin.net."}, false}, + {Record{Zone: "2.42.10.in-addr.arpa", Name: "0", Type: "PTR", Value: "prodnxsr0003-flannel.1.main.unkin.net."}, false}, + // a legit reverse PTR — kept + {Record{Zone: "24.18.198.in-addr.arpa", Name: "41", Type: "PTR", Value: "ausyd1nxvm2071.main.unkin.net."}, true}, + // CNAMEs carry no address, so range rules never touch them + {Record{Zone: "main.unkin.net", Name: "git.main.unkin.net.", Type: "CNAME", Value: "au-syd1-prod-halb-vrrp"}, true}, + } + for _, c := range cases { + if got := f.Allowed(c.rec); got != c.keep { + ip, _ := recordIP(c.rec) + t.Errorf("Allowed(%s %s %s)=%v want %v (ip=%v)", c.rec.Zone, c.rec.Name, c.rec.Value, got, c.keep, ip) + } + } +} + +func TestFilterAllowRangesAndDomains(t *testing.T) { + // allow-only ranges: only 198.18.0.0/16 addresses publish + f := &Filter{AllowRanges: cidrs(t, "198.18.0.0/16")} + if f.Allowed(Record{Zone: "main.unkin.net", Name: "x", Type: "A", Value: "10.42.0.5"}) { + t.Error("10.42.0.5 should be excluded by allow-ranges") + } + if !f.Allowed(Record{Zone: "main.unkin.net", Name: "x", Type: "A", Value: "198.18.24.5"}) { + t.Error("198.18.24.5 should be allowed") + } + + // deny-domains suffix match + fd := &Filter{DenyDomains: []string{"k8s.syd1.au.unkin.net"}} + if fd.Allowed(Record{Zone: "k8s.syd1.au.unkin.net", Name: "foo", Type: "A", Value: "1.2.3.4"}) { + t.Error("foo.k8s.syd1.au.unkin.net should be denied by domain") + } + if !fd.Allowed(Record{Zone: "main.unkin.net", Name: "foo", Type: "A", Value: "1.2.3.4"}) { + t.Error("foo.main.unkin.net should be allowed") + } +} + +func TestFilterApplyCounts(t *testing.T) { + f := &Filter{DenyRanges: cidrs(t, "10.42.0.0/16")} + s := NewSet() + for _, r := range []Record{ + {Zone: "main.unkin.net", Name: "a", Type: "A", TTL: 300, Value: "198.18.24.1"}, + {Zone: "main.unkin.net", Name: "b", Type: "A", TTL: 300, Value: "10.42.1.1"}, + {Zone: "main.unkin.net", Name: "c", Type: "A", TTL: 300, Value: "10.42.2.2"}, + } { + if err := s.Add(r); err != nil { + t.Fatal(err) + } + } + out, dropped := f.Apply(s) + if dropped != 2 || out.Len() != 1 { + t.Fatalf("dropped=%d kept=%d, want dropped=2 kept=1", dropped, out.Len()) + } +} + +func TestEmptyFilterIsPassthrough(t *testing.T) { + var f *Filter + if !f.Empty() { + t.Error("nil filter should be empty") + } + if !f.Allowed(Record{Zone: "z", Name: "n", Type: "A", Value: "10.42.0.1"}) { + t.Error("nil filter should allow everything") + } +} -- 2.47.3