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.).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] == ':' {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user