Files
jarl/docs/exposure.md
T
adamandClaude Opus 5.5 bdd7acfd3b Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.

A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.

Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:56 +01:00

8.8 KiB

Internet exposure

When a view (or the whole server) can be reached from the internet, a few things matter that don't on your own network: it shouldn't help attackers flood others with DNS answers (amplification), recurse for strangers, or tell scanners its exact version. Jarl works out which views are public, checks them, and stages the fixes for you to review.

In the web UI: Exposure (and its card on the dashboard). In the CLI: jarl exposure. In the API: GET /api/v1/exposure.

Which views are public

A view is public when its match-clients let in anyone: after ACLs and ranges are filled in, they end in a catch-all (any, 0.0.0.0/0, ::/0, or a prefix wider than /8 for IPv4 or /16 for IPv6), or they are empty, which named reads as "any". A typical external view (match-clients { any; } or { !internal; any; }) is public.

A view that lists the networks it serves is private, whatever kind of addresses those are. An internal view for your global IPv6 prefix (2a02:8013:7f3d:10::/64) is as private as one for 192.168.1.0/24: listing a network says "these are mine". Jarl doesn't look at whether addresses are private (RFC 1918, ULA) for this, because on IPv6 your own devices have global addresses.

A view whose match-destinations list only private addresses (it answers only on a private interface) is private too.

A server without views has no match list, so Jarl looks at allow-query and where named listens: it is public when anyone may query it (allow-query unset, or with a catch-all) and named listens on an address that isn't private (a public IPv4 address or a global IPv6 address of this host). This can't see your firewall, so it is a guess.

When Jarl gets it wrong, override it per view: public, private or auto. For example, behind a NAT port forward named only ever sees private addresses although it is reachable from the internet.

jarl view update external --exposure public
jarl settings set --exposure private          # a server without views

The Exposure page shows each view's classification and the reason ("match-clients ends in any", "match-clients lists only homelab, no catch-all"), with a selector for the override.

The checks

They run on public views only, and are warnings (they don't stop you applying) unless acknowledged.

Code What Fix Jarl can stage
exposure_rate_limit No response rate limiting: the view can be used to flood others with its answers (DNS amplification) rate-limit { responses-per-second 10; log-only yes; exempt-clients { your secondaries }; }
exposure_rate_limit_log_only (info) Rate limiting only logs The same block without log-only: start enforcing
security_open_resolver Recursion for anyone: an open resolver recursion no in that view
exposure_minimal_any ANY queries get full answers, a favourite for amplification minimal-any yes
exposure_cache_snooping allow-query-cache lets anyone see what your clients look up allow-query-cache { none; }
security_open_transfer Zone transfers for anyone none: allow your secondaries or a TSIG key
exposure_open_notify allow-notify accepts NOTIFY from anyone none: allow your primaries
exposure_version dig CH TXT version.bind shows the exact BIND version version none
exposure_hostname dig CH TXT hostname.bind shows the host name hostname none
exposure_management controls or statistics-channels on an address that isn't loopback, open to anyone none: use 127.0.0.1 or an allow list

The last three are server-wide: they are checked when any view is public.

A fix goes into your pending changes like any edit (or is applied at once in live mode), so you see the named.conf diff before it reaches named:

jarl exposure                                         # classification, findings, fixes
jarl exposure fix external exposure_rate_limit
jarl exposure fix server exposure_version

Response rate limiting

Response rate limiting (RRL, BIND's rate-limit) counts identical responses sent to the same client network. Past the limit, named drops some and "slips" others: it sends a short reply asking the client to retry over TCP (or with a DNS cookie). A real client does; the spoofed victim of an amplification attack never sees the answers it didn't ask for. ISC recommends it for authoritative servers only, so Jarl only suggests it for public views, never for your internal resolver.

Jarl starts from ISC's example, 10 identical responses per second per client network, and exempts the servers your zones notify and allow transfers to (also-notify and allow-transfer addresses, through ACLs too): limiting your own secondaries is the classic RRL mistake.

Rollout in two steps:

  1. The fix turns rate limiting on with log-only yes. named answers everyone as before, but logs every response it would have limited. With query statistics on, Jarl reads those lines: the Exposure page shows how many responses would have been limited, and which client networks and names. Check that your secondaries, monitoring, or a big shared resolver aren't among them (add them to exempt-clients if they are).
  2. When you're happy, Start enforcing (the fix of exposure_rate_limit_log_only) removes log-only.

In log-only mode named's own counters don't move: what would be limited is only in its log (category query-errors). That is why the activity needs query statistics; without them, look at named's log for would rate limit. When rate limiting is configured and statistics are on, Jarl adds query-errors to its log channel (keeping named's syslog destination for it).

The statistics also count it: the rrl breakdown (drop, slip, would-drop, would-slip), the ratelimited and ratelimited_name top lists, and in Prometheus jarl_ratelimit_logged_total{action} next to named's own jarl_ratelimit_responses_total (metrics.md).

You can tune the block like any option (jarl option set rate-limit '{ responses-per-second 20; window 15; exempt-clients { 192.0.2.53; }; }' --view external); the BIND ARM describes every setting.

Confirming a change that opens a view up

A change that introduces an open resolver, open zone transfers, cache snooping, open NOTIFY or an open management interface on a public view must be confirmed:

  • Web UI: the Changes page shows the finding with a checkbox (I understand: …); Apply stays disabled until it is ticked.
  • CLI: the command stops (exit 5) and says why; run it again with --accept-risk.
  • API: accept_risk: true in the apply body, or ?accept_risk=1 on a change applied at once; without it the answer is 409 confirm_risk. Previews (GET /changesets/{id}/preview) list them as risks.

Only changes that newly open something up ask: an unrelated change to a view that is already open, or taking back an acknowledgement, doesn't.

Acknowledging a finding

Some findings are deliberate: a public resolver for a club's network, a visible version string. Acknowledge them with a reason:

jarl exposure ack external security_open_resolver --reason "public resolver for the club, monitored"
jarl exposure ack server exposure_version --reason "version shown on purpose"
jarl exposure unack external security_open_resolver

The finding doesn't disappear: it stays visible as info, with who accepted it, when and why (open resolver … (acknowledged by adam on 12 Oct 2026: public resolver for the club, monitored)), on the Exposure page, the dashboard and in jarl check. An acknowledgement covers that one finding on that one view (or the server); the same finding elsewhere warns as usual. Acknowledging is a configuration change like any other, so it is in the history and audit log, and can be reverted.

Jarl isn't designed to run an open resolver, and an acknowledgement makes one deliberate and visible, not safe: a public resolver also needs abuse monitoring, response size limits and usually a machine of its own.

API

Method Path Body
GET /exposure scopes (per view, or the server: public, override, reason, rate_limit off/log-only/enforcing, findings with fix where Jarl has one), server (server-wide findings), rate_limiting (last 24h, with statistics: counts, networks, names)
POST /exposure/fix {"view": "external", "code": "exposure_rate_limit"} (view_id works too; no view: the server). Staged with ?changeset= like any change
POST /exposure/ack {"view": "external", "code": "security_open_resolver", "reason": "..."}
POST /exposure/unack {"view": "external", "code": "security_open_resolver"}

The override is a view's exposure field (PATCH /views/{id}), or the settings' for a server without views (PATCH /settings).