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` |
|
| `-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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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] == ':' {
|
||||||
|
|||||||
@@ -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