more config
build / build-push (push) Successful in 24s

This commit is contained in:
mats committed 2026-09-28 21:34:59 +02:00
1 parent 0b6dcbd547
commit d48c1863bf
9 files changed
+222 -7

No files matched your search

+34
View File
@@ -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 @<fancy-dns-address> 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.
+12
View File
@@ -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:
+5
View File
@@ -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
+1
View File
@@ -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 {
+7 -3
View File
@@ -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
})
+51
View File
@@ -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 == "" {
+30
View File
@@ -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")
}
}
+43 -4
View File
@@ -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 {
+39
View File
@@ -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)
}
}