From 87b6946ab78a37c4eb6a0ec0380fcdeaa04d9890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Mon, 28 Sep 2026 15:37:46 +0200 Subject: [PATCH] Support RPZ address overrides for local DNS RPZ A records were parsed as address redirects, but the query pipeline treated the address as a CNAME target and returned the wrong result. Answer IP-literal redirects directly with the matching A or AAAA record, and return NODATA for other query types. Add the requested EdgeRouter domain overrides as a local RPZ source so each bare owner covers its apex and all subdomains. Mount that source into the pods and document the address-record form. When the Zones page has no existing zone, start the Add Record form in New zone mode and tell the operator to enter its apex. --- README.md | 2 +- deploy/k8s/00-config.yaml | 5 +++++ deploy/k8s/07-rpz.yaml | 16 ++++++++++++++++ deploy/k8s/10-deployment.yaml | 7 +++++++ deploy/k8s/kustomization.yaml | 1 + internal/engine/engine.go | 27 ++++++++++++++++++++++++--- web/src/pages/Zones.tsx | 15 +++++++++++++-- 7 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 deploy/k8s/07-rpz.yaml diff --git a/README.md b/README.md index 08d7003..a85bc68 100644 --- a/README.md +++ b/README.md @@ -495,7 +495,7 @@ plain-list entry becomes `**.name`. | `hosts` | `IP name [name…]`, `0 name`, `localhost` lines, `#`/`;` comments | `0.0.0.0 ads.example.com` | `localhost`/`broadcasthost`/`ip6-*` lines are skipped; a line whose first field is not an address falls back to the plain parser, with a notice in the parse stats | | `adblock` | `\|\|host^` and `\|http://host/…` (blocked), `@@\|\|host^` (allowed), bare hostnames | `\|\|ads.example.com^$third-party` | element-hiding rules (`##`, `#@#`, `#?#`) and regex rules are skipped and reported in `stats.notices`; a URL path rule blocks the whole host | | `plain` | one name per line, `0.0.0.0 name`, `!allow name` / `!name` / `@@…` allow markers, adblock tails on pasted lines | `ads.example.com` | a single-label name without a dot is rejected | -| `rpz` | an RFC 1035 policy zone | `ads.example.com CNAME .` | parsed by `internal/zonefile`; the zone's own default TTL wins for records with no TTL column; a bare owner name covers the domain and everything under it (`**.name`, RPZ semantics), an owner written `*.name` covers only its subdomains | +| `rpz` | an RFC 1035 policy zone | `ads.example.com CNAME .` | parsed by `internal/zonefile`; the zone's own default TTL wins for records with no TTL column; a bare owner name covers the domain and everything under it (`**.name`, RPZ semantics), an owner written `*.name` covers only its subdomains; `example.com A 192.0.2.10` redirects matching A queries to that address | | `auto` | sniffs the first 200 meaningful lines | — | default; a `$TTL`/`$ORIGIN`/`$INCLUDE` line or a `name TTL IN A …` shape means `rpz`, an address-first line means `hosts`, `\|\|`/`##`/`@@` means `adblock`, otherwise `plain` | **Categories.** `blocklist.lists[].categories` are copied onto every rule from diff --git a/deploy/k8s/00-config.yaml b/deploy/k8s/00-config.yaml index cfdea52..0033a73 100644 --- a/deploy/k8s/00-config.yaml +++ b/deploy/k8s/00-config.yaml @@ -84,6 +84,11 @@ data: managed_zones: /var/lib/fancy-dns/managed-zones local_zones: [/etc/fancy-dns/zones/*.zone] lists: + - name: edge-router-x-address-overrides + format: rpz + path: /etc/fancy-dns/rpz/address-overrides.rpz + enabled: true + refresh: 0s - name: haas format: plain url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts diff --git a/deploy/k8s/07-rpz.yaml b/deploy/k8s/07-rpz.yaml new file mode 100644 index 0000000..9a5b512 --- /dev/null +++ b/deploy/k8s/07-rpz.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: fancy-dns-rpz + namespace: dns + labels: + app.kubernetes.io/name: fancy-dns + app.kubernetes.io/component: address-overrides +data: + address-overrides.rpz: | + $TTL 60 + tornberg.me A 10.10.3.10 + knatofs.se A 10.10.3.10 + k6n.net A 10.10.3.10 + slask.local A 10.10.3.10 + iamu.dev A 10.10.3.10 diff --git a/deploy/k8s/10-deployment.yaml b/deploy/k8s/10-deployment.yaml index c5d4745..f89b5f9 100644 --- a/deploy/k8s/10-deployment.yaml +++ b/deploy/k8s/10-deployment.yaml @@ -139,6 +139,9 @@ spec: - name: zones mountPath: /etc/fancy-dns/zones readOnly: true + - name: rpz + mountPath: /etc/fancy-dns/rpz + readOnly: true - name: tls mountPath: /etc/fancy-dns/tls readOnly: true @@ -157,6 +160,10 @@ spec: configMap: name: fancy-dns-zones optional: false + - name: rpz + configMap: + name: fancy-dns-rpz + optional: false - name: tls secret: secretName: fancy-dns-tls diff --git a/deploy/k8s/kustomization.yaml b/deploy/k8s/kustomization.yaml index 1a4df47..bbba73f 100644 --- a/deploy/k8s/kustomization.yaml +++ b/deploy/k8s/kustomization.yaml @@ -12,6 +12,7 @@ resources: - 00-config.yaml - 05-serviceaccount.yaml - 06-zones.yaml + - 07-rpz.yaml - 10-deployment.yaml - 20-service.yaml - 30-ingress.yaml diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 0025a7e..b7f018f 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -1033,9 +1033,9 @@ func sinkhole(q *dns.Msg, cfg config.Block) *dns.Msg { return m } -// redirect answers with a CNAME to a configured name, plus an A/AAAA for the -// target when we know it (a CNAME to a name that does not resolve would be a -// worse answer than no CNAME at all). +// redirect answers with the configured address when target is an IP literal. +// Otherwise it answers with a CNAME to a configured name, plus an A/AAAA for +// the target when available. func redirect(q *dns.Msg, target string, ttl uint32) *dns.Msg { m := new(dns.Msg) m.SetReply(q) @@ -1046,6 +1046,27 @@ func redirect(q *dns.Msg, target string, ttl uint32) *dns.Msg { if len(q.Question) == 0 { return m } + if addr, err := netip.ParseAddr(target); err == nil { + addr = addr.Unmap() + var rrType uint16 + switch { + case addr.Is4() && q.Question[0].Qtype == dns.TypeA: + rrType = dns.TypeA + case addr.Is6() && q.Question[0].Qtype == dns.TypeAAAA: + rrType = dns.TypeAAAA + default: + m.Ns = []dns.RR{syntheticSOA(q, ttl)} + return m + } + rr, err := dns.NewRR(q.Question[0].Name + " 0 IN " + dns.TypeToString[rrType] + " " + addr.String()) + if err == nil { + rr.Header().Ttl = ttl + m.Answer = []dns.RR{rr} + } else { + m.Ns = []dns.RR{syntheticSOA(q, ttl)} + } + return m + } cname, err := dns.NewRR(q.Question[0].Name + " 0 IN CNAME " + dns.Fqdn(target)) if err != nil { return m diff --git a/web/src/pages/Zones.tsx b/web/src/pages/Zones.tsx index 754da2e..17468b1 100644 --- a/web/src/pages/Zones.tsx +++ b/web/src/pages/Zones.tsx @@ -187,7 +187,14 @@ export default function Zones() { const openNew = (): void => { setEditing(null) - setForm({ zone: origin, name: '', type: data?.types[0] ?? 'A', ttl: '', value: '' }) + setNewZone('') + setForm({ + zone: origin || NEW_ZONE, + name: '', + type: data?.types[0] ?? 'A', + ttl: '', + value: '', + }) setFormError(null) setRecordOpen(true) } @@ -210,7 +217,11 @@ export default function Zones() { setFormError(null) const zoneName = form.zone === NEW_ZONE ? newZone.trim() : form.zone if (zoneName === '') { - setFormError('name the zone this record belongs to') + setFormError( + form.zone === NEW_ZONE + ? 'enter the new zone apex' + : 'choose the zone this record belongs to', + ) return } if (form.name.trim() === '') {