Support RPZ address overrides for local DNS
build / build-push (push) Successful in 30s

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.
This commit is contained in:
mats committed 2026-09-28 15:37:46 +02:00
1 parent 47ef5c1133
commit 87b6946ab7
7 files changed
+67 -6

No files matched your search

+1 -1
View File
@@ -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
+5
View File
@@ -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
+16
View File
@@ -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
+7
View File
@@ -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
+1
View File
@@ -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
+24 -3
View File
@@ -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
+13 -2
View File
@@ -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() === '') {