The header says "also in VIEW" for every zone of the same name in another view, linked or not, in the view's colour, keeping the open tab where the other zone has it. The zone API gains in_views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
13 KiB
Markdown
316 lines
13 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
|
|
|
|
**Mostly the same, a few differences (split DNS).** Create the external
|
|
copy *linked* ([below](#linked-zones)): Jarl keeps the shared records the
|
|
same in both views, and each view can have records of its own:
|
|
|
|
```sh
|
|
jarl zone create adbraun.lol --view external --link-with internal
|
|
jarl record set adbraun.lol www A 203.0.113.20 --view external --local # public address, external only
|
|
jarl record delete adbraun.lol nas-internal A --view external # gone from both: it was shared
|
|
```
|
|
|
|
Delete records you don't want outside before you link, or make them local
|
|
to the internal view first (`jarl record update ID --local`): a shared
|
|
record is in both views.
|
|
|
|
**Two separate copies.** `--clone-from` copies the zone once; from then on
|
|
the two are separate zones, and a record added to one isn't in the other:
|
|
|
|
```sh
|
|
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
|
```
|
|
|
|
**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; }`).
|
|
|
|
**Moving between views.** A zone's page lists the other views that have a
|
|
zone of the same name ("also in external", in each view's colour). Each
|
|
one links straight to that zone, on the tab you are on.
|
|
|
|
## Linked zones
|
|
|
|
Most split-horizon zones are the same in every view apart from a few
|
|
names. Linking zones of the same name in different views lets Jarl keep
|
|
the shared records in step, while each view can still have records of its
|
|
own and its own zone options.
|
|
|
|
* **Shared records** are in every linked view. Adding, changing,
|
|
disabling or deleting one in any view does the same in the others, in
|
|
the same changeset: the diff shows every view, and history, undo and
|
|
`jarl check` treat them together.
|
|
* **Local records** are in one view only (`--local`, or **Only here** in
|
|
the web UI). A local record overrides the shared records of the same name
|
|
and type in that view: `www A 10.0.10.5` local to internal, while the
|
|
external view keeps the shared `www A 203.0.113.20`. Delete the local
|
|
records and the name and type are gone from that view only: the other
|
|
views keep their records, each local to its view now. To give the view
|
|
the shared version back instead, **Share** it from a view that has it.
|
|
* A new record joins the set of the same name and type: local if that set
|
|
is local in its view, shared otherwise.
|
|
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
|
|
web UI) makes that view's records of that name and type the shared
|
|
version: the other views' records of that name and type are replaced.
|
|
* Zones stay separate in named, so each keeps its own serial, options
|
|
(allow-transfer, DNSSEC signing, ...) and file, and the views keep their
|
|
own `match-clients`, recursion and order.
|
|
|
|
```sh
|
|
jarl zone link adbraun.lol --view internal --with external # link two existing zones
|
|
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
|
jarl record list adbraun.lol --view internal # LINK column: shared or local
|
|
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local
|
|
jarl record update 1234 --local # this name and type: only this view
|
|
jarl record update 1234 --shared # this view's version, everywhere
|
|
jarl zone unlink adbraun.lol --view vps # records stay, all local
|
|
```
|
|
|
|
**Linking never changes what named answers.** A name and type with the
|
|
same records in every view becomes shared; one that differs stays local to
|
|
each view (Jarl lists them). Share the right version afterwards. Unlinking
|
|
keeps every record, all local; a link left with one zone is dissolved, as
|
|
when the other zone is deleted.
|
|
|
|
`--local` also works on a zone that isn't linked (its records are local
|
|
anyway), so scripts don't need to know; `--shared` there is refused.
|
|
|
|
**Permissions** are checked in every view a change reaches. A token with
|
|
a grant for one view only (`views=internal`) can change that view's local
|
|
records and make overrides, but not a shared record, since that would
|
|
change the other views too: Jarl says so and suggests a local record. A
|
|
grant without a view (the usual `zones=adbraun.lol`) covers every view.
|
|
|
|
**Importing a zone file** into a linked zone (`jarl zone import`) adds
|
|
the records like any others: shared, unless the name and type is local
|
|
there. With `--replace`, shared records missing from the file are deleted
|
|
in every linked view.
|
|
|
|
**ACME challenges** are written once per link, and so reach every linked
|
|
view; `jarl acme`, lego's httpreq and certificates Jarl obtains all do
|
|
this.
|
|
|
|
**Compared with BIND's `in-view`**, where one view serves another's zone
|
|
as it is: linked zones can differ per view, keep their own options, and
|
|
don't depend on the view order (`in-view` can only use a view defined
|
|
before it).
|
|
|
|
## Comparing views
|
|
|
|
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
|
|
same zone in another view (or all of them), from the view you're on.
|
|
The tab starts with a view the zone is linked to, if any, else the
|
|
first other view; `jarl zone diff` compares with every other view unless
|
|
`--with` names some:
|
|
|
|
```
|
|
$ jarl zone diff adbraun.lol --view internal
|
|
adbraun.lol in internal, compared with external
|
|
linked: a difference is a view's own records, there on purpose
|
|
external: 1 answered differently, 24 only in internal, 2 only in external, 31 the same
|
|
|
|
ANSWERED DIFFERENTLY (1)
|
|
nas A
|
|
internal 10.0.10.20 [local, id 812]
|
|
external 203.0.113.20 [local, id 790]
|
|
|
|
ONLY IN INTERNAL (24)
|
|
printer A 10.0.10.30
|
|
router A 10.0.10.1
|
|
...
|
|
|
|
ONLY IN EXTERNAL (2)
|
|
_acme-challenge TXT "…"
|
|
...
|
|
|
|
31 names and types are the same (--all lists them)
|
|
```
|
|
|
|
* **Answered differently**: names and types more than one view has, with
|
|
different records. In linked zones that is a view's own (local)
|
|
records, there on purpose; between zones that aren't linked, it is a
|
|
difference that crept in. **Use this everywhere** gives every view one
|
|
view's records (in linked zones by sharing them: `jarl record update ID
|
|
--shared`).
|
|
* **Only in …**: names and types some views lack. **Add to external**
|
|
gives that view the same records, its own.
|
|
* **The same**: how many; `--all` (or "List them") shows them.
|
|
|
|
One line per record: the CLI cuts long values to fit the terminal
|
|
(`--wide` shows them whole); the web UI shows the whole value on hover.
|
|
The apex NS records are left out, since views often list their name
|
|
servers differently on purpose.
|
|
|
|
**One name.** `jarl zone diff adbraun.lol nas` (and a type: `... @ TXT`)
|
|
shows just that name in every view, the same records included. In the
|
|
web UI, a record's **Compare** button does the same. `--with external`
|
|
(repeat it) picks the views to compare with.
|
|
|
|
## 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 (once per link, for linked zones). Use `--view` to limit it.
|
|
* `--view all` (or a list) on record commands changes each linked zone
|
|
once: the change reaches the others through the link. With `--local` it
|
|
goes to each view named.
|
|
* `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`.
|