records: add domain/IP-range allow+deny filtering
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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.).
This commit is contained in:
2026-07-18 01:59:29 +10:00
parent c667a8f5d6
commit 194080511c
5 changed files with 304 additions and 0 deletions
+16
View File
@@ -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
+12
View File
@@ -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)
+41
View File
@@ -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] == ':' {
+140
View File
@@ -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
}
+95
View File
@@ -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")
}
}