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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 10:38:56 +01:00
1 parent cf550bd512
commit bdd7acfd3b
50 files changed
+2019 -52

No files matched your search

+1
View File
@@ -471,6 +471,7 @@ written in Jarl's fixed order (docs/concepts.md).
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
| GET | `/catalog/rrtypes` | record types with field hints and examples |
| GET | `/catalog/grants` | resources, roles and the grant syntax |
| GET | `/exposure` | which views are reachable from the internet, the exposure findings and their fixes ([exposure.md](exposure.md#api)); `POST /exposure/fix`, `/exposure/ack`, `/exposure/unack` |
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
| GET | `/search?q=text` | zones, records and ACLs matching the text |
+15
View File
@@ -143,6 +143,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
| `-n, --dry-run` | Validate and show the diff without changing anything. |
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
| `--accept-risk` | Confirm a change that opens a public view up, such as an open resolver ([exposure.md](exposure.md)). Without it such a change stops with exit 5. |
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
## Exit codes
@@ -382,6 +383,20 @@ an administrator).
Changesets staged in the web UI show up here too, and the other way round.
## Internet exposure
```sh
jarl exposure # public views, findings, fixes, rate limiting
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
jarl exposure fix server exposure_version
jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
jarl exposure unack external security_open_resolver
jarl view update external --exposure public|private|auto
```
See [exposure.md](exposure.md).
## Views, ACLs, TSIG keys, options, settings
```sh
+171
View File
@@ -0,0 +1,171 @@
# 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.
```sh
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:
```sh
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](stats.md) 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](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:
```sh
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`).
+2 -1
View File
@@ -148,7 +148,8 @@ for them.
| `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. |
| `jarl_answers_total{source}` | `authoritative` or `recursive`. |
| `jarl_recursion_queries_total`, `jarl_dropped_queries_total`, `jarl_duplicate_queries_total` | Queries that caused recursion, that named dropped, and duplicates. |
| `jarl_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped`. |
| `jarl_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped` (named's counters; they don't move in log-only mode). |
| `jarl_ratelimit_logged_total{action}` | Rate limited responses from named's log: `drop`, `slip`, and in log-only mode `would-drop`, `would-slip` ([exposure.md](exposure.md#response-rate-limiting)). |
| `jarl_upstream_responses_by_rtt_total{rtt}` | Upstream answers during recursion by round-trip time (`<10ms` … `>1600ms`). |
| `jarl_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. |
| `jarl_cache_lookups_total{result}` | `hits` and `misses`. |
+3 -1
View File
@@ -103,6 +103,7 @@ lists, views, zones). named's own counters can't be filtered.
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
| `responses` | response log | lines read (a health check) |
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
| `rrl:drop`, `rrl:slip`, `rrl:would-drop`, `rrl:would-slip` | response log | responses [rate limiting](exposure.md#response-rate-limiting) limited, or would have in log-only mode (named's own `ratelimit:*` counters don't move then) |
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
the samples and `gn:KEY` their number, so a chart shows the average):
@@ -167,7 +168,8 @@ jarl stats top refused # who asks for what they may not have
jarl stats timeline --since 6h # sparkline; --table for every bucket
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
jarl stats heatmap --since 28d
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit | rrl
jarl stats top ratelimited # client networks rate limiting limits (or would)
jarl stats server # memory, CPU, cache, clients, host, disks now
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
jarl stats summary --since 30d -o json # export (also -o yaml)
+15
View File
@@ -124,11 +124,26 @@ From [blocking.md](blocking.md).
## Security lints
These and the exposure checks below only look at views that are
reachable from the internet ([exposure.md](exposure.md)); without views,
the open resolver and open transfer checks look at the server. Any of them
can be acknowledged (it then shows as info, with who accepted it and why),
and a change that newly introduces an open resolver, open transfers, cache
snooping, open NOTIFY or an open management interface on a public view
must be confirmed (`--accept-risk`).
| Code | Severity | What it catches |
|------|----------|-----------------|
| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks |
| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key |
| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead |
| `exposure_rate_limit` | warning | A public view without response rate limiting (`rate-limit`) |
| `exposure_rate_limit_log_only` | info | Rate limiting in log-only mode: check what it would limit, then enforce |
| `exposure_minimal_any` | warning | A public view answering ANY queries in full (`minimal-any` off) |
| `exposure_cache_snooping` | warning | A public view without recursion whose cache anyone may query (`allow-query-cache`) |
| `exposure_open_notify` | warning | A public view accepting NOTIFY from anyone (`allow-notify`) |
| `exposure_version`, `exposure_hostname` | warning | The server is public and shows its BIND version or host name to anyone (`version none`, `hostname none`) |
| `exposure_management` | warning | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone, while the server is public |
## What only BIND checks
+5
View File
@@ -81,6 +81,11 @@ avoids named-checkconf's warning that `allow-recursion` is still active in a
view with recursion off. Jarl warns (`security_open_resolver`) if a view
ever offers recursion to everyone.
Jarl counts the external view as reachable from the internet (its
match-clients end in `any`) and checks it: rate limiting, ANY queries,
the version string and more. See the **Exposure** page or `jarl exposure`,
and [exposure.md](exposure.md) to set up response rate limiting.
### 4. Give the external view its zones
**Different data per view (split DNS).** Copy the zone, then change what