# 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`).