records: add domain/IP-range allow+deny filtering #3
@@ -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