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>
7.2 KiB
Split DNS with views
Views let named answer differently depending on who asks. The usual setup for a homelab that is also authoritative on the internet has two views:
- internal: your networks get recursion (they use the server as their
resolver) and see private addresses (
nas.adbraun.lol → 10.10.0.20) and internal-only zones such as reverse zones for private networks. - external: everyone else gets authoritative answers only. That means public addresses, no recursion, and no internal zones.
How named treats views (and why Jarl asks to move your zones)
Without any view statements, named puts every zone into an implicit
default view. That's why an imported server shows no views. As soon as
one view exists, named requires every zone to be inside a view. So the
first view you create has to take over the existing zones, including the
root hints zone . that Debian/Ubuntu configure.
Jarl does this in the same changeset that creates the view:
- web UI: Views → New view. The box "Move the N zone(s) that are not in a view into this view" is ticked by default.
- CLI:
jarl view create NAME --adopt-zones - API:
"adopt_zones": true
Creating the first view without it is refused before anything reaches
named (zone_outside_view).
Views are matched in order: the first view whose match-clients (and
match-destinations) fit the client wins. Put the specific view first and
the catch-all last.
Step by step
1. Check the ACL for your networks
The internal view matches your networks. Usually that's the ACL you already
use for allow-recursion:
jarl acl list
jarl acl update homelab --add localhost # if missing; see the note below
Include localhost if the DNS server itself uses 127.0.0.1 as its
resolver. Otherwise the server's own lookups land in the external view and
get no recursion.
2. Create the internal view and move the existing zones into it
jarl view create internal --match-clients homelab --adopt-zones --dry-run # look first
jarl view create internal --match-clients homelab --adopt-zones
create view internal
move zone . into view internal
move zone 0.10.10.in-addr.arpa into view internal
move zone adbraun.lol into view internal
applied (changeset 4)
Nothing changes for your clients: they match internal and get the same
answers as before. The server-wide options (recursion, allow-recursion,
forwarders, ...) still apply, because views inherit them.
3. Create the external view
jarl view create external --match-clients any
jarl option set recursion no --view external
jarl option set allow-recursion '{ none; }' --view external
The inherited allow-recursion { homelab; } already keeps outsiders from
recursing. The explicit recursion no also stops them reading the cache,
and makes the intent obvious. Setting allow-recursion { none; } as well
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 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 should differ:
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
jarl record list adbraun.lol --view external
jarl record update <id> --data 203.0.113.20 # public address instead of 10.10.0.20
jarl record delete adbraun.lol nas-internal A --view external
From then on the two copies are separate zones. A record added to one isn't in the other, and the serials are separate.
The same data in both views. Use an in-view zone. The external view
then serves the internal view's zone, so there is one copy to maintain:
jarl zone create example.org --view external --type in-view --in-view internal
Don't do that for zones with private data. Internal-only zones (reverse zones for 10.x / 192.168.x) simply don't get an external copy: named answers outsiders with NXDOMAIN from its built-in empty zones.
5. Check from both sides
dig @ns1.adbraun.lol nas.adbraun.lol +short # from the LAN: 10.10.0.20
dig @<public-ip> nas.adbraun.lol +short # from outside: 203.0.113.20
dig @<public-ip> example.com # from outside: status REFUSED
jarl check
On the server, dig -b <source address> picks which view you test, or,
from anywhere, sign the query with the view's key (below):
jarl view key external --named-conf > external.key
dig -k external.key @ns1.adbraun.lol nas.adbraun.lol +short # 203.0.113.20, even from the LAN
View keys
named picks a view by where a query comes from, so a query this server sends to its own public address usually lands in the internal view: its source is one of your own networks. That makes checks of the external view from the server itself (such as Jarl's delegation check) see the wrong view.
So Jarl gives every view a TSIG key, jarl-view-NAME, and puts it at the
front of the view's match-clients:
view "internal" {
match-clients { !key jarl-view-external; key jarl-view-internal; homelab; };
...
view "external" {
match-clients { key jarl-view-external; any; };
A query signed with a view's key lands in that view, whatever its source;
the !key stops an earlier view from taking a later view's queries.
Clients don't send these keys, so for them nothing changes. Jarl signs its
own queries to this server's addresses with the key of the zone's view.
The keys live in keys.conf with your other keys, never in diffs or in
jarl tsig list; their secrets are derived from a random value in Jarl's
database (and in its backups), so they stay the same from one apply to
the next. jarl view key VIEW
shows one (it needs tsig:write and is audited). Key names starting with
jarl-view- are reserved, and the view keys can be used in your own
options (allow-transfer { key jarl-view-external; }).
Living with views
- Commands that name a zone that exists in several views need
--view(jarl record add adbraun.lol www A ... --view external). Jarl refuses to guess. jarl acmewrites challenge records into every view that has the zone, so Let's Encrypt (external) and internal clients see the same thing. Use--viewto limit it.jarl view order internal externalchanges the match order, andjarl view listshows it.- Deleting a view is refused while it still has zones, unless you pass
--cascade. - Each view has its own page (Views → the view's name): its settings, options and zones.
- Give each view a colour (the view's page → Settings → Colour, or
jarl view color internal green) to tell the zones of each view apart in the zone list. The colour only affects the web UI, so it applies at once, with no changeset or deploy.jarl view color internal defaultgoes back to the link colour. - Zone files are stored per view:
/var/lib/bind/jarl/internal/adbraun.lol.zone,/var/lib/bind/jarl/external/adbraun.lol.zone.