esc
↑↓ move ↵ open

Response Policy Zones

Policy feeds that rewrite, deny or drop answers, by name, by client address, or by the address in the answer.

RPZ expresses DNS policy as a zone. The rules are ordinary resource records in the standard encoding, which means commercial and community feeds load unmodified, whether you fetch them as a file or transfer them over AXFR.

[rpz]
enabled = true
mode    = "shadow"

[[rpz.zone]]
name   = "badfeed"
file   = "/var/lib/sdns/badfeed.zone"
policy = "given"

Start in shadow

mode = "shadow" evaluates every rule and counts every match, and changes no answer. mode = "enforce" applies them.

Shadow exists because a policy feed you have not run against your own traffic is an unknown. Watch rpz_action_total for a day; the counts are exactly what enforcement would have done. When the numbers look like what you intended, switch to enforce.

Policy applies to the recursive service: client queries with RD=1 in class IN. A query with RD=0, a question in another class, and the lookups sdns makes on its own behalf are not checked against it.

What can trigger a rule

The query name. The classic trigger: a rule owned by evil.example.rpz.zone. matches a query for evil.example.

The client’s address. A rule under rpz-client-ip matches on who is asking, which is how you carve out a network from a policy, or apply one only to it.

An address in the answer. A rule under rpz-ip matches on what the answer turned out to contain, the case a name-based feed cannot cover, because the name is new and only the hosting address is known bad.

Answer-address rules are evaluated after resolution, against the answer that came back. The cache still holds what the authority actually said; the policy is applied to the response the client receives, not to the stored copy. Two clients under different policies therefore share one cache entry and still get their own answers.

Actions

The feed’s own rules select an action. A zone may override them all:

policy Effect
given Use whatever the feed’s rule says (the default)
passthru Match, count, but answer normally
nxdomain Answer NXDOMAIN
nodata Answer NOERROR with no records
drop Send nothing at all
tcp-only Over UDP, answer truncated, forcing the client to retry over TCP; over any other transport, answer normally
cname Rewrite every match to cname (which must then be set)
disabled Load and count the zone, never act on it

disabled is per-zone what shadow is server-wide: it lets you add a feed and watch it in isolation while other zones stay in enforce.

A rewritten answer says so. An NXDOMAIN, a NODATA or a local data answer, cname included, carries the policy zone’s SOA in its Additional section, EDE 17 (Filtered) with the text rpz: filtered by zone <name> for a client that uses EDNS, and the AD bit cleared. A tcp-only reply is the bare truncated header and carries none of these.

Multiple zones

Zones are evaluated in the order written. The first enabled zone with a match wins, and no later zone can override it. A disabled zone never wins: its match is counted and the walk continues to the next zone. Order your feeds from most trusted to least.

File zones

[[rpz.zone]]
name   = "urlhaus"
file   = "/var/lib/sdns/urlhaus.rpz"
origin = "rpz.urlhaus.abuse.ch."
policy = "given"

Files are reloaded automatically when replaced, so a download cron is enough to keep a feed current, no restart and no signal. A replacement that fails to load, or that compiles no rules at all, is logged and counted in rpz_reload_errors_total, and the previous rules keep serving.

origin matters for downloaded feeds. Many publish their SOA as @ with the rules relative to it, leaving the apex for the consuming server to supply. Those files need origin. A file whose SOA carries an absolute owner name does not. Getting this wrong is not silent: the zone fails to compile and sdns -t says so, which is the point of running it before a restart.

AXFR zones

[[rpz.zone]]
name     = "vendorfeed"
source   = "203.0.113.5:53"
origin   = "rpz.vendor.example."
tsig_key = "feedkey.:hmac-sha256.:c2VjcmV0"

An AXFR-fed zone names its primary and its apex, and never a file. It follows the feed’s own SOA schedule: refresh, retry, and withdrawal of the rules once the zone passes SOA expire. Withdrawal fails open, an expired feed stops applying rather than starting to deny.

tsig_key is name:algorithm:base64-secret and signs the transfer when the provider requires it. A transfer whose serial has gone backwards is refused at the probe rather than being applied. A transfer that fails, compiles no rules, or whose SOA declares an expire of zero is not installed either: it is logged, the installed copy keeps serving until its own expire, and the next attempt comes after the SOA retry interval.

Watching it

rpz_action_total        matches by zone and action
rpz_zone_rules          rules currently loaded per zone
rpz_zone_rules_skipped  rules the loader could not use
rpz_zone_serial         serial of each AXFR zone, -1 for file zones and withdrawn feeds
rpz_reload_errors_total failed or empty file loads, AXFR feeds withdrawn at expire

rpz_reload_errors_total does not count a failed transfer: the feed retries on its SOA schedule, and a run of failures shows up as the counter moving once, when the copy passes its expire and is withdrawn. Watch the log, or rpz_zone_serial standing still, for the failures themselves.

Rules under rpz-nsdname and rpz-nsip are not evaluated. The loader skips them and counts them in rpz_zone_rules_skipped with reason="trigger-unsupported", so a feed that carries them shows a steady count there from its first load. The zone still works, without those rules. A feed made only of such rules compiles no rules at all, so sdns -t refuses it as a file zone, and as an AXFR zone it is never installed.

rpz_zone_rules_skipped climbing after a feed update usually means the feed started emitting a record type or an encoding this loader does not accept; the zone still works, with fewer rules than the publisher intended.