From d48c1863bfc50aa838af097c94627fc45d9e5128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Mon, 28 Sep 2026 21:34:59 +0200 Subject: [PATCH] more config --- README.md | 34 ++++++++++++++++++++ STATUS.md | 12 +++++++ deploy/k8s/00-config.yaml | 5 +++ internal/app/app.go | 1 + internal/app/hooks.go | 10 ++++-- internal/config/config.go | 51 ++++++++++++++++++++++++++++++ internal/config/config_test.go | 30 ++++++++++++++++++ internal/resolver/pool.go | 47 ++++++++++++++++++++++++--- internal/resolver/resolver_test.go | 39 +++++++++++++++++++++++ 9 files changed, 222 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a85bc68..670feb7 100644 --- a/README.md +++ b/README.md @@ -888,3 +888,37 @@ Found while writing this file; each is a to-do, not something fixed here. are advertised for exact origins too. Pinned by `TestCORSWildcardEchoesTheOrigin` and `TestCORSPreflight`; whether a wildcard should be refused outright is a decision. + +### Conditional forwarding + +To resolve DHCP names through a firewall, add suffix routes under `upstream`: + +```yaml +upstream: + # Keep your existing default servers and policy here. + forward_zones: + - zone: local + servers: + - addr: 10.10.0.1:53 + proto: udp +``` + +`local` matches both `local.` and every name below it, including +`fastpi.local.`. Do not write `*.local`. Matching ignores case and chooses the +most specific suffix. Forwarding targets are separate from default servers; +matched queries never fall back to public DNS, even if all targets fail. UDP +retries truncated replies over TCP. Local authoritative zones retain precedence, +and ACL and blocklist policies still apply. The firewall must answer these names +itself rather than forwarding them back to fancy-dns. + +Rules are configured in YAML, not in the console. Rebuild and deploy a binary +with forwarding support before using this key: older versions ignore unknown +keys. Send SIGHUP after editing YAML; upstream reloads flush the cache. The +Kubernetes ConfigMap includes `local` forwarding to `10.10.0.1`. Roll out the new +image/config together or reload the updated config in a compatible running pod. +Test with `dig @ fastpi.local A`. + +`.local` is reserved for multicast DNS, so some clients may send these names +to mDNS instead of their configured DNS server. For new residential networks, +use `home.arpa` (RFC 8375). Reverse DHCP lookups can use another route such as +`11.10.10.in-addr.arpa` for the `10.10.11.0/24` subnet. diff --git a/STATUS.md b/STATUS.md index d305cca..373d850 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,6 +8,18 @@ round before that brought the console onto the Go side of the API contract, so both halves of that are done. `AGENTS.md` holds the decisions, invariants and working rules; this file holds state and the plan. +## Conditional forwarding update + +`upstream.forward_zones` now routes an apex and its descendants to dedicated +upstreams, using the most specific case-insensitive label suffix. Routes reuse +health/breaker tracking and UDP-to-TCP retry, never fall back to public servers, +and apply to background cache refreshes too. Authoritative local zones, ACLs, +and blocklists retain their precedence. Upstream reloads flush the cache; console +upstream edits preserve the YAML-only routes and do not promote their targets to +default servers. The Kubernetes config routes `local` to `10.10.0.1:53` over UDP. +Direct UDP and TCP probes for `fastpi.local` both returned `10.10.11.117`. +This repository change still needs a rebuilt image and rollout to become live. + ## Goal A lightweight, high-performance recursive DNS server with: diff --git a/deploy/k8s/00-config.yaml b/deploy/k8s/00-config.yaml index 0033a73..81e432e 100644 --- a/deploy/k8s/00-config.yaml +++ b/deploy/k8s/00-config.yaml @@ -39,6 +39,11 @@ data: base_path: /dns-query upstream: + forward_zones: + - zone: local + servers: + - addr: 10.10.0.1:53 + proto: udp policy: parallel max_attempts: 3 healthcheck_interval: 30s diff --git a/internal/app/app.go b/internal/app/app.go index 527dfab..e00ba87 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -717,6 +717,7 @@ func (a *App) onConfigSwap(cfg *config.Config) { a.log.Info("configuration applied", "version", cfg.Version) if a.pool != nil { a.pool.Rebind(cfg.Upstream) + a.invalidateBlocklistCache() } a.reloadLocalZones() if a.blocks != nil && !cfg.Block.Enabled { diff --git a/internal/app/hooks.go b/internal/app/hooks.go index b8f5abe..2d84f6e 100644 --- a/internal/app/hooks.go +++ b/internal/app/hooks.go @@ -788,9 +788,11 @@ func (a *App) putACL(_ context.Context, groups []config.Group, def string) (int6 // getUpstreams returns the pool state. func (a *App) getUpstreams() any { cfg := a.cfg.Get() - servers := make([]resolver.Stats, 0, len(a.pool.All())) - for _, s := range a.pool.Stats() { - servers = append(servers, s) + // Keep forwarding targets out of the editable default-server list. + defaults := a.pool.Defaults() + servers := make([]resolver.Stats, 0, len(defaults)) + for _, u := range defaults { + servers = append(servers, u.Stats()) } return map[string]any{ "policy": cfg.Upstream.Policy, @@ -810,6 +812,8 @@ func (a *App) getUpstreams() any { // putUpstream validates and persists a new upstream config. func (a *App) putUpstream(_ context.Context, up config.Upstream) (int64, error) { err := a.mutateConfig(func(c *config.Config) error { + // The console edits default servers; preserve YAML-only forwarding rules. + up.ForwardZones = c.Upstream.ForwardZones c.Upstream = up return nil }) diff --git a/internal/config/config.go b/internal/config/config.go index 0a262df..664b728 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,7 @@ import ( "strings" "time" + "github.com/miekg/dns" "gopkg.in/yaml.v3" ) @@ -202,7 +203,14 @@ type EDNS struct { PreserveEDNS0 bool `yaml:"preserve"` } +// ForwardZone routes the apex and every name below it exclusively to Servers. +type ForwardZone struct { + Zone string `yaml:"zone" json:"zone"` + Servers []UpstreamServer `yaml:"servers" json:"servers"` +} + type Upstream struct { + ForwardZones []ForwardZone `yaml:"forward_zones,omitempty" json:"forward_zones,omitempty"` // Servers is the upstream pool. Servers []UpstreamServer `yaml:"servers"` // Policy is "parallel" (query all, first answer wins), "random" (pick N) @@ -962,6 +970,24 @@ func (c *Config) applyDefaults() { l.MaxBytes = 512 << 20 } } + for i := range c.Upstream.ForwardZones { + z := &c.Upstream.ForwardZones[i] + z.Zone = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(z.Zone), ".")) + for j := range z.Servers { + u := &z.Servers[j] + if u.Proto == "" { + u.Proto = "udp" + } + if u.Weight <= 0 { + u.Weight = 1 + } + if u.Addr != "" { + if _, _, err := net.SplitHostPort(u.Addr); err != nil { + u.Addr = net.JoinHostPort(u.Addr, defaultPort(u.Proto)) + } + } + } + } for i := range c.Upstream.Servers { u := &c.Upstream.Servers[i] if u.Proto == "" { @@ -1035,6 +1061,31 @@ func (c *Config) Validate() error { if len(c.Upstream.Servers) == 0 { add("upstream.servers must not be empty") } + zones := map[string]bool{} + for i, z := range c.Upstream.ForwardZones { + name := strings.ToLower(strings.TrimSuffix(z.Zone, ".")) + if _, ok := dns.IsDomainName(name); !ok || name == "" || strings.Contains(name, "*") { + add("upstream.forward_zones[%d]: invalid zone %q", i, z.Zone) + } + if zones[name] { + add("upstream.forward_zones[%d]: duplicate zone %q", i, z.Zone) + } + zones[name] = true + if len(z.Servers) == 0 { + add("upstream.forward_zones[%d]: servers must not be empty", i) + } + for _, u := range z.Servers { + host, port, err := net.SplitHostPort(u.Addr) + if err != nil || host == "" || port == "" { + add("upstream.forward_zones[%d]: invalid server address %q", i, u.Addr) + } + switch u.Proto { + case "udp", "tcp", "tls": + default: + add("upstream.forward_zones[%d]: invalid proto %q", i, u.Proto) + } + } + } seen := map[string]bool{} for i, u := range c.Upstream.Servers { if u.Addr == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7492fbf..80950da 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -371,3 +371,33 @@ func writeTempConfig(t *testing.T, content string) string { } return path } + +func TestForwardZoneConfig(t *testing.T) { + c := Default() + c.Block.Enabled = false + c.Upstream.ForwardZones = []ForwardZone{{Zone: "LOCAL.", Servers: []UpstreamServer{{Addr: "10.10.0.1"}}}} + c.applyDefaults() + if err := c.Validate(); err != nil { + t.Fatal(err) + } + z := c.Upstream.ForwardZones[0] + if z.Zone != "local" || z.Servers[0].Addr != "10.10.0.1:53" || z.Servers[0].Proto != "udp" { + t.Fatalf("defaults: %+v", z) + } + for _, bad := range []ForwardZone{ + {Zone: "*.local", Servers: z.Servers}, + {Zone: "", Servers: z.Servers}, + {Zone: "local"}, + {Zone: "local", Servers: []UpstreamServer{{Addr: "", Proto: "udp"}}}, + {Zone: "local", Servers: []UpstreamServer{{Addr: "10.10.0.1:53", Proto: "http"}}}, + } { + c.Upstream.ForwardZones = []ForwardZone{bad} + if err := c.Validate(); err == nil { + t.Fatalf("accepted %+v", bad) + } + } + c.Upstream.ForwardZones = []ForwardZone{z, z} + if err := c.Validate(); err == nil { + t.Fatal("accepted duplicate zone") + } +} diff --git a/internal/resolver/pool.go b/internal/resolver/pool.go index 1d1e37c..53bcfbb 100644 --- a/internal/resolver/pool.go +++ b/internal/resolver/pool.go @@ -255,8 +255,10 @@ func (u *Upstream) Stats() Stats { // from, the upstreams, and the address index used for per-client upstream // overrides. Readers take the pointer once and are then immune to reloads. type poolState struct { - cfg config.Upstream - ups []*Upstream + cfg config.Upstream + ups []*Upstream + defaults []*Upstream + routes map[string][]*Upstream // byAddr indexes the upstreams by every address they answer to, so an ACL // group naming either the configured Addr or the resolved one works. byAddr map[string][]*Upstream @@ -344,6 +346,22 @@ func buildState(cfg config.Upstream, carry *poolState, lookup bool) *poolState { } st.ups = append(st.ups, up) } + st.defaults = append([]*Upstream(nil), st.ups...) + st.routes = make(map[string][]*Upstream) + for _, z := range cfg.ForwardZones { + zone := strings.ToLower(dns.Fqdn(z.Zone)) + for _, s := range z.Servers { + addr, resolved := resolveAddr(s, bootstraps, lookup) + up := &Upstream{cfg: s, addr: addr, resolved: resolved} + if old := prev[s.Addr+"/"+s.Proto]; old != nil { + up.stats = old.stats + } else { + up.stats = newUpstreamStats() + } + st.ups = append(st.ups, up) + st.routes[zone] = append(st.routes[zone], up) + } + } st.byAddr = make(map[string][]*Upstream, len(st.ups)*2) for _, up := range st.ups { for _, a := range append([]string{up.cfg.Addr, up.addr}, up.resolved...) { @@ -392,6 +410,9 @@ func (p *Pool) Config() config.Upstream { // All returns every upstream. func (p *Pool) All() []*Upstream { return p.state.Load().all() } +// Defaults returns the servers eligible for queries without a forwarding route. +func (p *Pool) Defaults() []*Upstream { return p.state.Load().defaults } + // Get returns an upstream by configured or resolved address, or nil. func (p *Pool) Get(addr string) *Upstream { for _, u := range p.All() { @@ -457,8 +478,26 @@ func (p *Pool) clientFor(u *Upstream) *dns.Client { // attempt. func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, want []string, timeout time.Duration) (*dns.Msg, string, error) { st := p.state.Load() - candidates := st.all() - if len(want) > 0 { + candidates := st.defaults + // Walk complete labels, most specific first. A matched route never falls + // back to public resolvers, including when every target is unavailable. + matched := false + if len(q.Question) > 0 { + name := strings.ToLower(dns.Fqdn(q.Question[0].Name)) + for name != "" { + if route, ok := st.routes[name]; ok { + candidates = route + matched = true + break + } + dot := strings.IndexByte(name, '.') + if dot < 0 { + break + } + name = name[dot+1:] + } + } + if !matched && len(want) > 0 { candidates = p.restrict(st, want) } if len(candidates) == 0 { diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 2b70f7a..f5aa66a 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -656,3 +656,42 @@ func TestHealthCheckProbesEveryUpstream(t *testing.T) { t.Error("the dead upstream was not taken out of rotation") } } + +func TestForwardZones(t *testing.T) { + public := newFakeUpstream(t, fakeOpt{ip: "203.0.113.1"}) + firewall := newFakeUpstream(t, fakeOpt{ip: "10.10.11.117", truncateUDP: true}) + specific := newFakeUpstream(t, fakeOpt{ip: "10.10.11.118"}) + cfg := poolCfg(udpServer(public.addr)) + cfg.ForwardZones = []config.ForwardZone{ + {Zone: "local", Servers: []config.UpstreamServer{udpServer(firewall.addr)}}, + {Zone: "lab.local", Servers: []config.UpstreamServer{udpServer(specific.addr)}}, + } + p := NewPool(cfg) + for _, tc := range []struct{ name, addr string }{ + {"fastpi.LoCaL.", firewall.addr}, {"local.", firewall.addr}, + {"host.lab.local.", specific.addr}, {"notlocal.", public.addr}, + {"local.example.", public.addr}, + } { + _, addr, err := p.Exchange(testCtx(t), query(tc.name), []string{public.addr}, time.Second) + if err != nil || addr != tc.addr { + t.Fatalf("%s: addr=%s err=%v, want %s", tc.name, addr, err, tc.addr) + } + } + if firewall.tcpHits.Load() == 0 { + t.Fatal("forwarded truncated UDP answer did not retry TCP") + } + before := public.udpHits.Load() + cfg.ForwardZones[0].Servers = []config.UpstreamServer{udpServer("127.0.0.1:1")} + p.Rebind(cfg) + if _, _, err := p.Exchange(testCtx(t), query("fastpi.local."), nil, 50*time.Millisecond); err == nil { + t.Fatal("dead route succeeded") + } + if public.udpHits.Load() != before { + t.Fatal("private query leaked to public upstream") + } + cfg.ForwardZones = nil + p.Rebind(cfg) + if _, addr, err := p.Exchange(testCtx(t), query("fastpi.local."), nil, time.Second); err != nil || addr != public.addr { + t.Fatalf("removed route: %s %v", addr, err) + } +}