1 parent
0b6dcbd547
commit
d48c1863bf
9 files changed
+222
-7
No files matched your search
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user