jarld checks the reverse DNS of the public zones' MX and SPF addresses as the internet sees it, every 6 hours (Exposure page, jarl exposure mail, jarl_mail_reverse_dns_ok). The Server options page, jarl option generated and GET /options/generated list what Jarl writes into named.conf by itself; stats.rate_limit_log turns off the query-errors logging. Option lists of three or more elements are shown one per line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
15 KiB
Markdown
248 lines
15 KiB
Markdown
# 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). `stats.rate_limit_log: false` in jarl.yaml leaves
|
||
`query-errors` alone; the page then can't show what rate limiting does.
|
||
The Server options page lists this, and everything else Jarl adds to
|
||
named.conf by itself, under **Added by Jarl** (`jarl option generated`).
|
||
|
||
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)).
|
||
|
||
### Settings
|
||
|
||
You can tune the block like any option; `option set` replaces the whole
|
||
block, so copy the current value from `jarl option list --view external`
|
||
and edit it:
|
||
|
||
```sh
|
||
jarl option set rate-limit '{ responses-per-second 20; window 15; slip 2; exempt-clients { 192.0.2.53; }; log-only yes; }' --view external
|
||
```
|
||
|
||
named counts **identical responses** to the same **client network** (a
|
||
/24 for IPv4, a /56 for IPv6) over a **window**, and limits each kind of
|
||
response separately. These are the settings (BIND 9.20; the ARM has the
|
||
full text):
|
||
|
||
| Setting | Default | What it does | When to change it |
|
||
|---------|---------|--------------|-------------------|
|
||
| `responses-per-second` | 0 (off) | Answers with data for the same name and type, per client network. Wildcard answers count as the zone's apex. This is the main limit; Jarl's fix sets 10 | Raise it if a legitimate network shows up in log-only mode, e.g. a large shared resolver (mobile carriers, public resolvers) asking for your popular names |
|
||
| `nxdomains-per-second` | same as `responses-per-second` | NXDOMAIN answers (names that don't exist), counted together for a domain whatever the name asked | `0` turns NXDOMAIN limiting off, for zones that legitimately get many lookups of missing names |
|
||
| `nodata-per-second` | same as `responses-per-second` | Empty answers (the name exists, the type doesn't), counted together whatever the type | Rarely |
|
||
| `referrals-per-second` | same as `responses-per-second` | Referrals to child zones you delegate | Only if you delegate subdomains to other servers |
|
||
| `errors-per-second` | same as `responses-per-second` | Other errors (REFUSED, SERVFAIL, FORMERR), counted together whatever the name | Rarely. REFUSED and SERVFAIL can't be truncated, so slipped ones still go out at the slip rate |
|
||
| `all-per-second` | 0 (off) | All UDP answers to a client network, of any kind, like a firewall rate limit; over it, answers are only **dropped** (`slip` doesn't apply) | Usually leave off; if you use it, ISC says at least 4 times the other limits |
|
||
| `window` | 15 seconds (1–3600) | How far back named counts. A client over the limit stays limited until its rate over the window falls again | Longer windows remember attackers longer and also punish short legitimate bursts longer |
|
||
| `slip` | 2 (0–10) | Of the limited answers, every n-th "slips": instead of being dropped it gets a short reply asking the client to retry: truncated (TC) so it retries over TCP, or, when the query carried a DNS cookie, BADCOOKIE with a server cookie that lets it through on its retry. `0` drops all, `1` slips all | `1` if you'd rather every real client gets a retry hint; `0` drops all (spoofed victims see nothing, but real clients just time out) |
|
||
| `ipv4-prefix-length` | 24 | The size of the client network answers are counted for (IPv4) | `32` counts each address on its own: fairer to neighbours, more memory |
|
||
| `ipv6-prefix-length` | 56 | The same for IPv6 | `64` if one /56 holds many independent clients; `48` to treat a whole site as one |
|
||
| `exempt-clients` | none | Address match list (addresses, prefixes, [ranges](concepts.md#model-views-zones-records-options), ACLs) that is never limited. Jarl's fix puts your secondaries here | Add monitoring, other servers of yours, and anything legitimate that log-only mode shows |
|
||
| `log-only` | no | Count and log what would be limited, answer everyone normally | Jarl starts with `yes`; the "start enforcing" fix removes it |
|
||
| `qps-scale` | off | When the total query rate is above this, all limits shrink in proportion (at a rate of 1000 with `qps-scale 250`, a limit of 20 becomes 5) | Only on busy servers that see attacks spread over many networks |
|
||
| `max-table-size`, `min-table-size` | min 500 entries | The table of client networks named tracks (40–80 bytes per entry) | Only on busy servers |
|
||
|
||
What Jarl checked against real named: TCP answers are never limited;
|
||
`nxdomains-per-second` falls back to `responses-per-second` and `0`
|
||
turns it off; NXDOMAIN answers for different missing names in a zone
|
||
share one limit; `all-per-second` only drops; with `slip 1`, clients
|
||
without DNS cookies get truncated answers, and clients with cookies (dig
|
||
sends them by default) get through on their retry with the server's
|
||
cookie; `exempt-clients` is never limited; in log-only mode nothing is
|
||
limited and only the log shows what would be.
|
||
|
||
Rate limiting is per view: put it in the external view (`--view
|
||
external`). Set in the server options, it applies to every view that has
|
||
none of its own, your internal resolver included, which RRL isn't meant
|
||
for.
|
||
|
||
## Mail
|
||
|
||
Receivers such as Gmail and Outlook check the address mail comes from: it
|
||
needs a PTR record (reverse DNS), and the name it gives must resolve back
|
||
to the address. Without that, mail is rejected or marked as spam. The PTR
|
||
records of your public addresses live in the reverse zones of whoever
|
||
owns the addresses, your hosting provider or ISP: you set them in their
|
||
panel, not in Jarl. A reverse zone you create in Jarl only answers your
|
||
own clients (unless the owner delegated it to you).
|
||
|
||
So Jarl checks them as the internet sees them, through the public
|
||
resolvers of `acme.resolvers`: the addresses of every public zone's MX
|
||
hosts (receiving) and SPF `ip4:`, `ip6:`, `a` and `a:` addresses
|
||
(sending). The Exposure page's **Mail reverse DNS** panel shows each
|
||
address, its PTR, who runs its reverse zone (the provider, from the
|
||
zone's name server) and what to do; jarld checks a minute after it starts
|
||
and every 6 hours, and logs an address that goes wrong.
|
||
|
||
```
|
||
$ jarl exposure mail
|
||
ZONE ADDRESS FOR PTR STATUS
|
||
adbraun.lol 172.235.168.106 SPF ip4, MX mail.adbraun.lol mail.adbraun.lol ok
|
||
```
|
||
|
||
It warns when an address has no PTR record, or when its name doesn't
|
||
resolve back to it; notes when the name is an alias (CNAME), or isn't the
|
||
MX name (fine if the mail server uses it in HELO). `jarl exposure mail`
|
||
exits 1 when an address isn't right; Prometheus has
|
||
`jarl_mail_reverse_dns_ok{zone,address}`.
|
||
|
||
## 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`), `rate_limit_log`, `mail` (the last mail check) |
|
||
| POST | `/exposure/mail` | Checks the mail addresses' reverse DNS now: `checked`, `results` (`zone`, `address`, `roles`, `ptr`, `ok`, `level` ok/info/warning, `message`, `reverse_zone`, `managed_by`, `local_zone`), `errors` |
|
||
| 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`).
|