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>
184 lines
7.2 KiB
Markdown
184 lines
7.2 KiB
Markdown
# 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`:
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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](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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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):
|
|
|
|
```sh
|
|
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 acme` writes challenge records into **every** view that has the
|
|
zone, so Let's Encrypt (external) and internal clients see the same
|
|
thing. Use `--view` to limit it.
|
|
* `jarl view order internal external` changes the match order, and
|
|
`jarl view list` shows 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 default`
|
|
goes 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`.
|