records: add domain/IP-range allow+deny filtering #3

Merged
benvin merged 1 commits from benvin/record-filters into main 2026-07-18 08:28:03 +10:00
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` | | `-api` | `DNS_UPDATER_API` | `/run/dns-updater/api.sock` |
| `-log-level` | `DNS_UPDATER_LOG_LEVEL` | `info` | | `-log-level` | `DNS_UPDATER_LOG_LEVEL` | `info` |
| `-oneshot` | `DNS_UPDATER_ONESHOT` | `false` | | `-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 "..."; };`). 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 ## Status API
- `GET /status` → JSON: health, managed-record count, last reconcile/change - `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) return fmt.Errorf("load key: %w", err)
} }
log.Info("loaded tsig key", "name", strings.TrimSuffix(key.Name, "."), "algorithm", strings.TrimSuffix(key.Algorithm, ".")) 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) app := updater.New(cfg.Server, key, cfg.Timeout)
store := api.NewStore(version, cfg.Server, cfg.RecordsFile) store := api.NewStore(version, cfg.Server, cfg.RecordsFile)
@@ -162,6 +167,13 @@ func (d *daemon) reconcile(trigger string) error {
if err != nil { if err != nil {
d.log.Warn("some records skipped", "trigger", trigger, "err", err) 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) applied, aerr := records.LoadOrEmpty(d.cfg.StateFile)
if aerr != nil { if aerr != nil {
d.log.Warn("could not read applied state; assuming empty", "err", aerr) d.log.Warn("could not read applied state; assuming empty", "err", aerr)
+41
View File
@@ -6,8 +6,12 @@ package config
import ( import (
"flag" "flag"
"fmt" "fmt"
"net"
"os" "os"
"strings"
"time" "time"
"git.unkin.net/unkin/dns-updater/internal/records"
) )
// Config is the daemon configuration. // Config is the daemon configuration.
@@ -23,6 +27,7 @@ type Config struct {
Oneshot bool // reconcile once and exit (no watching) Oneshot bool // reconcile once and exit (no watching)
APIAddr string // status API address (unix socket path or host:port; empty disables) APIAddr string // status API address (unix socket path or host:port; empty disables)
LogLevel string // debug|info|warn|error LogLevel string // debug|info|warn|error
Filter records.Filter
} }
const defaultPort = "53" 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.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.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") 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 { if err := fs.Parse(args); err != nil {
return nil, err 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)") return nil, fmt.Errorf("server is required (-server or DNS_UPDATER_SERVER)")
} }
c.Server = withPort(c.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 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 { func withPort(s string) string {
for i := len(s) - 1; i >= 0; i-- { for i := len(s) - 1; i >= 0; i-- {
if s[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")
}
}