Compare commits

..
Author SHA1 Message Date
adamandClaude Opus 5.5 a93373687e Release 0.6.1-rc.2
release / release (push) Successful in 6m30s
0.6.1's second release candidate: reverse zones sort numeric labels by
value (2 before 101), and a reverse zone no longer reports addresses
only other views' records use as missing PTR records; filling names
them from the other view only with --other-views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:42:34 +01:00
adamandClaude Opus 5.5 6cd3f6ef4e Reverse zones: other views' addresses aren't missing PTRs
A reverse zone's missing PTR count covers only its own view's A and
AAAA records. Addresses only other views' records use are counted apart
(ptr_other_views, 'Other views' in zone show, a note on the zone page)
and filled only when asked: fill-ptr --other-views, zone create
--fill --other-views, or the New reverse zone dialog's checkbox. This
keeps one view's host names out of another view's reverse zones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:40:05 +01:00
adamandClaude Opus 5.5 d18fe18ccd Sort numeric labels by value
Records, zones and findings now order all-digit labels numerically, so
reverse zones list 2 before 101. model.CanonicalLess is replaced by
model.NameLess; other labels keep byte order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:49:58 +01:00
adamandClaude Opus 5.5 fa8feb6bd9 Release 0.6.1-rc.1
release / release (push) Successful in 4m41s
0.6.1's first release candidate: certificates for other hosts (jarl cert
get, cert schedule, the certificates grant), certificate export, the
missing PTR list, links to a zone in other views, one-view tokens with
linked zones, PTR records and overrides in linked zones per view, the
registrar and DS guidance for subdomain and reverse zones, the stats
heatmap and cname_chain fixes. Upgrading a 0.6.0 database was tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:00:20 +01:00
adamandClaude Opus 5.5 12013b12d5 Stats heatmap: empty hours same height; cname_chain only for long chains
The "Busiest times" grid's hours without data used the class "empty",
which picked up the padding of empty messages. One CNAME to another no
longer warns; three or more get an info note showing the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00
adamandClaude Opus 5.5 9db76a113c Certificates for other hosts: jarl cert get
jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.

- Certificates have an owner user; another user can't take the name.
  An existing one is obtained again only when a name is added or
  removed. A new certificates grant (names=) says which names a user
  may have; it is never part of "*". Users may own
  certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
  delete theirs; keys only over HTTPS, the socket or loopback; every
  download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
  saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00
adamandClaude Opus 5.5 b4bb99b482 Certificate export, missing PTR list, simpler "Used for"
- Certificates can be exported for use outside Jarl: Export… on the
  Certificates page (download or copy), jarl cert export, and
  GET /certificates/{name}/pem?part=fullchain|cert|key|bundle.
  Administrators only; key exports are audited.
- A reverse zone's missing-PTR banner lists the addresses and names
  (ptr_missing_list in the zone API).
- "Used for" shows DoT/DoH for named, and "not used by Jarl" instead of
  "nothing yet".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:07:26 +01:00
adamandClaude Opus 5.5 57012fece8 One-view tokens: local records in linked zones, zone lookup, view list
A token for one view of a linked zone (an ACME token for external)
couldn't add records: a new record was shared and reached the other
view. A new name and type is now local when the user may not change it
in the other views. A zone name in several views resolves to the only
one the user can see, and the error no longer names views they can't.
GET /views lists the views a user sees zones in (name, colour, count;
limited) without views:read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:57:06 +01:00
adamandClaude Opus 5.5 4cc2ab2287 Zone page: links to the same zone in the other views
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>
2026-10-18 15:45:41 +01:00
adamandClaude Opus 5.5 a36c54ce60 DNSSEC: say where the DS record really goes
The DNSSEC tab said "give this to your registrar" for every zone whose
parent wasn't in Jarl in the same view. The DNSSEC response now has
ds_at/ds_place/ds_note: the parent zone in Jarl (any view, its own
first, then a public one), the registrar for a registered domain, the
parent zone wherever it is hosted for a subdomain, the ISP or RIR for a
reverse zone, and nowhere for a private name. The page and the CLI use
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:40:58 +01:00
adamandClaude Opus 5.5 82c282cd32 Registrar: only for zones that are a registered domain
With porkbun.domains empty the Registrar tab was offered for every zone
and always failed for subdomain zones, reverse zones and private names.
dnsname.RegisteredDomain (public suffix list) now decides; the others
get a message naming what delegates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:36:39 +01:00
adamandClaude Opus 5.5 65086e314a Linked zones: PTR records follow each view, deleting an override stays deleted
Auto-PTR (and --ptr) changed PTR records in every linked view, whatever
view the address record was in. ptrSync now compares each touched A/AAAA
set before and after, per view, and changes only that view's reverse
zone. PTR records in linked reverse zones are local (fill too); a shared
PTR set is made local in each view before one view changes it.

Deleting a linked zone's local records no longer brings the shared ones
back into the view: the name and type are gone from that view and the
other views keep theirs, local to each. Share still restores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:29:12 +01:00
adamandClaude Opus 5.5 e38390df7d Zone settings: no empty changes, keep an outside name server absolute
- A zone update that changes nothing stages nothing: saving a zone's
  settings unchanged says "Nothing changed" instead of staging an empty
  change. Staging operations that change nothing is a 400 "nothing to
  change".
- The settings form showed the SOA MNAME without its final dot, which the
  server reads as relative to the zone, so saving wg.adbraun.lol turned
  ns1.adbraun.lol. into ns1.adbraun.lol.wg.adbraun.lol. Names outside the
  zone keep the dot, and MNAME is only sent when edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:13:01 +01:00
adamandClaude Opus 5.5 0e663ab36c New token form: only templates within what the token's user may do
A token never does more than its user, so offering wider templates only
misled. On the Users page the templates follow the token's user, not the
administrator making it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:37 +01:00
adamandClaude Opus 5.5 5cd22f52d4 Compare views: start with a view the zone is linked to
The tab picked the first other view in view order, so a zone linked with
one view opened compared with an unrelated one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:09:52 +01:00
adamandClaude Opus 5.5 aca0abc780 Add the jarld.service alias for jarl.service
systemctl restart/status jarld now work too. The package relinks a unit
enabled by an older version on upgrade, so the alias appears without
re-enabling; make uninstall removes the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:54:57 +01:00
adamandClaude Opus 5.5 9175d75d61 Add AGENTS.md and CLAUDE.md for coding agents
Ground rules, building and testing with real BIND, where things live,
web UI rules, BIND gotchas, and the git and release workflow.
docs/development.md lists the packages its layout was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:14 +01:00
adamandClaude Opus 5.5 48f0082d8b Release 0.6.0
release / release (push) Successful in 2m14s
Reverse zones made easy, comparing views rebuilt, exploring the statistics through a query log (filters, several values, exclusions), storage on the dashboard, encrypted DNS with several host names and views per listen address, option set matching named statements, forwarding over TLS documented. Upgrade from 0.5.2 tested in Debian 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:44:10 +01:00
adamandClaude Opus 5.5 d4399cf630 Release 0.6.0-rc.3
release / release (push) Successful in 2m11s
Since rc.2: the Encrypted DNS page and docs test with BIND's dig +tls / +https instead of kdig and curl's JSON API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:38:27 +01:00
adamandClaude Opus 5.5 d2a25d654d Encrypted DNS: test with BIND's dig, not kdig or curl's JSON API
The client hints and docs tested DoT with kdig (Knot's, often not installed) and DoH with curl's application/dns-json, which named's DoH doesn't speak (RFC 8484 wire format only). They use dig +tls / dig +https with +tls-ca and +tls-hostname now, with the port when it isn't the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:38:25 +01:00
adamandClaude Opus 5.5 87368c4aaf Release 0.6.0-rc.2
release / release (push) Successful in 2m13s
Since rc.1: the explore page has a filter bar, several values per filter, !value to leave one out and *.domain, a hide link on list entries, and refreshes with its own filters (the Statistics page's refresh drew over it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:19:04 +01:00
adamandClaude Opus 5.5 7d93c373d1 Explore: refresh with its own filters; the Statistics refresh stays on its page
The Statistics page's once-a-minute refresh also ran on #/stats/explore and drew the Statistics page over it, losing the filters. It now runs on the Statistics page only, and the explore page refreshes itself every minute with the same filters (not while the filter bar is being edited).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:16:10 +01:00
adamandClaude Opus 5.5 e94d3634b6 Explore: several values per filter, !value to leave one out, *.domain
Each explore filter takes several values, comma separated (any of them), !value to leave one out, and *.example.com for a name and the names below it. Every value is a chip with its own ✕; clicking a value in the lists adds it, and hide leaves it out. The filter bar's fields take lists. domain= still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:10:00 +01:00
adamandClaude Opus 5.5 a8c7760fc8 Explore: a filter bar, and Explore the answers on the Statistics page
The explore page has a filter bar to type filters in (client, name or it and below, view, type, result, blocked), starting from the current filters and suggesting values from what is shown, so taking every filter out no longer means going back. The Statistics page opens it with Explore the answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:58:45 +01:00
adamandClaude Opus 5.5 5443e95897 Release 0.6.0-rc.1
release / release (push) Successful in 2m13s
0.6.0's first release candidate: reverse zones made easy, comparing views rebuilt, exploring the statistics through a query log, storage on the dashboard, encrypted DNS with several host names and views per listen address, option set matching named statements, forwarding over TLS documented. The upgrade from 0.5.2 packages was tested in a Debian 13 container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:44:50 +01:00
adamandClaude Opus 5.5 ea26171875 option set: statements with a name replace only that one; forwarding over TLS documented
jarl option set tls NAME (and key, remote-servers, primaries, http, dnssec-policy, key-store, server, acl, view, zone, parental-agents) matches the name after the keyword too: setting tls cloudflare no longer replaces tls quad9. jarl option unset tls NAME removes one by its name. docs/encrypted-dns.md has forwarding to Quad9 or Cloudflare over TLS: one changeset, the options explained, several providers, checking it works, and why not over HTTPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:39:28 +01:00
adamandClaude Opus 5.5 250e734046 Query log wording: N answers so far, each kept for 7 days
"The query log holds 7 answers (stats.retention.queries)" read like 7 days' worth. The dashboard and the Statistics page now say how many answers it holds and how long each is kept; the dashboard's storage info has query_log_retention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:27:56 +01:00
adamandClaude Opus 5.5 1b102a78f6 Statistics page: the statistics database's size, and a fullness bar on the disk tiles
The server section shows the statistics database's size (with its -wal and -shm) and how many answers the query log holds, linking to the dashboard's Storage panel; the disk tiles get the same bar as the dashboard. GET /stats/server has database {path, bytes, query_log_rows}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:24:32 +01:00
adamandClaude Opus 5.5 01ec37b9b2 Explore the statistics: a query log to drill down by client, name, view and more; storage on the dashboard
jarld keeps every answer for a while (stats.retention.queries, default 7 days) in the statistics database: time, client, view, name, type, result and what blocked it. Every client, name, view, record type, result and blocklist on the Statistics page leads to an explore page for it: answers over time, the top values of every other dimension (each a link that narrows further) and the latest answers. GET /stats/explore.

The dashboard shows the size of Jarl's databases (and the query log's rows) and, per disk Jarl uses, how full it is and the databases' share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:15:36 +01:00
adamandClaude Opus 5.5 fef916b7ab Encrypted DNS: several host names, views per listen address, what named uses a certificate for
The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:49:49 +01:00
adamandClaude Opus 5.5 bfab83da6e Reverse zones: name hosts from other views' records; search and bulk dismiss for long lists
A reverse zone's fill (and fill-ptr, the missing-PTR counts) names an address no record of its own view uses from another view's record, so an internal reverse zone can give the PTR of a host only external has an AAAA for; New reverse zone with a view chosen lists those addresses, marked. Automatic suggestions stay per view.

For hundreds of addresses: the New reverse zone list has a search; on the Zones page the public addresses fold away, have a search, show 25 at a time and Dismiss these N dismisses everything a search found in one change (reverse.dismiss takes prefixes; --dismiss takes several).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:26:43 +01:00
adamandClaude Opus 5.5 304c89dba4 New reverse zone: pick from the networks your records use, sized around them
The Zones page's New reverse zone lists every network the records use without a reverse zone, in every view, also where nothing is suggested (suggestions off, dismissed, public views), marked. Picking one fills in the network and view; size buttons (/48 /56 /64 /128, /16 /24 /32) re-mask it around a record's address, so a /48 becomes the records' own /64. Fill stays on. jarl zone suggest-reverse --all [--view V]; GET /reverse-zones/suggestions?all=1&view=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:20:03 +01:00
adamandClaude Opus 5.5 b1719ce362 Compare views rebuilt: from the zone's view, in sections, one line per record
The Compare views tab and jarl zone diff compare a zone with the same zone in other views from the view you're on: a summary per view, what is answered differently (Use this everywhere), the names only some views have (Add to VIEW), and how many are the same. One name can be compared (jarl zone diff ZONE NAME [TYPE], a Compare button per record). The CLI prints one line per record, cut to the terminal (--wide shows it whole); the web UI cuts long values with the whole one on hover. GET /zones/{zone}/compare returns these sections, with ?with, ?name and ?type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:55:57 +01:00
adamandClaude Opus 5.5 ec16de875d Added by Jarl: wrap long statements with a CSS class, not an inline style
The page's Content Security Policy (style-src 'self') blocked the inline style attribute, so long statements didn't wrap and the browser console reported the violation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:27:02 +01:00
adamandClaude Opus 5.5 b24c730b97 Reverse zone suggestions: yours and public, dismiss, per-view switch; child zones take their records
Suggestions split into networks that are yours (private IPv4 /24, ULA /48, an IPv6 /48 this server has an address in), which Create all creates, and public addresses (/32 or /128) to create only if they are yours or dismiss. A view can turn suggestions off, which also keeps it out of choosing a reverse zone's view (database version 13). A zone created inside another in the same view takes the parent's records under it; a delegation and its glue stay, a linked parent keeps them with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:01:18 +01:00
adamandClaude Opus 5.5 aa4b004a14 Reverse zones made easy: suggested, filled, in the right view, PTRs kept in step
Missing reverse zones are suggested for the private views' A and AAAA records (/24 for private IPv4, /32 for a public address, /48 for IPv6) and created filled with PTR records (jarl zone suggest-reverse, the Zones page). zone create --fill and zone fill-ptr add the PTRs a reverse zone lacks; a reverse zone created without a view goes to the private view using its addresses. Per zone, auto_ptr keeps the PTR records of A and AAAA records in step on every change (database version 12). Zone commands take a network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:23:59 +01:00
adamandClaude Opus 5.5 a3b054cc86 Release 0.5.2
release / release (push) Successful in 2m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:02:12 +01:00
adamandClaude Opus 5.5 c27a6c10bd Mail reverse DNS check, what Jarl adds to named.conf, lists one per line
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>
2026-10-12 21:02:11 +01:00
adamandClaude Opus 5.5 d33f573782 Release 0.5.1
release / release (push) Successful in 2m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:46:57 +01:00
adamandClaude Opus 5.5 13ffd8cad4 Encrypted DNS health, comparing a zone's views, --local everywhere
jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:46:56 +01:00
adamandClaude Opus 5.5 67ae0091b3 Release 0.5.0
release / release (push) Successful in 2m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:27:25 +01:00
adamandClaude Opus 5.5 c7e5bbb628 Release 0.5.0-rc.1
release / release (push) Successful in 2m4s
0.4.1's release candidates grew into 0.5.0: exposure checks, certificates, linked zones, encrypted DNS and per-view statistics are new features. The upgrade from 0.4.0 packages was tested in a Debian 13 container. The CLI's filtered stats summary says answers for the busiest minute too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:19:38 +01:00
adamandClaude Opus 5.5 740f3cc328 Release 0.4.1-rc.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:18 +01:00
adamandClaude Opus 5.5 5fcf4082c9 Web UI: Server and account menus keep the top bar on one line
Server options, ACLs, TSIG keys, Exposure, Certificates, Encrypted DNS and the audit log are in a Server menu, Account, Users and Log out under the user's name; the right-hand group stays together when it wraps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:18 +01:00
adamandClaude Opus 5.5 1cec6cc393 Fix "update PTR": add a missing PTR, also in linked views
Ticking update PTR alone did nothing: the web UI didn't send it, and the engine only acted when the name or address changed. Alone it now adds a missing PTR record (or enables it); for linked zones every linked view's reverse zone counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:18 +01:00
adamandClaude Opus 5.5 921bdd6c95 Release 0.4.1-rc.5
release / release (push) Successful in 2m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:33 +01:00
adamandClaude Opus 5.5 12820b17bb Statistics per view: toggle views on the Statistics page
Answers, result codes, record types, zones, blocking, rate limiting and the top lists are also counted per view, from named's response log. The Statistics page has a toggle per view with All and None, remembered per browser; jarl stats --view and ?views= on the API. named's own counters stay for the whole server, and the page says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:33 +01:00
131 changed files with 10941 additions and 548 deletions

No files matched your search

+2
View File
@@ -3,3 +3,5 @@
*.db
*.db-wal
*.db-shm
/jarl
/jarld
+197
View File
@@ -0,0 +1,197 @@
# Working on Jarl
Notes for coding agents (and people) changing this repository. Read this
first. For more depth, see [docs/development.md](docs/development.md) for
layout, building, tests and releasing, and [docs/concepts.md](docs/concepts.md)
for how Jarl works from the outside.
Jarl manages a BIND 9.20 server. It has three parts:
* `jarld`, a daemon with an HTTP API, an embedded web UI and SQLite storage;
* `jarl`, a CLI for that API;
* a Debian package.
It's all Go: module path `jarl`, version in `go.mod`, `CGO_ENABLED=0`. The
SQLite driver is `modernc.org/sqlite`, so there is no cgo. The target is
Debian/Ubuntu with BIND 9.20, using Debian's paths (`/etc/bind`, the `bind`
group, AppArmor).
## Ground rules
* **The database is the source of truth.** Jarl renders `named.conf`,
`keys.conf` and the zone files from it. Never hand-edit rendered output or
add code that reads BIND's files back as state; the importer is the only
exception.
* **Every change goes through the engine** (`internal/engine`). An API
request becomes ops in `expand.go`/`ops.go`. The engine then runs
replay → validate → render → `named-checkconf`/`named-checkzone` →
atomic install → `rndc reload` → verify serials, and rolls back on
failure. Going through the engine gives a change permissions, dry runs,
changesets, history and undo for free, so don't write to the store
directly from handlers.
* **Every user-visible change updates `CHANGELOG.md`** under
`## [Unreleased]`, and the matching page in `docs/` in the same commit.
Docs that may need it:
* `cli.md`, `api.md` and `configuration.md`;
* `validation.md`, for new validation codes;
* `metrics.md`;
* the feature's own page.
Write docs in plain, direct English, like the existing pages.
* **Error messages** say what is wrong and how to fix it, naming the object.
For example: `view external is reachable from the internet …; set
minimal-any yes`.
* **New validation rules** get a stable `code`, a test, and a row in
`docs/validation.md`.
* **Schema changes** append a migration to the `migrations` slice in
`internal/store/store.go`, and never edit an old one. The statistics
database has its own list, `schema` in `internal/stats/store.go`. Add a note
under "Upgrading" in the CHANGELOG when a downgrade would break.
* `make test` runs `go vet` and a `gofmt -l` check, and both must pass.
`goimports` isn't installed, so fix imports by hand.
## Building and testing
```sh
make # bin/jarld, bin/jarl, bash completion
make test # vet + gofmt check + go test ./...
JARL_BIND_ROOT=DIR make test # also runs the integration tests against real BIND
JARL_BIND_ROOT=DIR make e2e # scripts/e2e.sh: real named + jarld, drives every CLI command
```
* **Real BIND without root.** Get the packages with `apt-get download` and
unpack them with `dpkg -x` into a directory; that directory is
`JARL_BIND_ROOT`. The package list is in docs/development.md, under "Tests
against real BIND". Without `JARL_BIND_ROOT` the integration tests skip,
so a green `make test` alone proves less than it looks.
* **Run e2e after any engine, render, CLI or server change.** It prints a
pass count, such as `606/606`; a failure prints the command and its
output.
* It uses fixed ports: DNS 25353, rndc 25953, HTTP 28053, lists 28054 and
metrics 28055. Stop anything left running on them first.
* `KEEP=1` leaves named and jarld running; the last line of the log has
their PIDs.
* `WORK=DIR` must name an empty directory.
* **Certificate tests** run against Pebble in Docker, unless you set
`PEBBLE=0`. If `docker info` fails, the Pebble block is silently skipped;
on this machine it needs `DOCKER_HOST=unix:///var/run/docker.sock`.
* **Unix socket paths** are limited to 107 bytes. Put test sockets under
`/run/user/$UID/`, not under a deep temporary directory.
* **The systemd sandbox isn't tested.** e2e runs outside systemd, so bugs
that only appear in the sandbox (`RestrictAddressFamilies`,
capabilities, read-only paths) need a package test. Run `make deb`, then
install it in a `debian:trixie` container. That container has BIND 9.20;
run it with `--privileged /sbin/init` when you need the real unit.
## Layout and where things live
docs/development.md has the full layout. Some places that are easy to miss:
* **Engine** (`internal/engine`):
* `expand.go` turns requests into ops.
* `linked.go`: linked zones, kept in step across views by `linkSync`.
* `reverse.go`: PTR fill, auto-PTR and reverse zone suggestions.
* `compare.go`: compare views.
* `exposure.go`: fixes for internet exposure.
* **Validation**: `internal/validate` holds all checks and lints.
`internal/catalog` is the BIND 9.20 grammar and ARM text for every
statement. Use it rather than hard-coding option knowledge.
* **Rendering**: `internal/render` writes the configuration.
`generated.go` holds the statements Jarl adds itself, shown to users as
"Added by Jarl".
* **Server**: `internal/server` has one `h_*.go` file per API area; routes
are in `routes.go`. The JSON types are in `internal/api`, shared with the
Go client in `internal/client`.
* **CLI**: `cmd/jarl` is built on cobra. `complete.go` completes the
placeholders in usage lines (`ZONE`, `VIEW`, …), so name them
consistently.
* **Statistics**: `internal/stats` is query statistics, kept in a
**separate** SQLite file, `stats.db`, with its own migrations.
* **Feature packages**: each has its own doc page.
* `encdns`: encrypted DNS;
* `certs` and `acme`: certificates and ACME;
* `blocking`: response policy zones;
* `dnssec`;
* `nscheck`: queries to other name servers;
* `mailcheck`: mail reverse DNS;
* `totp`: two-factor login;
* `backup`;
* `porkbun`: registrar API, with an in-memory `Fake`. Tests never call
the real API.
## Web UI rules
`internal/web/static/` holds the UI: plain ES modules, no framework, no
build step. The files are embedded with `go:embed`.
* **Rebuild jarld after editing them**, or you'll be testing the old files.
* **Build DOM only with `h()`** from `assets/ui.js`. Never use `innerHTML`
or `insertAdjacentHTML`. That's how record data can't inject markup.
* **The CSP is `self` only.** That rules out:
* inline scripts;
* `style="…"` attributes and inline style strings (`el.setAttribute("style", …)`);
* external fonts and CDNs.
Use a CSS class in `style.css`, or set individual properties with
`el.style.prop = …` (CSSOM is allowed).
* **Colors are CSS custom properties** with light and dark values. Reuse the
existing tokens.
* **Pages live in `assets/pages/`**, one file per route. The router is in
`app.js` and the API calls are in `api.js`.
* **What the user wants:** functionality and ease of use matter more than
visual polish. In pick lists, leave every checkbox unticked by default.
There's no JS test suite. To catch syntax and import errors without a
browser, bundle `assets/app.js` with esbuild, or load the page in headless
Chrome.
## BIND facts that bit us
* named 9.20 crashes on an empty `keys {}` in a `dnssec-policy`. Validation
refuses it.
* `named-checkconf` doesn't catch a TTL above `max-zone-ttl`, but named then
refuses to load the zone.
* `responselog` only takes effect when named starts; use `rndc responselog
on|off` instead.
* `named -g` sends all logging to stderr, so tests use `-f`.
* RPZ client-IP rules see IPv4 clients as v4-mapped (`::ffff:0:0/96`), so
IPv6 prefix covers must leave that range out.
* named serves DoH in RFC 8484 wire format only, not the JSON API. Test it
with BIND's `dig +https` / `dig +tls`, not curl or kdig.
* named can't forward over DoH, only over DoT (`forwarders port 853 tls NAME`).
* `rndc reconfig` makes named reload renewed TLS certificate files.
## Git and releases
These are the maintainer's rules; follow them unless told otherwise:
* **Commit only when asked. Never push**; the maintainer pushes, to Gitea
at gitea.adbraun.lol.
* **Work on `dev-X.Y.Z` branches** (for example `dev-0.6.1`) off `main`.
Don't name a branch like a tag (`v0.5.2`): `git push` then fails with
"src refspec matches more than one".
* **A release** is a commit titled `Release X`.
* It sets `VERSION`.
* It updates the status line in the README.
* For a final release, it moves `[Unreleased]` to
`## [X] - YYYY-MM-DD`. A release candidate (`X-rc.N`) may leave the
notes under `[Unreleased]`.
* Tag it annotated: `git tag -a vX -m "Jarl X"`.
* Before tagging, `make test` and the e2e run must pass.
* Check the notes with `VERSION=X scripts/gitea-release.sh notes`.
* **Pushing a `v*` tag** runs `.gitea/workflows/release.yml`. It builds the
tarballs, the CLI for each OS and the `.deb`s, and publishes them.
* **Before a minor release**, test an upgrade from the previous release's
`.deb`s in a container. Check data, history, answers from named, and that
an old backup restores.
## Shell hygiene
* **Never kill processes by pattern** with `pkill -f` or
`pgrep -f … | xargs kill` in the same shell command. The pattern matches
the shell running it, and kills that shell. Find the PID with `ps` and
kill it by number.
* **Start long-running test servers in the background** (your tool's
background mode, not `&` in a shell that exits), so they outlive the
command.
+348
View File
@@ -6,8 +6,332 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased]
### Added
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
--hook CMD` on a mail server or proxy has jarld obtain the certificate
(and renew it), waits, downloads it when it is newer than the host's
copy, and runs the hook with the file paths in `JARL_CERT_*`
variables. Its settings are saved, so `jarl cert get` alone refreshes
them all; `jarl cert schedule` writes a daily systemd timer for that;
`jarl cert saved` lists them with their expiry; `jarl cert forget`
stops one. A certificate belongs to the user that asked for it (other
users can't take the name), is obtained again only when a name is
added or removed, and the Certificates page shows its owner and who
downloaded it, flagging hosts that haven't fetched a renewal. A new
`certificates` grant (`certificates:write names=*.adbraun.lol`) says
which names a user may have; it is never part of `*`. Users may own
`certificates.max_per_user` certificates (10), or their own
`--cert-limit`. API: `POST /certificates/{name}/request`.
- Certificates can be exported for use outside Jarl: **Export…** on the
Certificates page (download, or copy to the clipboard),
`jarl cert export NAME [--dir DIR | --part PART]`
and `GET /certificates/{name}/pem?part=fullchain|cert|key|bundle`.
Administrators only; every export of the private key is audited
(`certificate.export_key`).
- A reverse zone's "addresses have no PTR record" banner lists them, with
the name each would get, instead of only counting them
(`ptr_missing_list` in the zone API).
- A zone's page links to the same zone in the other views ("also in
external"), in each view's colour, keeping the tab you are on. It
works for zones that aren't linked too, and the zone API's new
`in_views` lists them.
- `jarl.service` has the alias `jarld.service`, so `systemctl restart
jarld` works too. The alias is linked when the unit is enabled; the
package relinks a unit enabled by an older version on upgrade.
### Changed
- A reverse zone's missing PTR records count only its own view's A and
AAAA records. An address only another view's records use (an internal
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
longer reported as missing; it shows apart, as `Other views` in `jarl
zone show`, `ptr_other_views` in the API and a note on the zone's page.
Filling (`--fill`, `jarl zone fill-ptr`, the page's button) names those
from the other view only when asked, with `--other-views`
(`fill_other_views`, `?other_views=1`, a checkbox in the New reverse
zone dialog), so one view's host names don't leak into another. When
there is nothing else to fill, Jarl says how many there are and how to
add them.
- Records, zones and validation findings sort numeric labels by value, so
`2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the
web UI, the CLI and the rendered zone files. Names still group by label
from the right (subdomains stay under their parent); other labels keep
the old byte order (`host10` before `host2`). Zone files are rewritten
in the new order the next time they change; named doesn't care about
record order.
- `cname_chain` no longer warns about one CNAME pointing at another (a
service name at a host alias is common, and named answers the chain at
once). A chain of three or more CNAMEs gets an info note that shows
it.
- A certificate's private key is only exported over HTTPS, the local
socket, or to the server itself (plain HTTP from another host is
refused with `https_required`).
- The Certificates page's "Used for" column says DoT and DoH for named,
with the addresses in the tooltip, instead of listing every address. A
certificate with no use says "not used by Jarl" rather than "nothing
yet": it may well be used elsewhere.
- Deleting a linked zone's local records (an override) removes that name
and type from that view only. It used to bring the shared records back
into the view, so a delete looked undone. The other views keep their
records, local to each now; **Share** one view's version (`jarl record
update ID --shared`) to have it everywhere again.
- A linked zone's Compare views tab starts with a view it is linked to,
not the first other view.
- The New token form only offers templates within what the token's user
may do. A token never does more than its user, so offering more only
misled.
- A zone update that changes nothing is no change: saving a zone's
settings without changing them says "Nothing changed" instead of
staging an empty change. Staging operations that change nothing
(`POST /changesets/{id}/ops`, or `?stage=`) is a 400 "nothing to
change".
### Fixed
- The statistics' "Busiest times" grid drew hours without data taller
than the others (they picked up the page's padding for empty
messages).
- A token for one view of a linked zone (such as an ACME token with
`views=external`) couldn't add records: a new record is shared, and
that reached the other view. A new name and type is now local to the
view when the user may not change it in the other views.
- A zone name in several views had to be given with `--view` even when
the user could see it in only one (`jarl record list adbraun.lol` with
a one-view token), and the error named views the user can't see. Now
the one they can see is used.
- `jarl view list` was empty for a user without `views:read`, though
they can see zones in views. It now lists those views by name, colour
and visible zone count (marked `limited` in the API), without their
settings.
- The DNSSEC tab told you to give the DS record to your registrar for
every zone whose parent isn't in Jarl in the same view. Now it (and
`jarl zone dnssec status`, and the API's new `ds_at`, `ds_place` and
`ds_note`) says where it really goes: the parent zone in Jarl (in any
view now, the zone's own first), the registrar for a registered
domain, the parent zone wherever it is hosted for a subdomain, your
ISP or RIR for a reverse zone, and nowhere for a private name such as
`home.lan` (signing it has no effect).
- The Registrar tab (and `jarl zone registrar`, and the DNSSEC page's
registrar button) was offered for every zone when `porkbun.domains` is
empty, and always failed for subdomain zones (`lab.adbraun.lol`),
reverse zones and private names (`home.lan`): Porkbun only knows
registered domains. Only zones that are a registered domain (by the
public suffix list) get it now; the others say what delegates them
instead.
- Auto-PTR in linked zones changed PTR records in every linked view, not
just where the address record is. A record local to one view got PTR
records in the other views too; deleting or changing the address in
one view deleted or moved the PTR records of views that still had it.
PTR records now follow each view's own address records, are local to
their view in a linked reverse zone, and each zone's Reverse DNS
setting counts for its own view. **Fill** also adds local PTR records
now, not shared ones. The same applies to **Update PTR** (`--ptr`).
- Saving the settings of a zone whose primary name server is outside it
(`ns1.adbraun.lol.` for `wg.adbraun.lol`) changed the name server to
`ns1.adbraun.lol.wg.adbraun.lol.`. The form showed the name without its
final dot, which the server reads as relative to the zone. Names outside
the zone now keep the dot, and the name server is only sent when it was
changed.
### Upgrading
- Tested: a 0.6.0 install with views, linked and reverse zones and
statistics, opened by 0.6.1-rc.1: every zone, record and view kept,
named answered as before, and the new grant and limits worked.
- The database gains a migration (certificate owners, per-user
certificate limits, certificate downloads). Going back to an older
version afterwards means restoring a backup made before the upgrade.
## [0.6.0] - 2026-10-13
### Added
- Reverse zones made easy (docs/reverse.md):
- **Missing reverse zones**: the Zones page and `jarl zone
suggest-reverse` list the reverse zones the private views lack for
their A and AAAA records' addresses: the /24 of a private IPv4 network,
a /32 for a public address (the rest of its /24 belongs to others),
the /48 of IPv6 addresses. Create one, or all (`--create`), filled.
- **Fill**: `jarl zone create 10.0.10.0/24 --fill` (ticked in the New
zone dialog) adds a PTR record for each address the view's records use
there; `jarl zone fill-ptr` and the reverse zone's page add the ones
missing later. An address with several names gets its mail or name
server's name, else a host's.
- **The right view**: a reverse zone created without a view goes to the
private view whose records use its addresses.
- **PTR records kept in step** per zone (`jarl zone update ZONE
--auto-ptr`, zone settings → Reverse DNS): adding, changing,
disabling or deleting an A or AAAA record does the same to its PTR
record, as the Update PTR box does for one change.
- Suggestions come in two groups: **yours** (private networks, a ULA or
an IPv6 /48 this server has an address in), which Create all
creates, and **public addresses**, each a /32 or /128 to create only
if it is yours (not a web host's) or **Dismiss**. A view can turn
suggestions off (`jarl view update vps --reverse-suggestions=false`),
e.g. one only a VPS uses.
- A zone created inside another in the same view (a /24 inside a /8,
`lab.adbraun.lol` inside `adbraun.lol`) takes the parent's records
under it, which named would no longer serve from the parent; a
delegation and its glue stay.
- Zone commands take a network for a reverse zone: `jarl zone show
10.0.10.0/24`.
- **New reverse zone** (Zones page) lists the networks your records use
without a reverse zone, in every view, also where nothing is suggested
(suggestions off, dismissed, public views); pick one, change its size
(/48, /56, /64, /128 for IPv6, around the records' address) and create
it filled. With a view chosen, it also lists the addresses only other
views' records use, and fill names them from there (a VPS's AAAA in
external, its PTR in internal). `jarl zone suggest-reverse --all
[--view V]`. Both lists have a search, so hundreds of addresses stay
manageable: the public addresses fold away, show 25 at a time, and
**Dismiss these N** dismisses everything a search found in one change
(`--dismiss` takes several networks).
- Encrypted DNS: **several host names** (one certificate for all; the
health check says when the served certificate misses one), and **each
listen address gets its own views** for the host names' records
(`--address 10.0.10.5=internal`, a view checkbox per address in the
form), local to the view, with a warning when a public view would get
a private address. A view no longer ticked loses the address. The form
starts from where the records are now. The Certificates page and `jarl
cert show` say what named uses a certificate for (DNS over TLS or HTTPS,
on which addresses and ports).
- docs/encrypted-dns.md: forwarding to Quad9 or Cloudflare over TLS, how to
check it works, and why not over HTTPS.
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
and a **Compare** button on each record.
- **Exploring the statistics**: jarld keeps every answer for a while
(`stats.retention.queries`, default 7 days), and every client, name,
view, record type, result and blocklist on the Statistics page leads to
an explore page for it: answers over time, the top clients, names,
views, types, results and blocklists among them (each a link that
narrows it further), and the latest answers. "NXDOMAIN in external" →
the clients asking → everything one of them asks. A filter bar types
filters in, several values each (any of them), `!value` to leave one
out and `*.example.com` for a domain and below; **hide** on a list entry
leaves it out. **Explore the answers** opens the page from the
Statistics page. `GET /stats/explore`.
- **Storage** on the dashboard: the size of Jarl's databases (and how many
answers the query log holds), and per disk Jarl uses a bar of how full it
is and how much of it the databases take. The Statistics page shows the
statistics database's size too, and its disk tiles get the same bar.
### Changed
- `jarl option set tls NAME …` (and `key`, `remote-servers`, `http`,
`dnssec-policy`, `server` …) replaces only the statement with that name:
setting `tls cloudflare` used to replace `tls quad9`. `jarl option unset
tls cloudflare` removes one by its name.
- **Compare views** and `jarl zone diff` are rebuilt around the view
you're on (docs/views.md#comparing-views): a summary per view, then
what is answered differently (a view's own records in linked zones,
drift otherwise) with **Use this everywhere**, the names only some
views have, grouped, with **Add to VIEW**, and how many are the same.
One line per record: the CLI cuts long TXT records to the terminal
(`--wide` shows them whole). `--with` picks the views to compare with.
`GET /zones/{zone}/compare` returns these sections instead of `rows`.
### Fixed
- Encrypted DNS: the client hints tested with kdig (Knot's, often not
installed) and with curl's JSON API, which named's DoH doesn't speak;
they use BIND's `dig +tls` / `dig +https` now.
- Server options → Added by Jarl: long statements didn't wrap, and the
browser console reported a Content Security Policy violation (an inline
style the page's policy blocks).
### Upgrading from 0.5
- Tested: 0.5.2 packages with views, a linked zone, a reverse zone,
blocking, a `tls` statement and statistics, upgraded in place in Debian
13 (BIND 9.20.29): everything kept and answered as before, the new
features worked on the upgraded data, and a 0.5.2 backup restored.
- The database gets two new versions (12 and 13), applied when jarld
starts; a 0.5 jarld can't open it afterwards, so take a backup first (`sudo
jarld backup`). Nothing changes until you turn `auto_ptr` on for a
zone.
- The statistics database gets a new version too (the query log); 0.5
can't open it afterwards either. It starts empty and fills from then on.
## [0.5.2] - 2026-10-12
### Added
- Mail reverse DNS check: the addresses of the public zones' MX hosts and
SPF records are looked up as the internet sees them, every 6 hours: a
PTR record whose name resolves back to the address, which Gmail and
Outlook require. It says who runs the reverse zone (your provider) and
what to set there, and notes when Jarl has its own reverse zone for the
address, which only your clients see. The Exposure page (Check now),
`jarl exposure mail` and Prometheus `jarl_mail_reverse_dns_ok`.
- Added by Jarl: the Server options page, `jarl option generated` and
`GET /options/generated` list what Jarl writes into named.conf by
itself (keys.conf, the statistics channel and logging, blocking's
response-policy, the view keys), why, and what controls each.
- `stats.rate_limit_log` (default true): `false` stops Jarl adding named's
`query-errors` category to its log channel when rate limiting is set.
### Changed
- Option values with a list of three or more elements (allow-query,
forwarders, listen-on, ...) are shown one element per line.
## [0.5.1] - 2026-10-12
### Added
- Encrypted DNS health: jarld checks the DoT and DoH listeners every 10
minutes as a client would (the certificate is for the host name,
current and the one Jarl last wrote; a question gets an answer) and
says what is wrong: nothing listening, a firewall, the wrong or an
older certificate. On the Encrypted DNS page (Check now), the
dashboard, `jarl encrypted-dns health` and Prometheus
`jarl_encrypted_dns_up`.
- Comparing a zone's views: the Compare views tab and `jarl zone diff`
show the names and types that differ between the views that have a
zone, linked or not, with "Use this version" to share one view's
version. `GET /zones/{zone}/compare`.
### Changed
- `--local` (and `"local": true`) works on zones that aren't linked too:
their records are local anyway. `--shared` there is still refused.
- When `certificates.dir` changes, jarld points the `tls` statements
encrypted DNS wrote at the new place when it starts; named kept the old
copies before, which renewals no longer updated.
### Fixed
- Sharing a view's version of a name that was shared already (Use this
version on the shared side) didn't replace another view's override; it
said there was nothing to do.
## [0.5.0] - 2026-10-12
### Upgrading from 0.4
- The database gets three new versions (internet exposure, certificates,
linked zones), applied when jarld starts; a 0.4 jarld can't open it
afterwards, so take a backup first (`sudo jarld backup`). Backups made
by 0.4 restore into this version. Tested: 0.4.0 packages with views,
zones, records, blocking and statistics, upgraded in place and
restored from a 0.4.0 backup.
- Statistics per view start with this version: earlier figures are for
all views together.
- New exposure warnings (docs/exposure.md) appear for views reachable from
the internet, e.g. "no rate-limit" or "version visible" on an external
view: fix them from the Exposure page, or acknowledge them. A change
@@ -18,8 +342,25 @@ All notable changes to Jarl are documented here. The format follows
parser doesn't know them. Remove the ranges first if you need to
downgrade.
### Changed
- The web UI's top bar had grown too long, and the user name, Users and
Log out ended up on a second line on the left. The server's
configuration and security pages (Server options, ACLs, TSIG keys,
Exposure, Certificates, Encrypted DNS, Audit log) are now in a
**Server** menu, and Account, Users and Log out in a menu under your
name. On narrow screens the search box and your name stay together on
the right.
### Fixed
- "update PTR" when editing a record (`jarl record update ID --ptr`) did
nothing unless the name or address changed too, and the web UI didn't
even send it alone: a reverse zone created after the record never got
its PTR. Alone, it now adds the PTR record if it is missing (or enables
it). For records of linked zones, the reverse zones of every linked view
count, not only the record's own view.
- Option values in the web UI looked different from the editor: a long
line wrapped back to the left edge, losing its indentation, and the
column changed width when Edit opened. Values now keep their layout
@@ -40,6 +381,13 @@ All notable changes to Jarl are documented here. The format follows
### Added
- Statistics per view ([docs/stats.md](docs/stats.md#views)): the
Statistics page has a toggle per view, with All and None, remembered in
the browser, to see some views only or several together (linked views,
say). Answers, result codes, record types, zones, blocking, rate
limiting and the top lists follow it; named's own counters stay for the
whole server, and the page says so. `jarl stats --view`, API
`?views=`. Per-view figures start with this version.
- DNS over TLS and HTTPS ([docs/encrypted-dns.md](docs/encrypted-dns.md)):
`jarl encrypted-dns setup --hostname NAME --address ADDR` (and the
Encrypted DNS page) obtains a certificate for the host name if needed,
+8
View File
@@ -0,0 +1,8 @@
@AGENTS.md
## Claude Code specifics
- End commit messages with a `Co-Authored-By:` trailer naming the model,
e.g. `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Keep temporary scripts, extracted BIND trees, screenshots and test
servers' work directories in the session scratchpad, not in the repo.
+1
View File
@@ -114,6 +114,7 @@ install-files:
uninstall:
rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \
$(DESTDIR)$(UNITDIR)/jarld.service \
$(DESTDIR)$(UNITDIR)/jarl-backup.service $(DESTDIR)$(UNITDIR)/jarl-backup.timer \
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
$(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf
+20 -6
View File
@@ -25,7 +25,8 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
## Features
* **Everything in one place**: primary, secondary, forward, stub, mirror,
hint, redirect and in-view zones, reverse zones from a CIDR, split DNS with
hint, redirect and in-view zones, reverse zones from a CIDR (suggested,
filled and kept in step from your A records), split DNS with
views, ACLs, TSIG keys, and every BIND 9.20 option at server, view, zone
and top level, with the ARM's description and grammar built in.
* **Safe changes.** Every change is validated as a whole (CNAME rules,
@@ -147,6 +148,7 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
| [docs/encrypted-dns.md](docs/encrypted-dns.md) | DNS over TLS and HTTPS for your clients: setup, ports, client settings |
| [docs/certificates.md](docs/certificates.md) | TLS certificates Jarl obtains and renews for its web UI and named's DNS over TLS/HTTPS |
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
| [docs/reverse.md](docs/reverse.md) | Reverse zones: suggested from your records, created in the right view and filled, PTR records kept in step |
| [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
@@ -200,11 +202,23 @@ review step.
## Status
Version 0.4.0 is the current release, and 0.4.1 is in testing
(0.4.1-rc.4): it adds Prometheus metrics, address ranges in ACLs and
other address match lists, safer changeset handling, internet exposure
checks with response rate limiting, TLS certificates Jarl obtains and
renews itself, linked zones for split DNS, and DNS over TLS and HTTPS. 0.4.0 added blocking (blocklists and
Version 0.6.0 is the current release, and 0.6.1 is in testing
(0.6.1-rc.2): it gets certificates onto your other hosts (`jarl cert
get`: Jarl obtains and renews them, a host downloads them and runs a
hook, a daily timer keeps them current), exports certificates, fixes
PTR records and overrides in linked zones, and makes one-view tokens
(such as ACME tokens for the external view) work with linked zones.
Reverse zones list addresses in numeric order and no longer report
another view's hosts as missing PTR records.
0.6.0 makes reverse zones easy (suggested from your records,
created in the right view and filled, PTR records kept in step), rebuilds
comparing a zone's views, lets you explore the statistics down to each
client and name (a query log), shows the databases' size, and gives
encrypted DNS several host names and views per listen address. 0.5 added Prometheus metrics,
address ranges in ACLs and other address match lists, safer changeset
handling, internet exposure checks with response rate limiting, TLS
certificates Jarl obtains and renews itself, linked zones for split DNS,
DNS over TLS and HTTPS, and statistics per view. 0.4.0 added blocking (blocklists and
your own rules, like Pi-hole, done by named, with different blocking for
different devices), disabling zones, server statistics and view keys, on
top of 0.3.0's disabled records, script-friendly CLI output and client
+1 -1
View File
@@ -1 +1 @@
0.4.1-rc.4
0.6.1-rc.2
+8
View File
@@ -378,6 +378,9 @@ func printUser(w io.Writer, u api.User) {
if u.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", u.Comment)
}
if u.CertLimit != nil {
fmt.Fprintf(w, "Certificates:\tat most %d (jarl cert get)\n", *u.CertLimit)
}
fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt))
}
@@ -444,6 +447,7 @@ func userCmd() *cobra.Command {
}
var admin, noPassword, disabled bool
var role, comment, rename string
var certLimit int
var grants []string
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
var in api.UserInput
@@ -466,6 +470,9 @@ func userCmd() *cobra.Command {
if f.Changed("rename") {
in.Username = &rename
}
if f.Changed("cert-limit") {
in.CertLimit = &certLimit
}
return in, nil
}
addUserFlags := func(c *cobra.Command) {
@@ -473,6 +480,7 @@ func userCmd() *cobra.Command {
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
}
create := &cobra.Command{
Use: "create NAME",
+718
View File
@@ -0,0 +1,718 @@
package main
// jarl cert get: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts).
// jarld obtains and renews the certificate; this host asks for it, waits,
// downloads it when it is newer than its copy, and runs a hook. The
// settings of each certificate are saved, so a daily timer (jarl cert
// schedule) can run 'jarl cert get' for all of them.
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"jarl/internal/client"
)
// savedCert is a certificate's settings, saved by jarl cert get.
type savedCert struct {
Name string `json:"name"`
Domains []string `json:"domains"`
KeyType string `json:"key_type,omitempty"`
Dir string `json:"dir"`
Hook string `json:"hook,omitempty"`
Conn connection `json:"connection"`
SavedAt time.Time `json:"saved_at"`
}
// localMeta is what this host has of a certificate, kept next to its
// files as jarl-cert.json.
type localMeta struct {
Name string `json:"name"`
Domains []string `json:"domains"`
Serial string `json:"serial"`
NotAfter time.Time `json:"not_after"`
Fetched time.Time `json:"fetched"`
// HookFailed is set when the hook failed for these files: the next
// run runs it again.
HookFailed string `json:"hook_failed,omitempty"`
}
const metaFile = "jarl-cert.json"
// certConfigDir is where saved settings live: /etc/jarl/cert-get for
// root, ~/.config/jarl/cert-get otherwise.
func certConfigDir(flag string) string {
if flag != "" {
return flag
}
if os.Geteuid() == 0 {
return "/etc/jarl/cert-get"
}
if d, err := os.UserConfigDir(); err == nil {
return filepath.Join(d, "jarl", "cert-get")
}
return ".jarl-cert-get"
}
// defaultCertDir is where a certificate's files go without --dir.
func defaultCertDir(name string) string {
if os.Geteuid() == 0 {
return filepath.Join("/etc/jarl/certs", name)
}
return name
}
func loadSaved(dir string) ([]savedCert, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []savedCert
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
var sc savedCert
if err := json.Unmarshal(b, &sc); err != nil {
return nil, fmt.Errorf("%s: %w", filepath.Join(dir, e.Name()), err)
}
out = append(out, sc)
}
return out, nil
}
// save writes a certificate's settings, readable only by this user: they
// may hold the API token.
func (sc savedCert) save(dir string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
b, _ := json.MarshalIndent(sc, "", " ")
return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600)
}
func readMeta(dir string) (*localMeta, error) {
b, err := os.ReadFile(filepath.Join(dir, metaFile))
if err != nil {
return nil, err
}
var m localMeta
return &m, json.Unmarshal(b, &m)
}
func (m *localMeta) write(dir string) error {
b, _ := json.MarshalIndent(m, "", " ")
return writeAtomic(filepath.Join(dir, metaFile), append(b, '\n'), 0o644)
}
// writeAtomic replaces path with data, so a program never reads half a
// file.
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// requestAnswer is POST /certificates/{name}/request.
type requestAnswer struct {
Certificate certInfo `json:"certificate"`
Created bool `json:"created"`
Changed bool `json:"changed"`
Warnings []string `json:"warnings"`
}
// getResult is what one jarl cert get did.
type getResult struct {
Name string `json:"name"`
Dir string `json:"dir"`
Serial string `json:"serial"`
NotAfter time.Time `json:"not_after"`
Updated bool `json:"updated"` // new files written
Hook string `json:"hook,omitempty"`
}
// fetchCert asks jarld for the certificate, waits while it is obtained,
// downloads it when it is newer than this host's copy (or force) and runs
// the hook.
func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (*getResult, error) {
var ans requestAnswer
err := c.Do(ctx(), "POST", certPath(sc.Name)+"/request", nil, map[string]any{"domains": sc.Domains, "key_type": sc.KeyType}, &ans)
if err != nil {
return nil, err
}
for _, w := range ans.Warnings {
fmt.Fprintf(os.Stderr, "%s: %s\n", sc.Name, w)
}
ci := ans.Certificate
if ci.IssuingSince != nil {
tty := !flagQuiet && flagOutput == "table"
if tty {
what := "Obtaining"
if ans.Changed {
what = "Obtaining (its names changed)"
}
fmt.Fprintf(os.Stderr, "%s %s from the CA on jarld", what, sc.Name)
}
deadline := time.Now().Add(wait)
for ci.IssuingSince != nil && time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
if tty {
fmt.Fprint(os.Stderr, ".")
}
ci = certInfo{}
if err := c.Get(ctx(), certPath(sc.Name), nil, &ci); err != nil {
return nil, err
}
}
if tty {
fmt.Fprintln(os.Stderr)
}
if ci.IssuingSince != nil {
return nil, fmt.Errorf("%s: jarld is still obtaining it; run jarl cert get again later", sc.Name)
}
}
if ci.NotAfter == nil {
msg := firstNonEmpty(ci.LastError, ci.Status.Message, "jarld has no certificate for it yet")
return nil, fmt.Errorf("%s: %s", sc.Name, msg)
}
if ci.LastError != "" && ci.LastAttempt != nil && (ci.LastSuccess == nil || ci.LastAttempt.After(*ci.LastSuccess)) {
fmt.Fprintf(os.Stderr, "%s: jarld's last attempt failed (%s); this is the certificate it has\n", sc.Name, ci.LastError)
}
res := &getResult{Name: sc.Name, Dir: sc.Dir, Serial: ci.Serial, NotAfter: *ci.NotAfter}
meta, _ := readMeta(sc.Dir)
have := meta != nil && meta.Serial == ci.Serial && filesPresent(sc.Dir)
if have && !force && meta.HookFailed == "" {
return res, nil
}
renewed := meta != nil && meta.Serial != "" && meta.Serial != ci.Serial
if !have || force {
if err := downloadCert(c, sc, ci); err != nil {
return nil, err
}
res.Updated = true
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC()}
}
if sc.Hook != "" {
res.Hook = "ok"
if err := runHook(sc, ci, renewed); err != nil {
meta.HookFailed = err.Error()
res.Hook = "failed: " + err.Error()
} else {
meta.HookFailed = ""
}
}
if err := meta.write(sc.Dir); err != nil {
return nil, err
}
if meta.HookFailed != "" {
return res, fmt.Errorf("%s: the hook failed (%s); the next jarl cert get runs it again", sc.Name, meta.HookFailed)
}
return res, nil
}
var certFiles = []string{"cert.pem", "chain.pem", "fullchain.pem", "privkey.pem"}
func filesPresent(dir string) bool {
for _, f := range certFiles {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
return false
}
}
return true
}
// downloadCert writes the certificate's files: cert.pem, chain.pem (the
// intermediates), fullchain.pem and privkey.pem (mode 600).
func downloadCert(c *client.Client, sc savedCert, ci certInfo) error {
get := func(part string) ([]byte, error) {
var out string
err := c.Get(ctx(), certPath(sc.Name)+"/pem", url.Values{"part": {part}}, &out)
return []byte(out), err
}
var full, key []byte
// Twice at most: a renewal between the two downloads gives a key
// that doesn't match.
for try := 0; ; try++ {
var err error
if full, err = get("fullchain"); err != nil {
return err
}
if key, err = get("key"); err != nil {
return err
}
if _, err = tls.X509KeyPair(full, key); err == nil {
break
} else if try == 1 {
return fmt.Errorf("%s: the key jarld sent doesn't match the certificate: %w", sc.Name, err)
}
}
block, rest := pem.Decode(full)
if block == nil {
return fmt.Errorf("%s: jarld sent no certificate", sc.Name)
}
if err := os.MkdirAll(sc.Dir, 0o755); err != nil {
return err
}
files := []struct {
name string
data []byte
mode os.FileMode
}{
{"cert.pem", pem.EncodeToMemory(block), 0o644},
{"chain.pem", []byte(strings.TrimLeft(string(rest), "\n")), 0o644},
{"fullchain.pem", full, 0o644},
{"privkey.pem", key, 0o600},
}
// The key first: a program that reads both never pairs a new
// certificate with the old key for long.
slices.Reverse(files)
for _, f := range files {
if err := writeAtomic(filepath.Join(sc.Dir, f.name), f.data, f.mode); err != nil {
return err
}
}
return nil
}
// runHook runs the certificate's hook with sh, its files in the
// environment.
func runHook(sc savedCert, ci certInfo, renewed bool) error {
c, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(c, "sh", "-c", sc.Hook)
ren := "0"
if renewed {
ren = "1"
}
cmd.Env = append(os.Environ(),
"JARL_CERT_NAME="+sc.Name,
"JARL_CERT_DIR="+sc.Dir,
"JARL_CERT_CERT="+filepath.Join(sc.Dir, "cert.pem"),
"JARL_CERT_CHAIN="+filepath.Join(sc.Dir, "chain.pem"),
"JARL_CERT_FULLCHAIN="+filepath.Join(sc.Dir, "fullchain.pem"),
"JARL_CERT_KEY="+filepath.Join(sc.Dir, "privkey.pem"),
"JARL_CERT_DOMAINS="+strings.Join(ci.Domains, " "),
"JARL_CERT_SERIAL="+ci.Serial,
"JARL_CERT_NOT_AFTER="+ci.NotAfter.UTC().Format(time.RFC3339),
"JARL_CERT_RENEWED="+ren,
)
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
return cmd.Run()
}
func certGetCmd() *cobra.Command {
var domains []string
var dir, hook, keyType, configDir string
var noSave, force bool
var wait time.Duration
cmd := &cobra.Command{
Use: "get [NAME]",
Short: "Get a certificate for this host: jarld obtains it, this host downloads it",
Long: `Gets a certificate for this host (a mail server, a reverse proxy). jarld
obtains it from the CA and renews it, answering the challenges in its own
zones; this command asks for it, waits until it is there, downloads it
when it is newer than this host's copy, and runs --hook.
The token needs a certificates grant for the names, and the certificate
belongs to the token's user: another user can't take the name. Asking
again with the same names returns the certificate jarld has; adding or
removing a name gets a new one.
Files in --dir (default /etc/jarl/certs/NAME as root, ./NAME otherwise):
cert.pem, chain.pem (the intermediates), fullchain.pem, privkey.pem (mode
600), and jarl-cert.json (serial, expiry, when it was fetched).
The settings (names, --dir, --hook, the server and token) are saved in
/etc/jarl/cert-get/NAME.json (as root; ~/.config/jarl/cert-get otherwise,
readable only by you). 'jarl cert get' without a name then does every
saved certificate: run it daily (jarl cert schedule does that with a
systemd timer). Nothing is downloaded until jarld has renewed one.
--hook runs with sh after new files are written (and again on the next
run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT,
JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS,
JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and JARL_CERT_RENEWED (1 when it
replaced an older certificate).`,
Example: ` jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix dovecot'
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' --dir /etc/haproxy/certs/proxy \
--hook 'cat "$JARL_CERT_KEY" "$JARL_CERT_FULLCHAIN" > /etc/haproxy/proxy.pem && systemctl reload haproxy'
jarl cert get # every saved certificate (what the timer runs)
jarl cert saved # what this host has, and when it expires`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
saved, err := loadSaved(cdir)
if err != nil {
return err
}
var todo []savedCert
f := cmd.Flags()
switch {
case len(args) == 0:
if len(domains) > 0 {
return errors.New("give the certificate a name: jarl cert get NAME -d DOMAIN")
}
if len(saved) == 0 {
return fmt.Errorf("no saved certificates in %s: get one first (jarl cert get NAME -d DOMAIN)", cdir)
}
todo = saved
default:
name := args[0]
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == name })
var sc savedCert
if i >= 0 {
sc = saved[i]
} else if len(domains) == 0 {
return fmt.Errorf("%s isn't saved here: give its names (-d DOMAIN)", name)
}
sc.Name = name
if len(domains) > 0 {
sc.Domains = domains
}
if f.Changed("dir") || sc.Dir == "" {
sc.Dir = firstNonEmpty(dir, defaultCertDir(name))
}
if f.Changed("hook") {
sc.Hook = hook
}
if f.Changed("key-type") {
sc.KeyType = keyType
}
if abs, err := filepath.Abs(sc.Dir); err == nil {
sc.Dir = abs
}
// The server and token given now (flags, environment, the
// CLI configuration) replace the saved ones.
if i < 0 || f.Changed("server") || cmd.Root().PersistentFlags().Changed("server") || cmd.Root().PersistentFlags().Changed("token") || os.Getenv("JARL_TOKEN") != "" || os.Getenv("JARL_SERVER") != "" {
cn, err := resolveConnection()
if err != nil {
return err
}
sc.Conn = cn
}
todo = []savedCert{sc}
}
var results []*getResult
var failed []string
var lastErr error
for _, sc := range todo {
c, err := sc.Conn.open()
if err == nil {
var r *getResult
r, err = fetchCert(c, sc, force, wait)
if r != nil {
results = append(results, r)
}
}
if err != nil {
if len(todo) > 1 {
fmt.Fprintln(os.Stderr, "error:", err)
}
failed = append(failed, sc.Name)
lastErr = err
continue
}
if len(args) == 1 && !noSave {
sc.SavedAt = time.Now().UTC()
if err := sc.save(cdir); err != nil {
return fmt.Errorf("saving the settings: %w", err)
}
}
}
if err := output(results, func(w io.Writer) {
for _, r := range results {
what := "up to date"
if r.Updated {
what = "new files"
}
fmt.Fprintf(w, "%s\t%s\t%s, expires %s\t%s\n", r.Name, r.Dir, what, r.NotAfter.Local().Format("2006-01-02"), hookText(r.Hook))
}
}); err != nil {
return err
}
switch {
case len(failed) == 1 && len(todo) == 1:
return lastErr // its own exit code (3 forbidden, 6 conflict, ...)
case len(failed) > 0:
return fmt.Errorf("failed: %s", strings.Join(failed, ", "))
}
return nil
},
}
f := cmd.Flags()
f.StringArrayVarP(&domains, "domain", "d", nil, "a name for the certificate (repeatable; a wildcard as '*.adbraun.lol')")
f.StringVar(&dir, "dir", "", "directory for the files (default /etc/jarl/certs/NAME as root, ./NAME otherwise)")
f.StringVar(&hook, "hook", "", "shell command to run after new files are written")
f.StringVar(&keyType, "key-type", "", "key type of a new certificate: ec256 (default), ec384, rsa2048, rsa3072, rsa4096")
f.BoolVar(&force, "force", false, "download and run the hook even when this host has the current certificate")
f.BoolVar(&noSave, "no-save", false, "don't save the settings for later runs")
f.DurationVar(&wait, "wait", 15*time.Minute, "how long to wait while jarld obtains it")
f.StringVar(&configDir, "config-dir", "", "where the settings are saved (default /etc/jarl/cert-get as root, ~/.config/jarl/cert-get otherwise)")
_ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp))
return cmd
}
func hookText(h string) string {
if h == "" {
return ""
}
return "hook " + h
}
func certSavedCmd() *cobra.Command {
var configDir string
cmd := &cobra.Command{
Use: "saved",
Short: "The certificates this host gets with jarl cert get, and when they expire",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
saved, err := loadSaved(cdir)
if err != nil {
return err
}
type row struct {
savedCert
Local *localMeta `json:"local,omitempty"`
}
var rows []row
for _, sc := range saved {
sc.Conn.Token = "" // never print it
m, _ := readMeta(sc.Dir)
rows = append(rows, row{sc, m})
}
return output(rows, func(w io.Writer) {
if len(rows) == 0 {
fmt.Fprintf(w, "No saved certificates in %s.\n", cdir)
return
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tNAMES\tDIR\tEXPIRES\tFETCHED\tSERVER\tHOOK")
for _, r := range rows {
exp, fetched := "-", "-"
if r.Local != nil {
exp = r.Local.NotAfter.Local().Format("2006-01-02") + fmt.Sprintf(" (%dd)", int(time.Until(r.Local.NotAfter).Hours()/24))
fetched = r.Local.Fetched.Local().Format("2006-01-02 15:04")
if r.Local.HookFailed != "" {
fetched += " HOOK FAILED"
}
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Name, strings.Join(r.Domains, ","), r.Dir, exp, fetched, r.Conn.Server, firstNonEmpty(r.Hook, "-"))
}
tw.Flush()
})
},
}
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
return cmd
}
func certForgetCmd() *cobra.Command {
var configDir string
var del, yes bool
cmd := &cobra.Command{
Use: "forget NAME",
Short: "Stop getting a certificate on this host (its files stay)",
Long: `Removes a certificate's saved settings, so 'jarl cert get' (and the timer)
no longer fetches it. Its files stay where they are. With --delete, the
certificate is deleted on jarld too, so it is no longer renewed.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
path := filepath.Join(cdir, args[0]+".json")
saved, err := loadSaved(cdir)
if err != nil {
return err
}
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == args[0] })
if i < 0 {
return fmt.Errorf("%s isn't saved in %s", args[0], cdir)
}
if del {
if !confirm("Delete certificate "+args[0]+" on jarld too?", yes) {
return errors.New("aborted")
}
c, err := saved[i].Conn.open()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil {
return err
}
}
if err := os.Remove(path); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("forgot %s (its files stay in %s)\n", args[0], saved[i].Dir)
}
return nil
},
}
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
cmd.Flags().BoolVar(&del, "delete", false, "delete the certificate on jarld too")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
// Units written by jarl cert schedule.
const (
scheduleUnit = "jarl-cert-get"
unitDir = "/etc/systemd/system"
)
func scheduleUnits(bin, configDir string) (service, timer string) {
service = fmt.Sprintf(`# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
[Unit]
Description=Fetch certificates from Jarl (jarl cert get)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=%s cert get --config-dir %s
`, bin, configDir)
timer = `# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
[Unit]
Description=Fetch certificates from Jarl daily
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
`
return service, timer
}
func certScheduleCmd() *cobra.Command {
var remove, noSystemctl bool
var dir string
cmd := &cobra.Command{
Use: "schedule",
Short: "Run jarl cert get daily with a systemd timer (Linux, as root)",
Long: `Writes /etc/systemd/system/jarl-cert-get.service and .timer, which run
'jarl cert get' once a day for every certificate saved in /etc/jarl/cert-get,
and enables the timer. jarld renews certificates well before they expire;
the daily run downloads a renewed one and runs its hook.
Linux with systemd only, as root. Elsewhere, run 'jarl cert get' daily
yourself (cron, launchd, Task Scheduler).`,
Example: ` sudo jarl cert schedule
systemctl list-timers jarl-cert-get.timer
sudo jarl cert schedule --remove`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("jarl cert schedule writes systemd units, so it only works on Linux; run 'jarl cert get' daily with this system's scheduler")
}
if dir == unitDir && os.Geteuid() != 0 {
return errors.New("jarl cert schedule writes to /etc/systemd/system: run it as root (sudo)")
}
service := filepath.Join(dir, scheduleUnit+".service")
timer := filepath.Join(dir, scheduleUnit+".timer")
systemctl := func(a ...string) error {
if noSystemctl {
return nil
}
out, err := exec.Command("systemctl", a...).CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(a, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
if remove {
_ = systemctl("disable", "--now", scheduleUnit+".timer")
for _, f := range []string{timer, service} {
if err := os.Remove(f); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := systemctl("daemon-reload"); err != nil {
return err
}
if !flagQuiet {
fmt.Println("removed the timer; saved certificates stay in", certConfigDir(""))
}
return nil
}
bin, err := os.Executable()
if err != nil {
return err
}
if b, err := filepath.EvalSymlinks(bin); err == nil {
bin = b
}
cdir := certConfigDir("")
saved, err := loadSaved(cdir)
if err != nil {
return err
}
if len(saved) == 0 {
fmt.Fprintf(os.Stderr, "note: no certificates are saved in %s yet; get one with: jarl cert get NAME -d DOMAIN\n", cdir)
}
svc, tmr := scheduleUnits(bin, cdir)
if err := writeAtomic(service, []byte(svc), 0o644); err != nil {
return err
}
if err := writeAtomic(timer, []byte(tmr), 0o644); err != nil {
return err
}
if err := systemctl("daemon-reload"); err != nil {
return err
}
if err := systemctl("enable", "--now", scheduleUnit+".timer"); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("wrote %s and %s; the timer runs jarl cert get daily (systemctl list-timers %s.timer)\n", service, timer, scheduleUnit)
}
return nil
},
}
cmd.Flags().BoolVar(&remove, "remove", false, "disable and remove the timer")
cmd.Flags().StringVar(&dir, "unit-dir", unitDir, "where to write the units")
cmd.Flags().BoolVar(&noSystemctl, "no-systemctl", false, "only write the units")
_ = cmd.Flags().MarkHidden("unit-dir")
_ = cmd.Flags().MarkHidden("no-systemctl")
return cmd
}
+113 -2
View File
@@ -45,10 +45,18 @@ type certInfo struct {
NextAttempt *time.Time `json:"next_attempt,omitempty"`
IssuingSince *time.Time `json:"issuing_since,omitempty"`
Serving bool `json:"serving,omitempty"`
NamedUse []string `json:"named_use,omitempty"`
Files *struct {
Chain string `json:"chain"`
Key string `json:"key"`
} `json:"files,omitempty"`
Owner string `json:"owner,omitempty"`
PulledBy []struct {
Who string `json:"who"`
Serial string `json:"serial"`
IP string `json:"ip"`
At time.Time `json:"at"`
} `json:"pulled_by,omitempty"`
}
type certAccount struct {
@@ -98,9 +106,10 @@ used too (--cert-file and --key-file): Jarl rereads it when it changes.`,
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
jarl cert list
jarl cert issue ns1 # renew now
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)`,
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)
jarl cert export ns1 --dir /etc/ssl/ns1 # fullchain.pem and privkey.pem, for another program`,
}
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certDeleteCmd(), certAccountCmd())
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certGetCmd(), certSavedCmd(), certForgetCmd(), certScheduleCmd(), certDeleteCmd(), certAccountCmd())
return cmd
}
@@ -173,9 +182,26 @@ func printCert(w io.Writer, ci certInfo) {
}
}
fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", ")))
for _, u := range ci.NamedUse {
fmt.Fprintf(w, "named:\t%s\n", u)
}
if ci.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
}
if ci.Owner != "" {
fmt.Fprintf(w, "Owner:\t%s (jarl cert get)\n", ci.Owner)
}
for i, p := range ci.PulledBy {
label := ""
if i == 0 {
label = "Pulled by:"
}
cur := "current"
if p.Serial != ci.Serial {
cur = "an older one: not fetched since the renewal"
}
fmt.Fprintf(w, "%s\t%s from %s, %s (%s)\n", label, p.Who, dash(p.IP), fmtTime(&p.At), cur)
}
state := ci.Status.State
if ci.Status.Message != "" {
state += ": " + ci.Status.Message
@@ -489,6 +515,91 @@ is published and the CA isn't asked.`,
return cmd
}
func certExportCmd() *cobra.Command {
var dir, part string
var noKey, force bool
cmd := &cobra.Command{
Use: "export NAME",
Short: "Save a certificate (and its key) to use in another program",
Long: `Saves a certificate's current files, to use it outside Jarl (a mail
server, a reverse proxy): fullchain.pem and privkey.pem in --dir (the
key readable only by you), or one part on standard output with --part:
fullchain the certificate and its chain
cert the certificate alone
key the private key
bundle the key, then the chain (one file, as HAProxy wants)
Administrators only; every export of the key is audited. Jarl renews the
certificate, but not your copy: export it again after a renewal (the
'renews' date in 'jarl cert show'), or point the program at the files Jarl
writes for named (use dns) instead.`,
Example: ` jarl cert export ns1 --dir /etc/ssl/ns1
jarl cert export ns1 --part bundle > /etc/haproxy/ns1.pem
jarl cert export ns1 --no-key --dir .`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
get := func(p string) (string, error) {
var out string
err := c.Get(ctx(), certPath(args[0])+"/pem", url.Values{"part": {p}}, &out)
return out, err
}
if part != "" {
out, err := get(part)
if err != nil {
return err
}
_, err = os.Stdout.WriteString(out)
return err
}
files := []struct{ part, name string }{{"fullchain", "fullchain.pem"}, {"key", "privkey.pem"}}
if noKey {
files = files[:1]
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, f := range files {
path := dir + string(os.PathSeparator) + f.name
if _, err := os.Stat(path); err == nil && !force {
return fmt.Errorf("%s exists (--force replaces it)", path)
}
}
for _, f := range files {
out, err := get(f.part)
if err != nil {
return err
}
path := dir + string(os.PathSeparator) + f.name
mode := os.FileMode(0o644)
if f.part == "key" {
mode = 0o600
}
if err := os.WriteFile(path, []byte(out), mode); err != nil {
return err
}
if err := os.Chmod(path, mode); err != nil {
return err
}
if !flagQuiet {
fmt.Println("wrote", path)
}
}
return nil
},
}
cmd.Flags().StringVar(&dir, "dir", ".", "directory for fullchain.pem and privkey.pem")
cmd.Flags().StringVar(&part, "part", "", "write one part to standard output: fullchain, cert, key or bundle")
cmd.Flags().BoolVar(&noKey, "no-key", false, "only fullchain.pem")
cmd.Flags().BoolVar(&force, "force", false, "replace existing files")
_ = cmd.RegisterFlagCompletionFunc("part", cobra.FixedCompletions([]string{"fullchain", "cert", "key", "bundle"}, cobra.ShellCompDirectiveNoFileComp))
return cmd
}
func certDeleteCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"bytes"
"strings"
"testing"
"unicode/utf8"
)
func TestPrintCompare(t *testing.T) {
long := `"v=spf1 ` + strings.Repeat("include:_spf.example.net ", 10) + `-all"`
r := &compareReport{Zone: "adbraun.lol", Base: "internal", Views: []string{"internal", "external"}, Linked: true}
r.Summary = append(r.Summary, struct {
View string `json:"view"`
Different int `json:"different"`
OnlyBase int `json:"only_base"`
OnlyOther int `json:"only_other"`
Same int `json:"same"`
}{View: "external", Different: 1, OnlyBase: 1, Same: 5})
r.Different = []compareRow{{Name: "@", Type: "TXT", Views: []compareSet{
{View: "internal", Records: []string{long, `"short"`}, IDs: []int64{7, 8}, Linked: "local"},
{View: "external", Records: []string{`"other"`}, IDs: []int64{9}, Linked: "local"},
}}}
r.Only = append(r.Only, struct {
Views []string `json:"views"`
Rows []compareRow `json:"rows"`
}{Views: []string{"internal"}, Rows: []compareRow{{Name: "nas", Type: "A", Views: []compareSet{{View: "internal", Records: []string{"10.0.10.20"}}, {View: "external"}}}}})
r.Same = 5
var b bytes.Buffer
printCompare(&b, r, false, 60)
out := b.String()
// Record lines fit (the heading and summary are prose, and may wrap).
for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n")[3:] {
if n := utf8.RuneCountInString(l); n > 60 {
t.Errorf("line of %d characters: %q", n, l)
}
}
for _, want := range []string{
"adbraun.lol in internal, compared with external\n linked: ",
" external: 1 answered differently, 1 only in internal, 0 only in external, 5 the same",
"ANSWERED DIFFERENTLY (1)\n @ TXT\n internal \"v=spf1 include:",
"…\n \"short\"\n external \"other\" [local, id 9]",
"ONLY IN INTERNAL (1)\n nas A 10.0.10.20",
"5 names and types are the same (--all lists them)",
} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
// Width 0: whole.
b.Reset()
printCompare(&b, r, false, 0)
if !strings.Contains(b.String(), long+" [local, id 7]") {
t.Errorf("whole: %s", b.String())
}
}
+91 -8
View File
@@ -30,14 +30,19 @@ func viewCmd() *cobra.Command {
return output(views, func(w io.Writer) {
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
for i, v := range views {
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color))
clients, dests := orAny(v.MatchClients), orAny(v.MatchDestinations)
if v.Limited {
// No views:read: only the zones they can see.
clients, dests = "(not shown)", "(not shown)"
}
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, clients, dests, v.Zones, orDefault(v.Color))
}
})
},
}
var clients, dests []string
var recursiveOnly, comment, exposure string
var adopt, cascade, yes bool
var adopt, cascade, yes, reverseSuggest bool
viewBody := func(cmd *cobra.Command) map[string]any {
body := map[string]any{}
f := cmd.Flags()
@@ -63,6 +68,9 @@ func viewCmd() *cobra.Command {
if f.Changed("exposure") {
body["exposure"] = exposure
}
if f.Changed("reverse-suggestions") {
body["reverse_suggestions"] = reverseSuggest
}
return body
}
addViewFlags := func(c *cobra.Command) {
@@ -71,6 +79,7 @@ func viewCmd() *cobra.Command {
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
c.Flags().BoolVar(&reverseSuggest, "reverse-suggestions", true, "suggest reverse zones for the view's addresses (--reverse-suggestions=false: don't; docs/reverse.md)")
}
create := &cobra.Command{
Use: "create NAME",
@@ -582,6 +591,12 @@ func optionCmd() *cobra.Command {
Short: "Set an option (replaces an existing option of the same name)",
Long: `Set an option; VALUE is named.conf syntax without the trailing semicolon.
It replaces the option of that name. Statements told apart by the name
after the keyword (tls NAME, key NAME, remote-servers NAME, http NAME,
dnssec-policy NAME, server ADDRESS, ...) replace only the one with that
name: setting tls cloudflare leaves tls quad9 alone. --add adds another
instance of any other option that may occur several times.
jarl option set recursion no
jarl option set allow-recursion '{ homelab; localhost; }'
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
@@ -606,7 +621,11 @@ func optionCmd() *cobra.Command {
if cmd.Flags().Changed("comment") {
body["comment"] = comment
}
label := optionLabel(strings.ToLower(args[0]), value)
for _, o := range opts {
if label != "" && optionLabel(o.Name, o.Value) != label {
continue // another tls (key, server, ...) statement
}
if o.Name == strings.ToLower(args[0]) && !multiple {
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
if err != nil {
@@ -627,7 +646,7 @@ func optionCmd() *cobra.Command {
set.Flags().StringVar(&comment, "comment", "", "comment")
set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)")
unset := &cobra.Command{
Use: "unset NAME | unset --id ID",
Use: "unset NAME [LABEL] | unset --id ID",
Short: "Remove an option (BIND's default applies again)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
@@ -641,8 +660,8 @@ func optionCmd() *cobra.Command {
}
return report(resp)
}
if len(args) != 1 {
return fmt.Errorf("give the option name or --id")
if len(args) < 1 || len(args) > 2 {
return fmt.Errorf("give the option name (and the name after it, for tls NAME and the like) or --id")
}
sc, id, err := scope()
if err != nil {
@@ -654,15 +673,24 @@ func optionCmd() *cobra.Command {
}
var matches []api.Option
for _, o := range opts {
if o.Name == strings.ToLower(args[0]) {
if o.Name == strings.ToLower(args[0]) && (len(args) == 1 || strings.EqualFold(optionLabel(o.Name, o.Value), strings.Trim(args[1], `"`))) {
matches = append(matches, o)
}
}
switch len(matches) {
case 0:
return fmt.Errorf("option %s is not set here", args[0])
return fmt.Errorf("option %s is not set here", strings.Join(args, " "))
case 1:
default:
var labels []string
for _, o := range matches {
if l := optionLabel(o.Name, o.Value); l != "" {
labels = append(labels, l)
}
}
if len(labels) == len(matches) {
return fmt.Errorf("option %s is set %d times (%s); say which: jarl option unset %s %s", args[0], len(matches), strings.Join(labels, ", "), args[0], labels[0])
}
return fmt.Errorf("option %s is set %d times; remove one with --id", args[0], len(matches))
}
resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", matches[0].ID), nil, nil)
@@ -721,7 +749,35 @@ func optionCmd() *cobra.Command {
}
scopeArgs(describe)
describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type")
cmd.AddCommand(list, set, unset, describe)
generated := &cobra.Command{
Use: "generated",
Short: "What Jarl adds to named.conf by itself, why, and what controls it",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var gen []struct {
Where string `json:"where"`
Text string `json:"text"`
Why string `json:"why"`
Setting string `json:"setting"`
}
if err := c.Get(ctx(), "/options/generated", nil, &gen); err != nil {
return err
}
return output(gen, func(w io.Writer) {
for i, g := range gen {
if i > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "# %s: %s\n# controlled by: %s\n%s\n", g.Where, g.Why, g.Setting, g.Text)
}
})
},
}
cmd.AddCommand(list, set, unset, describe, generated)
return cmd
}
@@ -815,3 +871,30 @@ func settingsCmd() *cobra.Command {
cmd.AddCommand(show, set)
return cmd
}
// labelled are the statements told apart by the name after the keyword:
// tls quad9 { ... }, key xfer { ... }, server 192.0.2.1 { ... }.
var labelled = map[string]bool{"tls": true, "http": true, "key": true, "remote-servers": true, "primaries": true, "masters": true,
"parental-agents": true, "dnssec-policy": true, "key-store": true, "server": true, "view": true, "acl": true, "zone": true}
// optionLabel returns the name a labelled statement's value starts with
// ("quad9" for tls quad9 { ... }), lowercase; "" for other statements.
func optionLabel(name, value string) string {
if !labelled[name] {
return ""
}
v := strings.TrimSpace(value)
if v == "" || strings.HasPrefix(v, "{") {
return ""
}
if strings.HasPrefix(v, `"`) {
if end := strings.Index(v[1:], `"`); end >= 0 {
return strings.ToLower(v[1 : end+1])
}
}
end := strings.IndexAny(v, " \t\n{;")
if end < 0 {
end = len(v)
}
return strings.ToLower(v[:end])
}
+20 -9
View File
@@ -23,8 +23,8 @@ keeps writing the unsigned zone, and named keeps a signed copy next to it
and its keys in Jarl's key directory. See docs/dnssec.md.
Turning signing on is safe at any time; validation only starts once you add
the zone's DS record at the parent zone (your registrar), and 'status' says
when that is safe. Turning it off goes through BIND's insecure policy, which
the zone's DS record at the parent zone (your registrar for a domain, the
parent zone for a subdomain), and 'status' says when that is safe and where. Turning it off goes through BIND's insecure policy, which
keeps the zone signed until the DS is gone from the parent.`,
}
var view string
@@ -71,7 +71,7 @@ DS after that.`,
return err
}
if !flagQuiet {
fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
fmt.Printf("\nnamed is signing %s now. Add its DS record to the parent zone only when\n'jarl zone dnssec status %s' says the zone is ready; it also says where it goes.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
}
return nil
},
@@ -87,8 +87,8 @@ DS after that.`,
parent zone still has its DS record:
1. The first run switches the zone to BIND's insecure policy. named keeps it
signed and publishes a CDS "delete" record. Remove the DS record at your
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
signed and publishes a CDS "delete" record. Remove the DS record at the
parent ('status' says where), then run 'jarl zone dnssec check-ds' so named knows. named
waits until resolvers have forgotten the DS (the parent's DS TTL, often a
day) and then removes the keys and signatures.
2. Run it again when 'status' says turning off has finished: DNSSEC is then
@@ -137,7 +137,7 @@ A zone whose DS never reached the parent can be wiped at once instead
if next == "insecure" && st.ParentZone != "" {
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else if next == "insecure" {
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
fmt.Printf("\nNext: remove the DS record for %s %s, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), dsPlace(st), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else {
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
}
@@ -153,7 +153,7 @@ A zone whose DS never reached the parent can be wiped at once instead
Short: "Check the parent zone's DS records and tell named",
Long: `Asks every server of the parent zone for the zone's DS records, and tells
named (rndc dnssec -checkds) when its key's DS has appeared there (after you
added it at the registrar) or is gone (while turning DNSSEC off). named
added it there) or is gone (while turning DNSSEC off). named
moves to the next step only once it knows.
The parent is found through jarld's resolver, or --resolver. Exits with 2
@@ -345,9 +345,12 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
}
}
if len(ds) > 0 && r.Policy != "insecure" {
where := "at your registrar"
if r.DSAt == "none" {
fmt.Fprintf(w, "\n%s\n", r.DSNote)
}
where := dsPlace(r)
if r.ParentZone != "" {
where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there"
where += ", which is in Jarl: add it as a DS record there"
}
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
@@ -368,6 +371,14 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
}
}
// dsPlace says where the zone's DS record goes ("at your registrar").
func dsPlace(r *api.DNSSECResponse) string {
if r.DSPlace != "" {
return r.DSPlace
}
return "at the parent zone"
}
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
// record in its parent zone in Jarl.
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
+158 -32
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/url"
"strings"
"time"
"github.com/spf13/cobra"
)
@@ -16,14 +17,17 @@ type encEndpoint struct {
}
type encStatus struct {
Enabled bool `json:"enabled"`
Certificate string `json:"certificate"`
Hostname string `json:"hostname"`
DoTHost string `json:"dot_host"`
DoHURL string `json:"doh_url"`
DoT []encEndpoint `json:"dot"`
DoH []encEndpoint `json:"doh"`
Other bool `json:"other"`
Enabled bool `json:"enabled"`
Certificate string `json:"certificate"`
Hostname string `json:"hostname"`
Hostnames []string `json:"hostnames"`
AddrViews map[string][]string `json:"address_views"`
DoTHost string `json:"dot_host"`
DoHURL string `json:"doh_url"`
DoT []encEndpoint `json:"dot"`
DoH []encEndpoint `json:"doh"`
Other bool `json:"other"`
Health *encHealth `json:"health"`
Addresses []struct {
Address string `json:"address"`
Private bool `json:"private"`
@@ -85,7 +89,14 @@ views.`,
if !s.Enabled {
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
} else {
fmt.Fprintf(w, "Host names:\t%s\n", strings.Join(s.Hostnames, ", "))
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
for _, e := range append(append([]encEndpoint{}, s.DoT...), s.DoH...) {
if vs, ok := s.AddrViews[e.Address]; ok {
fmt.Fprintf(w, "Records:\t%s in %s\n", e.Address, strings.Join(vs, ", "))
delete(s.AddrViews, e.Address)
}
}
if len(s.DoT) > 0 {
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
}
@@ -99,6 +110,9 @@ views.`,
fmt.Fprintf(w, "For DoH clients:\t%s (Firefox, Chrome, Windows 11)\n", s.DoHURL)
}
}
if s.Enabled {
printHealth(w, s.Health)
}
if s.Other {
fmt.Fprintln(w, "Note:\tnamed has DoT or DoH listeners written by hand too; setup leaves them alone")
}
@@ -114,12 +128,13 @@ views.`,
})
},
}
var host, certName string
var hostList []string
var certName string
var addrs, views []string
var noDoT, noDoH bool
var dohPort, dotPort int
setup := &cobra.Command{
Use: "setup --hostname NAME --address ADDR...",
Use: "setup --hostname NAME... --address ADDR[=VIEW,...]...",
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)",
Long: `Sets up DoT (port 853) and DoH (port 443, or --doh-port) on the addresses
given, which should be this server's addresses on your own networks:
@@ -130,25 +145,57 @@ given, which should be this server's addresses on your own networks:
- named's tls statement and listen-on statements (and an explicit
listen-on { any; } when there was none, as the first listen-on would
otherwise stop named listening on port 53 everywhere);
- the host name's A and AAAA records, in the private views that have its
zone (or --view), local to the view when the zone is linked.
- the host names' A and AAAA records, local to each view: per address,
the views after = (--address 10.0.10.5=internal,lab), or the views of
--view for every address (default: the private views that have the
zone). A view no longer given loses the address.
It is one change, staged or applied like any other. Running it again
replaces the earlier setup.`,
Several --hostname: the certificate must be for all of them. It is one
change, staged or applied like any other. Running it again replaces the
earlier setup, ports included.`,
Example: ` jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address fdab:abab:abab:10::5=internal
jarl encrypted-dns setup --hostname dns.adbraun.lol --hostname doh.adbraun.lol --address 10.0.10.5 --view internal --dot-port 8853`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
host = strings.ToLower(strings.TrimSuffix(host, "."))
if host == "" {
var hosts []string
for _, h := range hostList {
for _, x := range strings.Split(h, ",") {
if x = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(x), ".")); x != "" {
hosts = append(hosts, x)
}
}
}
if len(hosts) == 0 {
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
}
host := hosts[0]
var s encStatus
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil {
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", strings.Join(hosts, ",")), &s); err != nil {
return err
}
// ADDR=VIEW,VIEW gives an address its own views.
addrViews := map[string][]string{}
perAddress := false
for i, a := range addrs {
addr, vs, ok := strings.Cut(a, "=")
addrs[i] = strings.TrimSpace(addr)
if ok {
perAddress = true
addrViews[addrs[i]] = nil
for _, v := range strings.Split(vs, ",") {
if v = strings.TrimSpace(v); v != "" {
addrViews[addrs[i]] = append(addrViews[addrs[i]], v)
}
}
}
}
if perAddress && len(views) > 0 {
return errors.New("give the views per address (--address ADDR=VIEW,...) or for all of them (--view), not both")
}
if len(addrs) == 0 {
var list []string
for _, a := range s.Addresses {
@@ -158,20 +205,24 @@ replaces the earlier setup.`,
}
// The certificate.
if certName == "" {
certs:
for _, ci := range s.Certificates {
if coversName(append(ci.Names, ci.Domains...), host) {
certName = ci.Name
break
for _, h := range hosts {
if !coversName(append(ci.Names, ci.Domains...), h) {
continue certs
}
}
certName = ci.Name
break
}
}
if certName == "" {
certName = strings.SplitN(host, ".", 2)[0]
fmt.Printf("Creating certificate %s for %s\n", certName, host)
fmt.Printf("Creating certificate %s for %s\n", certName, strings.Join(hosts, ", "))
var r struct {
Certificate certInfo `json:"certificate"`
}
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": []string{host}, "uses": []string{"dns"}}, &r); err != nil {
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": hosts, "uses": []string{"dns"}}, &r); err != nil {
return err
}
if err := waitIssue(c, certName); err != nil {
@@ -199,7 +250,7 @@ replaces the earlier setup.`,
}
}
}
body := map[string]any{"hostname": host, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
body := map[string]any{"hostnames": hosts, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
if cmd.Flags().Changed("doh-port") {
body["doh_port"] = dohPort
}
@@ -229,14 +280,24 @@ replaces the earlier setup.`,
if chk.Error != "" && !flagQuiet {
fmt.Println("Note:", chk.Error)
}
if len(views) == 0 {
for _, v := range s.Views {
if !v.Public && v.HasZone {
views = append(views, v.Name)
switch {
case perAddress:
for _, a := range addrs {
if _, ok := addrViews[a]; !ok {
addrViews[a] = []string{} // no records for it
}
}
body["address_views"] = addrViews
default:
if len(views) == 0 {
for _, v := range s.Views {
if !v.Public && v.HasZone {
views = append(views, v.Name)
}
}
}
body["views"] = views
}
body["views"] = views
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
if err != nil {
return err
@@ -245,14 +306,14 @@ replaces the earlier setup.`,
},
}
f := setup.Flags()
f.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)")
f.StringArrayVar(&hostList, "hostname", nil, "a name clients connect to (repeat, or comma separated, for several; the certificate must be for all)")
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on (repeat for several)")
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on, with the views that get it: 10.0.10.5=internal,lab (repeat for several)")
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS")
f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
f.StringArrayVar(&views, "view", nil, "views to add the host name's records to (default: the private views that have its zone)")
f.StringArrayVar(&views, "view", nil, "views where every address gets the host names' records (default: the private views that have the zone); per address: --address ADDR=VIEW")
var cAddrs []string
var cDoH int
@@ -308,7 +369,38 @@ replaces the earlier setup.`,
return report(resp)
},
}
cmd.AddCommand(setup, check, disable)
health := &cobra.Command{
Use: "health",
Short: "Check the DoT and DoH listeners now, as a client would",
Long: `Connects to each listener, checks the certificate it serves (for the
host name, current, the one Jarl last wrote) and asks a question. jarld
also does this every 10 minutes; the dashboard and Prometheus
(jarl_encrypted_dns_up) show the result. Exits 1 when a listener doesn't
work.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var h encHealth
if err := c.Do(ctx(), "POST", "/encrypted-dns/health", nil, map[string]any{}, &h); err != nil {
return err
}
if err := output(h, func(w io.Writer) { printHealth(w, &h) }); err != nil {
return err
}
ok := true
for _, p := range h.Probes {
ok = ok && p.OK
}
if !ok {
return errors.New("encrypted DNS isn't working everywhere")
}
return nil
},
}
cmd.AddCommand(setup, check, disable, health)
return cmd
}
@@ -337,3 +429,37 @@ func contains(list []string, s string) bool {
}
return false
}
type encHealth struct {
Checked time.Time `json:"checked"`
Probes []struct {
Kind string `json:"kind"`
Address string `json:"address"`
Port int `json:"port"`
OK bool `json:"ok"`
Problem string `json:"problem"`
Warning string `json:"warning"`
RCode string `json:"rcode"`
Ms int64 `json:"ms"`
} `json:"probes"`
}
func printHealth(w io.Writer, h *encHealth) bool {
if h == nil {
fmt.Fprintln(w, "Health:\tnot checked yet (jarl encrypted-dns health)")
return true
}
ok := true
for _, p := range h.Probes {
kind := map[string]string{"dot": "DoT", "doh": "DoH"}[p.Kind]
state := fmt.Sprintf("working (%s in %d ms)", p.RCode, p.Ms)
if !p.OK {
state, ok = "NOT WORKING: "+p.Problem, false
} else if p.Warning != "" {
state += "; " + p.Warning
}
fmt.Fprintf(w, "%s %s port %d:\t%s\n", kind, p.Address, p.Port, state)
}
fmt.Fprintf(w, "Checked:\t%s\n", h.Checked.Local().Format("2006-01-02 15:04:05"))
return ok
}
+81 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"errors"
"fmt"
"io"
"strings"
@@ -42,6 +43,54 @@ type exposureReport struct {
Count int64
} `json:"names"`
} `json:"rate_limiting"`
Mail *mailReport `json:"mail"`
}
type mailReport struct {
Checked string `json:"checked"`
Results []struct {
Zone string `json:"zone"`
Address string `json:"address"`
Roles []string `json:"roles"`
PTR []string `json:"ptr"`
OK bool `json:"ok"`
Level string `json:"level"`
Message string `json:"message"`
ManagedBy string `json:"managed_by"`
LocalZone string `json:"local_zone"`
} `json:"results"`
Errors []string `json:"errors"`
}
func printMail(w io.Writer, m *mailReport) {
if len(m.Results) == 0 && len(m.Errors) == 0 {
fmt.Fprintln(w, "No mail addresses: no public zone has an MX record or SPF addresses.")
return
}
fmt.Fprintln(w, "ZONE\tADDRESS\tFOR\tPTR\tSTATUS")
for _, r := range m.Results {
ptr := strings.Join(r.PTR, ",")
if ptr == "" {
ptr = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Zone, r.Address, strings.Join(r.Roles, ", "), ptr, r.Level)
if r.Level != "ok" {
fmt.Fprintf(w, " %s\n", r.Message)
}
if r.LocalZone != "" {
fmt.Fprintf(w, " Jarl's reverse zone %s only answers inside: receivers ask %s\n", r.LocalZone, orOwner(r.ManagedBy))
}
}
for _, e := range m.Errors {
fmt.Fprintf(w, "error: %s\n", e)
}
}
func orOwner(s string) string {
if s == "" {
return "the address's owner"
}
return s
}
// exposureScope turns "server" (or "") into the server, anything else
@@ -115,6 +164,10 @@ fix Jarl can stage for each.`,
}
}
}
if r.Mail != nil {
fmt.Fprintln(w, "\nMAIL REVERSE DNS")
printMail(w, r.Mail)
}
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
for _, e := range a.Counts {
@@ -186,6 +239,33 @@ fix Jarl can stage for each.`,
return report(resp)
},
}
cmd.AddCommand(fix, ack, unack)
mail := &cobra.Command{
Use: "mail",
Short: "Check the reverse DNS of the addresses that send and receive your mail, now",
Long: `Finds the addresses of the public zones' MX hosts and SPF records and checks,
through public resolvers, that each has a PTR record whose name resolves
back to it, as Gmail and Outlook do. Exits 1 when one doesn't.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var m mailReport
if err := c.Do(ctx(), "POST", "/exposure/mail", nil, map[string]any{}, &m); err != nil {
return err
}
if err := output(m, func(w io.Writer) { printMail(w, &m) }); err != nil {
return err
}
for _, r := range m.Results {
if !r.OK {
return errors.New("some mail addresses have no working reverse DNS")
}
}
return nil
},
}
cmd.AddCommand(fix, ack, unack, mail)
return cmd
}
+44 -9
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"net/netip"
"net/url"
"os"
"path/filepath"
@@ -23,6 +24,7 @@ import (
"jarl/internal/api"
"jarl/internal/client"
"jarl/internal/dnsname"
"jarl/internal/version"
)
@@ -101,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
return os.WriteFile(flagConfig, b, 0o600)
}
func newClient() (*client.Client, error) {
// connection is the server a command talks to and how: Token may be
// "@FILE" (read by open).
type connection struct {
Server string `json:"server"`
Token string `json:"token,omitempty"`
Insecure bool `json:"insecure,omitempty"`
CACert string `json:"ca_cert,omitempty"`
// Name is the named server's name ("" for one given by address).
Name string `json:"-"`
}
// resolveConnection works out the connection from the flags, the
// environment and the CLI configuration.
func resolveConnection() (connection, error) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
return connection{}, fmt.Errorf("reading %s: %w", flagConfig, err)
}
ts, err := resolveTargets(cfg, targetSpec(cfg))
if err != nil {
return nil, err
return connection{}, err
}
if len(ts) > 1 {
return nil, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
return connection{}, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
}
t := ts[0]
// A named server's own token wins: a token from the environment
@@ -123,15 +138,21 @@ func newClient() (*client.Client, error) {
sock, isSocket := client.SocketPath(t.URL)
if isSocket {
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
return nil, err
return connection{}, err
}
}
if token == "" && !isSocket {
if t.Name != "" {
return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
return connection{}, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
}
return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
return connection{}, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
}
return connection{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert), Name: t.Name}, nil
}
// open returns a client for the connection.
func (cn connection) open() (*client.Client, error) {
token := cn.Token
if strings.HasPrefix(token, "@") {
b, err := os.ReadFile(token[1:])
if err != nil {
@@ -139,7 +160,15 @@ func newClient() (*client.Client, error) {
}
token = strings.TrimSpace(string(b))
}
c, err := client.New(client.Options{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert)})
return client.New(client.Options{Server: cn.Server, Token: token, Insecure: cn.Insecure, CACert: cn.CACert})
}
func newClient() (*client.Client, error) {
cn, err := resolveConnection()
if err != nil {
return nil, err
}
c, err := cn.open()
if err != nil {
return nil, err
}
@@ -165,7 +194,7 @@ func newClient() (*client.Client, error) {
}
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
if !c.Preflight {
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
}
return c, nil
}
@@ -569,7 +598,13 @@ func q(kv ...string) url.Values {
}
// zonePath returns the API path for a zone given by name or ID.
// A network names its reverse zone: 10.0.10.0/24 is 10.0.10.in-addr.arpa.
func zonePath(zone, view string) (string, url.Values) {
if p, err := netip.ParsePrefix(strings.TrimSpace(zone)); err == nil {
if name, err := dnsname.ReverseZone(p); err == nil {
zone = name
}
}
z := strings.TrimSuffix(zone, ".")
if zone == "." {
z = "root"
+17
View File
@@ -232,3 +232,20 @@ func TestOutputFields(t *testing.T) {
}
}
}
func TestOptionLabel(t *testing.T) {
for _, c := range []struct{ name, value, want string }{
{"tls", `quad9 { remote-hostname "dns.quad9.net"; }`, "quad9"},
{"tls", `Cloudflare{ remote-hostname "cloudflare-dns.com"; }`, "cloudflare"},
{"key", `"xfer key" { algorithm hmac-sha256; secret "x"; }`, "xfer key"},
{"server", "192.0.2.1 { keys { xfer; }; }", "192.0.2.1"},
{"remote-servers", "secondaries { 192.0.2.53; }", "secondaries"},
{"forwarders", "{ 9.9.9.9; }", ""},
{"logging", "{ channel x { stderr; }; }", ""},
{"tls", "", ""},
} {
if got := optionLabel(c.name, c.value); got != c.want {
t.Errorf("optionLabel(%q, %q) = %q, want %q", c.name, c.value, got, c.want)
}
}
}
+37 -9
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"math"
"net/url"
"slices"
"strconv"
"strings"
@@ -21,8 +22,22 @@ func statsCmd() *cobra.Command {
Long: `Query statistics collected from named (see docs/stats.md). They must be
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
Ranges are given with --since: 90m, 24h, 7d, 2w. Use -o json or -o yaml to
export the data.`,
Ranges are given with --since: 90m, 24h, 7d, 2w. --view limits the figures
to some views (repeat it, or separate with commas): answers from named's
response log, per view; named's own counters stay for the whole server.
Use -o json or -o yaml to export the data.`,
}
var statsViews []string
cmd.PersistentFlags().StringSliceVar(&statsViews, "view", nil, "only these views (repeat, or comma separated)")
// withViews adds the --view filter to a query.
withViews := func(v url.Values) url.Values {
if len(statsViews) > 0 {
if v == nil {
v = url.Values{}
}
v.Set("views", strings.Join(statsViews, ","))
}
return v
}
// Each command has its own --since variable: their defaults differ, and
// a shared variable would take the last default for all of them.
@@ -77,14 +92,27 @@ export the data.`,
return err
}
var s stats.Summary
if err := c.Get(ctx(), "/stats/summary", q("since", summarySince, "top", strconv.Itoa(topN)), &s); err != nil {
if err := c.Get(ctx(), "/stats/summary", withViews(q("since", summarySince, "top", strconv.Itoa(topN))), &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "Range:\t%s to %s\n", s.From.Local().Format("2006-01-02 15:04"), s.To.Local().Format("2006-01-02 15:04"))
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
if len(s.FilterViews) > 0 {
since := ""
if s.ViewsSince != nil {
since = ", per view since " + s.ViewsSince.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "Views:\t%s (from the response log%s; named's own counters are left out)\n", strings.Join(s.FilterViews, ", "), since)
fmt.Fprintf(w, "Answers:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
} else {
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
}
if s.Peak != nil {
fmt.Fprintf(w, "Busiest %s:\t%s with %d queries\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries)
what := "queries"
if len(s.FilterViews) > 0 {
what = "answers"
}
fmt.Fprintf(w, "Busiest %s:\t%s with %d %s\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries, what)
}
if s.CacheHitRatio != nil {
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
@@ -157,7 +185,7 @@ export the data.`,
return err
}
var es []stats.Entry
if err := c.Get(ctx(), "/stats/top", q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit)), &es); err != nil {
if err := c.Get(ctx(), "/stats/top", withViews(q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit))), &es); err != nil {
return err
}
return output(es, func(w io.Writer) {
@@ -207,7 +235,7 @@ export the data.`,
qv.Add("key", k)
}
var tl stats.Timeline
if err := c.Get(ctx(), "/stats/timeline", qv, &tl); err != nil {
if err := c.Get(ctx(), "/stats/timeline", withViews(qv), &tl); err != nil {
return err
}
return output(tl, func(w io.Writer) {
@@ -256,7 +284,7 @@ export the data.`,
return err
}
var hm stats.Heatmap
if err := c.Get(ctx(), "/stats/heatmap", q("since", heatmapSince, "key", hmKey), &hm); err != nil {
if err := c.Get(ctx(), "/stats/heatmap", withViews(q("since", heatmapSince, "key", hmKey)), &hm); err != nil {
return err
}
return output(hm, func(w io.Writer) {
@@ -308,7 +336,7 @@ export the data.`,
return err
}
var es []stats.Entry
if err := c.Get(ctx(), "/stats/breakdown", q("group", args[0], "since", breakdownSince), &es); err != nil {
if err := c.Get(ctx(), "/stats/breakdown", withViews(q("group", args[0], "since", breakdownSince)), &es); err != nil {
return err
}
return output(es, func(w io.Writer) {
+407 -2
View File
@@ -93,6 +93,18 @@ func zoneCmd() *cobra.Command {
if z.InView != "" {
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
}
if z.AutoPTR {
fmt.Fprintf(w, "PTR records:\tkept in step with the A and AAAA records\n")
}
if z.Prefix != "" {
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
if z.PTRMissing > 0 {
fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0])
}
if z.PTROtherViews > 0 {
fmt.Fprintf(w, "Other views:\t%d addresses here only other views' records use, without a PTR record (to name them from there: jarl zone fill-ptr %s --other-views)\n", z.PTROtherViews, args[0])
}
}
if len(z.LinkedWith) > 0 {
fmt.Fprintf(w, "Linked with:\t%s (shared records are kept in step; 'jarl record list' shows which are local)\n", strings.Join(z.LinkedWith, ", "))
}
@@ -110,17 +122,22 @@ func zoneCmd() *cobra.Command {
var ns, records, primaries, forwarders, opts []string
var contact, comment, cloneFrom, file, inView, forward, linkWith string
var ttl uint32
var fill, fillOther bool
create := &cobra.Command{
Use: "create NAME",
Short: "Create a zone",
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to
create its reverse zone: a /32 (or /128) gives a zone for that one address,
and a network between octet boundaries, like a /22, gets one zone per /24
(created together).
(created together). Without --view, a reverse zone goes to the private
view whose records use its addresses; --fill adds a PTR record for each
of them, and --other-views also for the addresses only other views'
records use, named from there (docs/reverse.md).
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
jarl zone create 192.168.1.0/24
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
jarl zone create 10.0.10.0/24 --fill # with PTR records for the view's A records there
jarl zone create partner.example --type secondary --primary 192.0.2.53
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
jarl zone create example.com --view external --clone-from example.com --clone-view internal
@@ -156,6 +173,12 @@ and a network between octet boundaries, like a /22, gets one zone per /24
if linkWith != "" {
body["link_with"] = linkWith
}
if fill || fillOther {
body["fill"] = true
}
if fillOther {
body["fill_other_views"] = true
}
var recs []map[string]any
for _, r := range records {
f := strings.Fields(r)
@@ -228,9 +251,12 @@ and a network between octet boundaries, like a /22, gets one zone per /24
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
create.Flags().StringVar(&linkWith, "link-with", "", "link with the zone of the same name in this view: copy its shared records and keep them in step (docs/views.md#linked-zones)")
create.Flags().StringVar(&comment, "comment", "", "comment")
create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network")
create.Flags().BoolVar(&fillOther, "other-views", false, "reverse zones: --fill, also naming the addresses only other views' records use")
var refresh, retry, expire, minimum, serial uint32
var mname, policy, moveView string
var autoPTR bool
update := &cobra.Command{
Use: "update ZONE",
Short: "Change a zone's settings (SOA, TTL, view, comment)",
@@ -272,6 +298,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
if f.Changed("file") {
body["file"] = file
}
if f.Changed("auto-ptr") {
body["auto_ptr"] = autoPTR
}
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
}
@@ -296,6 +325,7 @@ and a network between octet boundaries, like a /22, gets one zone per /24
update.Flags().StringVar(&comment, "comment", "", "comment")
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view")
update.Flags().StringVar(&file, "file", "", "zone file (secondary, hint and redirect zones)")
update.Flags().BoolVar(&autoPTR, "auto-ptr", false, "keep the PTR records of the zone's A and AAAA records in step (--auto-ptr=false: stop)")
var yes bool
del := &cobra.Command{
@@ -424,7 +454,7 @@ equal to the file: records not in the file are deleted.`,
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false))
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd(), zoneFillPTRCmd(), zoneSuggestReverseCmd())
return cmd
}
@@ -937,3 +967,378 @@ zone is dissolved.`
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
return cmd
}
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
// that have it.
type compareSet struct {
View string `json:"view"`
Records []string `json:"records"`
IDs []int64 `json:"ids"`
Linked string `json:"linked"`
}
type compareRow struct {
Name string `json:"name"`
Type string `json:"type"`
Views []compareSet `json:"views"`
}
type compareReport struct {
Zone string `json:"zone"`
Base string `json:"base"`
Views []string `json:"views"`
Linked bool `json:"linked"`
Summary []struct {
View string `json:"view"`
Different int `json:"different"`
OnlyBase int `json:"only_base"`
OnlyOther int `json:"only_other"`
Same int `json:"same"`
} `json:"summary"`
Different []compareRow `json:"different"`
Only []struct {
Views []string `json:"views"`
Rows []compareRow `json:"rows"`
} `json:"only"`
Same int `json:"same"`
SameRows []compareRow `json:"same_rows"`
}
func zoneDiffCmd() *cobra.Command {
var view string
var with []string
var all, wide bool
cmd := &cobra.Command{
Use: "diff ZONE [NAME [TYPE]] [--view V] [--with W...] [--all] [--wide]",
Short: "Compare a zone with the same zone in other views",
Long: `Compares a zone in one view (--view, default the first view that has it)
with the same zone in the other views (--with, default all of them): how
many names each answers differently, the names and types only some views
have, and how many are the same. NAME (and TYPE) narrows it to one name,
the same records included. One line per record; long values are cut to
fit the terminal (--wide shows them whole).
In a linked zone a difference is a view's own (local) records, on
purpose; between zones that aren't linked it is drift. 'jarl record
update ID --shared' makes a linked view's version every view's.`,
Example: ` jarl zone diff adbraun.lol
jarl zone diff adbraun.lol --view internal --with external
jarl zone diff adbraun.lol nas
jarl zone diff adbraun.lol @ TXT --wide`,
Args: cobra.RangeArgs(1, 3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
if len(with) > 0 {
qv.Set("with", strings.Join(with, ","))
}
if len(args) > 1 {
qv.Set("name", args[1])
}
if len(args) > 2 {
qv.Set("type", args[2])
}
if all {
qv.Set("all", "1")
}
var r compareReport
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
return err
}
width := 0
if !wide {
width = terminalWidth()
}
return output(r, func(w io.Writer) { printCompare(w, &r, len(args) > 1, width) })
},
}
cmd.Flags().StringVar(&view, "view", "", "the view to compare from (default: the first that has the zone)")
cmd.Flags().StringArrayVar(&with, "with", nil, "a view to compare with (repeat; default: every other view with the zone)")
cmd.Flags().BoolVar(&all, "all", false, "also list what is the same")
cmd.Flags().BoolVarP(&wide, "wide", "w", false, "show long values whole instead of cutting them to the terminal")
return cmd
}
// printCompare prints a comparison as sections, one line per record,
// each cut to width characters (0: whole).
func printCompare(w io.Writer, r *compareReport, oneName bool, width int) {
vw := 0 // the views column
for _, v := range r.Views {
vw = max(vw, len(v))
}
line := func(indent, label, text string) {
s := indent + label + text
if width > 0 && utf8.RuneCountInString(s) > width {
keep := max(width-utf8.RuneCountInString(indent+label)-1, 8)
text = string([]rune(text)[:min(keep, utf8.RuneCountInString(text))]) + "…"
}
fmt.Fprintln(w, indent+label+text)
}
link := "not linked, so a difference may have crept in ('jarl zone link " + r.Zone + "' keeps them in step)"
if r.Linked {
link = "linked: a difference is a view's own records, there on purpose"
}
fmt.Fprintf(w, "%s in %s, compared with %s\n %s\n", r.Zone, r.Base, strings.Join(r.Views[1:], ", "), link)
if !oneName {
for _, s := range r.Summary {
fmt.Fprintf(w, " %s: %d answered differently, %d only in %s, %d only in %s, %d the same\n", s.View, s.Different, s.OnlyBase, r.Base, s.OnlyOther, s.View, s.Same)
}
}
name := func(row compareRow) string { return row.Name + " " + row.Type }
sets := func(row compareRow, local bool) {
for _, vs := range row.Views {
label := fmt.Sprintf(" %-*s ", vw, vs.View)
if len(vs.Records) == 0 {
line("", label, "-")
continue
}
for i, d := range vs.Records {
if i > 0 {
label = strings.Repeat(" ", 4+vw+2)
}
suffix := ""
if local && vs.Linked == "local" && i == 0 {
suffix = " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
}
line("", label, d+suffix)
}
}
}
if len(r.Different) > 0 {
fmt.Fprintf(w, "\nANSWERED DIFFERENTLY (%d)\n", len(r.Different))
for _, row := range r.Different {
fmt.Fprintln(w, " "+name(row))
sets(row, true)
}
}
for _, o := range r.Only {
fmt.Fprintf(w, "\nONLY IN %s (%d)\n", strings.ToUpper(strings.Join(o.Views, ", ")), len(o.Rows))
nw := 0
for _, row := range o.Rows {
nw = max(nw, len(name(row)))
}
for _, row := range o.Rows {
var recs []string
for _, vs := range row.Views {
if len(vs.Records) > 0 {
recs = vs.Records
break
}
}
for i, d := range recs {
label := fmt.Sprintf(" %-*s ", nw, name(row))
if i > 0 {
label = strings.Repeat(" ", 2+nw+2)
}
line("", label, d)
}
}
}
if len(r.SameRows) > 0 {
fmt.Fprintf(w, "\nTHE SAME IN %s (%d)\n", strings.ToUpper(strings.Join(r.Views, ", ")), len(r.SameRows))
nw := 0
for _, row := range r.SameRows {
nw = max(nw, len(name(row)))
}
for _, row := range r.SameRows {
for i, d := range row.Views[0].Records {
label := fmt.Sprintf(" %-*s ", nw, name(row))
if i > 0 {
label = strings.Repeat(" ", 2+nw+2)
}
line("", label, d)
}
}
} else if r.Same > 0 && !oneName {
fmt.Fprintf(w, "\n%d names and types are the same (--all lists them)\n", r.Same)
}
if oneName && len(r.Different) == 0 && len(r.Only) == 0 && len(r.SameRows) == 0 {
fmt.Fprintln(w, "\nNo records of that name in these views.")
}
}
// terminalWidth returns the terminal's width, or 0 when standard output
// isn't a terminal (then nothing is cut).
func terminalWidth() int {
fd := int(os.Stdout.Fd())
if !term.IsTerminal(fd) {
return 0
}
width, _, err := term.GetSize(fd)
if err != nil || width <= 0 {
return 0
}
return width
}
func zoneFillPTRCmd() *cobra.Command {
var view string
var otherViews bool
cmd := &cobra.Command{
Use: "fill-ptr ZONE [--view VIEW] [--other-views]",
Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records",
Long: `Adds a PTR record to a reverse zone for every address in its network that
the view's A and AAAA records use and that has none yet. An address with
several names gets the mail or name server's name, else a host's rather
than the domain's, else the shortest (docs/reverse.md).
--other-views also adds the addresses in the network that only other
views' records use, naming them from there. That publishes those views'
host names in this one, so it is never done unasked.`,
Example: ` jarl zone fill-ptr 10.0.10.0/24
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
if otherViews {
qv.Set("other_views", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
cmd.Flags().BoolVar(&otherViews, "other-views", false, "also name the addresses only other views' records use")
return cmd
}
type reverseSuggestion struct {
ViewID int64 `json:"view_id"`
View string `json:"view"`
Prefix string `json:"prefix"`
Zone string `json:"zone"`
Group string `json:"group"`
Hidden string `json:"hidden"`
Why string `json:"why"`
Fill int `json:"fill"`
Samples []string `json:"samples"`
}
func zoneSuggestReverseCmd() *cobra.Command {
var create, all bool
var dismiss, undismiss []string
var view string
cmd := &cobra.Command{
Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]",
Short: "The reverse zones your private views lack for their records' addresses",
Long: `Lists the reverse zones the private views (or the server, without views)
lack for the addresses of their A and AAAA records, in two groups:
yours the /24 of a private IPv4 network, the /48 of a ULA or of an IPv6
prefix this server has an address in
public a /32 (or /128) for any other public address: create it only if
the address is yours, not a web host's
--create creates the ones that are yours, filled with PTR records, in one
changeset; create a public one with 'jarl zone create NETWORK --view V
--fill'. --dismiss hides a suggestion for good (--undismiss shows it
again). A view can stop suggestions: jarl view update V
--reverse-suggestions=false (docs/reverse.md).
--all lists every network the views' records use without a reverse zone,
also where nothing is suggested unasked (a view with suggestions off, a
dismissed network, a public view), to create one by hand; --view narrows
the list to one view and adds the networks only other views' records use
(hidden "records in VIEW"): create one with --fill --other-views to name
them from there. Change the size as you need: a /48 suggested for
IPv6 can be created as a /56 or /64 instead.`,
Example: ` jarl zone suggest-reverse
jarl zone suggest-reverse --create
jarl zone create 139.144.144.154/32 --view internal --fill
jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal
jarl zone suggest-reverse --all --view vps
jarl zone create 2a02:8013:7f3d::/48 --view internal --fill`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if len(dismiss) > 0 || len(undismiss) > 0 {
path, prefixes := "/reverse-zones/dismiss", dismiss
if len(undismiss) > 0 {
path, prefixes = "/reverse-zones/undismiss", undismiss
}
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefixes": prefixes})
if err != nil {
return err
}
return report(resp)
}
if create && (all || view != "") {
return fmt.Errorf("--create creates the suggestions that are yours; create others one by one: jarl zone create NETWORK --view VIEW --fill")
}
qv := q("view", view)
if all {
qv.Set("all", "1")
}
var sugg []reverseSuggestion
if err := c.Get(ctx(), "/reverse-zones/suggestions", qv, &sugg); err != nil {
return err
}
if !create {
return output(sugg, func(w io.Writer) {
if len(sugg) == 0 {
fmt.Fprintln(w, "No reverse zones missing.")
return
}
head := "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE"
if all {
head = "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tHIDDEN\tFOR EXAMPLE"
}
fmt.Fprintln(w, head)
for _, s := range sugg {
v := s.View
if v == "" {
v = "-"
}
cols := []string{s.Group, v, s.Prefix, s.Zone, fmt.Sprint(s.Fill)}
if all {
cols = append(cols, orDash(s.Hidden))
}
cols = append(cols, strings.Join(s.Samples, ", "))
fmt.Fprintln(w, strings.Join(cols, "\t"))
}
fmt.Fprintln(w, "\nCreate yours, filled: jarl zone suggest-reverse --create. A public one only if the address is yours:\njarl zone create NETWORK --view VIEW --fill (or hide it: --dismiss NETWORK --view VIEW).")
})
}
var reqs []api.ChangeRequest
for _, s := range sugg {
if s.Group != "yours" {
continue
}
body := map[string]any{"name": s.Prefix, "fill": true}
if s.ViewID != 0 {
body["view_id"] = s.ViewID
}
data, err := json.Marshal(body)
if err != nil {
return err
}
reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data})
}
if len(reqs) == 0 {
fmt.Println("No reverse zones of yours missing (public addresses are created one by one: see 'jarl zone suggest-reverse').")
return nil
}
return submitBatch(c, fmt.Sprintf("%d suggested reverse zones", len(reqs)), reqs)
},
}
cmd.Flags().BoolVar(&create, "create", false, "create the ones that are yours, filled with PTR records")
cmd.Flags().StringSliceVar(&dismiss, "dismiss", nil, "hide the suggestions for these networks (comma separated or repeated)")
cmd.Flags().StringSliceVar(&undismiss, "undismiss", nil, "show dismissed suggestions again")
cmd.Flags().StringVar(&view, "view", "", "only this view; with --dismiss/--undismiss: the suggestion's view")
cmd.Flags().BoolVar(&all, "all", false, "also networks not suggested unasked (suggestions off, dismissed, public views)")
return cmd
}
+7 -2
View File
@@ -151,7 +151,8 @@ func statsOptions(cfg *config.Config) stats.Options {
sc := cfg.Stats
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
RetainQueries: time.Duration(sc.Retention.Queries),
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
for _, p := range sc.ExcludeClients {
if pf, err := config.ParsePrefix(p); err == nil {
o.Exclude = append(o.Exclude, pf.Masked())
@@ -188,6 +189,7 @@ func renderExtras(cfg *config.Config) render.Extras {
h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)}
if cfg.Stats.QueryLog {
h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize
h.NoRateLimitLog = !cfg.Stats.RateLimitLog
}
x.Stats = h
}
@@ -316,6 +318,7 @@ func serveCmd() *cobra.Command {
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
Resolvers: a.cfg.ACME.Resolvers, CABundle: a.cfg.ACME.CABundle, CheckPropagation: a.cfg.ACME.CheckPropagation,
Dir: a.cfg.CertDir(), Version: version.String(), Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
MaxPerUser: a.cfg.Certificates.MaxPerUser,
}, a.store, a.eng, a.log)
cm.Start(ctx)
if a.cfg.ACME.Account.EABHMAC != "" {
@@ -333,7 +336,9 @@ func serveCmd() *cobra.Command {
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
Certs: cm,
Certs: cm,
Resolvers: a.cfg.ACME.Resolvers,
RateLimitLog: a.cfg.Stats.Enabled && a.cfg.Stats.QueryLog && a.cfg.Stats.RateLimitLog,
HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
+7
View File
@@ -38,6 +38,13 @@ if [ "$1" = configure ]; then
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
systemctl start jarl-backup.timer || true
fi
# The jarld.service alias is linked when jarl.service is enabled;
# relink a unit enabled before the alias existed (this neither
# starts nor stops jarld).
if [ -n "$2" ] && systemctl is-enabled --quiet jarl.service 2>/dev/null \
&& [ ! -e /etc/systemd/system/jarld.service ]; then
systemctl reenable jarl.service >/dev/null 2>&1 || true
fi
# An upgrade restarts a running jarld (its database is upgraded
# when it starts). A first install starts nothing: Jarl needs
# 'jarld init' and 'jarld adopt' first.
+2
View File
@@ -65,3 +65,5 @@ SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
# 'systemctl restart jarld' works too (the daemon's binary is jarld).
Alias=jarld.service
+3 -1
View File
@@ -48,7 +48,9 @@ jarl acme unset DOMAIN [VALUE...] # remove these values, or all of them
* The zone is found automatically: the longest matching primary zone.
* If the zone exists in several **views** (split DNS), the record is written
into every view, so the CA (on the internet, in your external view) and
your internal resolvers see the same thing. `--view NAME` limits it to one.
your internal resolvers see the same thing. `--view NAME` limits it to one,
and so does a token for one view (`views=external`): in a linked zone its
records are then local to that view.
* All values and views go into one changeset. Setting a value that is
already there succeeds, so a retried hook doesn't fail, and removing a
value that isn't there succeeds as well.
+42 -7
View File
@@ -190,6 +190,8 @@ ZoneInput:
```json
{
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
"fill": true, // reverse zones: PTR records for the view's A/AAAA records there
"fill_other_views": true, // with fill: also for addresses only other views' records use
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
"in_view": "internal", // type in-view only
@@ -199,6 +201,7 @@ ZoneInput:
"default_ttl": 3600,
"serial_policy": "date", // date, increment, unixtime
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
"auto_ptr": true, // keep the PTRs of the zone's A/AAAA records in step (docs/reverse.md)
"comment": "",
// create only:
"nameservers": ["ns1.adbraun.lol."], // default: settings
@@ -208,6 +211,12 @@ ZoneInput:
}
```
A reverse zone created without a view goes to the private view whose
records use its addresses. Zones come back with `prefix` (reverse zones:
the network), `ptr_missing` (addresses in it that the view's A and AAAA
records use, without a PTR record) and `ptr_other_views` (addresses in it
without a PTR record that only other views' records use; not missing).
`/serials` and `/delegation` send DNS queries from the server, and only
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
`/serials` works for primary, secondary and mirror zones:
@@ -287,11 +296,20 @@ view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)).
},
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
"policies": ["default"],
"parent_zone": "", // set when the parent zone is a primary zone in Jarl
"ds_at": "registrar", // parent_zone, registrar, parent, reverse or none
"ds_place": "at your registrar", // completes "add the DS record ..."
"ds_note": "", // for ds_at none: why it can't have one
"parent_zone": "", // set when the parent zone is a primary zone in Jarl (any view, its own first)
"registrar": "porkbun" // when the DS can be published through Jarl
}
```
`ds_at` says where the DS record goes ([dnssec.md](dnssec.md#should-you-sign)):
`parent_zone` (a zone in Jarl: `parent_zone`, `parent_zone_id`,
`parent_view`), `registrar` (the zone is a registered domain), `parent` (a
subdomain whose parent zone is hosted elsewhere), `reverse` (whoever
delegates the addresses) or `none` (nothing on the internet delegates it).
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
@@ -347,12 +365,19 @@ In a linked zone ([views.md](views.md#linked-zones)), records carry
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
true` (this view only, an override) or `false` (shared; on an update or a
set it makes this view's version every view's). Zones carry `link_group`
and `linked_with` (the other views).
and `linked_with` (the other views). Every zone in a view carries
`in_views`: the zones of the same name in the other views, linked or not
and of any type, in view order (`[{"id": 289, "view": "external", "type":
"primary", "linked": true}]`).
| Method | Path | Body |
|--------|------|------|
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records; `?other_views=1` also for the addresses only other views' records use, named from there ([reverse.md](reverse.md)) |
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
that view's zone of the same name.
@@ -400,7 +425,9 @@ meantime stops it (and gives a `disabled_conflict` warning until then).
`PUT /rrsets` makes the RRset exactly the given data: a disabled record
with one of the values is enabled, other disabled records are left alone.
Disabling a record that is already disabled is a 409; with `missing_ok=1`
(by match) it's "nothing to do".
(by match) it's "nothing to do". A zone update that leaves the zone as it
is (the same SOA, TTL, comment...) changes nothing, so applying it is
"nothing to do" and staging it is "nothing to change".
### Views
@@ -469,7 +496,8 @@ listing with `?changeset=` marks them `pending`.
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see) |
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see). `formatted` lays out a block over several lines, a list of three or more one element per line |
| GET | `/options/generated` | What Jarl adds to named.conf by itself: `where`, `text`, `why`, `setting` (what turns it on or off) |
| POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` |
| GET / PATCH / DELETE | `/options/{id}` | |
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
@@ -498,7 +526,7 @@ written in Jarl's fixed order (docs/concepts.md).
| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` |
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` |
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations |
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations; 400 "nothing to change" if they change nothing |
| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation |
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
@@ -539,6 +567,10 @@ in it and its records.
### Query statistics
Every statistics endpoint below takes `views=internal,vps` to limit the
figures to those views ([stats.md](stats.md#views)); a breakdown that only
exists for the whole server then answers `400 not_per_view`.
Needs `stats:read`. When statistics are disabled, every endpoint but
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
@@ -549,7 +581,8 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}]}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log |
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}], database: {path, bytes, query_log_rows}}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
```sh
@@ -573,6 +606,8 @@ Administrators only. See [certificates.md](certificates.md#api).
| GET, PATCH, DELETE | `/certificates/{name}` | |
| POST | `/certificates/{name}/issue` | 202: being obtained (poll until `issuing_since` is gone); a file certificate is reread (200) |
| POST | `/certificates/{name}/check` | the routes of a certificate's names |
| POST | `/certificates/{name}/request` | `{"domains": [...], "key_type": "ec256"}`: a certificate for another host, owned by the caller's user (needs `certificates:write` for every name). New: created and obtained, 202 (poll `GET /certificates/{name}` until `issuing_since` is gone). Existing: must be the caller's (else 409); obtained again only when its names change. Answers `certificate`, `created`, `changed` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
| GET | `/certificates/{name}/pem?part=PART` | the certificate as PEM: `fullchain` (default), `cert`, `key`, `bundle`; key exports audited ([certificates.md](certificates.md#using-a-certificate-outside-jarl)) |
`GET /status` includes `certificates` for administrators: `total`,
`problems` (failing, expiring, expired), `next` and `next_after`,
@@ -599,7 +634,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|--------|------|------|
| GET | `/users` | admin |
| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) |
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, `username` to rename, and `cert_limit` (certificates the user may own through `jarl cert get`; negative: the `certificates.max_per_user` default). Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
| GET / POST | `/users/{id}/tokens` | admin (or yourself) |
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
+104 -2
View File
@@ -192,8 +192,15 @@ jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
```
The Certificates page (and `jarl cert show`) says what named uses each
for: `DNS over TLS on 10.0.10.5, fd00::5 port 853`, `DNS over HTTPS on
… port 443`, or a hand-written `tls` statement no listener uses.
A certificate named uses can't be deleted or lose its `dns` use until
named no longer names its files.
named no longer names its files. If you change `certificates.dir`, jarld
writes the certificates to the new place when it starts and points the
`tls` statements encrypted DNS wrote there (a hand-written `tls`
statement you change yourself).
## A certificate you renew yourself
@@ -209,11 +216,105 @@ and the last good one stays in use. jarld runs as user `jarl`, which must
be able to read both files (e.g. `chgrp jarl` and `chmod 640`, or have
your renewal hook copy them somewhere it can read).
## Using a certificate outside Jarl
A certificate needn't be used by Jarl at all ("not used by Jarl" on the
Certificates page): Jarl obtains and renews it, and you export it for
another program, such as a mail server or a reverse proxy. **Export…** on
the Certificates page downloads each file or copies it to the clipboard
(copying needs the web UI over HTTPS, or from localhost). Or:
```sh
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem, and privkey.pem (mode 600)
jarl cert export mail --part bundle > /etc/haproxy/mail.pem
```
The parts are `fullchain` (the certificate and its chain), `cert` (the
certificate alone), `key` and `bundle` (the key, then the chain, in one
file, as HAProxy wants). Administrators can export any certificate, and
the owner of a certificate for another host (below) their own. Every
export of the key is in the audit log (`certificate.export_key`), and a key
only goes over HTTPS, the local socket, or to this server itself.
Jarl renews the certificate, not your copy: export it again after each
renewal (`jarl cert show` says when that is). For another host, `jarl
cert get` (below) does that for you. A program on this server can instead
read the files Jarl writes for named (use `dns`), which Jarl keeps current.
## Certificates for other hosts
`jarl cert get` gets a certificate onto another host (a mail server, a
reverse proxy) with one command: jarld obtains it from the CA and renews
it, answering the challenges in its own zones as for its own certificates;
the host asks for it, waits until it is there, downloads it and runs a
hook. The host needs only a token, not an ACME client or access to DNS.
**On Jarl**, as an administrator, give the hosts a user and a token each:
```sh
jarl user create hosts --no-password --grant "certificates:write names=*.adbraun.lol"
jarl token create mx --user hosts # one token per host
```
The `certificates` grant says which names the user may have certificates
for. It is never part of `*`, so `operator` and `readonly` users can't
get certificates (or download keys) unless you add one. A certificate
belongs to the user that asked for it: its tokens can download it, other
users can't take its name or see it, and the certificates Jarl uses
itself are the administrators'. Hosts that should share certificates use
the same user (each with its own token, so you can revoke one host
alone); hosts that shouldn't, use different users. A user may own
`certificates.max_per_user` certificates (10 by default), or what `jarl
user update NAME --cert-limit N` (or the user's form) sets.
**On the host:**
```sh
export JARL_SERVER=https://jarl.adbraun.lol:8053 JARL_TOKEN=@/root/jarl.token
jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot'
sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get'
jarl cert saved # what this host has, and when it expires
```
* **Asking again** with the same names returns the certificate jarld has:
nothing is obtained, nothing is downloaded until jarld renews it.
**Adding or removing a name** gets a new certificate. A different
`--key-type` alone doesn't; delete the certificate for that.
* **Files**, in `--dir` (default `/etc/jarl/certs/NAME` as root, `./NAME`
otherwise): `cert.pem`, `chain.pem` (the intermediates),
`fullchain.pem`, `privkey.pem` (mode 600) and `jarl-cert.json` (serial,
expiry, when it was fetched). Each file is replaced at once, never
half written. The key is checked against the certificate before
anything is written.
* **Saved settings**: the names, `--dir`, `--hook`, the server and the
token are saved in `/etc/jarl/cert-get/NAME.json` (as root;
`~/.config/jarl/cert-get` otherwise), readable only by their owner.
`jarl cert get` without a name does every saved certificate. A token
given as `@FILE` is saved as that reference, not copied. `jarl cert
forget NAME` stops getting one (`--delete` deletes it on jarld too).
* **The hook** runs with `sh` after new files are written, with
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`,
`JARL_CERT_SERIAL`, `JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when
it replaced an older certificate). If it fails, `jarl cert get` exits
non-zero and runs it again next time. It has 5 minutes.
* **`jarl cert schedule`** (Linux with systemd, as root) writes
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
`jarl cert get` daily at a random time within the hour after midnight,
and catch up after the host was off. `--remove` takes them away. On
other systems, run `jarl cert get` daily with their scheduler.
**On the Certificates page**, the administrators see each certificate's
owner and who downloaded it: the token, from where, when, and whether
that host has the current certificate or hasn't fetched the renewed one
yet. `jarl cert show NAME` says the same.
## Security notes
- Certificates Jarl obtains, their keys and the ACME account key are kept
in Jarl's database, so they are in its backups (like TSIG secrets). The
API never returns a key.
API returns a key only to an administrator who exports it, and records
that in the audit log.
- Every publicly trusted certificate is published in Certificate
Transparency logs, with all its names. A name like `ns1.adbraun.lol`
used for both the web UI and DNS reveals less than separate names. Avoid
@@ -233,6 +334,7 @@ your renewal hook copy them somewhere it can read).
| GET, PATCH, DELETE | `/certificates/{name}` | `domains` and `uses` replace the whole list |
| POST | `/certificates/{name}/issue` | obtain now: 202, then poll `GET /certificates/{name}` until `issuing_since` is gone; a file certificate is reread (200) |
| POST | `/certificates/{name}/check` | the routes for an existing certificate |
| GET | `/certificates/{name}/pem?part=fullchain` | the certificate as PEM, to save: `fullchain` (default), `cert`, `key` or `bundle` (key and chain). Exports of the key are audited |
## Testing with Pebble
+37 -1
View File
@@ -177,6 +177,14 @@ jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol.
jarl zone create 192.168.20.0/24 # reverse zone from a network
jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.in-addr.arpa
jarl zone create 10.0.10.100/32 # a zone for just that address
jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs
jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views # also name addresses only other views' records use
jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours)
jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
jarl view update vps --reverse-suggestions=false # no suggestions for this view
jarl zone update adbraun.lol --auto-ptr # keep PTRs in step with the A/AAAA records (docs/reverse.md)
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
@@ -221,6 +229,8 @@ jarl zone unlink adbraun.lol --view vps
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
jarl record update 1234 --local | --shared
jarl zone diff adbraun.lol [--view V] [--with W] [--all] [--wide] # compared with the same zone in other views
jarl zone diff adbraun.lol nas [A] # just one name
```
When a zone exists in several views, add `--view NAME` to commands that
@@ -307,7 +317,10 @@ adds the quotes and splits long values into 255-byte strings. In reverse zones t
host name (a bare `nas` is refused, since it would mean
`nas.0.10.10.in-addr.arpa.`). `--ptr`
keeps the PTR record in the reverse zone in step (when that zone is managed
by Jarl). Every record type BIND supports can be used. Jarl knows the
by Jarl); `jarl record update ID --ptr` with nothing else adds the PTR record
if it is missing, e.g. after creating the reverse zone. In linked zones, the
reverse zone of each linked view that has the record counts
([reverse.md](reverse.md#keeping-ptr-records-in-step)). Every record type BIND supports can be used. Jarl knows the
syntax of the common ones and checks them strictly, and the rest are
validated by parsing.
@@ -402,6 +415,7 @@ jarl exposure # public views, findings,
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
jarl exposure fix server exposure_version
jarl exposure mail # reverse DNS of the addresses that send and receive mail
jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
jarl exposure unack external security_open_resolver
jarl view update external --exposure public|private|auto
@@ -415,6 +429,7 @@ See [exposure.md](exposure.md).
jarl encrypted-dns # what is set up, what to give clients
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 [--address IPv6] [--doh-port 8443] [--no-doh]
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
jarl encrypted-dns health # check the listeners now, as a client would
jarl encrypted-dns disable
```
@@ -432,6 +447,13 @@ jarl cert show ns1
jarl cert issue ns1 # renew now (--no-wait to return at once)
jarl cert update ns1 --challenge porkbun # auto, jarl or porkbun
jarl cert update ns1 --use dns # --use and --domain replace the list (--use none)
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey.pem, for another program
jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle
jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook
jarl cert get # every saved certificate (what the timer runs)
jarl cert saved # this host's certificates and their expiry
jarl cert forget mail [--delete] # stop getting it here
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
jarl cert delete ns1
jarl cert account # the ACME account (set in jarl.yaml)
```
@@ -469,11 +491,22 @@ jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol
jarl option set allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
jarl option set tls 'quad9 { remote-hostname "dns.quad9.net"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel
jarl option set tls 'cloudflare { remote-hostname "cloudflare-dns.com"; }' --toplevel # tls quad9 stays
jarl option unset tls cloudflare --toplevel # statements with a name: say which
jarl option unset forwarders
jarl option describe # every option allowed at this scope
jarl option describe allow-query-cache # grammar and ARM description
jarl option generated # what Jarl adds to named.conf by itself, and why
```
`option set` replaces the option of that name. Statements told apart by
the name after the keyword (`tls NAME`, `key NAME`, `remote-servers NAME`,
`http NAME`, `dnssec-policy NAME`, `server ADDRESS`, ...) replace only the
one with that name; `--add` adds another instance of any other option that
may occur several times. Forwarding to Quad9 or Cloudflare over TLS:
[encrypted-dns.md](encrypted-dns.md#forwarding-over-tls).
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
be given without the braces: elements are separated by `;`, `,` or spaces,
and `key NAME` and `port N` stay with their element. Options are always
@@ -528,6 +561,9 @@ working.
## Query statistics
`--view internal,vps` (or repeated) limits any `jarl stats` command to those
views ([stats.md](stats.md#views)).
```sh
jarl stats status
jarl stats summary --since 7d
+13
View File
@@ -48,6 +48,14 @@ overwritten. Jarl records a checksum of every file it writes and reports
**drift** when a file changes behind its back. It then refuses to overwrite
the file until you say so (`--force`, or `jarl resync --force`).
Some of named.conf comes from Jarl itself rather than from your options:
the include of keys.conf, the statistics channel and the logging
statistics need, blocking's `response-policy` and policy zones, and the
view keys at the front of each view's `match-clients`. The Server options
page lists them under **Added by Jarl**, with why each is there and what
turns it on or off (`jarl option generated`). Where you have a statement
of your own (`logging`, `statistics-channels`), Jarl merges into it.
Comments are part of the data model: zones, records, ACLs, views, keys and
options can all carry a comment, and Jarl writes it into the generated file.
Comments in imported zone files are kept.
@@ -250,6 +258,11 @@ boundary. So:
(RFC 2317 delegation, where an ISP hands you part of a /24, is set up by
hand with CNAMEs.)
Jarl suggests the reverse zones your private views lack, creates them in
the right view filled with PTR records, and can keep PTR records in step
with a zone's A and AAAA records: [reverse.md](reverse.md). A zone created
inside another (a /24 inside a /8) takes the parent's records under it.
### Dynamic updates and TSIG keys
Jarl writes every zone file, so it can't let named change a zone through
+2 -1
View File
@@ -101,6 +101,7 @@ jarld restarts.
| `acme.ca_bundle` | `""` | PEM file of extra CA certificates to trust for the ACME server (a private CA). |
| `acme.check_propagation` | `true` | Wait until every name server of the zone serves a challenge before the CA is told to look (they are asked directly, on port 53). |
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the `dns` use are written for named: `<dir>/<name>/fullchain.pem` and `privkey.pem`. |
| `certificates.max_per_user` | `10` | How many certificates a user may own through `jarl cert get` ([certificates.md](certificates.md#certificates-for-other-hosts)). A user's own limit (`jarl user update NAME --cert-limit N`) replaces it. |
### ACME (lego httpreq endpoint)
@@ -163,7 +164,7 @@ check` says so).
|-----|---------|---------|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). |
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). Subdomain and reverse zones never get the registrar features ([dnssec.md](dnssec.md#porkbun)). |
The key can move your domain anywhere, so it is only ever read from this
file: it isn't stored in the database or backups, and there is no API to
+6
View File
@@ -32,6 +32,12 @@ internal/
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
(the downloads are in engine/blocklists.go)
config/ jarld's YAML configuration
certs/ certificates: Jarl as an ACME client (lego), renewal, files for named
encdns/ encrypted DNS (DoT/DoH) setup, status and health checks
mailcheck/ reverse DNS check of the addresses mail is sent from
totp/ two-factor login codes and recovery codes
version/ what a build reports (release tag, commit, commits since a tag)
testutil/ test helpers, chiefly finding BIND's tools (JARL_BIND_ROOT)
deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
contrib/acme.sh/ acme.sh DNS API hook
+23
View File
@@ -30,6 +30,17 @@ Signing an internal-only zone (one no registrar delegates, or a split-DNS
view only your own network reaches) adds nothing. There is no parent to
publish a DS, and BIND doesn't validate the zones it serves itself.
**Where the DS record goes** depends on the zone. The DNSSEC tab and
`jarl zone dnssec status` say which:
| Zone | The DS record goes |
|---|---|
| a registered domain (`adbraun.lol`) | at your registrar ([Porkbun](#porkbun) can do it for you) |
| a subdomain whose parent zone is in Jarl (`lab.adbraun.lol`) | in the parent zone, as a DS record named `lab`: one button, or the `jarl record add` command shown. The parent in the zone's own view counts first, else one in another view (a public one first). |
| a subdomain whose parent zone is hosted elsewhere | in that parent zone, wherever it is hosted (at Porkbun: the domain's DNS records) |
| a reverse zone | with whoever delegates it to you: your ISP, or your RIR for an address block of your own. Many ISPs don't take DS records. |
| a private name (`home.lan`) | nowhere: nothing on the internet delegates it, so signing it has no effect |
**Split DNS.** In Jarl a zone belongs to a view, so you sign the external
view's `adbraun.lol` and leave the internal view's copy unsigned. That is
the usual setup, with one catch. Once the DS is at the parent, the world
@@ -204,6 +215,18 @@ root:jarl`, `chmod 640`, as the package installs it); jarld warns at start
when everyone can read it. Only administrators can use the registrar
features.
Only a zone that is a registered domain gets the registrar features
(`adbraun.lol`, `example.co.uk`, judged by the public suffix list). Their
parent zone delegates the others, not a registrar:
* a subdomain zone such as `lab.adbraun.lol`: the NS and DS records in
`adbraun.lol`;
* a reverse zone: whoever holds the addresses (your ISP, or the RIR);
* a zone under a suffix that isn't public (`home.lan`, `home.arpa`):
nobody.
The zone's **Name servers** tab (Check delegation) checks all of them.
**Using it**: the zone's **Registrar** tab, or `jarl zone registrar`:
| | |
+123 -17
View File
@@ -14,10 +14,15 @@ host name's address records in your private views.
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
That is encryption between your clients and this server. For encryption
between this server and the resolvers it forwards to (Quad9, Cloudflare),
see [Forwarding over TLS](#forwarding-over-tls) below.
## Setting it up
```sh
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5=internal --address 203.0.113.5=external
```
This:
@@ -47,25 +52,37 @@ This:
`listen-on` statement at all. The first one, for DoT, would end that, so
when you have none the setup adds `listen-on { any; };` (and the same
for IPv6) to keep plain DNS where it was.
4. **adds the host name's A and AAAA records** with the chosen addresses,
in the private views that have a zone for it (`--view` to choose). In a
linked zone they are local to the view, so the name doesn't reach your
external view.
4. **adds the host names' A and AAAA records**, each address in the views
you give it: `--address 10.0.10.5=internal,lab`, or every address in
the views of `--view` (default: the private views that have a zone for
the name). A view's clients should only be given an address they can
reach, so a private address in a public view gets a warning. The
records are local to their view (in a linked zone the other views keep
theirs), and a view you no longer give an address loses it; other
records of the names stay.
Running it again replaces the earlier setup (other addresses, ports, DoT
or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable`
takes the listeners and the `tls` statement out; the certificate and the
records stay.
**Several host names** (`--hostname dns.adbraun.lol --hostname
doh.adbraun.lol`, or comma separated): each gets the records, the
certificate must be for all of them (a new one is obtained with all of
them), and the health check says when the certificate named serves misses
one. Clients may use any of them; the client hints use the first.
Running it again replaces the earlier setup: other addresses, views, host
names, **ports** (clients set up with another port need theirs changed
too), DoT or DoH only with `--no-doh` / `--no-dot`. The web UI's form
starts from the current setup, with each address's views as its records
are now. `jarl encrypted-dns disable` takes the listeners and the `tls`
statement out; the certificate and the records stay.
| Option | Default | |
|--|--|--|
| `--hostname` | | The name clients connect to; the certificate is for it |
| `--address` | | This server's addresses to listen on (repeat). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there |
| `--hostname` | | A name clients connect to (repeat for several); the certificate is for all of them |
| `--address` | | This server's addresses to listen on (repeat), with the views that get its records after `=` (`10.0.10.5=internal`). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there |
| `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
| `--no-dot`, `--no-doh` | both on | |
| `--dot-port` | 853 | Clients expect 853 |
| `--doh-port` | 443 | Any free port; clients take it in the URL |
| `--view` | private views with the zone | Where the host name's records go |
| `--view` | private views with the zone | Where every address's records go, when `--address` gives no views |
In the web UI the same form checks the ports as you choose, offers the
free port, and obtains the certificate before setting up.
@@ -94,14 +111,21 @@ For DoH clients: https://dns.adbraun.lol/dns-query
| Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → custom: the DoH URL |
| Windows 11 | Settings → Network → your connection → DNS server assignment → Edit: the server's address, DNS over HTTPS on (manual template), the DoH URL |
Test from a machine on your network:
Test from a machine on your network, with BIND's own `dig` (9.18 or
later; `bind9-dnsutils` on Debian):
```sh
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com
dig +https @10.0.10.5 example.com
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
dig +tls +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoT, port 853
dig +https +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoH, port 443, /dns-query
```
`+tls-ca` checks the certificate against the system's CAs and
`+tls-hostname` against the host name, as clients do; add `-p PORT` for
another port. Without `+tls-ca` the query is still encrypted, but the
certificate isn't checked. named's DoH speaks the standard wire format
(RFC 8484), not the JSON API some public resolvers also offer, so
`curl ... accept: application/dns-json` doesn't work against it.
**Android away from home.** Private DNS in hostname mode applies on every
network. Away from home, your server can't be reached, and Android then
resolves nothing at all ("Private DNS server cannot be accessed"). Use a
@@ -113,6 +137,87 @@ Clients check the certificate against the host name, so the name must
resolve to this server for them: the setup adds it to your private views.
Open the ports in the server's firewall for your own networks only.
## Health
jarld checks the listeners itself, a minute after it starts and then
every 10 minutes, the way a client would: it connects to each, checks the
certificate named serves (for the host name, current, and the one Jarl
last wrote, so a renewal named hasn't reread shows up) and asks for the
host name. The Encrypted DNS page shows the result per listener (**Check
now** checks again), the dashboard a card and the problems, Prometheus
`jarl_encrypted_dns_up{kind,address,port}`:
```sh
jarl encrypted-dns health # check now; exit 1 when a listener doesn't work
```
What it can say:
| | |
|--|--|
| nothing listens on ADDR port P | named couldn't bind the port: look in named's log (ports below 1024 need named to start as root, as the Debian service does) |
| no answer within 5s | a firewall on the server |
| a certificate for X, not HOST | named serves another certificate |
| an older certificate | named hasn't reread the renewed files (`rndc reconfig`) |
| not from a CA this server trusts (warning) | a staging or test CA: clients reject it unless they trust it |
| REFUSED (warning) | it works, but this server's own address gets a view that doesn't answer for the host name |
## Forwarding over TLS
When a view forwards its recursive queries (to Quad9, Cloudflare, your
ISP's resolver), named can send them over TLS, so your ISP doesn't see the
names you look up: only connections to the forwarder on port 853. It takes
two options, a `tls` statement for each provider (the name its certificate
must have) and the view's `forwarders` using it. Stage them in one
changeset, so one revert undoes them:
```sh
CS=$(jarl changeset create "internal: forward to Quad9 over TLS" -q)
jarl option set tls 'quad9 { remote-hostname "dns.quad9.net"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel -C $CS
jarl option set forwarders '{ 9.9.9.9 port 853 tls quad9; 149.112.112.112 port 853 tls quad9; 2620:fe::fe port 853 tls quad9; 2620:fe::9 port 853 tls quad9; }' --view internal -C $CS
jarl option set forward only --view internal -C $CS
jarl changeset diff $CS # what named.conf gets
jarl changeset apply $CS
# undo: jarl changeset apply $(jarl changeset revert $CS -q)
```
* **`remote-hostname` and `ca-file`** make named check the forwarder's
certificate against that name (Debian's CA bundle at that path). Without
them the connection is encrypted but anyone in between could answer.
* **`forward only`** stops named falling back to plain DNS (to the root
servers) when the forwarders don't answer: lookups then fail instead
(SERVFAIL), which you notice, rather than quietly going unencrypted.
* **Several providers**: a `tls` statement each, and their addresses in one
`forwarders` list. Setting `tls cloudflare` doesn't touch `tls quad9`:
`jarl option set` replaces only the statement of that name.
```sh
jarl option set tls 'cloudflare { remote-hostname "cloudflare-dns.com"; ca-file "/etc/ssl/certs/ca-certificates.crt"; }' --toplevel
jarl option set forwarders '{ 9.9.9.9 port 853 tls quad9; 149.112.112.112 port 853 tls quad9; 1.1.1.1 port 853 tls cloudflare; 1.0.0.1 port 853 tls cloudflare; 2606:4700:4700::1111 port 853 tls cloudflare; 2606:4700:4700::1001 port 853 tls cloudflare; }' --view internal
```
named sends each query to whichever forwarder has answered fastest, not
in order, and the providers filter differently (Quad9 blocks known
malicious domains, Cloudflare's 1.1.1.1 doesn't; 1.1.1.2 does, with
`remote-hostname "security.cloudflare-dns.com"`).
* **DNS over HTTPS** can't be used for forwarding: BIND serves DoH to
clients but forwards only over plain DNS or TLS. For a DoH upstream, run
a local proxy (dnscrypt-proxy, cloudflared) and forward to it on
`127.0.0.1`.
* Providers write their DoT address as `tls://dns.quad9.net`: the name is
`remote-hostname`, and the addresses it resolves to go in `forwarders`
(named needs addresses there, not names).
**Checking it works**: Quad9 says how a query reached it.
```sh
dig +short TXT proto.on.quad9.net @YOUR-SERVER # "dot": over TLS
```
`do53-udp` means plain DNS. `ss -tnp | grep :853` shows named's connections
only briefly: it closes them when idle. `tcpdump -ni any port 853` shows
the traffic as it happens.
## What Jarl checks
* A `tls` statement whose files don't exist is an error
@@ -125,7 +230,8 @@ Open the ports in the server's firewall for your own networks only.
| Method | Path | Body |
|--------|------|------|
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostname`, `dot` and `doh` (address and port), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone` for `?hostname=`), `certificates` |
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostnames` (and `hostname`, the first), `dot` and `doh` (address and port), `address_views` (per address, the views whose records give a host name it), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone`), `certificates`. `?hostname=a,b` asks about other names |
| POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
| POST | `/encrypted-dns` | `{"hostname", "certificate", "addresses", "dot", "doh", "dot_port", "doh_port", "views"}`: staged or applied like any change; 409 while the certificate hasn't been obtained |
| POST | `/encrypted-dns` | `{"hostnames", "certificate", "addresses", "address_views": {"10.0.10.5": ["internal"]}, "dot", "doh", "dot_port", "doh_port"}` (`"hostname"` and `"views"`, every address in each, work too): staged or applied like any change; 409 while the certificate hasn't been obtained |
| DELETE | `/encrypted-dns` | Turns it off |
| POST | `/encrypted-dns/health` | Checks the listeners now: `checked`, `probes` (kind, address, port, `ok`, `problem`, `warning`, certificate names, expiry and serial, `trusted`, `rcode`, `ms`). `GET /encrypted-dns` has the last check as `health` |
+36 -2
View File
@@ -107,7 +107,10 @@ 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).
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
@@ -160,6 +163,36 @@ 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
@@ -204,7 +237,8 @@ monitoring, response size limits and usually a machine of its own.
| 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`) |
| 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"}` |
+4
View File
@@ -317,6 +317,10 @@ systemctl status jarl
journalctl -u jarl -f
```
The unit also answers to `jarld` (`Alias=jarld.service`), so
`systemctl restart jarld` and `journalctl -u jarl` work alike once it
is enabled.
Open `https://<management-address>:8053/` and log in as `admin`.
## 9. Set up the CLI
+11
View File
@@ -132,6 +132,8 @@ same values.
| `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). |
| `jarl_certificate_failing{certificate,source}` | 1 when obtaining (or reading) the certificate failed the last time, or it has expired. |
| `jarl_certificate_renewal_failures{certificate}` | Failed attempts to obtain a certificate since its last success. |
| `jarl_mail_reverse_dns_ok{zone,address}` | 1 when an address that sends or receives a public zone's mail has a PTR record that resolves back to it, at the last check, every 6 hours ([exposure.md](exposure.md#mail)). |
| `jarl_encrypted_dns_up{kind,address,port}` | 1 when the DoT or DoH listener answered with the right, current certificate at the last check ([encrypted-dns.md](encrypted-dns.md#health)). |
### Queries (with `stats.enabled`)
@@ -232,6 +234,15 @@ groups:
expr: jarl_certificate_expiry_timestamp_seconds - time() < 10 * 86400
annotations:
summary: "Certificate {{ $labels.certificate }} expires within 10 days"
- alert: EncryptedDNSDown
expr: jarl_encrypted_dns_up == 0
for: 20m
annotations:
summary: "DNS over {{ $labels.kind }} on {{ $labels.address }} port {{ $labels.port }} isn't working (see the Encrypted DNS page)"
- alert: MailReverseDNS
expr: jarl_mail_reverse_dns_ok == 0
annotations:
summary: "{{ $labels.address }} ({{ $labels.zone }} mail) has no reverse DNS that resolves back: receivers may reject its mail"
- alert: BlockingPausedForLong
expr: jarl_blocking_paused == 1
for: 24h
+15 -3
View File
@@ -118,7 +118,8 @@ A user's permissions are a list of grants. Administrators (`admin: true`)
may do everything, and only they can manage users, see everyone's tokens,
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
whose key can move the domain), and manage TLS certificates (whose keys
can impersonate the server; [certificates.md](certificates.md)).
can impersonate the server; [certificates.md](certificates.md)). The one
exception is certificates for other hosts, below.
A grant is written as:
@@ -137,7 +138,8 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
| `stats` | query statistics, including client addresses and the names they look up (read only) |
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
| `*` | all of the above |
| `certificates` | certificates for other hosts (`jarl cert get`), by the names in them: `write` gets one obtained, `read` only downloads it. Never part of `*` |
| `*` | all of the above, except `certificates` |
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
Each takes comma separated patterns where `*` and `?` are wildcards, and a
@@ -146,6 +148,8 @@ missing filter matches everything:
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
* `views=internal`: view names (`_default` means zones outside any view)
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
(for `certificates`: the names in the certificate; `*.adbraun.lol` also
allows the wildcard certificate `*.adbraun.lol`)
* `types=TXT,CNAME`: record types
Examples:
@@ -157,10 +161,18 @@ zones:write zones=*.lab.adbraun.lol create/delete zones be
records:write zones=adbraun.lol views=internal only the internal copy of a split zone
records:write names=_acme-challenge.* types=TXT ACME challenges anywhere
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
certificates:write names=mail.adbraun.lol,smtp.adbraun.lol certificates for a mail server (jarl cert get)
```
A user who may see some records of a zone may also see that the zone exists
(its name and type), but not its other records.
(its name and type), but not its other records. They also see the zone's
view in `jarl view list` (`GET /views`): its name, colour and how many
zones they can see there, marked `limited`, but not its settings
(`match-clients` and the rest need `views:read`).
A zone name that is in several views needs a view (`--view`), unless the
user can see the zone in only one of them: a token for
`views=external` finds the external copy without being told.
### Roles
+194
View File
@@ -0,0 +1,194 @@
# Reverse zones
A reverse zone answers "which host has this address?" with PTR records:
`20.10.0.10.in-addr.arpa. PTR nas.adbraun.lol.`. Your own clients use them
for names in logs, `traceroute`, the router's client list and `ssh`
warnings. For your private networks they only exist if this server has
them, and they are easy to forget, so Jarl can work them out from the
records you already have.
Who asks matters:
* **Your own clients** ask this server, so a reverse zone in a private
view (internal) answers them.
* **The internet** asks whoever owns the address: your ISP or hosting
provider for public addresses. Mail servers check those PTR records, and
you set them in the provider's panel, not in Jarl. The Exposure page
checks them ([exposure.md](exposure.md#mail)). A reverse zone for a
public address in a public view (external) does nothing unless the owner
delegated it to you.
So reverse zones belong in private views. A view whose clients reach it
from the internet gets no suggestions, and neither does a view you turn
them off for: a view only a few servers use, such as one for your VPS,
matches only their addresses and so counts as private, but its clients
don't need reverse zones (`jarl view update vps
--reverse-suggestions=false`, or the view's settings). Exposure doesn't
change.
## Missing reverse zones
The Zones page lists, under **Missing reverse zones**, the reverse zones a
private view lacks for the addresses of its A and AAAA records, in two
groups:
```
$ jarl zone suggest-reverse
GROUP VIEW NETWORK ZONE PTRS FOR EXAMPLE
yours internal 10.0.10.0/24 10.0.10.in-addr.arpa 14 10.0.10.1 router.adbraun.lol, ...
yours internal 2a02:8013:7f3d::/48 d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa 9 2a02:8013:7f3d:10::5 nas.adbraun.lol, ...
public internal 139.144.144.154/32 154.144.144.139.in-addr.arpa 1 139.144.144.154 ldn.adbraun.lol
public internal 185.199.108.153/32 153.108.199.185.in-addr.arpa 1 185.199.108.153 blog.adbraun.lol
$ jarl zone suggest-reverse --create # yours, filled, in one changeset
$ jarl zone create 139.144.144.154/32 --view internal --fill # a public one that is yours
$ jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal # GitHub's, not yours
```
**Yours**, created by **Create all** (`--create`):
* the **/24** of a private IPv4 address (10/8, 172.16/12, 192.168/16,
100.64/10);
* the **/48** of a ULA (fd00::/8), or of a public IPv6 prefix this server
has an address in (the /48 your ISP delegates): one zone for all its
subnets. If you have a /56, create that instead (`jarl zone create
2a02:db8:1:ab00::/56`).
**Public addresses, only if they're yours**: any other public address,
each as a /32 (or /128) of its own. Jarl can't tell your VPS from a web
host's server (a `blog` record pointing at GitHub Pages), and a zone for
someone else's address would show your clients your name for it instead
of theirs. The rest of the /24 belongs to others: a /24 zone here would
answer NXDOMAIN to your clients for all of their addresses. Create the
ones that are yours one by one, and **Dismiss** the others: Jarl
remembers, per view (`--undismiss` brings one back).
There can be hundreds of public addresses (records pointing at a web
host or a CDN), so the group is folded away when it is long. Opened, it
has a search and shows 25 at a time; **Dismiss these N** dismisses every
address the search found, in one change: search `104.16.` and dismiss
the CDN's addresses together. From the CLI, `--dismiss` takes several
networks (comma separated or repeated). The New reverse zone list has a
search too.
Loopback and link-local addresses, wildcards and disabled records are left
out.
## Creating one by hand
Where nothing is suggested (a view with suggestions off, a dismissed
network, a public view) or the suggested size isn't the one you want,
**New reverse zone** on the Zones page lists every network your records
use that has no reverse zone, in every view, marked when it isn't
suggested unasked. Pick one: the network and the view are filled in.
Then the **Size** buttons change it, around the address of one of the
records: a suggested `2a02:8013:7f3d::/48` becomes
`2a02:8013:7f3d:10::/64` for the subnet the records are in, or `/128`
for one address. The zone name shows as you go, and **PTR records** stays
ticked, so the zone is created with a PTR record for each address
(IPv6 nibble names written for you).
```sh
jarl zone suggest-reverse --all --view vps # every network, hidden ones marked
jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill
```
**Records in another view.** With a view chosen (`--view internal`, or
the dialog's View), the list also has the addresses only other views'
records use, marked "records in external": say a VPS's AAAA record that
only the external view has. Fill names those from the other view's
record only when asked: `--other-views` (with `--fill` or `jarl zone
fill-ptr`), or the dialog's **Also for addresses only other views'
records use**, which is ticked for you when you pick a "records in"
network. Otherwise one view's host names would turn up in another's
reverse zone, which is what views are for keeping apart. Jarl says which
names came from another view. Keeping PTR records in step (below) stays
within a zone's own view and its linked views: a later change to that
external record doesn't reach the internal reverse zone.
```sh
jarl zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views
```
## Creating and filling
```sh
jarl zone create 10.0.10.0/24 --fill # the view whose records use 10.0.10.x
jarl zone fill-ptr 10.0.10.0/24 # later: add what is missing
```
**The view.** A reverse zone created without `--view` goes to the private
view whose records use addresses in its network (views with suggestions
off don't count). If none does, it goes to the only private view. If
several do, Jarl asks you to name the view, or to create the zone in each.
**Inside another zone.** A zone created inside an existing one in the same
view, a /24 inside a /8 or `lab.adbraun.lol` inside `adbraun.lol`, takes
the parent's records under it, in the same changeset: named answers those
names from the new zone, so left in the parent they would never be served
again. PTR records you added by hand come along; `--fill` then adds only
what is still missing. A delegation (NS records at the new zone's name,
with their glue) stays in the parent. A linked parent keeps its records,
with a warning, since its other views would lose them too.
**Fill** adds a PTR record for every address in the network that the
view's A and AAAA records use. An address with several names gets one PTR:
1. the name of a mail or name server (an MX or NS target), else
2. a host's name rather than the domain's own (`nas.adbraun.lol`, not
`adbraun.lol`), else
3. the shortest name, then the first alphabetically.
Jarl says which name it chose, and which it didn't, so you can change it.
A reverse zone's page shows the addresses that still lack a PTR record and
the name each would get (the first 25), with a button to add them. `jarl
zone show` shows how many as `Missing PTRs`; the API lists them in
`ptr_missing_list`. Only the zone's own view's records count: an address
that only another view's records use isn't missing (say internal's
`claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`).
Those are shown apart, as `Other views` in `jarl zone show`
(`ptr_other_views` in the API) and a quiet note with a **Name them from
there** button on the page; filling never adds them unasked.
## Keeping PTR records in step
The **Update PTR** box on an A or AAAA record (`--ptr`) moves the PTR record
along with that one change. To have that on every change, turn it on for
the forward zone:
```sh
jarl zone update adbraun.lol --view internal --auto-ptr # off: --auto-ptr=false
```
(Zone settings → **Reverse DNS** in the web UI.) Then adding, changing,
disabling, enabling or deleting an A or AAAA record in the zone does the
same to its PTR record, in the reverse zone of each view that has the
record. A whole set replaced at once (`jarl record set`, a batch) works
too.
**Linked zones** ([views.md](views.md#linked-zones)). PTR records follow
each view's own address records:
* A shared record gets a PTR record in every linked view that has it; a
record local to one view gets one in that view only.
* An address that goes from one view (deleted, changed, or overridden by
a local record) loses its PTR record there only. Views that still have
the address keep theirs.
* Each zone's own **Reverse DNS** setting counts: a linked zone without
it doesn't get PTR records from its partner's changes.
* In a linked reverse zone, these PTR records (and those **Fill** adds)
are local to their view. A PTR record that is shared becomes local in
each view the first time one of them changes it, so that a change in
one view doesn't reach the others.
* **One name per address.** When an address's PTR record names another
host, it stays: a second A record for the same address doesn't add a
second PTR record.
* **No reverse zone, no PTR.** An address without a reverse zone in the
view, such as a public address in the external view, is skipped without
a warning. The suggestions above show what is missing.
* **Wildcards** (`*.dev`) never get a PTR record.
The PTR changes are part of the same changeset, so you review them, and
they are applied or undone together with the record.
+73
View File
@@ -60,6 +60,7 @@ off again. The statistics database stays until you delete it
stats:
enabled: false
query_log: true # per-query data; false = named's counters only
rate_limit_log: true # with a rate-limit: also log what it limits (query-errors)
database: /var/lib/jarl/stats.db
statistics_channel: 127.0.0.1:8058 # must be a loopback address
log_file: /var/log/named/jarl-responses.log
@@ -69,6 +70,7 @@ stats:
minute: 7d # per-minute counters
hour: 90d # hourly counters and top lists
day: 730d # daily counters and top lists
queries: 7d # every answer, for exploring (0: none)
top_n: 100 # names and clients kept per hour and per day
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37 (IPv6: /48)
exclude_clients: [] # e.g. [10.10.0.250] to ignore a monitoring probe
@@ -81,6 +83,13 @@ the response log. Logging every answer costs named some CPU and disk I/O,
which is negligible at homelab volumes. At tens of thousands of queries per
second, consider counters only.
`rate_limit_log` matters only with a `rate-limit` set: Jarl then sends
named's `query-errors` category (where rate limiting logs what it limits,
or would in log-only mode) to the response log too, and keeps it in
syslog. That other query errors come along is the price; `false` stops it,
and the Exposure page can't show rate limiting's activity then
([exposure.md](exposure.md#response-rate-limiting)).
`exclude_clients` drops those clients from the log-derived figures (top
lists, views, zones). named's own counters can't be filtered.
@@ -184,6 +193,70 @@ jarl stats summary --since 30d -o json # export (also -o yaml)
`operator` roles include it, `dns-editor` doesn't. Statistics show client
addresses and what they look up, so grant it with care.
## Exploring
The counters and top lists say how much, not who asked for what: "100
NXDOMAIN answers in the external view" can't tell you which clients asked.
For that, jarld also keeps every answer for a while, the **query log**: time,
client, view, name, record type, result, and the blocklist (or your rules)
that blocked it. It is in the statistics database, from the response log
named writes anyway, and answers older than `retention.queries` (default
7 days) are deleted every hour; `0` keeps none.
With it, every client, name, view, record type, result and blocklist on the
Statistics page is a link to the **explore** page (`#/stats/explore`),
which shows, for what you clicked:
* the answers over time (and how many were blocked);
* the top clients, names, views, record types, results and blocklists
among them, each a link that narrows the filter further;
* the latest 100 answers, every cell a link.
So "external, NXDOMAIN" leads to the clients asking, a client to everything
it asks, one of its names to who else asks for it. The page also opens
from **Explore the answers** at the top of the Statistics page, and has a
filter bar to type filters in: clients, names, views, record types,
results, blocked or not; it suggests values from what is shown.
Each kind of filter takes several values, comma separated, and matches
any of them: `10.0.0.5, 10.0.0.7` is either client. `!value` leaves one
out (`!10.0.0.250` hides a monitoring probe), and `*.example.com` is the
name and every name below it, so `!*.apple.com` drops a chatty device's
noise. Kinds combine: this client, in this view, NXDOMAIN, not that name.
In the lists, clicking a value adds it and **hide** leaves it out; every
value has a chip with a ✕ to take it out again. Filters combine (this
client, in this view, blocked only) and each has a ✕ to take it out. A name
can be widened to the names below it (`domain=`). The Statistics page's
view switches apply here too.
`anonymize_clients` and `exclude_clients` apply to the query log as to
everything else. Its size: each answer takes about 150 to 200 bytes with
its indexes, so 50,000 answers a day for 7 days is about 60 MB, 200,000 a
day about 250 MB. The Statistics page's server section shows the
statistics database's size and how many answers the query log holds, next
to the disks (with a bar of how full each is); the dashboard's Storage
panel has all of Jarl's databases.
## Views
With views, the Statistics page has a toggle per view (and **All** and
**None**): switch some off to see only the others, for example your
internal and VPS views without the internet's noise on the external one,
or several linked views together. The choice is remembered in your
browser. The CLI takes `--view` on every `jarl stats` command (repeat it,
or separate views with commas), and the API `?views=internal,vps`.
Only what named's response log says can be split by view: answers (shown
as *answers* instead of queries), result codes, record types, zones,
blocking, rate limiting and the top lists. named's own counters (answer
sources, transport, upstream response times, DNSSEC, the cache and the
server) are for the whole server, and the page says so; asking for them
per view (`jarl stats breakdown answer --view internal`) is refused.
Per-view figures are kept from the version that added them (Jarl 0.4.1);
the page says since when. They take some more room in the statistics
database, roughly in proportion to the number of views.
## How data is kept
- Minute counters are kept for `retention.minute`, hourly counters and top
+1 -1
View File
@@ -34,7 +34,7 @@ showing them.
| `cname_multiple` | error | A name can have only one CNAME |
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
| `cname_loop` | error | CNAMEs that point back to themselves |
| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution |
| `cname_chain` | info | a chain of three or more CNAMEs (one CNAME to another says nothing): works, but each is another lookup for some resolvers |
| `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone |
| `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) |
| `apex_ns_missing` | error | A zone needs NS records at its apex |
+65 -2
View File
@@ -168,6 +168,10 @@ 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
@@ -183,7 +187,9 @@ own and its own zone options.
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 view gets the shared ones back.
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
@@ -209,11 +215,17 @@ 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.
new name and type it adds is local to its view without being asked (an
ACME token for `views=external` adds its `_acme-challenge` TXT records to
the external view only). 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
@@ -229,6 +241,57 @@ 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`
+94 -1
View File
@@ -34,6 +34,24 @@ type Zone struct {
LinkedWith []string `json:"linked_with,omitempty"`
// Links are the zones this one is linked with, with their views.
Links []ZoneLink `json:"links,omitempty"`
// InViews are the zones of this name in the other views (any type),
// in view order: for going straight to one.
InViews []ZoneLink `json:"in_views,omitempty"`
// OtherViews are the other views with a primary zone of this name
// (linked or not): what GET /zones/{zone}/compare compares.
OtherViews []string `json:"other_views,omitempty"`
// Prefix is the network a reverse zone answers for; PTRMissing how
// many of its view's A and AAAA addresses in it have no PTR record
// (POST /zones/{zone}/fill-ptr adds them).
Prefix string `json:"prefix,omitempty"`
PTRMissing int `json:"ptr_missing,omitempty"`
// PTRMissingList are the first 25 of them: the PTR records adding
// them would create.
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"`
// PTROtherViews counts the addresses in it, without a PTR record,
// that only other views' records use: not missing, but fill-ptr with
// other_views=1 can name them from there.
PTROtherViews int `json:"ptr_other_views,omitempty"`
// Pending is set when listing with a changeset: add, change or delete.
Pending string `json:"pending,omitempty"`
}
@@ -59,6 +77,21 @@ type View struct {
model.View
Zones int `json:"zone_count"`
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
// Limited is set for a view listed only because the principal can see
// zones in it (no views:read): only its name, colour and the zones
// they can see are given.
Limited bool `json:"limited,omitempty"`
}
// PTRFill is a PTR record a reverse zone lacks: Name (the PTR owner) for
// Address would give Target. Others are its other names; From the view
// whose record names it, when the zone's own view has none.
type PTRFill struct {
Address string `json:"address"`
Name string `json:"name"`
Target string `json:"target"`
Others []string `json:"others,omitempty"`
From string `json:"from,omitempty"`
}
// ViewColor sets the colour the web UI shows a view in.
@@ -266,6 +299,40 @@ type StatusResponse struct {
// Certificates sums up the TLS certificates Jarl manages
// (administrators only, when there are any).
Certificates *CertSummary `json:"certificates,omitempty"`
// EncryptedDNS is the last check of DNS over TLS and HTTPS, when it
// is set up (for those who may see options).
EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"`
// Storage is the size of Jarl's databases and how full the disks Jarl
// uses are (administrators only).
Storage *Storage `json:"storage,omitempty"`
}
// Storage is the size of Jarl's databases and the disks Jarl uses.
type Storage struct {
Databases []Database `json:"databases"`
Total int64 `json:"total"` // the databases' bytes
Disks []Disk `json:"disks"`
}
// Database is one of Jarl's databases: its file with its -wal and -shm.
type Database struct {
Label string `json:"label"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
// Rows is how many answers the query log holds (statistics only),
// and QueryLogRetention how long it keeps them ("7d").
QueryLogRows *int64 `json:"query_log_rows,omitempty"`
QueryLogRetention string `json:"query_log_retention,omitempty"`
}
// Disk is a file system Jarl uses: what of Jarl's it holds, its size,
// what is free, and how much of it the databases take.
type Disk struct {
Path string `json:"path"`
Holds []string `json:"holds"`
Total int64 `json:"total"`
Free int64 `json:"free"`
Databases int64 `json:"databases"`
}
// CertSummary is what the dashboard reports about certificates.
@@ -357,6 +424,9 @@ type UserInput struct {
Role *string `json:"role,omitempty"` // a role template name
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
// CertLimit is how many certificates the user may own (jarl cert
// get); a negative number goes back to certificates.max_per_user.
CertLimit *int `json:"cert_limit,omitempty"`
}
// TokenInput creates a token.
@@ -423,8 +493,20 @@ type DNSSECResponse struct {
View string `json:"view,omitempty"`
Type string `json:"type"`
*dnssec.Status
// DSAt says where the zone's DS record goes: "parent_zone" (a primary
// zone in Jarl, below), "registrar" (the zone is a registered domain),
// "parent" (a subdomain whose parent zone is hosted elsewhere),
// "reverse" (whoever delegates the addresses) or "none" (nothing on
// the internet delegates the zone, so it can't have one).
DSAt string `json:"ds_at"`
// DSPlace completes "add the DS record …" ("at your registrar"); ""
// for "none".
DSPlace string `json:"ds_place,omitempty"`
// DSNote explains DSAt "none".
DSNote string `json:"ds_note,omitempty"`
// ParentZone is set when the parent zone is a primary zone in Jarl
// (in the same view): the DS record then goes there, as a record.
// (in the zone's own view, else preferably a public one): the DS
// record then goes there, as a record.
ParentZone string `json:"parent_zone,omitempty"`
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
ParentView string `json:"parent_view,omitempty"`
@@ -574,4 +656,15 @@ type BlockRule struct {
type ZoneLink struct {
ID int64 `json:"id"`
View string `json:"view"`
// Type and Linked are set in Zone.InViews.
Type string `json:"type,omitempty"`
Linked bool `json:"linked,omitempty"`
}
// EncDNSSummary is what the dashboard says about encrypted DNS.
type EncDNSSummary struct {
Checked time.Time `json:"checked"`
OK bool `json:"ok"`
Problems []string `json:"problems"`
Warnings []string `json:"warnings"`
}
+20
View File
@@ -134,3 +134,23 @@ func TestRoleTemplatesValid(t *testing.T) {
}
}
}
func TestCertificateGrants(t *testing.T) {
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
if err != nil {
t.Fatal(err)
}
if !GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.adbraun.lol"}) || GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.example.org"}) {
t.Error("names filter")
}
if _, err := ParseGrant("certificates:read types=TXT"); err == nil {
t.Error("types on certificates accepted")
}
if _, err := ParseGrant("certificates:read zones=adbraun.lol"); err == nil {
t.Error("zones on certificates accepted")
}
// "*" never covers certificates: they hand out private keys.
if GrantAllows(model.Grant{Resource: ResAll, Access: Write}, Action{Resource: ResCerts, Name: "mail.adbraun.lol"}) {
t.Error("* covers certificates")
}
}
+13 -4
View File
@@ -21,11 +21,14 @@ const (
ResAudit = "audit" // audit log and everyone's changesets
ResStats = "stats" // query statistics
ResBlocking = "blocking" // blocklists and block/allow rules
ResAll = "*"
// ResCerts is certificates for other hosts (jarl cert get), by the
// names in them. It is never part of "*": it hands out private keys.
ResCerts = "certificates"
ResAll = "*"
)
// Resources lists the resources in display order.
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking, ResCerts}
// Access levels.
const (
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
for i, t := range g.Types {
g.Types[i] = strings.ToUpper(t)
}
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
return fmt.Errorf("names and types filters only apply to the records resource")
if len(g.Types) > 0 && g.Resource != ResRecords {
return fmt.Errorf("the types filter only applies to the records resource")
}
if len(g.Names) > 0 && g.Resource != ResRecords && g.Resource != ResCerts {
return fmt.Errorf("the names filter only applies to the records and certificates resources")
}
if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews {
return fmt.Errorf("zone and view filters only apply to the zones, records and views resources")
@@ -228,6 +234,9 @@ func GrantAllows(g model.Grant, a Action) bool {
if g.Resource != ResAll && g.Resource != a.Resource {
return false
}
if g.Resource == ResAll && a.Resource == ResCerts {
return false // only an explicit certificates grant
}
if a.Write && g.Access != Write {
return false
}
+32
View File
@@ -25,6 +25,7 @@ import (
"github.com/miekg/dns"
"jarl/internal/auth"
"jarl/internal/encdns"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/porkbun"
@@ -488,3 +489,34 @@ func TestCleanLegoError(t *testing.T) {
t.Errorf("rate limit: %s", got)
}
}
// When certificates.dir changes, Start writes the certificates to the new
// place and points encrypted DNS's tls statement there.
func TestDirChangeFollowed(t *testing.T) {
e := newEnv(t, false)
ctx := context.Background()
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
now := time.Now()
certFile, keyFile := filepath.Join(e.dir, "c.pem"), filepath.Join(e.dir, "k.pem")
cert, key := makeCert(t, []string{"dns.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
os.WriteFile(certFile, cert, 0o600)
os.WriteFile(keyFile, key, 0o600)
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("dns"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"dns"}}, admin); err != nil {
t.Fatal(err)
}
e.submit(engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(encdns.TLSValue("dns", e.m.cfg.Dir))}))
moved := filepath.Join(e.dir, "tls-moved")
cfg := e.m.cfg
cfg.Dir = moved
m2 := New(cfg, e.st, e.eng, slog.New(slog.NewTextHandler(io.Discard, nil)))
m2.Start(ctx)
if _, err := os.Stat(filepath.Join(moved, "dns", ChainFile)); err != nil {
t.Fatalf("not written to the new place: %v", err)
}
for _, o := range e.state().ScopedOptions(model.ScopeTopLevel, 0) {
if o.Name == "tls" && !strings.Contains(o.Value, moved+"/dns/fullchain.pem") {
t.Errorf("tls statement still points elsewhere: %s", o.Value)
}
}
}
+165
View File
@@ -22,6 +22,7 @@ import (
legolog "github.com/go-acme/lego/v4/log"
"jarl/internal/auth"
"jarl/internal/encdns"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/porkbun"
@@ -51,6 +52,9 @@ type Config struct {
PropagationTimeout time.Duration
// SkipPropagationCheck is !CheckPropagation (set by New).
SkipPropagationCheck bool
// MaxPerUser is how many certificates a user may own (Ensure), unless
// the user has a limit of their own.
MaxPerUser int
}
// Manager keeps certificates current and serves jarld's own.
@@ -131,6 +135,8 @@ type Input struct {
Challenge *string `json:"challenge"`
KeyType *string `json:"key_type"`
Comment *string `json:"comment"`
// Owner sets the owning user (Ensure only).
Owner *int64 `json:"-"`
}
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
@@ -209,6 +215,9 @@ func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
if in.Comment != nil {
c.Comment = *in.Comment
}
if in.Owner != nil {
c.OwnerID = *in.Owner
}
switch c.Source {
case model.CertSourceACME:
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
@@ -294,6 +303,128 @@ func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, err
return c, err
}
// PEM returns a certificate's current chain and private key, for
// exporting it: from Jarl's store, or from the files of one you renew
// yourself.
func (m *Manager) PEM(ctx context.Context, name string) (*model.Certificate, []byte, []byte, error) {
c, err := m.Get(ctx, name)
if err != nil {
return nil, nil, nil, err
}
if c.Source == model.CertSourceFile {
cert, err := os.ReadFile(c.CertFile)
if err != nil {
return nil, nil, nil, fileErr(err)
}
key, err := os.ReadFile(c.KeyFile)
if err != nil {
return nil, nil, nil, fileErr(err)
}
return c, cert, key, nil
}
if c.CertPEM == "" {
return nil, nil, nil, &InputError{Msg: "certificate " + name + " hasn't been obtained yet, so there is nothing to export"}
}
return c, []byte(c.CertPEM), []byte(c.KeyPEM), nil
}
// ErrTaken is Ensure's answer for a name another user's certificate (or
// the administrators') has.
var ErrTaken = errors.New("certificate name taken")
// LimitFor is how many certificates u may own.
func (m *Manager) LimitFor(u *model.User) int {
if u != nil && u.CertLimit != nil {
return *u.CertLimit
}
return m.cfg.MaxPerUser
}
// EnsureResult is what Ensure did.
type EnsureResult struct {
Certificate *model.Certificate
Created bool // a new certificate
Changed bool // its names changed, so it is obtained again
// Ignored says what of the request an existing certificate keeps as
// it is (a different key type).
Ignored string
}
// Ensure is jarl cert get: the certificate name, owned by p's user, for
// domains. A new one is created (within the user's limit) and obtained
// at once. An existing one must be the user's: it is obtained again only
// when its names change (a name added or removed); otherwise it is
// returned as it is, renewed by the loop as usual. The caller checks that
// p may have these names.
func (m *Manager) Ensure(ctx context.Context, name string, domains []string, keyType string, p *auth.Principal) (*EnsureResult, error) {
owner := p.User
want, err := normDomains(domains)
if err != nil {
return nil, err
}
c, err := m.Get(ctx, name)
switch {
case errors.Is(err, ErrNotFound):
all, err := m.List(ctx)
if err != nil {
return nil, err
}
n := 0
for _, o := range all {
if o.OwnerID == owner.ID {
n++
}
}
if limit := m.LimitFor(owner); n >= limit {
return nil, inputErr("user %s has %d certificates, its limit (an administrator can raise it: jarl user update %s --cert-limit N, or certificates.max_per_user in jarl.yaml)", owner.Username, n, owner.Username)
}
in := Input{Name: &name, Domains: &want, Owner: &owner.ID, Comment: model.Ptr("for " + owner.Username + " (jarl cert get)")}
if keyType != "" {
in.KeyType = &keyType
}
c, err = m.Create(ctx, in, p)
if errors.Is(err, ErrConflict) {
return nil, ErrTaken // created meanwhile
}
if err != nil {
return nil, err
}
if _, err := m.IssueAsync(ctx, name, p); err != nil {
return nil, err
}
c, err = m.Get(ctx, name)
return &EnsureResult{Certificate: c, Created: true}, err
case err != nil:
return nil, err
}
if c.OwnerID != owner.ID && !(p.IsAdmin() && c.OwnerID == 0) {
return nil, ErrTaken
}
if c.Source != model.CertSourceACME {
return nil, inputErr("certificate %s is read from files on the server, so Jarl can't obtain it for you", name)
}
res := &EnsureResult{Certificate: c}
if keyType != "" && keyType != c.KeyType {
res.Ignored = "it keeps its key type " + c.KeyType + " (changing it means a new certificate: delete this one, or ask an administrator)"
}
have := slices.Clone(c.Domains)
slices.Sort(have)
sorted := slices.Clone(want)
slices.Sort(sorted)
if !slices.Equal(have, sorted) {
if c, err = m.Update(ctx, name, Input{Domains: &want}, p); err != nil {
return nil, err
}
res.Certificate, res.Changed = c, true
}
if res.Changed || c.CertPEM == "" && m.Issuing(name).IsZero() && c.Failures == 0 {
if _, err := m.IssueAsync(ctx, name, p); err != nil {
return nil, err
}
}
return res, nil
}
// Create adds a certificate. A file-source certificate is read at once;
// one Jarl obtains is left for Issue (or the background loop).
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
@@ -766,6 +897,40 @@ func (m *Manager) Start(ctx context.Context) {
}
}
}
m.followDir(ctx, all)
}
// followDir points the tls statements encrypted DNS wrote (jarl-NAME) at
// certificates.dir when it has changed: Start has just written the
// certificates there, and named would otherwise keep serving the old
// copies, which renewals no longer update.
func (m *Manager) followDir(ctx context.Context, all []*model.Certificate) {
st, err := m.store.LoadState(ctx)
if err != nil {
return
}
var reqs []engine.Request
for _, b := range encdns.TLSBlocks(st) {
name, ok := strings.CutPrefix(b.Name, encdns.Prefix)
if !ok || filepath.Dir(b.CertFile) == m.FilesDir(name) {
continue
}
i := slices.IndexFunc(all, func(c *model.Certificate) bool { return c.Name == name })
if i < 0 || !all[i].HasUse(model.CertUseDNS) {
continue
}
if _, err := os.Stat(filepath.Join(m.FilesDir(name), ChainFile)); err != nil {
continue // not written there (yet)
}
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(encdns.TLSValue(name, m.cfg.Dir))}))
m.log.Info("certificates.dir changed: pointing named at the new files", "tls", b.Name, "was", filepath.Dir(b.CertFile), "now", m.FilesDir(name))
}
if len(reqs) == 0 {
return
}
if _, err := m.eng.Submit(ctx, reqs, auth.System, "jarld", "certificates.dir changed: tls statements follow it", engine.ApplyOptions{}); err != nil {
m.log.Error("pointing named at the moved certificate files failed; run 'jarl encrypted-dns setup' again", "err", err)
}
}
// Run renews certificates when they are due and rereads file-source ones
+22 -1
View File
@@ -51,6 +51,10 @@ type Certificates struct {
// HTTPS): one directory per certificate, readable by bind.file_group.
// "" is <bind.config_dir>/tls.
Dir string `yaml:"dir"`
// MaxPerUser is how many certificates a user may own through jarl
// cert get (certificates for other hosts), unless the user has a
// limit of their own (jarl user update --cert-limit). Default 10.
MaxPerUser int `yaml:"max_per_user"`
}
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
@@ -364,6 +368,10 @@ type Stats struct {
// per-zone counts) from named's response log. Without it only named's
// own counters are collected.
QueryLog bool `yaml:"query_log"`
// RateLimitLog sends rate limiting's log lines (named's query-errors
// category) to the response log too, so the Exposure page and the
// statistics can show what was, or would be, limited.
RateLimitLog bool `yaml:"rate_limit_log"`
// Database is the statistics database, kept apart from Jarl's
// configuration database.
Database string `yaml:"database"`
@@ -391,6 +399,9 @@ type StatsRetention struct {
Minute Duration `yaml:"minute"`
Hour Duration `yaml:"hour"`
Day Duration `yaml:"day"`
// Queries is how long the query log keeps every answer, for exploring
// (docs/stats.md#exploring); 0 keeps none.
Queries Duration `yaml:"queries"`
}
// Duration is a time.Duration written as "15s" or "12h" in YAML. Days
@@ -451,14 +462,16 @@ func Default() *Config {
},
Stats: Stats{
QueryLog: true,
RateLimitLog: true,
Database: "/var/lib/jarl/stats.db",
StatisticsChannel: "127.0.0.1:8058",
LogFile: "/var/log/named/jarl-responses.log",
LogSize: "20m",
LogVersions: 2,
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 24 * time.Hour)},
TopN: 100,
},
Certificates: Certificates{MaxPerUser: 10},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
@@ -599,6 +612,9 @@ func (s *Stats) check() error {
if r.Minute < Duration(time.Hour) || r.Hour < Duration(24*time.Hour) || r.Day < Duration(24*time.Hour) {
return fmt.Errorf("retention must keep minutes for at least 1h, hours and days for at least 1d")
}
if r.Queries < 0 || (r.Queries > 0 && r.Queries < Duration(time.Hour)) {
return fmt.Errorf("retention.queries must be at least 1h, or 0 for no query log")
}
if s.TopN < 10 || s.TopN > 10000 {
return fmt.Errorf("top_n must be between 10 and 10000")
}
@@ -736,6 +752,10 @@ stats:
# Per-query data (top names and clients, per view and zone). false
# collects only named's own counters, with no logging overhead.
query_log: true
# With a rate-limit set, also log what rate limiting limits (or would,
# in log-only mode): named's query-errors category, which Jarl then adds
# to its log channel (and keeps in syslog). The Exposure page shows it.
rate_limit_log: true
database: /var/lib/jarl/stats.db
statistics_channel: 127.0.0.1:8058
log_file: /var/log/named/jarl-responses.log
@@ -745,6 +765,7 @@ stats:
minute: 7d # per-minute counters
hour: 90d # hourly counters and top lists
day: 730d # daily counters and top lists
queries: 7d # every answer, to explore (who asked what); 0: none
top_n: 100 # names and clients kept per hour and per day
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
+67
View File
@@ -11,6 +11,7 @@ import (
"github.com/miekg/dns"
"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)
// Root is the root domain name.
@@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string {
return local + "@" + strings.Join(labels[1:], ".")
}
// RegisteredDomain returns the domain registered at a registrar that name
// is in, without a trailing dot: adbraun.lol for lab.adbraun.lol, from
// the public suffix list. ok is false when no registrar is involved:
// reverse zones and the rest of .arpa (delegated by whoever holds the
// addresses), names under a suffix the list doesn't know (home.lan,
// internal) and public suffixes themselves (co.uk).
func RegisteredDomain(name string) (string, bool) {
n := strings.ToLower(strings.TrimSuffix(name, "."))
if n == "" || n == "arpa" || strings.HasSuffix(n, ".arpa") {
return "", false
}
suffix, icann := publicsuffix.PublicSuffix(n)
if !icann || suffix == n {
return "", false
}
d, err := publicsuffix.EffectiveTLDPlusOne(n)
if err != nil {
return "", false
}
return d, true
}
// IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
func IsReverse(name string) bool {
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
@@ -469,3 +492,47 @@ func BestZone(name string, zones []string) string {
}
return best
}
// PrefixFromReverse returns the network a reverse zone answers for:
// 10.0.10.in-addr.arpa. is 10.0.10.0/24, a /32 zone a single address, and
// an ip6.arpa zone of 12 nibbles a /48. False for names that aren't whole
// octets or nibbles (classless delegations such as 0-63.1.0.10.in-addr.arpa.).
func PrefixFromReverse(zone string) (netip.Prefix, bool) {
zone = strings.ToLower(Fqdn(zone))
switch {
case strings.HasSuffix(zone, ".in-addr.arpa."):
labels := strings.Split(strings.TrimSuffix(zone, ".in-addr.arpa."), ".")
if len(labels) > 4 {
return netip.Prefix{}, false
}
var b [4]byte
for i, l := range labels {
v, err := strconv.Atoi(l)
if err != nil || v < 0 || v > 255 || strconv.Itoa(v) != l {
return netip.Prefix{}, false
}
b[len(labels)-1-i] = byte(v)
}
return netip.PrefixFrom(netip.AddrFrom4(b), 8*len(labels)), true
case strings.HasSuffix(zone, ".ip6.arpa."):
labels := strings.Split(strings.TrimSuffix(zone, ".ip6.arpa."), ".")
if len(labels) > 32 {
return netip.Prefix{}, false
}
var b [16]byte
for i, l := range labels {
v := strings.IndexByte("0123456789abcdef", l[0])
if len(l) != 1 || v < 0 {
return netip.Prefix{}, false
}
n := len(labels) - 1 - i // nibble position from the left
if n%2 == 0 {
b[n/2] |= byte(v) << 4
} else {
b[n/2] |= byte(v)
}
}
return netip.PrefixFrom(netip.AddrFrom16(b), 4*len(labels)), true
}
return netip.Prefix{}, false
}
+52
View File
@@ -217,3 +217,55 @@ func TestReverseZones(t *testing.T) {
t.Errorf("ReverseZone /22: %v", err)
}
}
func TestPrefixFromReverse(t *testing.T) {
for zone, want := range map[string]string{
"10.0.10.in-addr.arpa.": "10.0.10.0/24",
"10.in-addr.arpa": "10.0.0.0/8",
"154.144.144.139.in-addr.arpa.": "139.144.144.154/32",
"d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa.": "2a02:8013:7f3d::/48",
"b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.": "fdab:abab:abab::/48",
"0-63.1.0.10.in-addr.arpa.": "",
"example.com.": "",
"256.10.in-addr.arpa.": "",
} {
p, ok := PrefixFromReverse(zone)
got := ""
if ok {
got = p.String()
}
if got != want {
t.Errorf("PrefixFromReverse(%q) = %q, want %q", zone, got, want)
}
}
// Round trip with ReverseZone.
for _, s := range []string{"192.168.1.0/24", "2a02:8013:7f3d::/48", "fd00::/8", "10.0.0.5/32"} {
z, err := ReverseZone(netip.MustParsePrefix(s))
if err != nil {
t.Fatal(err)
}
if p, ok := PrefixFromReverse(z); !ok || p.String() != s {
t.Errorf("%s -> %s -> %v", s, z, p)
}
}
}
func TestRegisteredDomain(t *testing.T) {
for name, want := range map[string]string{
"adbraun.lol.": "adbraun.lol",
"lab.adbraun.lol.": "adbraun.lol",
"Example.CO.UK": "example.co.uk",
"co.uk.": "",
"lol.": "",
"10.0.10.in-addr.arpa.": "",
"d.f.ip6.arpa.": "",
"home.arpa.": "",
"nas.home.lan.": "",
"": "",
} {
got, ok := RegisteredDomain(name)
if got != want || ok != (want != "") {
t.Errorf("RegisteredDomain(%q) = %q, %v; want %q", name, got, ok, want)
}
}
}
+192 -28
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"net/netip"
"path/filepath"
"slices"
"strconv"
"strings"
@@ -208,8 +209,10 @@ func GetStatus(st *model.State, certDir string) Status {
// Setup is what to set up.
type Setup struct {
// Hostname is the name clients connect to (the certificate's).
Hostname string `json:"hostname"`
// Hostnames are the names clients connect to; the certificate must be
// for all of them. Hostname (one) works too.
Hostnames []string `json:"hostnames,omitempty"`
Hostname string `json:"hostname,omitempty"`
// Certificate is the Jarl certificate (with the dns use) to serve.
Certificate string `json:"certificate"`
// Addresses are the addresses of this server to listen on.
@@ -218,9 +221,27 @@ type Setup struct {
DoH bool `json:"doh"`
DoHPort int `json:"doh_port"` // default 443
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
// Views get the hostname's A/AAAA records (default: none). In a
// linked zone they are local to the view.
Views []string `json:"views"`
// AddressViews says, per listen address, the views where the host
// names get it (A or AAAA): a private address shouldn't reach a public
// view. Views (every address in each) works too. The records are local
// to their view in a linked zone.
AddressViews map[string][]string `json:"address_views,omitempty"`
Views []string `json:"views,omitempty"`
// PublicViews are the views the internet reaches (filled in by jarld,
// for a warning).
PublicViews map[string]bool `json:"-"`
}
// Hosts returns the setup's host names, normalized, the first first.
func (s Setup) Hosts() []string {
var out []string
for _, h := range append(append([]string{}, s.Hostnames...), s.Hostname) {
h = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(h), "."))
if h != "" && !slices.Contains(out, h) {
out = append(out, h)
}
}
return out
}
// Plan returns the requests that set up encrypted DNS as s says,
@@ -248,10 +269,15 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
if s.Certificate == "" {
return nil, nil, fmt.Errorf("choose the certificate to serve")
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), "."))
if host == "" || !strings.Contains(host, ".") {
hosts := s.Hosts()
if len(hosts) == 0 {
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
}
for _, host := range hosts {
if !strings.Contains(host, ".") || strings.HasPrefix(host, "*") {
return nil, nil, fmt.Errorf("%q isn't a host name clients can connect to, e.g. dns.example.com", host)
}
}
var v4, v6 []string
for _, a := range s.Addresses {
ip, err := netip.ParseAddr(strings.TrimSpace(a))
@@ -272,9 +298,7 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
return nil, nil, fmt.Errorf("choose at least one address to listen on")
}
tlsName := Prefix + s.Certificate
dir := filepath.Join(certDir, s.Certificate)
tlsValue := fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
tlsName, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
tlsValue := TLSValue(s.Certificate, certDir)
var reqs []engine.Request
// Take out the earlier setup's listeners and tls statements.
@@ -327,32 +351,98 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
add("listen-on", v4)
add("listen-on-v6", v6)
// The host name's addresses, in the views asked for.
fqdn := host + "."
for _, vn := range s.Views {
v := st.ViewByName(vn)
if v == nil {
// The host names' addresses, in the views asked for, per address.
want := map[string][]string{} // view -> addresses
all := append(append([]string{}, v4...), v6...)
if s.AddressViews != nil {
for a, vs := range s.AddressViews {
ip, err := netip.ParseAddr(strings.TrimSpace(a))
if err != nil || !slices.Contains(all, ip.Unmap().String()) {
return nil, nil, fmt.Errorf("address_views: %s isn't one of the addresses to listen on", a)
}
for _, vn := range vs {
want[vn] = append(want[vn], ip.Unmap().String())
}
}
} else {
for _, vn := range s.Views {
want[vn] = all
}
}
for vn, addrs := range want {
if st.ViewByName(vn) == nil {
return nil, nil, fmt.Errorf("view %q does not exist", vn)
}
z := zoneFor(st, fqdn, v.ID)
if z == nil {
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
continue
if s.PublicViews[vn] {
for _, a := range addrs {
if ip, err := netip.ParseAddr(a); err == nil && Private(ip) {
notes = append(notes, fmt.Sprintf("view %s is reachable from the internet, but gets the private address %s for %s: its clients can't reach that", vn, a, strings.Join(hosts, ", ")))
}
}
}
var local *bool
if z.LinkGroup != 0 {
local = model.Ptr(true)
}
// Ours are the addresses this setup (or the one before) gives the host
// names: a view that no longer gets one loses it; other records of the
// names stay.
ours := map[string]bool{}
for _, a := range all {
ours[a] = true
}
for _, l := range current {
if l.Managed() {
for _, a := range l.Addrs {
ours[a] = true
}
}
for _, set := range []struct {
typ string
addrs []string
}{{"A", v4}, {"AAAA", v6}} {
if len(set.addrs) == 0 {
}
names := slices.Clone(hosts)
for _, h := range st.Settings.EncryptedDNSHosts {
if !slices.Contains(names, h) {
names = append(names, h)
}
}
for _, v := range st.OrderedViews() {
for _, host := range names {
fqdn := host + "."
z := zoneFor(st, fqdn, v.ID)
wanted := want[v.Name]
if !slices.Contains(hosts, host) {
wanted = nil // a host name no longer used
}
if z == nil {
if len(wanted) > 0 {
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
}
continue
}
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local}))
for _, typ := range []string{"A", "AAAA"} {
var have, next []string
for _, r := range st.RRset(z.ID, fqdn, typ) {
have = append(have, r.Data)
if !ours[r.Data] {
next = append(next, r.Data)
}
}
for _, a := range wanted {
if strings.Contains(a, ":") == (typ == "AAAA") && !slices.Contains(next, a) {
next = append(next, a)
}
}
slices.Sort(have)
slices.Sort(next)
if slices.Equal(have, next) {
continue
}
if next == nil {
next = []string{}
}
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: typ, Data: next, Local: model.Ptr(true)}))
}
}
}
if !slices.Equal(st.Settings.EncryptedDNSHosts, hosts) {
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &hosts}))
}
return reqs, notes, nil
}
@@ -371,6 +461,69 @@ func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
return best
}
// CurrentAddressViews returns, per address, the views whose records give
// one of the host names that address (what the setup form starts from).
func CurrentAddressViews(st *model.State, hosts []string) map[string][]string {
out := map[string][]string{}
for _, v := range st.OrderedViews() {
for _, host := range hosts {
fqdn := strings.TrimSuffix(host, ".") + "."
z := zoneFor(st, fqdn, v.ID)
if z == nil {
continue
}
for _, typ := range []string{"A", "AAAA"} {
for _, r := range st.RRset(z.ID, fqdn, typ) {
if !r.Disabled && !slices.Contains(out[r.Data], v.Name) {
out[r.Data] = append(out[r.Data], v.Name)
}
}
}
}
}
return out
}
// CertUses says what of named's configuration uses the certificate whose
// files are in dir: "DNS over TLS on 10.0.10.5, fd00::5 port 853", or the
// tls statement alone when no listener uses it.
func CertUses(st *model.State, dir string) []string {
var out []string
for _, b := range TLSBlocks(st) {
if filepath.Dir(b.CertFile) != filepath.Clean(dir) {
continue
}
used := false
byKind := map[string]map[int][]string{}
for _, l := range Listeners(st) {
if l.TLS != b.Name {
continue
}
used = true
k := l.Kind()
if byKind[k] == nil {
byKind[k] = map[int][]string{}
}
byKind[k][l.EffectivePort()] = append(byKind[k][l.EffectivePort()], l.Addrs...)
}
for _, k := range []string{"dot", "doh"} {
label := map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[k]
ports := make([]int, 0, len(byKind[k]))
for p := range byKind[k] {
ports = append(ports, p)
}
slices.Sort(ports)
for _, p := range ports {
out = append(out, fmt.Sprintf("%s on %s port %d", label, strings.Join(byKind[k][p], ", "), p))
}
}
if !used {
out = append(out, "tls statement "+b.Name+" (no listener uses it)")
}
}
return out
}
// Disable returns the requests that take the setup out: its listeners and
// tls statement. Plain listeners and the host name's records stay.
func Disable(st *model.State) []engine.Request {
@@ -385,6 +538,9 @@ func Disable(st *model.State) []engine.Request {
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
}
}
if len(reqs) > 0 && len(st.Settings.EncryptedDNSHosts) > 0 {
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &[]string{}}))
}
return reqs
}
@@ -393,3 +549,11 @@ func Private(a netip.Addr) bool {
a = a.Unmap()
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || netip.MustParsePrefix("100.64.0.0/10").Contains(a)
}
// TLSValue is the tls statement the setup writes for a certificate whose
// files are in certDir/NAME.
func TLSValue(cert, certDir string) string {
dir := filepath.Join(certDir, cert)
return fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
Prefix+cert, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
}
+67
View File
@@ -186,3 +186,70 @@ func TestPorts(t *testing.T) {
t.Errorf("free DoH port = %d, want 4443", p)
}
}
// Each address gets its own views; a view no longer given loses it, and
// records of the host names that aren't the setup's stay.
func TestSetupAddressViews(t *testing.T) {
e := newEnv(t)
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
e.submit(engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{Zone: "example.com", View: "external", Name: "doh.example.com.", Type: "A", Data: []string{"198.51.100.9"}, Local: model.Ptr(true)}))
hosts := func(st *model.State, view string) []string {
z := st.FindZone("example.com.", st.ViewByName(view).ID)
var out []string
for _, r := range st.ZoneRecords(z.ID) {
if strings.HasPrefix(r.Name, "dns.") || strings.HasPrefix(r.Name, "doh.") {
out = append(out, strings.SplitN(r.Name, ".", 2)[0]+" "+r.Type+" "+r.Data)
}
}
return out
}
setup := Setup{Hostnames: []string{"dns.example.com", "doh.example.com"}, Certificate: "dns", DoT: true, DoH: true,
Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}, "10.0.10.6": {"external"}},
PublicViews: map[string]bool{"external": true}}
reqs, notes, err := Plan(e.state(), setup, e.certDir)
if err != nil {
t.Fatal(err)
}
if !slices.ContainsFunc(notes, func(n string) bool {
return strings.Contains(n, "view external is reachable from the internet, but gets the private address 10.0.10.6")
}) {
t.Errorf("notes %q", notes)
}
e.submit(reqs...)
st := e.state()
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5", "doh A 10.0.10.5"}; !slices.Equal(got, want) {
t.Errorf("internal %q, want %q", got, want)
}
// The external doh record that isn't the setup's stays.
if got, want := hosts(st, "external"), []string{"dns A 10.0.10.6", "dns A 203.0.113.5", "doh A 10.0.10.6", "doh A 198.51.100.9", "doh A 203.0.113.5"}; !slices.Equal(got, want) {
t.Errorf("external %q, want %q", got, want)
}
if !slices.Equal(st.Settings.EncryptedDNSHosts, []string{"dns.example.com", "doh.example.com"}) {
t.Errorf("hosts %q", st.Settings.EncryptedDNSHosts)
}
av := CurrentAddressViews(st, st.Settings.EncryptedDNSHosts)
if !slices.Equal(av["10.0.10.5"], []string{"internal"}) || !slices.Equal(av["10.0.10.6"], []string{"external"}) {
t.Errorf("address views %v", av)
}
uses := CertUses(st, filepath.Join(e.certDir, "dns"))
if len(uses) != 2 || !strings.HasPrefix(uses[0], "DNS over TLS on 10.0.10.5, 203.0.113.5, 10.0.10.6 port 853") || !strings.HasPrefix(uses[1], "DNS over HTTPS on") {
t.Errorf("cert uses %q", uses)
}
// 10.0.10.6 no longer in external, doh.example.com no longer a host name.
setup = Setup{Hostname: "dns.example.com", Certificate: "dns", DoT: true, Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}}}
reqs, _, err = Plan(st, setup, e.certDir)
if err != nil {
t.Fatal(err)
}
e.submit(reqs...)
st = e.state()
if got, want := hosts(st, "external"), []string{"dns A 203.0.113.5", "doh A 198.51.100.9"}; !slices.Equal(got, want) {
t.Errorf("external after %q, want %q", got, want)
}
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5"}; !slices.Equal(got, want) {
t.Errorf("internal after %q, want %q", got, want)
}
}
+245
View File
@@ -0,0 +1,245 @@
package encdns
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"syscall"
"time"
"github.com/miekg/dns"
)
// Probe is the outcome of checking one DoT or DoH listener the way a
// client would: connect, check the certificate, ask a question.
type Probe struct {
Kind string `json:"kind"` // dot or doh
Address string `json:"address"`
Port int `json:"port"`
// OK: it answered over TLS with a certificate for the host name that
// is current. Problem says what is wrong when not; Warning notes
// something that doesn't stop clients from working everywhere.
OK bool `json:"ok"`
Problem string `json:"problem,omitempty"`
Warning string `json:"warning,omitempty"`
// What the certificate named served says.
CertNames []string `json:"cert_names,omitempty"`
CertNotAfter *time.Time `json:"cert_not_after,omitempty"`
CertSerial string `json:"cert_serial,omitempty"`
// Trusted: it chains to a CA this server trusts (not so for a
// staging or test CA).
Trusted bool `json:"trusted"`
RCode string `json:"rcode,omitempty"`
Millis int64 `json:"ms"`
}
// Health is the last check of every listener.
type Health struct {
Checked time.Time `json:"checked"`
Probes []Probe `json:"probes"`
}
// OK reports whether every listener works.
func (h *Health) OK() bool {
for _, p := range h.Probes {
if !p.OK {
return false
}
}
return true
}
// Check probes every DoT and DoH listener of the setup. hostname is what
// clients ask for, serial the certificate Jarl last wrote for named ("" if
// unknown), roots the CAs to trust (nil: the system's).
func Check(ctx context.Context, s Status, hostname, serial string, roots *x509.CertPool) *Health {
h := &Health{Checked: time.Now(), Probes: []Probe{}}
for _, e := range s.DoT {
h.Probes = append(h.Probes, probe(ctx, "dot", e, hostname, serial, roots))
}
for _, e := range s.DoH {
h.Probes = append(h.Probes, probe(ctx, "doh", e, hostname, serial, roots))
}
return h
}
const probeTimeout = 5 * time.Second
func probe(ctx context.Context, kind string, e Endpoint, hostname, serial string, roots *x509.CertPool) Probe {
p := Probe{Kind: kind, Address: e.Address, Port: e.Port}
addr := net.JoinHostPort(e.Address, strconv.Itoa(e.Port))
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
start := time.Now()
q := new(dns.Msg)
q.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
q.RecursionDesired = true
var state tls.ConnectionState
var answer *dns.Msg
var err error
cfg := &tls.Config{ServerName: hostname, InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} // checked below, to say what is wrong
if kind == "dot" {
cfg.NextProtos = []string{"dot"}
state, answer, err = askDoT(ctx, addr, cfg, q)
} else {
state, answer, err = askDoH(ctx, addr, hostname, e.Port, cfg, q)
}
p.Millis = time.Since(start).Milliseconds()
if len(state.PeerCertificates) > 0 {
leaf := state.PeerCertificates[0]
p.CertNames, p.CertSerial = leaf.DNSNames, fmt.Sprintf("%x", leaf.SerialNumber)
na := leaf.NotAfter
p.CertNotAfter = &na
inter := x509.NewCertPool()
for _, c := range state.PeerCertificates[1:] {
inter.AddCert(c)
}
_, verr := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: hostname})
p.Trusted = verr == nil
switch {
case leaf.VerifyHostname(hostname) != nil:
p.Problem = fmt.Sprintf("named serves a certificate for %s, not %s: clients reject it", strings.Join(leaf.DNSNames, ", "), hostname)
case time.Now().After(leaf.NotAfter):
p.Problem = "named serves an expired certificate (until " + leaf.NotAfter.Format(time.DateOnly) + ")"
case serial != "" && p.CertSerial != serial:
p.Problem = "named still serves an older certificate (serial " + p.CertSerial + ", Jarl has " + serial + "): it hasn't reread the files (rndc reconfig)"
case !p.Trusted:
p.Warning = "the certificate isn't from a CA this server trusts (a staging or test CA?): clients reject it unless they trust that CA"
}
}
switch {
case err != nil:
p.Problem = describe(err, e)
case p.Problem != "":
case answer == nil:
p.Problem = "no DNS answer"
default:
p.RCode = dns.RcodeToString[answer.Rcode]
p.OK = true
if answer.Rcode == dns.RcodeRefused {
p.Warning = strings.TrimPrefix(p.Warning+"; ", "; ") + "it answers, but REFUSED: this server's own address gets a view that doesn't answer for " + hostname
}
}
return p
}
func askDoT(ctx context.Context, addr string, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
d := &tls.Dialer{NetDialer: &net.Dialer{Timeout: probeTimeout}, Config: cfg}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return tls.ConnectionState{}, nil, err
}
defer conn.Close()
tc := conn.(*tls.Conn)
state := tc.ConnectionState()
conn.SetDeadline(time.Now().Add(probeTimeout))
co := &dns.Conn{Conn: conn}
if err := co.WriteMsg(q); err != nil {
return state, nil, err
}
r, err := co.ReadMsg()
return state, r, err
}
func askDoH(ctx context.Context, addr, hostname string, port int, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
cfg = cfg.Clone()
cfg.NextProtos = []string{"h2", "http/1.1"}
tr := &http.Transport{
TLSClientConfig: cfg,
ForceAttemptHTTP2: true,
// Whatever the host name resolves to here: the listener's address.
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: probeTimeout}).DialContext(ctx, network, addr)
},
}
defer tr.CloseIdleConnections()
wire, err := q.Pack()
if err != nil {
return tls.ConnectionState{}, nil, err
}
url := "https://" + net.JoinHostPort(hostname, strconv.Itoa(port)) + "/dns-query"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wire))
if err != nil {
return tls.ConnectionState{}, nil, err
}
req.Header.Set("Content-Type", "application/dns-message")
req.Header.Set("Accept", "application/dns-message")
resp, err := (&http.Client{Transport: tr, Timeout: probeTimeout}).Do(req)
if err != nil {
return tls.ConnectionState{}, nil, err
}
defer resp.Body.Close()
var state tls.ConnectionState
if resp.TLS != nil {
state = *resp.TLS
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return state, nil, err
}
if resp.StatusCode != http.StatusOK {
return state, nil, fmt.Errorf("HTTP %d at /dns-query", resp.StatusCode)
}
r := new(dns.Msg)
if err := r.Unpack(body); err != nil {
return state, nil, fmt.Errorf("not a DNS answer: %w", err)
}
return state, r, nil
}
// describe says what a failed probe means.
func describe(err error, e Endpoint) string {
var ne net.Error
switch {
case errors.Is(err, syscall.ECONNREFUSED):
return fmt.Sprintf("nothing listens on %s port %d: named may not have been able to bind it (look in named's log; ports below 1024 need named to start as root)", e.Address, e.Port)
case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded):
return fmt.Sprintf("no answer from %s port %d within %s (a firewall on the server?)", e.Address, e.Port, probeTimeout)
}
return err.Error()
}
// RequireNames marks the probes whose certificate doesn't cover every host
// name clients may use (the first is what the probe asked for).
func (h *Health) RequireNames(hosts []string) {
for i := range h.Probes {
p := &h.Probes[i]
if p.Problem != "" || len(p.CertNames) == 0 {
continue
}
var missing []string
for _, host := range hosts {
if !nameCovered(p.CertNames, host) {
missing = append(missing, host)
}
}
if len(missing) > 0 {
p.OK = false
p.Problem = fmt.Sprintf("named serves a certificate for %s, which doesn't cover %s: clients using that name reject it", strings.Join(p.CertNames, ", "), strings.Join(missing, ", "))
}
}
}
func nameCovered(names []string, host string) bool {
host = strings.ToLower(strings.TrimSuffix(host, "."))
for _, n := range names {
n = strings.ToLower(strings.TrimSuffix(n, "."))
if n == host {
return true
}
if rest, ok := strings.CutPrefix(n, "*."); ok {
if _, parent, ok := strings.Cut(host, "."); ok && parent == rest {
return true
}
}
}
return false
}
+133
View File
@@ -0,0 +1,133 @@
package encdns
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"io"
"math/big"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
func testCert(t *testing.T, names ...string) (tls.Certificate, *x509.Certificate) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{SerialNumber: big.NewInt(0xabc123), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
leaf, _ := x509.ParseCertificate(der)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
}
// answer answers every question with 192.0.2.53.
var answer = dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Answer = append(m.Answer, &dns.A{Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("192.0.2.53")})
w.WriteMsg(m)
})
func listeners(t *testing.T, cert tls.Certificate) (dot, doh Endpoint) {
cfg := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
if err != nil {
t.Fatal(err)
}
srv := &dns.Server{Listener: ln, Net: "tcp-tls", Handler: answer}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
hl, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
if err != nil {
t.Fatal(err)
}
hs := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/dns-query" {
http.NotFound(w, r)
return
}
b, _ := io.ReadAll(r.Body)
q := new(dns.Msg)
if q.Unpack(b) != nil {
http.Error(w, "bad", 400)
return
}
m := new(dns.Msg)
m.SetReply(q)
m.Rcode = dns.RcodeSuccess
out, _ := m.Pack()
w.Header().Set("Content-Type", "application/dns-message")
w.Write(out)
})}
go hs.Serve(hl)
t.Cleanup(func() { hs.Close() })
port := func(l net.Listener) int { return l.Addr().(*net.TCPAddr).Port }
return Endpoint{"127.0.0.1", port(ln)}, Endpoint{"127.0.0.1", port(hl)}
}
func TestHealth(t *testing.T) {
ctx := context.Background()
cert, leaf := testCert(t, "dns.example.com")
dot, doh := listeners(t, cert)
roots := x509.NewCertPool()
roots.AddCert(leaf)
st := Status{Enabled: true, DoT: []Endpoint{dot}, DoH: []Endpoint{doh}}
h := Check(ctx, st, "dns.example.com", "abc123", roots)
if !h.OK() || len(h.Probes) != 2 {
t.Fatalf("working setup: %+v", h.Probes)
}
for _, p := range h.Probes {
if !p.Trusted || p.RCode != "NOERROR" || p.Warning != "" || p.CertSerial != "abc123" {
t.Errorf("%s: %+v", p.Kind, p)
}
}
// named hasn't reread a renewed certificate.
if h := Check(ctx, st, "dns.example.com", "def456", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "older certificate") {
t.Errorf("stale: %+v", h.Probes[0])
}
// The wrong name.
if h := Check(ctx, st, "other.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "not other.example.com") {
t.Errorf("wrong name: %+v", h.Probes[0])
}
// A CA this server doesn't trust: works, with a warning.
if h := Check(ctx, st, "dns.example.com", "abc123", x509.NewCertPool()); !h.OK() || !strings.Contains(h.Probes[1].Warning, "staging") {
t.Errorf("untrusted: %+v", h.Probes[1])
}
// Nothing listening.
l, _ := net.Listen("tcp", "127.0.0.1:0")
gone := l.Addr().(*net.TCPAddr).Port
l.Close()
if h := Check(ctx, Status{Enabled: true, DoT: []Endpoint{{"127.0.0.1", gone}}}, "dns.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "nothing listens") {
t.Errorf("nothing listening: %+v", h.Probes[0])
}
}
func TestRequireNames(t *testing.T) {
h := &Health{Probes: []Probe{
{Kind: "dot", OK: true, CertNames: []string{"dns.example.com", "*.example.net"}},
{Kind: "doh", OK: false, Problem: "nothing listens", CertNames: nil},
}}
h.RequireNames([]string{"dns.example.com", "doh.example.net"})
if !h.Probes[0].OK {
t.Errorf("covered names: %+v", h.Probes[0])
}
h.RequireNames([]string{"dns.example.com", "doh.example.com"})
if p := h.Probes[0]; p.OK || !strings.Contains(p.Problem, "doesn't cover doh.example.com") {
t.Errorf("missing name: %+v", p)
}
if h.Probes[1].Problem != "nothing listens" {
t.Errorf("other problem replaced: %+v", h.Probes[1])
}
}
+236
View File
@@ -0,0 +1,236 @@
package engine
import (
"fmt"
"slices"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
)
// ViewSet is what one view has for a name and type.
type ViewSet struct {
View string `json:"view"`
ZoneID int64 `json:"zone_id"`
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
IDs []int64 `json:"ids"` // the records, in the same order
Data []string `json:"data"` // their data alone, in the same order
TTL *uint32 `json:"ttl,omitempty"` // the first record's own TTL
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
}
// CompareRow is one name and type across views.
type CompareRow struct {
Name string `json:"name"` // relative to the zone
FQDN string `json:"fqdn"`
Type string `json:"type"`
Same bool `json:"same"`
Views []ViewSet `json:"views"` // in the order of the zones given
}
// CompareZones lines up the records of zones of the same name in
// different views (docs/views.md#linked-zones), one row per name and type,
// sorted like a zone file.
func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
type key struct{ name, typ string }
sets := make([]map[key][]*model.Record, len(zones))
var keys []key
seen := map[key]bool{}
var all []*model.Record
for i, z := range zones {
sets[i] = map[key][]*model.Record{}
for _, r := range st.ZoneRecords(z.ID) {
k := key{r.Name, r.Type}
sets[i][k] = append(sets[i][k], r)
if !seen[k] {
seen[k] = true
keys = append(keys, k)
all = append(all, r)
}
}
}
if len(zones) > 0 {
// Zone file order: the apex first, then by name and type.
model.SortRecords(all, zones[0])
keys = keys[:0]
for _, r := range all {
keys = append(keys, key{r.Name, r.Type})
}
}
var out []CompareRow
for _, k := range keys {
row := CompareRow{FQDN: k.name, Type: k.typ, Same: true}
if len(zones) > 0 {
row.Name = relName(k.name, zones[0].Name)
}
var first string
for i, z := range zones {
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}, Data: []string{}}
rs := slices.Clone(sets[i][k])
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
for _, r := range rs {
d := r.Data
if r.TTL != nil {
d = fmt.Sprintf("%s (TTL %d)", d, *r.TTL)
}
if r.Disabled {
d += " (disabled)"
}
vs.Records = append(vs.Records, d)
vs.IDs = append(vs.IDs, r.ID)
vs.Data = append(vs.Data, r.Data)
if vs.TTL == nil && r.TTL != nil {
vs.TTL = model.Ptr(*r.TTL)
}
}
if z.LinkGroup != 0 && len(rs) > 0 {
vs.Linked = "shared"
if rs[0].SharedID == 0 {
vs.Linked = "local"
}
}
sig := strings.Join(vs.Records, "\n")
if i == 0 {
first = sig
} else if sig != first {
row.Same = false
}
row.Views = append(row.Views, vs)
}
out = append(out, row)
}
return out
}
func relName(name, zone string) string {
if name == zone {
return "@"
}
return strings.TrimSuffix(name, "."+zone)
}
// CompareReport is a zone in one view (the base) next to the same zone in
// other views (docs/views.md#comparing-views): what the other views answer
// differently, the names only some views have, and how much is the same.
type CompareReport struct {
Zone string `json:"zone"`
Base string `json:"base"` // the view compared from
Views []string `json:"views"` // the base first
Linked bool `json:"linked"` // all in one link
// Summary is, per other view, how it differs from the base.
Summary []CompareSummary `json:"summary"`
// Different are names and types more than one view has, with
// different records.
Different []CompareRow `json:"different"`
// Only are the names and types only some views have, by the views
// that have them.
Only []CompareOnly `json:"only"`
Same int `json:"same"`
// SameRows are the ones that are the same (when asked for).
SameRows []CompareRow `json:"same_rows,omitempty"`
}
// CompareSummary counts how a view differs from the base view.
type CompareSummary struct {
View string `json:"view"`
Different int `json:"different"` // both have it, with different records
OnlyBase int `json:"only_base"` // the base has it, this view doesn't
OnlyOther int `json:"only_other"` // this view has it, the base doesn't
Same int `json:"same"`
}
// CompareOnly are names and types that only some views have.
type CompareOnly struct {
Views []string `json:"views"`
Rows []CompareRow `json:"rows"`
}
// CompareFilter narrows a comparison to a name, and optionally a type.
type CompareFilter struct {
Name string // relative ("www", "@") or absolute
Type string
All bool // include what is the same
}
// Compare builds the report for zones (the base first). Apex NS records are
// left out: they name the servers, which views often list differently on
// purpose.
func Compare(st *model.State, zones []*model.Zone, f CompareFilter) *CompareReport {
rep := &CompareReport{Zone: dnsname.Display(zones[0].Name), Base: st.ViewName(zones[0]), Different: []CompareRow{}, Only: []CompareOnly{}, Linked: true}
for _, z := range zones {
rep.Views = append(rep.Views, st.ViewName(z))
rep.Linked = rep.Linked && z.LinkGroup != 0 && z.LinkGroup == zones[0].LinkGroup
}
for _, v := range rep.Views[1:] {
rep.Summary = append(rep.Summary, CompareSummary{View: v})
}
want := ""
if f.Name != "" {
if o, err := dnsname.Owner(f.Name, zones[0].Name); err == nil {
want = o
}
}
only := map[string]int{}
for _, row := range CompareZones(st, zones) {
if row.FQDN == zones[0].Name && row.Type == "NS" {
continue
}
if want != "" && (row.FQDN != want || f.Type != "" && !strings.EqualFold(row.Type, f.Type)) {
continue
}
base := row.Views[0]
for i, vs := range row.Views[1:] {
s := &rep.Summary[i]
switch {
case len(base.Records) > 0 && len(vs.Records) > 0 && slices.Equal(base.Records, vs.Records):
s.Same++
case len(base.Records) > 0 && len(vs.Records) > 0:
s.Different++
case len(base.Records) > 0:
s.OnlyBase++
case len(vs.Records) > 0:
s.OnlyOther++
}
}
if row.Same {
rep.Same++
if f.All || want != "" {
rep.SameRows = append(rep.SameRows, row)
}
continue
}
var having []string
versions := map[string]bool{}
for _, vs := range row.Views {
if len(vs.Records) > 0 {
having = append(having, vs.View)
versions[strings.Join(vs.Records, "\n")] = true
}
}
if len(versions) > 1 {
rep.Different = append(rep.Different, row)
continue
}
k := strings.Join(having, ",")
i, ok := only[k]
if !ok {
i = len(rep.Only)
only[k] = i
rep.Only = append(rep.Only, CompareOnly{Views: having})
}
rep.Only[i].Rows = append(rep.Only[i].Rows, row)
}
// The base's own names first, then by how many views have them.
slices.SortStableFunc(rep.Only, func(a, b CompareOnly) int {
ab, bb := slices.Contains(a.Views, rep.Base), slices.Contains(b.Views, rep.Base)
if ab != bb {
if ab {
return -1
}
return 1
}
return len(b.Views) - len(a.Views)
})
return rep
}
+5 -3
View File
@@ -25,7 +25,8 @@ func DiffOps(from, to *model.State) []model.Op {
s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings")
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse)),
EncryptedDNSHosts: model.Ptr(slices.Clone(s.EncryptedDNSHosts))}, "restore settings")
}
for _, k := range sortedKeys(to.Keys) {
@@ -50,7 +51,8 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Views[k]
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged)),
ReverseSuggestions: model.Ptr(!n.ReverseSuggestionsOff), DismissedReverse: model.Ptr(slices.Clone(n.DismissedReverse))}
if n.MatchRecursiveOnly != nil {
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
} else {
@@ -66,7 +68,7 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Zones[k]
s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)}
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup), AutoPTR: model.Ptr(n.AutoPTR)}
if n.Type == model.ZonePrimary {
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
}
+4 -1
View File
@@ -285,7 +285,7 @@ func (e *Engine) expandAll(ctx context.Context, work *model.State, reqs []Reques
var warnings []string
c := &applyCtx{p: p, now: e.Now()}
for i, req := range reqs {
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now()}
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now(), exposures: e.Exposures}
more, err := x.expand(req)
if err != nil {
if ee, ok := AsError(err); ok && len(reqs) > 1 {
@@ -330,6 +330,9 @@ func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.
if err != nil {
return nil, err
}
if len(ops) == 0 {
return nil, errf(KindBadRequest, "nothing to change")
}
intro := validate.Introduced(before, e.validate(work))
if validate.HasErrors(intro) {
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
+36
View File
@@ -462,3 +462,39 @@ func TestParseWarningsReachTheResult(t *testing.T) {
t.Errorf("ops = %+v", res.Ops)
}
}
func TestUnchangedZoneUpdateStagesNothing(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("wg.example.com")}))
z := env.state().FindZone("wg.example.com.", 0)
if z.SOA.MName != "ns1.example.com." {
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{MName: model.Ptr("ns1.example.com.")}}))
z = env.state().FindZone("wg.example.com.", 0)
}
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
if err != nil {
t.Fatal(err)
}
// The settings form saved as it was.
same := ZoneInput{Comment: model.Ptr(z.Comment), DefaultTTL: model.Ptr(z.DefaultTTL), SerialPolicy: model.Ptr(z.SerialPolicy),
SOA: &SOAInput{RName: model.Ptr("hostmaster@example.com"), Refresh: model.Ptr(z.SOA.Refresh), Retry: model.Ptr(z.SOA.Retry),
Expire: model.Ptr(z.SOA.Expire), Minimum: model.Ptr(z.SOA.Minimum)}}
if z.SOA.RName != "hostmaster.example.com." {
same.SOA.RName = nil
}
_, err = env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, same)}, env.admin)
if e, ok := AsError(err); !ok || e.Kind != KindBadRequest || e.Message != "nothing to change" {
t.Fatalf("unchanged update: %v", err)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, same)); e.Message != "nothing to do" {
t.Errorf("unchanged update applied: %+v", e)
}
// A real change still stages, and an absolute MNAME stays absolute.
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{Comment: model.Ptr("the VPN")})}, env.admin); err != nil {
t.Fatal(err)
}
if got := env.state().FindZone("wg.example.com.", 0).SOA.MName; got != "ns1.example.com." {
t.Errorf("mname = %q", got)
}
}
+443 -106
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"net/netip"
"reflect"
"slices"
"strings"
"time"
@@ -27,6 +29,15 @@ type expander struct {
p *auth.Principal
now time.Time
warnings []string
// exposures classifies the views as public or private (nil in tests:
// every view private).
exposures func(*model.State) []validate.Exposure
// ptrForced are the address sets whose PTR records the request being
// expanded asked to update (ptrForce).
ptrForced map[rrKey]bool
// sharing are the local sets the request deletes so that the shared
// version reaches them (dropOverrides).
sharing map[rrKey]bool
}
func (x *expander) ids(n int) ([]int64, error) {
@@ -65,11 +76,13 @@ func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, er
if zone == "" {
return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)")
}
return FindZoneRef(x.st, zone, view)
return FindZoneRef(x.st, zone, view, func(z *model.Zone) bool { return x.p.CanSeeZone(z.Name, x.st.ViewName(z)) })
}
// FindZoneRef finds a zone by name and optional view name.
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
// FindZoneRef finds a zone by name and optional view name. Without a
// view, a name in several views needs one, counting only the zones sees
// allows (nil: all): a token for one view finds that view's zone.
func FindZoneRef(st *model.State, zone, view string, sees func(*model.Zone) bool) (*model.Zone, error) {
name, err := ParseZoneName(zone)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
@@ -86,6 +99,16 @@ func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
return z, nil
}
zs := st.ZonesNamed(name)
if sees != nil && len(zs) > 1 {
var vis []*model.Zone
for _, z := range zs {
if sees(z) {
vis = append(vis, z)
}
}
zs = vis // none: not found, without naming the views
}
st.SortByView(zs)
switch len(zs) {
case 0:
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
@@ -131,13 +154,20 @@ func dateSerial(now time.Time) uint32 {
}
func (x *expander) expand(req Request) ([]model.Op, error) {
x.ptrForced, x.sharing = nil, nil
ops, err := x.expandOne(req)
if err != nil {
return nil, err
}
switch req.Op {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
// Changes to linked zones reach the other zones of the link.
// Changes to linked zones reach the other zones of the link, then
// PTR records follow the addresses in each view.
if ops, err = x.linkSync(ops); err != nil {
return nil, err
}
return x.ptrSync(ops)
case OpZoneFillPTR:
return x.linkSync(ops)
}
return ops, nil
@@ -151,6 +181,10 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
return x.zoneUnlink(req)
case OpZoneCreate:
return x.zoneCreate(req)
case OpZoneFillPTR:
return x.zoneFillPTR(req)
case OpReverseDismiss, OpReverseUndismiss:
return x.reverseDismiss(req, req.Op == OpReverseDismiss)
case OpZoneUpdate, OpZoneDelete:
z := x.st.Zones[req.ID]
if z == nil {
@@ -185,6 +219,11 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
if in.LinkWith != "" {
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
}
// An update that leaves the zone as it is stages nothing (a
// settings form saved without changes).
if zoneUnchanged(x.st, z, &in) {
return nil, nil
}
}
return ops, nil
case OpRecordCreate:
@@ -375,6 +414,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
return nil, errf(KindBadRequest, "%v", err)
}
in.Type = &zt
viewGiven := in.View != nil || in.ViewID != nil
if in.View != nil {
if *in.View != "" {
v := x.st.ViewByName(*in.View)
@@ -385,6 +425,79 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
}
in.View = nil
}
if !viewGiven && in.LinkWith == "" && in.CloneFrom == 0 && dnsname.IsReverse(name) {
// A reverse zone belongs in the private view whose records use
// its addresses (docs/reverse.md).
v, err := reverseViewFor(x.st, x.publicViews(), name)
if err != nil {
return nil, err
}
if v != nil {
in.ViewID = &v.ID
}
}
var moveOps []model.Op
var moved []*model.Record
if zt == model.ZonePrimary {
// The records a parent zone in the view has under the new zone
// move into it: named answers those names from the new zone, so
// left behind they would never be served again.
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
if parent := x.parentZone(name, vid); parent != nil {
moved = x.recordsUnder(parent, name)
switch {
case len(moved) == 0:
case parent.LinkGroup != 0:
x.warnings = append(x.warnings, fmt.Sprintf("zone %s is linked, so its %d records under %s stay there (hidden by the new zone): move them by hand", zoneDesc(x.st, parent), len(moved), dnsname.Display(name)))
moved = nil
default:
for _, r := range moved {
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data)}
if r.TTL != nil {
ri.TTL = model.Some(*r.TTL)
}
if r.Comment != "" {
ri.Comment = model.Ptr(r.Comment)
}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
in.Records = append(in.Records, ri)
moveOps = append(moveOps, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("move %s %s %s from zone %s into the new zone", r.Name, r.Type, r.Data, zoneDesc(x.st, parent))))
}
}
}
}
fill, fillOther := in.Fill, in.FillOtherViews
in.Fill, in.FillOtherViews = false, false
if fill {
if zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "fill only applies to primary zones")
}
if _, ok := dnsname.PrefixFromReverse(name); !ok {
return nil, errf(KindBadRequest, "fill only applies to reverse zones of whole octets or nibbles (like 10.0.10.0/24)")
}
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
fills := PTRFills(x.st, vid, name, moved, fillOther)
if len(fills) == 0 {
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
}
if !fillOther {
if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to name them from there, fill it with other views (jarl zone fill-ptr ZONE --other-views)"); h != "" {
x.warnings = append(x.warnings, h)
}
}
x.warnings = append(x.warnings, fillWarnings(fills)...)
for _, f := range fills {
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
}
}
nameservers := in.Nameservers
records := in.Records
cloneFrom := in.CloneFrom
@@ -602,7 +715,45 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
r.in.ZoneID = ids[0]
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
}
return ops, nil
return append(ops, moveOps...), nil
}
// parentZone returns the most specific primary zone in a view that
// contains name (not name itself).
func (x *expander) parentZone(name string, viewID int64) *model.Zone {
var best *model.Zone
for _, z := range x.st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && dnsname.IsStrictSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
return best
}
// recordsUnder returns a zone's records at or below name, except a
// delegation's NS and DS records and its glue, which belong to the parent.
func (x *expander) recordsUnder(z *model.Zone, name string) []*model.Record {
all := x.st.ZoneRecords(z.ID)
var ns []string
for _, r := range all {
if r.Name == name && r.Type == "NS" {
ns = append(ns, strings.ToLower(r.Data))
}
}
var out []*model.Record
for _, r := range all {
if !dnsname.IsSubdomain(r.Name, name) {
continue
}
if r.Name == name && (r.Type == "NS" || r.Type == "DS") {
continue
}
if (r.Type == "A" || r.Type == "AAAA") && slices.Contains(ns, strings.ToLower(r.Name)) {
continue // glue
}
out = append(out, r)
}
return out
}
func deref(s *string) string {
@@ -636,58 +787,247 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
return netip.Addr{}, false
}
// ptrOps returns operations creating the PTR record for an address record.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) {
addr, ok := addrOf(p)
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
// PTR records follow address records per view (docs/reverse.md): after a
// request's operations (and those keeping linked zones in step), ptrSync
// compares each touched A or AAAA set before and after, in every view it
// is in, and changes the PTR records of that view's reverse zone to match.
// So a record local to one view of a linked zone changes PTR records in
// that view only, and an address that goes from one view keeps its PTR
// record in the views that still have it.
// ptrForce marks a name and type whose PTR records the request asked to
// update (ptr: true), in its zone and the zones linked with it.
func (x *expander) ptrForce(z *model.Zone, name, typ string) {
if x.ptrForced == nil {
x.ptrForced = map[rrKey]bool{}
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
return nil, nil
x.ptrForced[rrKey{z.ID, name, typ}] = true
for _, o := range x.st.LinkedWith(z) {
x.ptrForced[rrKey{o.ID, name, typ}] = true
}
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) {
if r.Disabled {
in := RecordInput{Disabled: model.Ptr(false)}
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
}
return nil, nil // already there
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
}
// Whether a set has an address: none, only in disabled records, enabled.
const (
addrNone = iota
addrDisabled
addrEnabled
)
// addrStates returns the addresses of a zone's A or AAAA set.
func addrStates(st *model.State, k rrKey) map[netip.Addr]int {
out := map[netip.Addr]int{}
for _, r := range st.RRset(k.zone, k.name, k.typ) {
a, err := netip.ParseAddr(r.Data)
if err != nil {
continue
}
s := addrDisabled
if !r.Disabled {
s = addrEnabled
}
out[a.Unmap()] = max(out[a.Unmap()], s)
}
return out
}
// ptrSync returns ops followed by the PTR changes they call for: for the
// zone's auto_ptr, or because the request asked (ptrForce).
func (x *expander) ptrSync(ops []model.Op) ([]model.Op, error) {
forced := x.ptrForced
x.ptrForced = nil
work := x.st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range ops {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
keys := affectedKeys(x.st, work, ops)
for _, k := range sortedForced(forced) {
if !slices.Contains(keys, k) {
keys = append(keys, k)
}
}
var extra []model.Op
for _, k := range keys {
z := work.Zones[k.zone]
if z == nil || (k.typ != "A" && k.typ != "AAAA") || strings.HasPrefix(k.name, "*.") {
continue
}
force := forced[k]
if !force && !z.AutoPTR {
continue
}
before, after := addrStates(x.st, k), addrStates(work, k)
var addrs []netip.Addr
for _, m := range []map[netip.Addr]int{before, after} {
for a := range m {
if !slices.Contains(addrs, a) {
addrs = append(addrs, a)
}
}
}
slices.SortFunc(addrs, func(a, b netip.Addr) int { return a.Compare(b) })
for _, a := range addrs {
was, is := before[a], after[a]
if !force && (was == is || was == addrNone && is == addrDisabled) {
continue
}
rz, ptrName := reverseZoneFor(work, z.ViewID, a)
if rz == nil {
if force && is == addrEnabled {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in %s, so no PTR record was added there (create one: jarl zone create %s/%d%s)",
a, viewOrServer(work, z.ViewID), a, a.BitLen(), viewFlag(work, z.ViewID)))
}
continue
}
more, err := x.ptrChange(work, rz, ptrName, k.name, is, !force)
if err != nil {
return nil, err
}
for _, op := range more {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
extra = append(extra, more...)
}
}
if len(extra) == 0 {
return ops, nil
}
// The PTR records are local (see ptrChange), so this only keeps a
// linked reverse zone consistent.
extra, err := x.linkSync(extra)
if err != nil {
return nil, err
}
return append(ops, extra...), nil
}
func sortedForced(m map[rrKey]bool) []rrKey {
var out []rrKey
for k := range m {
out = append(out, k)
}
slices.SortFunc(out, func(a, b rrKey) int {
if a.zone != b.zone {
return int(a.zone - b.zone)
}
if a.name != b.name {
return strings.Compare(a.name, b.name)
}
return strings.Compare(a.typ, b.typ)
})
return out
}
func viewFlag(st *model.State, viewID int64) string {
if v := st.Views[viewID]; v != nil {
return " --view " + v.Name
}
return ""
}
// ptrChange returns the operations that give reverse zone rz's PTR
// records at ptrName for host owner the state of its address: none
// (deleted), disabled, or enabled (added when missing).
//
// In a linked reverse zone the PTR records are local to each view, since
// each view's follow its own address records: a shared set at ptrName
// is made local in every view of the link first, as each view has it.
func (x *expander) ptrChange(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
nwarn := len(x.warnings)
ops, err := x.ptrChangeIn(st, rz, ptrName, owner, state, auto)
if err != nil || len(ops) == 0 || rz.LinkGroup == 0 {
return ops, err
}
var local []model.Op
for _, m := range st.LinkMembers(rz.LinkGroup) {
for _, r := range st.RRset(m.ID, ptrName, "PTR") {
if r.SharedID != 0 {
local = append(local, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
fmt.Sprintf("linked: %s PTR %s becomes local to %s (PTR records follow each view's own address records)", r.Name, r.Data, zoneDesc(st, m))))
}
}
}
if len(local) == 0 {
return ops, nil
}
// Again on the local records (their versions changed).
scratch := st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range local {
if err := applyOp(scratch, op, c); err != nil {
return nil, err
}
}
x.warnings = x.warnings[:nwarn]
ops, err = x.ptrChangeIn(scratch, rz, ptrName, owner, state, auto)
if err != nil {
return nil, err
}
return append(local, ops...), nil
}
func (x *expander) ptrChangeIn(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
var ops []model.Op
switch state {
case addrNone:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) {
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("delete %s PTR %s", r.Name, r.Data)))
}
}
case addrDisabled:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) && !r.Disabled {
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{Disabled: model.Ptr(true)}, fmt.Sprintf("disable %s PTR %s", r.Name, r.Data)))
}
}
default:
return x.ptrCreateIn(st, rz, ptrName, owner, auto)
}
return ops, nil
}
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled).
// auto is set when the zone's auto_ptr asked rather than the request: an
// address whose PTR record names another host keeps it then.
func (x *expander) ptrCreateIn(st *model.State, rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
var other string
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) {
if r.Disabled {
in := RecordInput{Disabled: model.Ptr(false)}
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
}
return nil, nil // already there
}
if !r.Disabled {
other = r.Data
}
}
if other != "" {
if auto {
// One name per address: the one it has stays.
return nil, nil
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, other))
}
ids, err := x.ids(1)
if err != nil {
return nil, err
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
if rz.LinkGroup != 0 {
in.SharedID = model.Ptr(int64(0))
}
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
}
// ptrSetDisabled returns operations disabling or enabling the PTR record
// of an address record.
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
}
var ops []model.Op
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
in := RecordInput{Disabled: model.Ptr(disabled)}
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data)))
}
}
return ops
}
func enableWord(disabled bool) string {
if disabled {
return "disable"
@@ -695,19 +1035,6 @@ func enableWord(disabled bool) string {
return "enable"
}
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
}
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
// The stored operation holds the canonical data, so applying it doesn't
// notice again.
@@ -734,7 +1061,12 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
return nil, err
}
x.parseWarnings(owner, p)
ptr := in.PTR
if in.PTR {
if _, ok := addrOf(p); !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
x.ptrForce(z, owner, p.Type)
}
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
ids, err := x.ids(1)
@@ -757,19 +1089,12 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
}
in.SharedID = &ids[0]
}
} else if in.Local != nil && *in.Local {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
} else if in.Local != nil && !*in.Local {
return nil, notLinked(x.st, z)
}
// local on a zone that isn't linked: its records are local anyway.
in.Local = nil
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
if ptr {
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, nil
}
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
@@ -790,10 +1115,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
}
var pre []model.Op
if in.Local != nil {
if z.LinkGroup == 0 {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
if z.LinkGroup == 0 && !*in.Local {
return nil, notLinked(x.st, z)
}
if want := *in.Local; want != (r.SharedID == 0) {
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
// The whole set of this name and type changes sides.
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
id := int64(0)
@@ -804,10 +1129,11 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
}
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
}
if !want {
// Sharing a version makes it every view's.
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
}
}
if z.LinkGroup != 0 && !*in.Local {
// Sharing a version makes it every view's, also when it is
// shared already and another view overrides it.
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
}
in.Local = nil
if string(mustJSON(in)) == "{}" {
@@ -885,29 +1211,26 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if len(pre) > 0 {
base = 0 // the record was just updated by pre
}
ops := append(pre, newOp(OpRecordUpdate, r.ID, base, in, summary))
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
ops := pre
if string(mustJSON(in)) != "{}" || !ptr {
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
}
moved := p.Data != r.Data || owner != r.Name
if ptr && moved {
if ptr {
// The PTR records of the old and the new record, whichever is an
// address (ptrSync).
_, isAddr := addrOf(p)
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if !disabled {
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
if _, ok := addrOf(old); ok {
isAddr = true
x.ptrForce(z, r.Name, r.Type)
}
ops = append(ops, more...)
}
}
if ptr && !moved && toggled {
if _, ok := addrOf(p); !ok {
if !isAddr {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
if _, ok := addrOf(p); ok {
x.ptrForce(z, owner, p.Type)
}
}
return ops, nil
}
@@ -931,10 +1254,7 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
}
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
if in.PTR {
z := x.st.Zones[r.ZoneID]
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
}
return ops, nil
}
@@ -970,14 +1290,12 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
if in.MissingOK {
what += " (if present)"
}
ptr := in.PTR
if in.PTR && parsed != nil {
x.ptrForce(z, owner, typ)
}
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type = &owner, &typ
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
if ptr && parsed != nil {
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
}
return ops, nil
return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, nil
}
// recordSetDisabled expands record.disable and record.enable: the record
@@ -1097,8 +1415,8 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
// Explicitly shared: this version becomes every view's.
pre = x.dropOverrides(z, owner, typ)
}
} else if in.Local != nil && *in.Local {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
} else if in.Local != nil && !*in.Local {
return nil, notLinked(x.st, z)
}
in.Local = nil
if len(canon) > 0 {
@@ -1300,3 +1618,22 @@ func firstNonEmptyStr(a, b string) string {
}
return b
}
// notLinked refuses sharing a record of a zone that isn't linked.
func notLinked(st *model.State, z *model.Zone) error {
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
}
// zoneUnchanged reports whether an update would leave the zone as it is.
// Errors are left for applying the update to report.
func zoneUnchanged(st *model.State, z *model.Zone, in *ZoneInput) bool {
if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 {
return false
}
c := st.Clone()
n := c.Zones[z.ID]
if err := setZoneFields(c, n, in, false); err != nil {
return false
}
return reflect.DeepEqual(n, z)
}
+46 -1
View File
@@ -59,8 +59,25 @@ const (
// into zone and record updates.
OpZoneLink = "zone.link"
OpZoneUnlink = "zone.unlink"
// Filling a reverse zone with the PTR records of its view's A and
// AAAA records (docs/reverse.md) becomes record.create operations.
OpZoneFillPTR = "zone.fill_ptr"
// Dismissing a reverse zone suggestion, and taking that back, become
// view.update or settings.update operations.
OpReverseDismiss = "reverse.dismiss"
OpReverseUndismiss = "reverse.undismiss"
)
// DismissInput dismisses a reverse zone suggestion: a network in a view
// (by ID or name), or the server's (neither given).
type DismissInput struct {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Prefix string `json:"prefix,omitempty"`
// Prefixes dismisses (or brings back) several at once.
Prefixes []string `json:"prefixes,omitempty"`
}
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
// (by ID or name), or of the server (neither given).
type AckInput struct {
@@ -142,6 +159,15 @@ type ZoneInput struct {
// name in this view, copying its shared records (or all of its
// records, when it isn't linked yet).
LinkWith string `json:"link_with,omitempty"`
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
// step (docs/reverse.md).
AutoPTR *bool `json:"auto_ptr,omitempty"`
// Fill (create only, reverse zones) adds a PTR record for every
// address of the view's A and AAAA records in the zone's range;
// FillOtherViews also for the addresses only other views' records
// use, named from there.
Fill bool `json:"fill,omitempty"`
FillOtherViews bool `json:"fill_other_views,omitempty"`
// Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to
@@ -174,7 +200,9 @@ type RecordInput struct {
// Disabled keeps the record in Jarl but out of the zone.
Disabled *bool `json:"disabled,omitempty"`
// PTR also creates (or deletes, disables or enables) the matching PTR
// record in the reverse zone, for A and AAAA records.
// record in the reverse zone, for A and AAAA records. On an update that
// changes nothing else it adds the PTR record if it is missing. In a
// linked zone, every linked view's reverse zone counts.
PTR bool `json:"ptr,omitempty"`
// MissingOK makes a delete-by-match succeed when nothing matches.
MissingOK bool `json:"missing_ok,omitempty"`
@@ -199,6 +227,13 @@ type LinkInput struct {
With []string `json:"with,omitempty"`
}
// FillPTRInput is zone.fill_ptr: OtherViews also fills the addresses only
// other views' records use, naming them from there.
type FillPTRInput struct {
ZoneID int64 `json:"zone_id,omitempty"`
OtherViews bool `json:"other_views,omitempty"`
}
// RRSetInput replaces all records of one name and type.
type RRSetInput struct {
ZoneID int64 `json:"zone_id,omitempty"`
@@ -235,6 +270,11 @@ type ViewInput struct {
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
// ReverseSuggestions turns the reverse zone suggestions for the
// view's addresses on or off (docs/reverse.md).
ReverseSuggestions *bool `json:"reverse_suggestions,omitempty"`
// DismissedReverse are the networks whose suggestion was dismissed.
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
}
// ReorderInput sets the order of views.
@@ -281,6 +321,11 @@ type SettingsInput struct {
// Exposure and Acknowledged are the server's (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
// DismissedReverse are dismissed reverse zone suggestions (without
// views).
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
// EncryptedDNSHosts are the encrypted DNS setup's host names.
EncryptedDNSHosts *[]string `json:"encrypted_dns_hosts,omitempty"`
}
// DeleteInput carries options for delete operations.
+55 -17
View File
@@ -106,8 +106,8 @@ func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
//
// - z overrides it (local records): z's shared copies go; the others
// keep theirs.
// - z's override was just removed: z gets the shared records back from
// a zone that has them.
// - z's override was just removed: z goes without the name and type,
// and the other zones' shared copies become local to each.
// - otherwise z's shared records are the truth: every zone that doesn't
// override the name and type gets the same (created, updated or
// deleted to match).
@@ -123,24 +123,40 @@ func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]
}
truth := st.SharedRecords(z.ID, name, typ)
members := st.LinkedWith(z)
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) && x.sharing[rrKey{z.ID, name, typ}] {
// Another view's version was shared (dropOverrides): z gets it.
for _, o := range members {
if src := st.SharedRecords(o.ID, name, typ); len(src) > 0 && !st.HasLocal(o.ID, name, typ) {
for _, r := range src {
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
if err != nil {
return nil, err
}
ops = append(ops, op)
}
break
}
}
return ops, nil
}
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
// The override was removed: back to the shared records.
// z's local records are gone, and with them the name and type from
// z: the other views keep theirs, each its own now (a shared set
// would be in z too). Share sets one version for all again.
for _, o := range members {
if st.HasLocal(o.ID, name, typ) {
continue
}
src := st.SharedRecords(o.ID, name, typ)
if len(src) == 0 {
continue
var more []model.Op
for _, r := range st.SharedRecords(o.ID, name, typ) {
more = append(more, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
fmt.Sprintf("linked: %s %s %s stays in %s, local to it now (%s has none)", name, typ, r.Data, where(o), where(z))))
}
for _, r := range src {
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
if err != nil {
return nil, err
}
ops = append(ops, op)
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
return nil, errf(KindForbidden, "deleting %s %s from %s makes it local in %s, which you may not change; ask someone who may to delete it, or share %s's version (jarl record update ID --shared)",
dnsname.Display(name), typ, where(z), where(o), where(o))
}
break
ops = append(ops, more...)
}
return ops, nil
}
@@ -440,12 +456,30 @@ func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
// localFor works out whether a new record (or set) in zone z is local:
// as asked, or like the records of the same name and type already there.
// A new name and type is shared, unless the principal may not change it
// in every linked view (an ACME token for the external view only): then
// it is local to z's view, which is all they may change.
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
has := x.st.HasLocal(z.ID, name, typ)
if want == nil {
return has, nil
if want != nil {
return *want, nil
}
return *want, nil
if x.st.HasLocal(z.ID, name, typ) {
return true, nil
}
if len(x.st.SharedRecords(z.ID, name, typ)) > 0 {
return false, nil
}
var not []string
for _, o := range x.st.LinkedWith(z) {
if !x.p.Can(recordAction(x.st, o, name, typ, true)) {
not = append(not, x.st.ViewName(o))
}
}
if len(not) > 0 {
x.warnings = append(x.warnings, fmt.Sprintf("%s %s is local to view %s: you may not change it in %s", dnsname.Display(name), typ, x.st.ViewName(z), strings.Join(not, ", ")))
return true, nil
}
return false, nil
}
// shareAllOps returns the operations that start a link with zone z: it
@@ -468,6 +502,10 @@ func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
for _, o := range x.st.LinkedWith(z) {
for _, r := range x.st.RRset(o.ID, name, typ) {
if r.SharedID == 0 {
if x.sharing == nil {
x.sharing = map[rrKey]bool{}
}
x.sharing[rrKey{o.ID, name, typ}] = true
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
}
}
+165 -3
View File
@@ -130,10 +130,15 @@ func TestLinkedZones(t *testing.T) {
if !slices.Contains(env.view("external"), "www A 192.0.2.11 S") || slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external"))
}
// Removing the override brings the shared records back.
// Removing the override removes www A from internal only: external
// keeps its record, local now. Sharing it brings it back to internal.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
t.Errorf("override removed: internal %q", env.view("internal"))
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "www A ") }) || !slices.Contains(env.view("external"), "www A 192.0.2.11 L") {
t.Errorf("override removed: internal %q external %q", env.view("internal"), env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "www", "A"), RecordInput{Local: model.Ptr(false)}))
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") || !slices.Contains(env.view("external"), "www A 192.0.2.11 S") {
t.Errorf("shared again: internal %q external %q", env.view("internal"), env.view("external"))
}
// Make a shared set local, then share another view's version.
@@ -285,3 +290,160 @@ func TestLinkExisting(t *testing.T) {
t.Errorf("shared from internal: external %q", env.view("external"))
}
}
// --local on a zone that isn't linked is fine (its records are local
// anyway); --shared there is refused.
func TestLocalOnUnlinkedZone(t *testing.T) {
env := linkedEnv(t)
local, shared := true, false
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("a"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.1"), Local: &local}))
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "b", Type: "A", Data: []string{"10.0.0.2"}, Local: &local}))
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.0.3"), Local: &local}))
if v := env.view("internal"); !slices.Contains(v, "a A 10.0.0.3 L") || !slices.Contains(v, "b A 10.0.0.2 L") {
t.Errorf("records: %q", v)
}
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("c"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.4"), Local: &shared})); !strings.Contains(e.Message, "nothing to share") {
t.Errorf("--shared on an unlinked zone: %v", e)
}
}
func TestCompareZones(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}))
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}))
st := env.state()
zones := st.ZonesNamed("example.com.")
st.SortByView(zones)
diff := map[string][]string{}
same := 0
for _, row := range CompareZones(st, zones) {
if row.Same {
same++
continue
}
var cells []string
for _, vs := range row.Views {
cells = append(cells, vs.View+"="+strings.Join(vs.Records, ",")+"/"+vs.Linked)
}
diff[row.Name+" "+row.Type] = cells
}
want := map[string][]string{
"www A": {"internal=192.0.2.10/shared", "external=203.0.113.10/local"},
"nas A": {"internal=10.0.0.20/local", "external=/"},
}
for k, v := range want {
if !slices.Equal(diff[k], v) {
t.Errorf("%s: %q, want %q", k, diff[k], v)
}
}
if len(diff) != 2 || same < 3 {
t.Errorf("differ %v, same %d", diff, same)
}
// "Use this version" on the shared one replaces external's override.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
if v := env.view("external"); !slices.Contains(v, "www A 192.0.2.10 S") || slices.Contains(v, "www A 203.0.113.10 L") {
t.Errorf("sharing the shared version: external %q", v)
}
}
func TestCompareReport(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin,
NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}),
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}),
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("printer"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.30"), Local: model.Ptr(true)}),
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("shop"), Type: model.Ptr("CNAME"), Data: model.Ptr("shops.example.net."), Local: model.Ptr(true)}))
st := env.state()
zones := []*model.Zone{st.FindZone("example.com.", st.ViewByName("internal").ID), st.FindZone("example.com.", st.ViewByName("external").ID)}
rep := Compare(st, zones, CompareFilter{})
if rep.Base != "internal" || !rep.Linked || !slices.Equal(rep.Views, []string{"internal", "external"}) {
t.Errorf("report %+v", rep)
}
if s := rep.Summary[0]; s.View != "external" || s.Different != 1 || s.OnlyBase != 2 || s.OnlyOther != 1 || s.Same != 2 {
t.Errorf("summary %+v", s)
}
if len(rep.Different) != 1 || rep.Different[0].Name != "www" {
t.Errorf("different %+v", rep.Different)
}
var only []string
for _, o := range rep.Only {
var names []string
for _, r := range o.Rows {
names = append(names, r.Name)
}
only = append(only, strings.Join(o.Views, "+")+": "+strings.Join(names, ","))
}
want(t, "only", only, "internal: nas,printer", "external: shop")
if rep.Same != 2 || rep.SameRows != nil {
t.Errorf("same %d, rows %v", rep.Same, rep.SameRows)
}
// One name: just its rows, the same ones included.
rep = Compare(st, zones, CompareFilter{Name: "www"})
if len(rep.Different) != 1 || len(rep.Only) != 0 || len(rep.SameRows) != 0 {
t.Errorf("www: %+v", rep)
}
rep = Compare(st, zones, CompareFilter{Name: "mail.example.com.", Type: "A"})
if len(rep.SameRows) != 1 || rep.SameRows[0].Name != "mail" || len(rep.Different)+len(rep.Only) != 0 {
t.Errorf("mail A: %+v", rep)
}
}
// A shared record made local in one view, then deleted there, is gone from
// that view only; the other view keeps it, local now.
func TestDeleteLocalOverride(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, recIn("internal", "a", "A", "192.0.2.50"))
if !slices.Contains(env.view("external"), "a A 192.0.2.50 S") {
t.Fatalf("not shared: %q", env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Local: model.Ptr(true)}))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil))
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "a A ") }) {
t.Errorf("internal still has a: %q", env.view("internal"))
}
if !slices.Contains(env.view("external"), "a A 192.0.2.50 L") {
t.Errorf("external: %q", env.view("external"))
}
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
t.Errorf("validation: %+v", probs)
}
}
// An ACME token for one view of a linked zone: its challenge records are
// local to that view, the zone is found without naming the view, and they
// go again without touching the other view.
func TestLinkedACMEToken(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
u := &model.User{Username: "certbot", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Zones: []string{"example.com"}, Views: []string{"external"}, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}}
if err := env.st.CreateUser(env.ctx, u); err != nil {
t.Fatal(err)
}
bot := &auth.Principal{User: u, Method: "token"}
name, typ, data := "_acme-challenge", "TXT", "token-value"
res := env.submit(bot, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: &name, Type: &typ, Data: &data}))
if !slices.Contains(env.view("external"), "_acme-challenge TXT \"token-value\" L") || slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "_acme-challenge") }) {
t.Errorf("challenge: internal %q external %q", env.view("internal"), env.view("external"))
}
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "local to view external") }) {
t.Errorf("warnings %q", res.Warnings)
}
env.submit(bot, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: &name, Type: &typ, Data: &data}))
if slices.ContainsFunc(env.view("external"), func(s string) bool { return strings.HasPrefix(s, "_acme-challenge") }) {
t.Errorf("not deleted: %q", env.view("external"))
}
// The admin, who may change both, still gets a shared record.
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("both"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.7")}))
if !slices.Contains(env.view("external"), "both A 192.0.2.7 S") {
t.Errorf("admin: %q", env.view("external"))
}
// Without a view, the admin is still asked which.
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.8")})); !strings.Contains(e.Message, "several views (internal, external)") {
t.Errorf("admin without a view: %v", e)
}
}
+16
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/netip"
"slices"
"strings"
"time"
@@ -268,6 +269,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.LinkGroup != nil {
z.LinkGroup = *in.LinkGroup
}
if in.AutoPTR != nil {
z.AutoPTR = *in.AutoPTR
}
return nil
}
@@ -805,6 +809,12 @@ func setViewFields(v *model.View, in *ViewInput) error {
if in.Acknowledged != nil {
v.Acknowledged = cleanAcks(*in.Acknowledged)
}
if in.ReverseSuggestions != nil {
v.ReverseSuggestionsOff = !*in.ReverseSuggestions
}
if in.DismissedReverse != nil {
v.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
return nil
}
@@ -1401,6 +1411,12 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
if in.Acknowledged != nil {
s.Acknowledged = cleanAcks(*in.Acknowledged)
}
if in.DismissedReverse != nil {
s.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
if in.EncryptedDNSHosts != nil {
s.EncryptedDNSHosts = slices.Clone(*in.EncryptedDNSHosts)
}
s.Version++
return nil
}
+53
View File
@@ -0,0 +1,53 @@
package engine
import (
"slices"
"strings"
"testing"
"jarl/internal/model"
)
// Ticking "update PTR" on an unchanged address record adds its PTR when
// the reverse zone is there (it was created after the record).
func TestUpdatePTRAddsMissing(t *testing.T) {
env := newEnv(t)
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}))
env.submit(env.admin, rec("example.com", "ldn", "A", "139.144.144.154"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32")}))
var id int64
for _, r := range env.state().Records {
if r.Name == "ldn.example.com." {
id = r.ID
}
}
env.submit(env.admin, NewRequest(OpRecordUpdate, id, RecordInput{PTR: true}))
got := env.records("154.144.144.139.in-addr.arpa.")
if !slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "PTR ldn.example.com.") }) {
t.Errorf("reverse zone = %q", got)
}
}
// In linked zones the PTR goes to the reverse zone of whichever linked
// view has one.
func TestUpdatePTRLinked(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, recIn("internal", "ldn", "A", "139.144.144.154"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32"), View: model.Ptr("external")}))
res := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true}))
st := env.state()
z := st.FindZone("154.144.144.139.in-addr.arpa.", st.ViewByName("external").ID)
ok := false
for _, r := range st.ZoneRecords(z.ID) {
ok = ok || r.Type == "PTR" && r.Data == "ldn.example.com."
}
if !ok {
t.Errorf("no PTR in external's reverse zone (warnings %q)", res.Warnings)
}
// Again: already there, nothing to do.
if e := env.submitErr(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true})); !strings.Contains(e.Message, "nothing to do") {
t.Errorf("second time: %v", e)
}
}
+565
View File
@@ -0,0 +1,565 @@
package engine
import (
"fmt"
"net/netip"
"slices"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/validate"
)
// Reverse zones made easy (docs/reverse.md): filling a reverse zone from
// the A and AAAA records of its view, choosing its view, and suggesting
// the reverse zones a view lacks.
// addrUse is an address a forward record gives a name.
type addrUse struct {
addr netip.Addr
name string // the owner name, absolute
apex bool
view string // another view's record (fillAddresses), else ""
}
// forwardAddresses returns the addresses of the enabled A and AAAA
// records of a view's primary forward zones (wildcards aside: a PTR record
// can't name them).
func forwardAddresses(st *model.State, viewID int64) []addrUse {
var out []addrUse
for _, z := range st.ZonesInView(viewID) {
if z.Type != model.ZonePrimary || z.Disabled || dnsname.IsReverse(z.Name) {
continue
}
for _, r := range st.ZoneRecords(z.ID) {
if r.Disabled || (r.Type != "A" && r.Type != "AAAA") || strings.HasPrefix(r.Name, "*.") {
continue
}
a, err := netip.ParseAddr(r.Data)
if err != nil {
continue
}
out = append(out, addrUse{addr: a.Unmap(), name: r.Name, apex: r.Name == z.Name})
}
}
return out
}
// fillAddresses returns a view's addresses (forwardAddresses), then those
// of the other views' records that none of its own records use, marked
// with their view: a reverse zone in the internal view can name a host
// only the external view has a record for.
func fillAddresses(st *model.State, viewID int64) []addrUse {
out := forwardAddresses(st, viewID)
if len(st.Views) == 0 {
return out
}
own := map[netip.Addr]bool{}
for _, u := range out {
own[u.addr] = true
}
for _, v := range st.OrderedViews() {
if v.ID == viewID {
continue
}
for _, u := range forwardAddresses(st, v.ID) {
if !own[u.addr] {
u.view = v.Name
out = append(out, u)
}
}
}
return out
}
// PTRFill is a PTR record filling a reverse zone would add.
type PTRFill struct {
Address string `json:"address"`
Name string `json:"name"` // the PTR owner name
Target string `json:"target"` // the host name it gives
Others []string `json:"others,omitempty"` // other names with this address
// From is the view whose record names the host, when the zone's own
// view has none for the address.
From string `json:"from,omitempty"`
}
// PTRFills returns the PTR records a reverse zone named zoneName in a view
// lacks for the addresses of the view's A and AAAA records: one per
// address, naming its mail or name server host when it is one, otherwise
// a host rather than the domain itself, then the shortest name. With
// otherViews, an address no record of the view uses is named from another
// view's records (fillAddresses), marked From; that is only on request,
// since it publishes one view's host names in another. existing are the
// zone's records (none for a new zone).
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record, otherViews bool) []PTRFill {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok {
return nil
}
has := map[string]bool{}
for _, r := range existing {
if r.Type == "PTR" {
has[r.Name] = true
}
}
servers := serverNames(st, viewID)
byAddr := map[netip.Addr][]addrUse{}
var order []netip.Addr
addrs := forwardAddresses(st, viewID)
if otherViews {
addrs = fillAddresses(st, viewID)
}
for _, u := range addrs {
if !prefix.Contains(u.addr) {
continue
}
if _, seen := byAddr[u.addr]; !seen {
order = append(order, u.addr)
}
if !slices.ContainsFunc(byAddr[u.addr], func(o addrUse) bool { return o.name == u.name }) {
byAddr[u.addr] = append(byAddr[u.addr], u)
}
}
slices.SortFunc(order, func(a, b netip.Addr) int { return a.Compare(b) })
var out []PTRFill
for _, a := range order {
owner := dnsname.ReverseName(a)
if has[owner] {
continue
}
uses := byAddr[a]
slices.SortStableFunc(uses, func(x, y addrUse) int {
rank := func(u addrUse) int {
switch {
case servers[u.name]:
return 0
case !u.apex:
return 1
}
return 2
}
if d := rank(x) - rank(y); d != 0 {
return d
}
if d := strings.Count(x.name, ".") - strings.Count(y.name, "."); d != 0 {
return d
}
return strings.Compare(x.name, y.name)
})
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name, From: uses[0].view}
for _, u := range uses[1:] {
if u.name != f.Target {
f.Others = append(f.Others, u.name)
}
}
out = append(out, f)
}
return out
}
// serverNames are the hosts the view's MX and NS records name.
func serverNames(st *model.State, viewID int64) map[string]bool {
out := map[string]bool{}
for _, z := range st.ZonesInView(viewID) {
if z.Type != model.ZonePrimary {
continue
}
for _, r := range st.ZoneRecords(z.ID) {
switch r.Type {
case "MX":
if f := strings.Fields(r.Data); len(f) == 2 {
out[strings.ToLower(f[1])] = true
}
case "NS":
out[strings.ToLower(r.Data)] = true
}
}
}
return out
}
func fillWarnings(fills []PTRFill) []string {
var out []string
for _, f := range fills {
if f.From != "" {
out = append(out, fmt.Sprintf("%s: only view %s has a record for it, so its PTR record gives %s from there", f.Address, f.From, dnsname.Display(f.Target)))
}
if len(f.Others) > 0 {
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
}
}
return out
}
func displayAll(names []string) []string {
out := make([]string, len(names))
for i, n := range names {
out[i] = dnsname.Display(n)
}
return out
}
// otherViewsHint says how many addresses only other views' records use in
// a reverse zone, and how to add them; "" when there are none.
func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []*model.Record, own int, how string) string {
n := len(PTRFills(st, viewID, zoneName, existing, true)) - own
if n <= 0 {
return ""
}
what := "addresses"
if n == 1 {
what = "address"
}
return fmt.Sprintf("%d %s in %s only other views' records use; %s", n, what, dnsname.Display(zoneName), how)
}
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
var in FillPTRInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
id := in.ZoneID
if id == 0 {
id = req.ID
}
z := x.st.Zones[id]
if z == nil {
return nil, errf(KindNotFound, "zone %d does not exist", id)
}
if z.Type != model.ZonePrimary {
return nil, errf(KindBadRequest, "zone %s isn't a primary zone", zoneDesc(x.st, z))
}
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
}
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), in.OtherViews)
if len(fills) == 0 {
msg := fmt.Sprintf("nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
if !in.OtherViews {
if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to name them from there, fill with other views (--other-views)"); h != "" {
msg += "; " + h
}
}
return nil, errf(KindBadRequest, "%s", msg)
}
x.warnings = append(x.warnings, fillWarnings(fills)...)
ids, err := x.ids(len(fills))
if err != nil {
return nil, err
}
var ops []model.Op
for i, f := range fills {
// Local to the view in a linked zone, like the PTR records that
// follow address records (ptrChange).
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
if z.LinkGroup != 0 {
ri.SharedID = model.Ptr(int64(0))
}
ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
}
return ops, nil
}
func viewOrServer(st *model.State, viewID int64) string {
if v := st.Views[viewID]; v != nil {
return "view " + v.Name
}
return "the server"
}
// publicViews returns which views the internet reaches.
func (x *expander) publicViews() map[int64]bool {
out := map[int64]bool{}
if x.exposures == nil {
return out
}
for _, e := range x.exposures(x.st) {
out[e.ViewID] = e.Public
}
return out
}
// reverseViewFor chooses the view of a new reverse zone given without one:
// the private view whose records use addresses in its range, or the only
// private view; views with reverse suggestions off don't count. nil with
// no error leaves the choice to the usual rules.
func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok || len(st.Views) == 0 {
return nil, nil
}
var private, using []*model.View
for _, v := range st.OrderedViews() {
if public[v.ID] || v.ReverseSuggestionsOff {
continue
}
private = append(private, v)
if slices.ContainsFunc(forwardAddresses(st, v.ID), func(u addrUse) bool { return prefix.Contains(u.addr) }) {
using = append(using, v)
}
}
switch {
case len(using) == 1:
return using[0], nil
case len(using) > 1:
var names []string
for _, v := range using {
names = append(names, v.Name)
}
return nil, errf(KindBadRequest, "views %s all have addresses in %s: give the view (or create the zone in each)", strings.Join(names, " and "), prefix)
case len(private) == 1:
return private[0], nil
}
return nil, nil
}
// ReverseSuggestion is a reverse zone a view lacks for addresses its
// records use.
type ReverseSuggestion struct {
ViewID int64 `json:"view_id"`
View string `json:"view,omitempty"`
Prefix string `json:"prefix"`
Zone string `json:"zone"`
// Group is "yours" for networks that are yours for sure (private
// ranges, IPv6 prefixes this server has an address in), "public" for
// a public address that may be someone else's (a web host's).
Group string `json:"group"`
// Hidden says why the suggestion isn't made unasked (with All only):
// "public view", "suggestions off" or "dismissed".
Hidden string `json:"hidden,omitempty"`
Why string `json:"why"`
Fill int `json:"fill"` // PTR records creating it with fill adds
Samples []string `json:"samples"` // a few of them, "address name"
}
// Suggestion groups.
const (
SuggestYours = "yours"
SuggestPublic = "public"
)
// ReverseSuggestions returns the reverse zones the private views (or the
// server, without views) lack for their A and AAAA records' addresses:
//
// - yours: the /24 of a private IPv4 address, the /48 of a ULA or of a
// public IPv6 prefix this server has an address in (local);
// - public: a /32 (or /128) for any other public address. It may be a
// web host's; the rest of its /24 surely belongs to others, and a
// zone for it here would hide their names from your clients.
//
// Public views, views with suggestions turned off and dismissed networks
// get none: the internet asks the addresses' owners.
func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []netip.Addr) []ReverseSuggestion {
return ReverseCandidates(st, exposures, local, ReverseOptions{})
}
// ReverseOptions widen or narrow ReverseCandidates.
type ReverseOptions struct {
// All includes what isn't suggested unasked, marked Hidden: public
// views, views with suggestions off, dismissed networks.
All bool
// View narrows it to one view (by name; "" all). With All, the view
// also gets the addresses only other views' records use, marked
// Hidden "records in VIEW": a reverse zone there can name them.
View string
}
// ReverseCandidates is ReverseSuggestions with options: with All, every
// network the views' records use that no reverse zone in their view
// covers, for creating one by hand where suggestions are off.
func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []netip.Addr, opt ReverseOptions) []ReverseSuggestion {
public := map[int64]bool{}
for _, e := range exposures {
public[e.ViewID] = e.Public
}
type scope struct {
id int64
dismissed []string
hidden string
}
var scopes []scope
if len(st.Views) > 0 {
for _, v := range st.OrderedViews() {
if opt.View != "" && !strings.EqualFold(v.Name, opt.View) {
continue
}
hidden := ""
switch {
case public[v.ID]:
hidden = "public view"
case v.ReverseSuggestionsOff:
hidden = "suggestions off"
}
if hidden == "" || opt.All {
scopes = append(scopes, scope{v.ID, v.DismissedReverse, hidden})
}
}
} else if !public[0] || opt.All {
hidden := ""
if public[0] {
hidden = "public server"
}
scopes = append(scopes, scope{0, st.Settings.DismissedReverse, hidden})
}
out := []ReverseSuggestion{}
for _, sc := range scopes {
seen := map[netip.Prefix]bool{}
addrs := forwardAddresses(st, sc.id)
if opt.All && opt.View != "" {
addrs = fillAddresses(st, sc.id)
}
for _, u := range addrs {
a := u.addr
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
continue
}
if rz, _ := reverseZoneFor(st, sc.id, a); rz != nil {
continue
}
p, group, why := suggestedPrefix(a, local)
hidden := sc.hidden
if hidden == "" && u.view != "" {
hidden = "records in " + u.view
}
if seen[p] {
continue
}
if slices.Contains(sc.dismissed, p.String()) {
if !opt.All {
continue
}
if hidden == "" {
hidden = "dismissed"
}
}
seen[p] = true
name, err := dnsname.ReverseZone(p)
if err != nil {
continue
}
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
fills := PTRFills(st, sc.id, name, nil, opt.All && opt.View != "")
s.Fill = len(fills)
for i, f := range fills {
if i == 3 {
break
}
s.Samples = append(s.Samples, f.Address+" "+dnsname.Display(f.Target))
}
out = append(out, s)
}
}
slices.SortStableFunc(out, func(a, b ReverseSuggestion) int {
if a.Group != b.Group {
return strings.Compare(b.Group, a.Group) // yours first
}
if a.View != b.View {
return strings.Compare(a.View, b.View)
}
return netip.MustParsePrefix(a.Prefix).Addr().Compare(netip.MustParsePrefix(b.Prefix).Addr())
})
return out
}
// suggestedPrefix returns the reverse zone network for an address, its
// group and why.
func suggestedPrefix(a netip.Addr, local []netip.Addr) (netip.Prefix, string, string) {
switch {
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
return netip.PrefixFrom(a, 24).Masked(), SuggestYours, "private network"
case a.Is4():
return netip.PrefixFrom(a, 32), SuggestPublic, "a public address: create it only if it is yours (not a web host's); the rest of its /24 belongs to others"
case a.IsPrivate():
return netip.PrefixFrom(a, 48).Masked(), SuggestYours, "your ULA /48 (one zone for all its subnets)"
}
p := netip.PrefixFrom(a, 48).Masked()
if slices.ContainsFunc(local, func(l netip.Addr) bool { return p.Contains(l.Unmap()) }) {
return p, SuggestYours, "your IPv6 /48 (this server has an address in it): one zone for all its subnets"
}
return netip.PrefixFrom(a, 128), SuggestPublic, "a public address outside this server's networks: create it only if it is yours"
}
// ReverseSuggestions lists the reverse zones the views lack (opt.All:
// also where suggestions aren't made unasked).
func (e *Engine) ReverseSuggestions(st *model.State, opt ReverseOptions) []ReverseSuggestion {
var local []netip.Addr
if e.cfg.LocalAddrs != nil {
local = e.cfg.LocalAddrs()
}
return ReverseCandidates(st, e.Exposures(st), local, opt)
}
// reverseDismiss expands reverse.dismiss and reverse.undismiss.
func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error) {
var in DismissInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
raw := in.Prefixes
if in.Prefix != "" {
raw = append(raw, in.Prefix)
}
if len(raw) == 0 {
return nil, errf(KindBadRequest, "give the network (prefix) of the suggestion")
}
var prefixes []string
for _, r := range raw {
p, err := netip.ParsePrefix(strings.TrimSpace(r))
if err != nil {
return nil, errf(KindBadRequest, "prefix: %v", err)
}
if s := p.Masked().String(); !slices.Contains(prefixes, s) {
prefixes = append(prefixes, s)
}
}
var v *model.View
switch {
case in.ViewID != 0:
v = x.st.Views[in.ViewID]
case in.View != "":
v = x.st.ViewByName(in.View)
}
if (in.ViewID != 0 || in.View != "") && v == nil {
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
}
if v == nil && len(x.st.Views) > 0 {
return nil, errf(KindBadRequest, "give the view of the suggestion")
}
list, where := x.st.Settings.DismissedReverse, "the server"
if v != nil {
list, where = v.DismissedReverse, "view "+v.Name
}
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return slices.Contains(prefixes, s) })
what := prefixes[0]
if len(prefixes) > 1 {
what = fmt.Sprintf("%d networks (%s, …)", len(prefixes), prefixes[0])
}
summary := "dismiss the reverse zone suggestion for " + what + " in " + where
if dismiss {
out = append(out, prefixes...)
slices.Sort(out)
} else {
if len(out) == len(list) {
return nil, errf(KindConflict, "%s isn't dismissed in %s", what, where)
}
summary = "suggest a reverse zone for " + what + " in " + where + " again"
}
if out == nil {
out = []string{}
}
if v != nil {
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{DismissedReverse: &out}, summary)}, nil
}
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{DismissedReverse: &out}, summary)}, nil
}
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
func viewName(st *model.State, id int64) string {
if v := st.Views[id]; v != nil {
return v.Name
}
return ""
}
+427
View File
@@ -0,0 +1,427 @@
package engine
import (
"net/netip"
"slices"
"strings"
"testing"
"jarl/internal/model"
)
// ptrs returns a reverse zone's PTR records in a view as "owner target",
// with " disabled" when so.
func (env *testEnv) ptrs(view, zone string) []string {
env.t.Helper()
s := env.state()
z := s.FindZone(zone, s.ViewByName(view).ID)
if z == nil {
env.t.Fatalf("no zone %s in %s", zone, view)
}
var out []string
for _, r := range s.ZoneRecords(z.ID) {
if r.Type == "PTR" {
d := r.Name + " " + r.Data
if r.Disabled {
d += " disabled"
}
out = append(out, d)
}
}
return out
}
func TestReverseFillAndSuggest(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin,
recIn("internal", "@", "A", "192.0.2.25"),
recIn("internal", "nas", "A", "10.0.10.20"),
recIn("internal", "files", "A", "10.0.10.20"),
recIn("internal", "nas", "AAAA", "fdab:abab:abab:10::20"),
recIn("internal", "*.dev", "A", "10.0.10.99"),
recIn("internal", "lo", "A", "127.0.0.1"))
sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil)
var got []string
for _, s := range sugg {
got = append(got, s.Group+" "+s.View+" "+s.Prefix+" "+s.Zone)
}
want(t, "suggestions", got,
"yours internal 10.0.10.0/24 10.0.10.in-addr.arpa",
"yours internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa",
"public internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
"public internal 192.0.2.25/32 25.2.0.192.in-addr.arpa")
if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" {
t.Errorf("10.0.10.0/24: %+v", sugg[0])
}
// No view given: the private view whose records use the addresses.
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), Fill: true}))
want(t, "10.0.10/24", env.ptrs("internal", "10.0.10.in-addr.arpa."), "20.10.0.10.in-addr.arpa. files.example.com.")
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "10.0.10.20 has several names") }) {
t.Errorf("warnings %q", res.Warnings)
}
// The mail server's name beats the domain's own.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
want(t, "192.0.2.25", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
// Filling an existing zone; then there is nothing left to add.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
st := env.state()
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{}))
if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
t.Errorf("ip6 fill: %q", p)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") {
t.Errorf("second fill: %v", e)
}
got = got[:0]
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
got = append(got, s.Prefix)
}
want(t, "suggestions left", got, "192.0.2.10/32")
// Addresses that two private views use: the view must be given.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("lab"), MatchClients: &[]string{"172.16.0.0/12"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.test"), View: model.Ptr("lab")}),
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "lab.test", View: "lab", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.10")}))
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.10/32")})); !strings.Contains(e.Message, "views internal and lab all have addresses") {
t.Errorf("two views: %v", e)
}
}
func TestAutoPTR(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
st := env.state()
zid := st.FindZone("example.com.", st.ViewByName("internal").ID).ID
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(true)}))
rz := "10.0.10.in-addr.arpa."
res := env.submit(env.admin, recIn("internal", "host", "A", "10.0.10.30"), recIn("internal", "pub", "A", "203.0.113.5"))
want(t, "added", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
if len(res.Warnings) > 0 {
t.Errorf("no reverse zone for 203.0.113.5 isn't worth a warning: %q", res.Warnings)
}
// A second name for the address: its PTR stays.
res = env.submit(env.admin, recIn("internal", "alias", "A", "10.0.10.30"))
want(t, "second name", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
if len(res.Warnings) > 0 {
t.Errorf("warnings %q", res.Warnings)
}
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "alias", "A"), nil))
// Moved, disabled, enabled, deleted.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Data: model.Ptr("10.0.10.31")}))
want(t, "moved", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(true)}))
want(t, "disabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com. disabled")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(false)}))
want(t, "enabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "host", "A"), nil))
want(t, "deleted", env.ptrs("internal", rz))
// A whole set replaced.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.40", "10.0.10.41"}}))
want(t, "set", env.ptrs("internal", rz), "40.10.0.10.in-addr.arpa. multi.example.com.", "41.10.0.10.in-addr.arpa. multi.example.com.")
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.41"}}))
want(t, "set shrunk", env.ptrs("internal", rz), "41.10.0.10.in-addr.arpa. multi.example.com.")
// Deleting by name and type.
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("multi"), Type: model.Ptr("A")}))
want(t, "deleted by match", env.ptrs("internal", rz))
// Off again: records come and go alone.
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(false)}))
env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50"))
want(t, "off", env.ptrs("internal", rz))
}
func suggested(env *testEnv, local ...netip.Addr) []string {
var out []string
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), local) {
out = append(out, s.Group+" "+s.View+" "+s.Prefix)
}
return out
}
// Public IPv6: the /48 only when this server has an address in it, else
// the address alone, as a public suggestion. Dismissed networks and views
// with suggestions off get none.
func TestReverseSuggestionGroups(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin,
recIn("internal", "home", "AAAA", "2a02:8013:7f3d:10::5"),
recIn("internal", "gh", "AAAA", "2606:50c0:8000::153"))
want(t, "no local addresses", suggested(env),
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32",
"public internal 2606:50c0:8000::153/128", "public internal 2a02:8013:7f3d:10::5/128")
want(t, "this server is in 2a02:8013:7f3d::/48", suggested(env, netip.MustParseAddr("2a02:8013:7f3d:10:1266:6aff:fef9:f5b1")),
"yours internal 2a02:8013:7f3d::/48",
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32", "public internal 2606:50c0:8000::153/128")
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
if got := suggested(env); slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("dismissed still suggested: %q", got)
}
if e := env.submitErr(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"})); e.Kind != KindConflict {
t.Errorf("undismiss of one not dismissed: %v", e)
}
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
// Several at once.
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) {
t.Errorf("dismissed together, still suggested: %q", got)
}
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("undismissed not suggested: %q", got)
}
// A second private view using the same address (a VPS's view): with
// its suggestions off it gets none, and doesn't count when choosing
// the view of a new reverse zone.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("vps"), MatchClients: &[]string{"198.51.100.7"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("vps"), LinkWith: "internal"}))
if got := suggested(env); !slices.Contains(got, "public vps 192.0.2.25/32") {
t.Errorf("vps view: %q", got)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32")})); !strings.Contains(e.Message, "views internal and vps") {
t.Errorf("two views: %v", e)
}
vps := env.state().ViewByName("vps")
env.submit(env.admin, NewRequest(OpViewUpdate, vps.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, " vps ") }) {
t.Errorf("vps view with suggestions off: %q", got)
}
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
want(t, "went to internal", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
}
// A zone created inside another takes the parent's records under it.
func TestChildZoneTakesRecords(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.0.0/8"), View: model.Ptr("internal")}))
ptr := func(name, data string) Request {
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "10.in-addr.arpa", View: "internal", Name: &name, Type: model.Ptr("PTR"), Data: &data})
}
env.submit(env.admin, ptr("20.10.0.10.in-addr.arpa.", "printer.example.com."), ptr("5.20.0.10.in-addr.arpa.", "other.example.com."))
env.submit(env.admin, recIn("internal", "nas", "A", "10.0.10.30"))
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal"), Fill: true}))
want(t, "/24", env.ptrs("internal", "10.0.10.in-addr.arpa."),
"20.10.0.10.in-addr.arpa. printer.example.com.", "30.10.0.10.in-addr.arpa. nas.example.com.")
want(t, "/8 keeps the rest", env.ptrs("internal", "10.in-addr.arpa."), "5.20.0.10.in-addr.arpa. other.example.com.")
for _, p := range res.Problems {
if p.Code == "shadowed_by_zone" {
t.Errorf("still hidden records: %v", p)
}
}
// A forward child: its delegation and glue stay in the parent.
env.submit(env.admin,
recIn("internal", "lab", "NS", "ns.lab.example.com."),
recIn("internal", "ns.lab", "A", "10.0.30.1"),
recIn("internal", "www.lab", "A", "10.0.30.2"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.example.com"), View: model.Ptr("internal")}))
st := env.state()
lab := st.FindZone("lab.example.com.", st.ViewByName("internal").ID)
var labRecs []string
for _, r := range st.ZoneRecords(lab.ID) {
if r.Type != "NS" {
labRecs = append(labRecs, r.Name+" "+r.Type)
}
}
want(t, "lab", labRecs, "www.lab.example.com. A")
v := env.view("internal") // without NS records
if !slices.Contains(v, "ns.lab A 10.0.30.1 L") || slices.ContainsFunc(v, func(s string) bool { return strings.HasPrefix(s, "www.lab ") }) {
t.Errorf("parent after: %q", v)
}
if env.recordID("internal", "lab", "NS") == 0 {
t.Error("the delegation left the parent")
}
}
// With All, the networks no suggestion is made for unasked are listed too,
// marked: views with suggestions off, dismissed ones, public views.
func TestReverseCandidatesAll(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "nas", Type: "AAAA", Data: []string{"2a02:8013:7f3d:10::5"}, Local: model.Ptr(true)}))
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"}))
internal := env.state().ViewByName("internal")
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
list := func(opt ReverseOptions) []string {
var out []string
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, opt) {
out = append(out, s.View+" "+s.Prefix+" "+s.Hidden)
}
return out
}
want(t, "unasked", list(ReverseOptions{}))
want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}),
"internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off")
got := list(ReverseOptions{All: true, View: "external"})
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 public view")
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)}))
want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}),
"internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ")
}
// An address only another view has a record for: a reverse zone in the
// internal view is offered for it, and filled from that view's record.
func TestReverseFromOtherView(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)}))
var got []string
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
got = append(got, s.Prefix+" "+s.Hidden)
}
if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") {
t.Errorf("internal candidates %q", got)
}
// Not suggested unasked: the internal view's own records don't use it.
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
if strings.HasPrefix(s.Prefix, "2600:") {
t.Errorf("suggested unasked: %+v", s)
}
}
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
if strings.HasPrefix(s.Prefix, "2600:") && (s.Fill != 1 || len(s.Samples) != 1) {
t.Errorf("candidate counts the other view's address: %+v", s)
}
}
// Filling doesn't take names from other views unasked: it would
// publish one view's host names in another. It says how to.
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
st := env.state()
var z *model.Zone
for _, o := range st.ZonesInView(st.ViewByName("internal").ID) {
if strings.HasSuffix(o.Name, ".ip6.arpa.") {
z = o
}
}
if p := env.ptrs("internal", z.Name); len(p) != 0 {
t.Errorf("filled from another view unasked: %q", p)
}
if !slices.ContainsFunc(res.Warnings, func(w string) bool {
return strings.Contains(w, "1 address in") && strings.Contains(w, "--other-views")
}) {
t.Errorf("warnings %q", res.Warnings)
}
// Not missing: no record of the zone's own view uses the address.
if f := PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false); len(f) != 0 {
t.Errorf("missing %+v", f)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") || !strings.Contains(e.Message, "--other-views") {
t.Errorf("fill-ptr: %v", e)
}
res = env.submit(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{OtherViews: true}))
if p := env.ptrs("internal", z.Name); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
t.Errorf("filled %q", p)
}
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
t.Errorf("warnings %q", res.Warnings)
}
}
// ptrsMarked is ptrs with S (shared) or L (local) after each record.
func (env *testEnv) ptrsMarked(view, zone string) []string {
env.t.Helper()
s := env.state()
z := s.FindZone(zone, s.ViewByName(view).ID)
var out []string
for _, r := range s.ZoneRecords(z.ID) {
if r.Type == "PTR" {
mark := " L"
if r.SharedID != 0 {
mark = " S"
}
out = append(out, strings.TrimSuffix(r.Name, ".10.0.10.in-addr.arpa.")+" "+strings.TrimSuffix(r.Data, ".example.com.")+mark)
}
}
return out
}
// Auto-PTR with linked forward and reverse zones: PTR records follow each
// view's own address records, and are local to their view.
func TestAutoPTRLinked(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("external"), LinkWith: "internal"}))
st := env.state()
for _, v := range []string{"internal", "external"} {
env.submit(env.admin, NewRequest(OpZoneUpdate, st.FindZone("example.com.", st.ViewByName(v).ID).ID, ZoneInput{AutoPTR: model.Ptr(true)}))
}
rz := "10.0.10.in-addr.arpa."
local := func(view, name, data string) Request {
r := recIn(view, name, "A", data)
var in RecordInput
_ = decode(r.Data, &in)
in.Local = model.Ptr(true)
return NewRequest(OpRecordCreate, 0, in)
}
// A local record: a local PTR record in its own view only.
env.submit(env.admin, local("internal", "a", "10.0.10.1"))
want(t, "local a internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "local a external", env.ptrsMarked("external", rz))
// The other view adds the same name (shared, as it has no local
// one): its own PTR record; deleting it in one view leaves the other.
env.submit(env.admin, recIn("external", "a", "A", "10.0.10.1"))
want(t, "both internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "both external", env.ptrsMarked("external", rz), "1 a L")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil))
want(t, "deleted in external: internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "deleted in external: external", env.ptrsMarked("external", rz))
// Both local; one changes its address, the other keeps its PTR.
env.submit(env.admin, local("external", "a", "10.0.10.1"))
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.10.2")}))
want(t, "moved internal", env.ptrsMarked("internal", rz), "2 a L")
want(t, "moved external", env.ptrsMarked("external", rz), "1 a L")
// Disabling in one view disables there only.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "a", "A"), RecordInput{Disabled: model.Ptr(true)}))
want(t, "disabled external", env.ptrs("external", rz), "1.10.0.10.in-addr.arpa. a.example.com. disabled")
want(t, "disabled: internal", env.ptrsMarked("internal", rz), "2 a L")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil))
want(t, "gone internal", env.ptrsMarked("internal", rz))
want(t, "gone external", env.ptrsMarked("external", rz))
// A shared record: a local PTR in each view; deleting it removes both.
env.submit(env.admin, recIn("internal", "b", "A", "10.0.10.3"))
want(t, "shared internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "shared external", env.ptrsMarked("external", rz), "3 b L")
// Overridden in one view with another address: that view's PTR moves.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")}))
want(t, "override internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "override external", env.ptrsMarked("external", rz), "4 b L")
// Sharing internal's version: external gets it back, and its PTR.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "b", "A"), RecordInput{Local: model.Ptr(false)}))
want(t, "shared again internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "shared again external", env.ptrsMarked("external", rz), "3 b L")
// The override deleted: external goes without b (and its PTR);
// internal keeps it, local now.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")}))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "b", "A"), nil))
want(t, "override gone internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "override gone external", env.ptrsMarked("external", rz))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "b", "A"), nil))
want(t, "b gone internal", env.ptrsMarked("internal", rz))
// A shared PTR record from before (shared by hand) becomes local in
// each view when one view's address goes.
env.submit(env.admin, recIn("internal", "c", "A", "10.0.10.5"))
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR")}))
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "external", Name: model.Ptr("5"), Type: model.Ptr("PTR")}))
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR"), Data: model.Ptr("c.example.com."), Local: model.Ptr(false)}))
want(t, "shared ptr external", env.ptrsMarked("external", rz), "5 c S")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "c", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.6")}))
want(t, "unshared internal", env.ptrsMarked("internal", rz), "5 c L")
want(t, "unshared external", env.ptrsMarked("external", rz), "6 c L")
}
+6
View File
@@ -66,3 +66,9 @@ func (e *Engine) ViewKey(ctx context.Context, st *model.State, z *model.Zone) *n
}
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
}
// GeneratedStatements lists what Jarl adds to named.conf by itself
// (render.GeneratedStatements).
func (e *Engine) GeneratedStatements(ctx context.Context, st *model.State) []render.Generated {
return render.GeneratedStatements(st, e.cfg.Paths, e.extras(ctx))
}
+339
View File
@@ -0,0 +1,339 @@
// Package mailcheck checks the reverse DNS of the addresses that send and
// receive mail for your public zones (docs/exposure.md#mail): receivers
// such as Gmail and Outlook reject or mark mail from an address without a
// PTR record, or whose PTR name doesn't resolve back to it. Those records
// belong to whoever owns the address (your hosting provider or ISP), not
// to Jarl, so the check asks the internet and says who to ask.
package mailcheck
import (
"context"
"errors"
"fmt"
"net/netip"
"slices"
"strings"
"time"
"github.com/miekg/dns"
"golang.org/x/net/publicsuffix"
"jarl/internal/model"
)
// Target is an address mail comes from or goes to, and why it is checked.
type Target struct {
Zone string `json:"zone"`
Address string `json:"address"`
Roles []string `json:"roles"` // e.g. "MX mail.adbraun.lol", "SPF ip4"
Host string `json:"host,omitempty"` // the MX host name, when it is one
}
// Result is the check of one address.
type Result struct {
Target
PTR []string `json:"ptr"` // the names its PTR records give
// OK: a PTR name resolves back to the address (forward-confirmed).
OK bool `json:"ok"`
// Level is ok, info or warning; Message says what to do.
Level string `json:"level"`
Message string `json:"message"`
// ReverseZone and ManagedBy say where the PTR record lives.
ReverseZone string `json:"reverse_zone,omitempty"`
ManagedBy string `json:"managed_by,omitempty"`
// LocalZone is a reverse zone for the address in Jarl ("view/zone"),
// which the internet doesn't ask unless the address's owner delegated
// it to this server.
LocalZone string `json:"local_zone,omitempty"`
}
// Report is a whole check.
type Report struct {
Checked time.Time `json:"checked"`
Results []Result `json:"results"`
// Errors are lookups that failed (no internet, say).
Errors []string `json:"errors,omitempty"`
}
// Targets finds the addresses to check in the public zones: the MX hosts
// (receiving) and the SPF record's addresses (sending). Names are looked
// up later, through the resolver.
func Targets(st *model.State, public func(viewID int64) bool) (addrs []Target, names []Target) {
for _, z := range st.SortedZones() {
if z.Type != model.ZonePrimary || z.Disabled || !public(z.ViewID) {
continue
}
zone := strings.TrimSuffix(z.Name, ".")
for _, r := range st.ZoneRecords(z.ID) {
if r.Name != z.Name || r.Disabled {
continue
}
switch r.Type {
case "MX":
f := strings.Fields(r.Data)
if len(f) == 2 && f[1] != "." {
names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(strings.ToLower(f[1]), "."), Roles: []string{"MX " + strings.TrimSuffix(f[1], ".")}})
}
case "TXT":
text := strings.ReplaceAll(strings.ReplaceAll(r.Data, `" "`, ""), `"`, "")
if !strings.HasPrefix(strings.ToLower(text), "v=spf1") {
continue
}
for _, mech := range strings.Fields(text)[1:] {
m := strings.ToLower(strings.TrimLeft(mech, "+?~"))
switch {
case strings.HasPrefix(m, "ip4:"), strings.HasPrefix(m, "ip6:"):
v := m[4:]
p, err := netip.ParsePrefix(v)
if err != nil {
if a, err := netip.ParseAddr(v); err == nil {
p = netip.PrefixFrom(a, a.BitLen())
} else {
continue
}
}
if p.IsSingleIP() {
addrs = append(addrs, Target{Zone: zone, Address: p.Addr().String(), Roles: []string{"SPF " + m[:3]}})
}
case m == "a":
names = append(names, Target{Zone: zone, Host: zone, Roles: []string{"SPF a"}})
case strings.HasPrefix(m, "a:"):
names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(m[2:], "."), Roles: []string{"SPF " + m}})
}
}
}
}
}
return addrs, names
}
// Resolver asks recursive resolvers (the public ones of acme.resolvers).
type Resolver struct {
Servers []string
Timeout time.Duration
}
func (r Resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) {
if len(r.Servers) == 0 {
return nil, errors.New("no resolvers configured (acme.resolvers)")
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
m.SetEdns0(1232, false)
t := r.Timeout
if t == 0 {
t = 5 * time.Second
}
var last error
for _, s := range r.Servers {
in, _, err := (&dns.Client{Timeout: t}).ExchangeContext(ctx, m, s)
if err == nil && in.Truncated {
in, _, err = (&dns.Client{Timeout: t, Net: "tcp"}).ExchangeContext(ctx, m, s)
}
if err == nil && in.Rcode != dns.RcodeServerFailure && in.Rcode != dns.RcodeRefused {
return in, nil
}
if err != nil {
last = fmt.Errorf("%s: %w", s, err)
} else {
last = fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode])
}
}
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], last)
}
// addresses returns a name's A and AAAA addresses (after CNAMEs).
func (r Resolver) addresses(ctx context.Context, name string) ([]netip.Addr, bool, error) {
var out []netip.Addr
alias := false
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
in, err := r.query(ctx, name, t)
if err != nil {
return nil, false, err
}
for _, rr := range in.Answer {
switch v := rr.(type) {
case *dns.A:
a, _ := netip.AddrFromSlice(v.A.To4())
out = append(out, a)
case *dns.AAAA:
a, _ := netip.AddrFromSlice(v.AAAA)
out = append(out, a)
case *dns.CNAME:
alias = true
}
}
}
return out, alias, nil
}
// Check looks up every target's reverse DNS.
func Check(ctx context.Context, st *model.State, public func(int64) bool, res Resolver) *Report {
rep := &Report{Checked: time.Now(), Results: []Result{}}
addrs, names := Targets(st, public)
// Names to addresses, as the internet sees them.
for _, n := range names {
as, _, err := res.addresses(ctx, n.Host)
if err != nil {
rep.Errors = append(rep.Errors, err.Error())
continue
}
if len(as) == 0 {
rep.Errors = append(rep.Errors, fmt.Sprintf("%s (%s, %s) has no address as the internet sees it: not published yet, or a typo?", n.Host, n.Zone, strings.Join(n.Roles, ", ")))
}
for _, a := range as {
t := n
t.Address = a.String()
addrs = append(addrs, t)
}
}
// One result per address, with every reason it is checked.
merged := map[string]*Target{}
var order []string
for _, t := range addrs {
k := t.Zone + "|" + t.Address
if m := merged[k]; m != nil {
for _, role := range t.Roles {
if !slices.Contains(m.Roles, role) {
m.Roles = append(m.Roles, role)
}
}
if m.Host == "" {
m.Host = t.Host
}
continue
}
t := t
merged[k] = &t
order = append(order, k)
}
for _, k := range order {
r := checkOne(ctx, *merged[k], res)
r.LocalZone = localReverse(st, r.Address, r.ReverseZone)
rep.Results = append(rep.Results, r)
}
return rep
}
// localReverse returns a reverse zone in Jarl that covers the address,
// unless it is the zone the internet asks (delegated to this server).
func localReverse(st *model.State, addr, internetZone string) string {
rev, err := dns.ReverseAddr(addr)
if err != nil {
return ""
}
rev = strings.ToLower(rev)
best := ""
var bz *model.Zone
for _, z := range st.SortedZones() {
n := strings.ToLower(dns.Fqdn(z.Name))
if z.Type == model.ZonePrimary && dns.IsSubDomain(n, rev) && len(n) > len(best) {
best, bz = n, z
}
}
if bz == nil || strings.EqualFold(strings.TrimSuffix(best, "."), internetZone) {
return ""
}
if v := st.ViewName(bz); v != "" {
return v + "/" + strings.TrimSuffix(bz.Name, ".")
}
return strings.TrimSuffix(bz.Name, ".")
}
func checkOne(ctx context.Context, t Target, res Resolver) Result {
r := Result{Target: t, PTR: []string{}}
a, err := netip.ParseAddr(t.Address)
if err != nil {
r.Level, r.Message = "warning", err.Error()
return r
}
rev, _ := dns.ReverseAddr(a.String())
in, err := res.query(ctx, rev, dns.TypePTR)
if err != nil {
r.Level, r.Message = "warning", err.Error()
return r
}
// Who manages the reverse zone: the SOA in the answer.
for _, sec := range [][]dns.RR{in.Ns, in.Answer} {
for _, rr := range sec {
if soa, ok := rr.(*dns.SOA); ok {
r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns)
}
}
}
if r.ReverseZone == "" {
if s, err := res.query(ctx, rev, dns.TypeSOA); err == nil {
for _, sec := range [][]dns.RR{s.Answer, s.Ns} {
for _, rr := range sec {
if soa, ok := rr.(*dns.SOA); ok {
r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns)
}
}
}
}
}
for _, rr := range in.Answer {
if p, ok := rr.(*dns.PTR); ok {
r.PTR = append(r.PTR, strings.TrimSuffix(strings.ToLower(p.Ptr), "."))
}
}
where := "whoever runs the address's reverse zone (your hosting provider or ISP)"
if r.ManagedBy != "" {
where = "the reverse zone's owner (" + r.ManagedBy + ": your hosting provider's or ISP's panel)"
}
if len(r.PTR) == 0 {
r.Level = "warning"
r.Message = fmt.Sprintf("%s has no PTR record: many receivers reject or mark mail from it. Set its reverse DNS to %s at %s", t.Address, suggestName(t), where)
return r
}
var notes []string
for _, name := range r.PTR {
as, alias, err := res.addresses(ctx, name)
if err != nil {
notes = append(notes, err.Error())
continue
}
if slices.Contains(as, a) {
r.OK = true
if alias {
notes = append(notes, name+" is an alias (CNAME): receivers expect the PTR to name a host with its own A/AAAA record")
}
if t.Host != "" && name != t.Host && strings.HasPrefix(strings.Join(t.Roles, " "), "MX") {
notes = append(notes, fmt.Sprintf("the PTR name is %s, not the MX name %s: fine if the mail server says %s in HELO", name, t.Host, name))
}
}
}
switch {
case !r.OK:
r.Level = "warning"
r.Message = fmt.Sprintf("the PTR record of %s names %s, which doesn't resolve back to %s: receivers check that it does. Point the PTR at a name with an A/AAAA record for %s (set at %s), or add that record", t.Address, strings.Join(r.PTR, ", "), t.Address, t.Address, where)
case len(notes) > 0:
r.Level, r.Message = "info", strings.Join(notes, "; ")
default:
r.Level, r.Message = "ok", fmt.Sprintf("%s → %s → %s", t.Address, strings.Join(r.PTR, ", "), t.Address)
}
return r
}
func suggestName(t Target) string {
if t.Host != "" {
return t.Host
}
return "the host's name (with an A/AAAA record for it)"
}
// managedBy turns the reverse zone's primary name server into its owner's
// domain: ns1.linode.com -> linode.com, dns0.zen.co.uk -> zen.co.uk.
func managedBy(ns string) string {
ns = strings.TrimSuffix(strings.ToLower(ns), ".")
labels := strings.Split(ns, ".")
// The registered domain under the ICANN suffix: the list's private
// part (members.linode.com) names customers, not the provider.
for i := 1; i < len(labels); i++ {
if suffix, icann := publicsuffix.PublicSuffix(strings.Join(labels[i:], ".")); icann && suffix == strings.Join(labels[i:], ".") {
return strings.Join(labels[i-1:], ".")
}
}
return ns
}
+108
View File
@@ -0,0 +1,108 @@
package mailcheck
import (
"context"
"net"
"strings"
"testing"
"github.com/miekg/dns"
"jarl/internal/model"
)
// fakeResolver answers from a fixed set of records, as a recursive
// resolver would, with the reverse zone's SOA in negative answers.
func fakeResolver(t *testing.T, records ...string) string {
t.Helper()
var rrs []dns.RR
for _, s := range records {
rr, err := dns.NewRR(s)
if err != nil {
t.Fatal(err)
}
rrs = append(rrs, rr)
}
soa, _ := dns.NewRR("2.0.192.in-addr.arpa. 3600 IN SOA ns1.linode.com. hostmaster.linode.com. 1 3600 900 1209600 300")
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, q *dns.Msg) {
m := new(dns.Msg)
m.SetReply(q)
qn := strings.ToLower(q.Question[0].Name)
for _, rr := range rrs {
if strings.ToLower(rr.Header().Name) == qn && (rr.Header().Rrtype == q.Question[0].Qtype || rr.Header().Rrtype == dns.TypeCNAME) {
m.Answer = append(m.Answer, rr)
}
}
if len(m.Answer) == 0 && dns.IsSubDomain(soa.Header().Name, qn) {
if q.Question[0].Qtype == dns.TypeSOA && qn == soa.Header().Name {
m.Answer = append(m.Answer, soa)
} else {
m.Ns = append(m.Ns, soa)
}
}
w.WriteMsg(m)
})}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
return pc.LocalAddr().String()
}
func TestCheck(t *testing.T) {
st := model.NewState()
ext := &model.View{ID: 1, Name: "external", MatchClients: []string{"any"}}
in := &model.View{ID: 2, Name: "internal", MatchClients: []string{"lan"}}
st.Views[1], st.Views[2] = ext, in
z := &model.Zone{ID: 10, Name: "example.com.", ViewID: 1, Type: model.ZonePrimary}
zi := &model.Zone{ID: 11, Name: "example.com.", ViewID: 2, Type: model.ZonePrimary}
rev := &model.Zone{ID: 12, Name: "27.2.0.192.in-addr.arpa.", ViewID: 2, Type: model.ZonePrimary}
st.Zones[10], st.Zones[11], st.Zones[12] = z, zi, rev
rec := func(id, zone int64, name, typ, data string) {
st.Records[id] = &model.Record{ID: id, ZoneID: zone, Name: name, Type: typ, Data: data}
}
rec(1, 10, "example.com.", "MX", "10 mail.example.com.")
rec(2, 10, "example.com.", "TXT", `"v=spf1 mx ip4:192.0.2.26 ip4:192.0.2.27" " ip4:198.51.100.0/24 -all"`)
rec(3, 10, "www.example.com.", "MX", "10 ignored.example.com.") // not the apex
rec(4, 11, "example.com.", "MX", "10 internal.example.com.") // private view
rec(5, 10, "example.com.", "TXT", `"google-site-verification=x"`)
rec(6, 10, "example.com.", "MX", "20 backup.example.com.") // not on the internet
res := Resolver{Servers: []string{fakeResolver(t,
"mail.example.com. 300 IN A 192.0.2.25",
"25.2.0.192.in-addr.arpa. 300 IN PTR mail.example.com.",
"27.2.0.192.in-addr.arpa. 300 IN PTR li-27.members.linode.com.",
"li-27.members.linode.com. 300 IN A 192.0.2.99",
)}}
public := func(id int64) bool { return id == 1 }
rep := Check(context.Background(), st, public, res)
if len(rep.Errors) != 1 || !strings.Contains(rep.Errors[0], "backup.example.com (example.com, MX backup.example.com) has no address") {
t.Fatalf("errors %v", rep.Errors)
}
got := map[string]Result{}
for _, r := range rep.Results {
got[r.Address] = r
}
if len(got) != 3 {
t.Fatalf("results %+v", rep.Results)
}
if r := got["192.0.2.25"]; !r.OK || r.Level != "ok" || r.Roles[0] != "MX mail.example.com" || r.ManagedBy != "" && r.ManagedBy != "linode.com" {
t.Errorf("mail: %+v", r)
}
if r := got["192.0.2.26"]; r.OK || r.Level != "warning" || !strings.Contains(r.Message, "no PTR record") || r.ManagedBy != "linode.com" || r.ReverseZone != "2.0.192.in-addr.arpa" {
t.Errorf("no PTR: %+v", r)
}
if r := got["192.0.2.27"]; r.OK || !strings.Contains(r.Message, "doesn't resolve back") || r.LocalZone != "internal/27.2.0.192.in-addr.arpa" {
t.Errorf("not forward-confirmed: %+v", r)
}
}
func TestManagedBy(t *testing.T) {
for in, want := range map[string]string{"ns1.linode.com.": "linode.com", "dns0.zen.co.uk.": "zen.co.uk", "ns1.members.linode.com.": "linode.com", "localhost.": "localhost"} {
if got := managedBy(in); got != want {
t.Errorf("managedBy(%q) = %q, want %q", in, got, want)
}
}
}
+1 -1
View File
@@ -204,7 +204,7 @@ func (s *State) SortedBlockRules() []*BlockRule {
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Name != b.Name {
return CanonicalLess(a.Name+".", b.Name+".")
return NameLess(a.Name+".", b.Name+".")
}
if a.Exact != b.Exact {
return !a.Exact
+18 -1
View File
@@ -39,7 +39,8 @@ type Certificate struct {
Comment string `json:"comment"`
// The current certificate: acme source only (a file source's is read
// from its files). The key never leaves the server through the API.
// from its files). The key leaves the server only through the
// administrators' export (GET /certificates/{name}/pem), audited.
CertPEM string `json:"-"`
KeyPEM string `json:"-"`
@@ -61,11 +62,27 @@ type Certificate struct {
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
LastRoutes []string `json:"last_routes,omitempty"`
// OwnerID is the user a certificate for another host belongs to
// (jarl cert get); 0 for the administrators' own.
OwnerID int64 `json:"owner_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
}
// CertificatePull records that someone downloaded a certificate: who
// ("token NAME" or "user NAME"), which one (Serial), when and from where.
type CertificatePull struct {
CertID int64 `json:"-"`
Who string `json:"who"`
UserID int64 `json:"user_id,omitempty"`
TokenID int64 `json:"token_id,omitempty"`
Serial string `json:"serial"`
IP string `json:"ip,omitempty"`
At time.Time `json:"at"`
}
// HasUse reports whether c is used for use.
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
+3
View File
@@ -76,6 +76,9 @@ type User struct {
TOTPSecret string `json:"-"`
TOTPPending string `json:"-"`
TOTPLastStep int64 `json:"-"`
// CertLimit is how many certificates the user may own (jarl cert get);
// nil: certificates.max_per_user in jarl.yaml.
CertLimit *int `json:"cert_limit,omitempty"`
}
// HasTOTP reports whether the user logs in with a TOTP code.
+57 -11
View File
@@ -54,6 +54,11 @@ type View struct {
// internet (docs/exposure.md): "" works it out, ExposurePublic or
// ExposurePrivate say so.
Exposure string `json:"exposure,omitempty"`
// ReverseSuggestionsOff stops suggesting reverse zones for the view's
// addresses (docs/reverse.md), e.g. for a view only a few servers use;
// DismissedReverse are the networks whose suggestion was dismissed.
ReverseSuggestionsOff bool `json:"reverse_suggestions_off,omitempty"`
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
// Acknowledged are exposure findings accepted on purpose; they are
// shown as info instead of warnings.
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
@@ -170,6 +175,10 @@ type Zone struct {
// keeps their shared records in step (docs/views.md#linked-zones). 0
// for a zone that isn't linked; linked zones share the ID.
LinkGroup int64 `json:"link_group,omitempty"`
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
// step with them, in the reverse zones of the zone's views, as if every
// change asked for it (docs/reverse.md).
AutoPTR bool `json:"auto_ptr,omitempty"`
Meta
}
@@ -263,7 +272,13 @@ type Settings struct {
// for server-wide findings such as a visible version): see View.
Exposure string `json:"exposure,omitempty"`
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
Version int `json:"version"`
// DismissedReverse are dismissed reverse zone suggestions (without
// views; see View).
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
// EncryptedDNSHosts are the host names clients use for DNS over TLS
// and HTTPS (docs/encrypted-dns.md); the first is checked first.
EncryptedDNSHosts []string `json:"encrypted_dns_hosts,omitempty"`
Version int `json:"version"`
}
// BlockingPaused reports whether blocking is paused, for a time or until
@@ -509,14 +524,14 @@ func (s *State) SortedZones() []*Zone {
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
return pi < pj
}
return CanonicalLess(out[i].Name, out[j].Name)
return NameLess(out[i].Name, out[j].Name)
})
return out
}
// SortZones sorts zones by name in DNS canonical order.
// SortZones sorts zones by name (see NameLess).
func SortZones(zs []*Zone) {
sort.Slice(zs, func(i, j int) bool { return CanonicalLess(zs[i].Name, zs[j].Name) })
sort.Slice(zs, func(i, j int) bool { return NameLess(zs[i].Name, zs[j].Name) })
}
// FindZone finds a zone by name within a view (viewID 0 for no view).
@@ -631,7 +646,7 @@ func typeOrder(t string) string {
}
// SortRecords sorts records in zone-file order: apex first, then names in
// DNS canonical order (so subdomains group together), then by type and
// NameLess order (so subdomains group together), then by type and
// data.
func SortRecords(rs []*Record, z *Zone) {
apex := ""
@@ -647,7 +662,7 @@ func SortRecords(rs []*Record, z *Zone) {
if b.Name == apex {
return false
}
return CanonicalLess(a.Name, b.Name)
return NameLess(a.Name, b.Name)
}
if a.Type != b.Type {
return typeOrder(a.Type) < typeOrder(b.Type)
@@ -659,19 +674,50 @@ func SortRecords(rs []*Record, z *Zone) {
})
}
// CanonicalLess compares names in DNS canonical order (RFC 4034 section
// 6.1): by label from the right.
func CanonicalLess(a, b string) bool {
// NameLess compares names label by label from the right, like DNS
// canonical order (RFC 4034 section 6.1), except that two all-digit
// labels compare as numbers, so 2.10.0.10.in-addr.arpa comes before
// 101.10.0.10.in-addr.arpa. It orders names for people and zone files;
// BIND does its own canonical ordering where DNSSEC needs it.
func NameLess(a, b string) bool {
la := strings.Split(strings.TrimSuffix(a, "."), ".")
lb := strings.Split(strings.TrimSuffix(b, "."), ".")
for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
if la[i] != lb[j] {
return la[i] < lb[j]
if c := compareLabel(la[i], lb[j]); c != 0 {
return c < 0
}
}
return len(la) < len(lb)
}
func compareLabel(a, b string) int {
if a == b {
return 0
}
if allDigits(a) && allDigits(b) {
na, nb := strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0")
if len(na) != len(nb) {
return len(na) - len(nb)
}
if c := strings.Compare(na, nb); c != 0 {
return c
}
}
return strings.Compare(a, b)
}
func allDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// RRSetKey identifies an RRset.
type RRSetKey struct {
Name string
+42
View File
@@ -0,0 +1,42 @@
package model
import (
"slices"
"testing"
)
func TestNameLess(t *testing.T) {
want := []string{
"10.0.10.in-addr.arpa.",
"1.10.0.10.in-addr.arpa.",
"02.10.0.10.in-addr.arpa.",
"2.10.0.10.in-addr.arpa.",
"10.10.0.10.in-addr.arpa.",
"101.10.0.10.in-addr.arpa.",
"x.101.10.0.10.in-addr.arpa.",
"254.10.0.10.in-addr.arpa.",
"a.10.0.10.in-addr.arpa.",
}
got := slices.Clone(want)
slices.Reverse(got)
slices.SortFunc(got, func(a, b string) int {
if NameLess(a, b) {
return -1
}
if NameLess(b, a) {
return 1
}
return 0
})
if !slices.Equal(got, want) {
t.Fatalf("got %v\nwant %v", got, want)
}
// Non-numeric labels keep canonical (byte) order.
if !NameLess("host10.example.", "host2.example.") {
t.Error("host10 should sort before host2")
}
if !NameLess("example.", "www.example.") {
t.Error("parent should sort before child")
}
}
+35
View File
@@ -182,3 +182,38 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
}
}
}
// FormatValueList is FormatValue, except that a value whose block is a
// plain list (no nested blocks) of at least min elements is laid out one
// element per line, for reading: allow-query, exempt-clients, forwarders.
func FormatValueList(name, value string, min int) string {
st, err := ParseOptionValue(name, value)
if err != nil {
return FormatValue(name, value)
}
items := st.Items[1:]
blocks := 0
var list *Block
for _, it := range items {
if it.Block != nil {
blocks++
list = it.Block
}
}
if blocks != 1 || len(list.Stmts) < min || blockHasBlocks(list) {
return FormatValue(name, value)
}
var sb strings.Builder
for _, it := range items {
if it.Block == nil {
sb.WriteString(Compact([]Item{it}) + " ")
continue
}
sb.WriteString("{\n")
for _, s := range list.Stmts {
sb.WriteString("\t" + Compact(s.Items) + ";\n")
}
sb.WriteString("} ")
}
return strings.TrimSpace(sb.String())
}
+20
View File
@@ -16,3 +16,23 @@ func TestFormatValue(t *testing.T) {
}
}
}
func TestFormatValueList(t *testing.T) {
cases := []struct{ name, value, want string }{
{"allow-query", "{ homelab; localhost; 10.0.0.0/8; }", "{\n\thomelab;\n\tlocalhost;\n\t10.0.0.0/8;\n}"},
{"allow-query", "{ homelab; localhost; }", "{ homelab; localhost; }"}, // short: as it was
{"listen-on", "port 853 tls dot { 10.0.0.1; 10.0.0.2; 10.0.0.3; }", "port 853 tls dot {\n\t10.0.0.1;\n\t10.0.0.2;\n\t10.0.0.3;\n}"},
{"allow-transfer", "{ !10.0.0.9; key xfer; 10.0.0.0/8; }", "{\n\t!10.0.0.9;\n\tkey xfer;\n\t10.0.0.0/8;\n}"},
{"recursion", "yes", "yes"},
}
for _, c := range cases {
if got := FormatValueList(c.name, c.value, 3); got != c.want {
t.Errorf("%s %q:\n got %q\nwant %q", c.name, c.value, got, c.want)
}
}
// A nested block (rate-limit with exempt-clients) keeps FormatValue's layout.
v := "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.1; 192.0.2.2; 192.0.2.3; }; }"
if got := FormatValueList("rate-limit", v, 3); got != FormatValue("rate-limit", v) {
t.Errorf("nested: %q", got)
}
}
+142
View File
@@ -0,0 +1,142 @@
package render
import (
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// Generated is a statement Jarl adds to named's configuration by itself,
// not from an option you set: why, and what controls it.
type Generated struct {
Where string `json:"where"` // top level, options, or "view NAME"
Text string `json:"text"` // as written in named.conf
Why string `json:"why"` // what it is for
Setting string `json:"setting"` // what turns it on or off
}
// GeneratedStatements lists what Render adds to named.conf beyond the
// options in the database, so nothing there comes as a surprise.
func GeneratedStatements(s *model.State, p Paths, x Extras) []Generated {
var out []Generated
add := func(where, text, why, setting string) {
out = append(out, Generated{Where: where, Text: strings.TrimRight(text, "\n"), Why: why, Setting: setting})
}
add("top level", "include "+namedconf.QuoteString(p.KeysConfig())+";",
"TSIG keys (and the view keys) live in their own file, which only named and jarld can read", "always")
st := x.Stats
logResponses := st != nil && st.LogFile != ""
pzs := PolicyZones(s, p)
if st != nil {
_, user := findTop(s, "statistics-channels")
why := "named publishes its counters for jarld's query statistics, on a loopback address only"
if user {
why += " (added to your statistics-channels statement)"
}
add("top level", formatStmt(namedconf.NewStmt("statistics-channels", namedconf.NewBlock(st.statsInet())), 0), why, "stats.enabled in jarl.yaml")
}
if logResponses {
b := namedconf.NewBlock(st.logChannel())
cat := func(name string, extra ...string) {
ch := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))
for _, e := range extra {
ch.Stmts = append(ch.Stmts, namedconf.NewStmt(e))
}
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", name, ch))
}
why := []string{"responses: every answer, for the statistics' names, clients and views"}
cat("responses")
if len(pzs) > 0 {
cat("rpz")
why = append(why, "rpz: what blocking blocked")
}
if logRateLimit(s, st) {
cat("query-errors", "default_syslog")
why = append(why, "query-errors: what rate limiting limited, or would in log-only mode, for the Exposure page (also to syslog, as before)")
}
w := "named logs to a file jarld reads (" + strings.Join(why, "; ") + ")"
if _, user := findTop(s, "logging"); user {
w += "; merged into your logging statement"
}
setting := "stats.query_log in jarl.yaml"
if logRateLimit(s, st) || hasRateLimit(s) {
setting += "; query-errors: stats.rate_limit_log"
}
add("top level", formatStmt(namedconf.NewStmt("logging", b), 0), w, setting)
if s.FindOption(model.ScopeOptions, 0, "responselog") == nil {
add("options", "responselog yes;", "named logs answers from the start, for the statistics", "stats.query_log in jarl.yaml")
}
}
views := s.OrderedViews()
scopes := []struct {
id int64
name string
}{{0, "options"}}
if len(views) > 0 {
scopes = scopes[:0]
for _, v := range views {
scopes = append(scopes, struct {
id int64
name string
}{v.ID, "view " + v.Name})
}
}
for _, sc := range scopes {
zs := scopeZones(pzs, sc.id)
if len(zs) == 0 {
continue
}
w := newConfWriter()
writeResponsePolicy(w, zs, logResponses)
writePolicyZones(w, s, zs, &Output{}, 0)
add(sc.name, dedent(w.sb.String()), "blocking: your rules and blocklists, as response policy zones named loads", "the Blocking page (jarl block, jarl blocklist)")
}
if viewKeysOn(s, Extras{ViewKeySeed: x.ViewKeySeed}) {
for _, v := range views {
mc := viewMatchClients(s, x, v)
var keys []string
for _, e := range mc {
if strings.Contains(e, "key ") {
keys = append(keys, e+";")
}
}
add("view "+v.Name, "match-clients { "+strings.Join(keys, " ")+" ... };",
"view keys: a query signed with a view's key gets that view, from any address, and never another view (jarl view key)", "always, when there are views")
}
}
return out
}
func findTop(s *model.State, name string) (*model.Option, bool) {
for _, o := range s.ScopedOptions(model.ScopeTopLevel, 0) {
if o.Name == name {
return o, true
}
}
return nil, false
}
func hasRateLimit(s *model.State) bool {
for _, o := range s.Options {
if o.Name == "rate-limit" {
return true
}
}
return false
}
// logRateLimit reports whether rate limiting's log lines go to the
// response log (docs/exposure.md).
func logRateLimit(s *model.State, st *StatsHooks) bool {
return st != nil && st.LogFile != "" && !st.NoRateLimitLog && hasRateLimit(s)
}
// dedent removes one leading tab from every line.
func dedent(s string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = strings.TrimPrefix(l, "\t")
}
return strings.Join(lines, "\n")
}
+1 -4
View File
@@ -274,10 +274,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
logRewrites := logResponses && len(pzs) > 0
// Rate limiting logs what it limits (or would, in log-only mode) as
// query-errors; the statistics count it (docs/exposure.md).
logRRL := false
for _, o := range s.Options {
logRRL = logRRL || logResponses && o.Name == "rate-limit"
}
logRRL := logRateLimit(s, st)
sawStats, sawLogging := false, false
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
w.write("\n")
+3
View File
@@ -26,6 +26,9 @@ type StatsHooks struct {
LogFile string // response log; "" disables response logging
LogVersions int
LogSize string
// NoRateLimitLog keeps rate limiting's log lines (category
// query-errors) out of the response log (stats.rate_limit_log: false).
NoRateLimitLog bool
}
// StatsChannelName is the logging channel Jarl adds for responses.
+42
View File
@@ -82,3 +82,45 @@ func TestStatsHooksMerged(t *testing.T) {
func TestStatsHooksWithNamed(t *testing.T) {
checkWithNamed(t, sampleState(t), Extras{Stats: statsHooks()})
}
// Rate limiting's log lines go to the response log unless
// stats.rate_limit_log is off; the list of what Jarl adds says so.
func TestRateLimitLogAndGenerated(t *testing.T) {
s := sampleState(t)
for id, o := range s.Options {
if o.Name == "logging" {
delete(s.Options, id)
}
}
s.Options[20000] = &model.Option{ID: 20000, Scope: model.ScopeOptions, Name: "rate-limit", Value: "{ responses-per-second 10; log-only yes; }", Position: 20000}
p := Paths{ConfigDir: "/etc/bind/jarl", ZoneDir: "/var/lib/bind/jarl", CacheDir: "/var/cache/bind/jarl"}
conf := string(Render(s, p, Extras{Stats: statsHooks()}).File(p.MainConfig()).Content)
if !strings.Contains(conf, "category query-errors { jarl_responses; default_syslog; };") {
t.Errorf("query-errors not logged:\n%s", conf)
}
gen := GeneratedStatements(s, p, Extras{Stats: statsHooks()})
var logging *Generated
for i := range gen {
if strings.HasPrefix(gen[i].Text, "logging") {
logging = &gen[i]
}
}
if logging == nil || !strings.Contains(logging.Text, "query-errors") || !strings.Contains(logging.Setting, "stats.rate_limit_log") {
t.Errorf("generated logging = %+v (all %+v)", logging, gen)
}
if !strings.HasPrefix(gen[0].Text, "include ") || gen[0].Where != "top level" {
t.Errorf("first generated = %+v", gen[0])
}
h := statsHooks()
h.NoRateLimitLog = true
conf = string(Render(s, p, Extras{Stats: h}).File(p.MainConfig()).Content)
if strings.Contains(conf, "query-errors") || !strings.Contains(conf, "category responses") {
t.Errorf("rate_limit_log off but query-errors logged:\n%s", conf)
}
for _, g := range GeneratedStatements(s, p, Extras{Stats: h}) {
if strings.Contains(g.Text, "query-errors") {
t.Errorf("listed while off: %+v", g)
}
}
}
+173
View File
@@ -0,0 +1,173 @@
package server
import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
"os"
"strings"
"testing"
"time"
"jarl/internal/auth"
"jarl/internal/model"
)
// certUser creates a service user with grants and returns a token.
func certUser(t *testing.T, e *apiEnv, name string, grants ...string) (string, *model.User) {
t.Helper()
gs := []model.Grant{}
for _, s := range grants {
g, err := auth.ParseGrant(s)
if err != nil {
t.Fatal(err)
}
gs = append(gs, g)
}
u := &model.User{Username: name, Grants: gs}
if err := e.st.CreateUser(context.Background(), u); err != nil {
t.Fatal(err)
}
tok, pid, sh := auth.NewToken()
if err := e.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: name + "-host", PublicID: pid, SecretHash: sh}); err != nil {
t.Fatal(err)
}
return tok, u
}
// obtained gives a certificate a current certificate, as if the CA had
// signed it.
func obtained(t *testing.T, e *apiEnv, name string, domains ...string) {
t.Helper()
certFile, keyFile := selfSigned(t, t.TempDir(), domains...)
cb, _ := os.ReadFile(certFile)
kb, _ := os.ReadFile(keyFile)
ctx := context.Background()
for i := 0; i < 50 && e.srv.cfg.Certs.Issuing(name) != (time.Time{}); i++ {
time.Sleep(20 * time.Millisecond) // the failing attempt (no CA) ends
}
c, err := e.st.CertificateByName(ctx, name)
if err != nil {
t.Fatal(err)
}
na, nb := time.Now().Add(30*24*time.Hour), time.Now()
c.CertPEM, c.KeyPEM, c.NotAfter, c.NotBefore, c.Serial, c.Failures, c.LastError = string(cb), string(kb), &na, &nb, "s-"+name, 0, ""
if err := e.st.UpdateCertificate(ctx, c); err != nil {
t.Fatal(err)
}
}
func TestCertGet(t *testing.T) {
e := newAPIEnv(t)
withCerts(e)
mx, mxUser := certUser(t, e, "mx", "certificates:write names=*.adbraun.lol")
other, _ := certUser(t, e, "other", "certificates:write names=*.adbraun.lol")
wide, _ := certUser(t, e, "wide", "*:write")
req := func(tok, name string, domains ...string) (map[string]any, int) {
b, status := e.req("POST", "/certificates/"+name+"/request", tok, map[string]any{"domains": domains})
var out map[string]any
_ = jsonUnmarshal(b, &out)
return out, status
}
// "*" doesn't cover certificates.
if _, st := req(wide, "mail", "mail.adbraun.lol"); st != 403 {
t.Errorf("*:write: %d", st)
}
// Names outside the grant.
if out, st := req(mx, "mail", "mail.example.org"); st != 403 || !strings.Contains(errMsg(out), "mail.example.org") {
t.Errorf("other names: %d %v", st, out)
}
// A new certificate, owned by mx.
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 && st != 202 || out["created"] != true {
t.Fatalf("create: %d %v", st, out)
}
c, _ := e.st.CertificateByName(context.Background(), "mail")
if c.OwnerID != mxUser.ID || len(c.Uses) != 0 {
t.Errorf("owner %d uses %v", c.OwnerID, c.Uses)
}
// Another user can't have the name, or see it.
if out, st := req(other, "mail", "mail.adbraun.lol"); st != 409 || !strings.Contains(errMsg(out), "taken") {
t.Errorf("taken: %d %v", st, out)
}
e.do("GET", "/certificates/mail", other, nil, 404)
if l := e.do("GET", "/certificates", other, nil, 200)["certificates"].([]any); len(l) != 0 {
t.Errorf("other's list: %v", l)
}
// Nothing to download until it is obtained.
e.do("GET", "/certificates/mail/pem", mx, nil, 400)
obtained(t, e, "mail", "mail.adbraun.lol")
// The same names: the same certificate.
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 || out["created"] != false || out["changed"] != false {
t.Errorf("again: %d %v", st, out)
}
if c2, _ := e.st.CertificateByName(context.Background(), "mail"); c2.Serial != "s-mail" {
t.Errorf("replaced: %v", c2.Serial)
}
// Download: recorded, with the token.
if b, st := e.req("GET", "/certificates/mail/pem?part=key", mx, nil); st != 200 || !strings.Contains(string(b), "PRIVATE KEY") {
t.Errorf("key: %d", st)
}
got := e.do("GET", "/certificates/mail", e.token, nil, 200)
pulls, _ := got["pulled_by"].([]any)
if got["owner"] != "mx" || len(pulls) != 1 || !strings.Contains(pulls[0].(map[string]any)["who"].(string), "mx-host") || pulls[0].(map[string]any)["serial"] != "s-mail" {
t.Errorf("owner %v pulled_by %v", got["owner"], got["pulled_by"])
}
// A name added: obtained again.
if out, st := req(mx, "mail", "mail.adbraun.lol", "smtp.adbraun.lol"); out["changed"] != true || st != 200 && st != 202 {
t.Errorf("SAN added: %d %v", st, out)
}
// The owner's own list.
if l := e.do("GET", "/certificates", mx, nil, 200)["certificates"].([]any); len(l) != 1 {
t.Errorf("mx's list: %v", l)
}
// Jarl's own certificates are the administrators'.
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.adbraun.lol")
e.do("POST", "/certificates", e.token, map[string]any{"name": "web", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
if _, st := req(mx, "web", "jarl.adbraun.lol"); st != 409 {
t.Errorf("Jarl's own: %d", st)
}
e.do("GET", "/certificates/web/pem", mx, nil, 404)
// The limit: one more for mx, then no more.
limit := 2
mxUser.CertLimit = &limit
if err := e.st.UpdateUser(context.Background(), mxUser); err != nil {
t.Fatal(err)
}
if out, st := req(mx, "www", "www.adbraun.lol"); st != 200 && st != 202 {
t.Errorf("second: %d %v", st, out)
}
if out, st := req(mx, "third", "x.adbraun.lol"); st != 400 || !strings.Contains(errMsg(out), "limit") {
t.Errorf("over the limit: %d %v", st, out)
}
// The owner may delete theirs.
e.do("DELETE", "/certificates/www", mx, nil, 200)
}
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// errMsg is an error answer's message.
func errMsg(out map[string]any) string {
e, _ := out["error"].(map[string]any)
m, _ := e["message"].(string)
return m
}
// A private key goes back only over HTTPS or to this server itself.
func TestKeyTransport(t *testing.T) {
e := newAPIEnv(t)
for addr, want := range map[string]bool{"127.0.0.1:5000": true, "[::1]:5000": true, "192.0.2.7:5000": false} {
r, _ := http.NewRequest("GET", "/x", nil)
r.RemoteAddr = addr
if ok, why := e.srv.keyTransport(r); ok != want {
t.Errorf("%s: %v (%s)", addr, ok, why)
}
}
r, _ := http.NewRequest("GET", "/x", nil)
r.RemoteAddr, r.TLS = "192.0.2.7:5000", &tls.ConnectionState{}
if ok, _ := e.srv.keyTransport(r); !ok {
t.Error("HTTPS refused")
}
}
+37 -1
View File
@@ -45,7 +45,7 @@ func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile st
func withCerts(env *apiEnv) *certs.Manager {
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir", MaxPerUser: 10}, env.st, env.srv.eng, quiet)
env.srv.cfg.Certs = m
return m
}
@@ -165,3 +165,39 @@ func TestHTTPSAndPlainOnOnePort(t *testing.T) {
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
}
}
// A certificate's files can be exported for use elsewhere; the key only
// by an administrator, audited.
func TestCertificateExport(t *testing.T) {
env := newAPIEnv(t)
withCerts(env)
certFile, keyFile := selfSigned(t, t.TempDir(), "mail.example.com")
env.do("POST", "/certificates", env.token, map[string]any{"name": "mail", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
chain, _ := os.ReadFile(certFile)
key, _ := os.ReadFile(keyFile)
for part, want := range map[string]string{"": string(chain), "fullchain": string(chain), "cert": string(chain), "key": string(key), "bundle": string(key) + "\n" + string(chain)} {
b, status := env.req("GET", "/certificates/mail/pem?part="+part, env.token, nil)
if status != 200 || string(b) != want {
t.Errorf("part %q: %d %q", part, status, b)
}
}
if _, status := env.req("GET", "/certificates/mail/pem?part=nope", env.token, nil); status != 400 {
t.Errorf("bad part: %d", status)
}
if _, status := env.req("GET", "/certificates/none/pem", env.token, nil); status != 404 {
t.Errorf("no such certificate: %d", status)
}
// Not for a user who isn't an administrator.
hash, _ := auth.HashPassword("pw-pw-pw-pw")
u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}}
env.st.CreateUser(context.Background(), u)
tok, pid, sh := auth.NewToken()
env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh})
if _, status := env.req("GET", "/certificates/mail/pem?part=key", tok, nil); status != 403 {
t.Errorf("non-admin: %d", status)
}
audit, _ := env.req("GET", "/audit?action=certificate.export_key", env.token, nil)
if n := strings.Count(string(audit), "certificate.export_key"); n != 2 {
t.Errorf("audited key exports: %d (%s)", n, audit)
}
}
+119
View File
@@ -0,0 +1,119 @@
package server
import (
"strings"
"testing"
)
// The DNSSEC page says where each zone's DS record goes.
func TestDNSSECWhereDSGoes(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
zone := func(name, view string) {
e.do("POST", "/zones", e.token, map[string]any{"name": name, "view": view, "nameservers": []string{"ns1.example.net."}}, 201)
}
zone("adbraun.lol", "external")
zone("lab.adbraun.lol", "external")
zone("dev.adbraun.lol", "internal") // its parent only in external
zone("sub.example.com", "external")
zone("10.0.10.0/24", "internal")
zone("home.lan", "internal")
for _, c := range []struct{ zone, at, place, parent string }{
{"adbraun.lol?view=external", "registrar", "at your registrar", ""},
{"lab.adbraun.lol?view=external", "parent_zone", "in the parent zone adbraun.lol", "adbraun.lol."},
{"dev.adbraun.lol?view=internal", "parent_zone", "in the parent zone adbraun.lol (view external)", "adbraun.lol."},
{"sub.example.com?view=external", "parent", "in its parent zone (most likely example.com)", ""},
{"10.0.10.in-addr.arpa?view=internal", "reverse", "your ISP", ""},
{"home.lan?view=internal", "none", "", ""},
} {
r := e.do("GET", "/zones/"+strings.Replace(c.zone, "?", "/dnssec?", 1), e.token, nil, 200)
place, _ := r["ds_place"].(string)
parent, _ := r["parent_zone"].(string)
if r["ds_at"] != c.at || !strings.Contains(place, c.place) || (c.place == "" && place != "") || parent != c.parent {
t.Errorf("%s: ds_at %v, ds_place %q, parent_zone %q", c.zone, r["ds_at"], place, parent)
}
}
}
// A zone lists the zones of its name in the other views.
func TestZoneInViews(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."}}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "external", "nameservers": []string{"ns1.example.net."}}, 201)
z := e.do("GET", "/zones/adbraun.lol?view=internal", e.token, nil, 200)
in, _ := z["in_views"].([]any)
if len(in) != 1 || in[0].(map[string]any)["view"] != "external" || in[0].(map[string]any)["type"] != "primary" {
t.Errorf("in_views = %v", z["in_views"])
}
}
// A token for records in one view of a zone that is in two: the zone is
// found without naming the view, and view list names the views it can
// see zones in, without their settings.
func TestOneViewToken(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
for _, v := range []string{"internal", "external"} {
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": v, "nameservers": []string{"ns1.example.net."}}, 201)
}
e.do("POST", "/users", e.token, map[string]any{"username": "certbot", "password": "", "grant_strings": []string{"records:write zones=adbraun.lol views=external names=_acme-challenge.* types=TXT"}}, 201)
var uid int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "certbot" {
uid = int64(u["id"].(float64))
}
}
tok := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "certbot"}, 201)["token"].(string)
if z := e.do("GET", "/zones/adbraun.lol", tok, nil, 200); z["view"] != "external" {
t.Errorf("zone without a view: %v", z["view"])
}
e.do("GET", "/zones/adbraun.lol/records", tok, nil, 200)
views := e.list("/views", tok)
if len(views) != 1 || views[0]["name"] != "external" || views[0]["limited"] != true || views[0]["match_clients"] != nil {
t.Errorf("views = %v", views)
}
// The admin still has to say which.
e.do("GET", "/zones/adbraun.lol", e.token, nil, 400)
}
// A reverse zone lists the PTR records it lacks, not just how many.
func TestPTRMissingList(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "nameservers": []string{"ns1.example.net."},
"records": []map[string]any{{"name": "nas", "type": "A", "data": "10.60.60.5"}}}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "10.60.60.0/24", "nameservers": []string{"ns1.example.net."}}, 201)
z := e.do("GET", "/zones/60.60.10.in-addr.arpa", e.token, nil, 200)
list, _ := z["ptr_missing_list"].([]any)
if z["ptr_missing"] != float64(1) || len(list) != 1 || list[0].(map[string]any)["address"] != "10.60.60.5" || list[0].(map[string]any)["target"] != "nas.adbraun.lol." {
t.Errorf("ptr_missing %v, list %v", z["ptr_missing"], z["ptr_missing_list"])
}
}
// An address only another view's records use isn't missing from a
// reverse zone: it is counted apart, and filled only when asked.
func TestPTROtherViews(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
e.do("POST", "/views", e.token, map[string]any{"name": "vps"}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."},
"records": []map[string]any{{"name": "claude", "type": "A", "data": "10.0.10.101"}}}, 201)
for _, v := range []string{"internal", "vps"} {
e.do("POST", "/zones", e.token, map[string]any{"name": "10.0.10.0/24", "view": v, "nameservers": []string{"ns1.example.net."}}, 201)
}
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=internal", e.token, nil, 200); z["ptr_missing"] != float64(1) || z["ptr_other_views"] != nil {
t.Errorf("internal: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
}
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_missing"] != nil || z["ptr_other_views"] != float64(1) {
t.Errorf("vps: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
}
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps", e.token, nil, 400)
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps&other_views=1", e.token, nil, 201)
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_other_views"] != nil {
t.Errorf("vps after filling: ptr_other_views %v", z["ptr_other_views"])
}
}
+6
View File
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
if in.Comment != nil {
u.Comment = *in.Comment
}
if in.CertLimit != nil {
u.CertLimit = in.CertLimit
if *in.CertLimit < 0 {
u.CertLimit = nil
}
}
if in.Password != nil {
if *in.Password == "" {
u.PasswordHash = "" // service account: tokens only
+256 -6
View File
@@ -2,13 +2,19 @@ package server
import (
"context"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/certs"
"jarl/internal/encdns"
"jarl/internal/model"
)
@@ -21,8 +27,15 @@ type certView struct {
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
// NamedUse says what of named's configuration uses it: "DNS over TLS
// on 10.0.10.5 port 853" (list and get only).
NamedUse []string `json:"named_use,omitempty"`
// IssuingSince is set while Jarl is obtaining it.
IssuingSince *time.Time `json:"issuing_since,omitempty"`
// Owner is the user a certificate for another host belongs to; PulledBy
// who downloaded it (list and get, administrators only).
Owner string `json:"owner,omitempty"`
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
}
type certFiles struct {
@@ -57,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
return nil
}
// certsAccess lets in administrators and users with a certificates grant
// (certificates for other hosts: their own only).
func (s *Server) certsAccess(p *auth.Principal) error {
if s.cfg.Certs == nil {
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
}
if p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResCerts}) {
return nil
}
return forbidden("certificates need an administrator, or a certificates grant for their names (certificates:write names=*.example.com)")
}
// certAllowed reports whether p may see (or, with write, change) c: an
// administrator any; anyone else their own, while their grants cover all
// its names.
func certAllowed(p *auth.Principal, c *model.Certificate, write bool) bool {
if p.IsAdmin() {
return true
}
if p.User == nil || c.OwnerID == 0 || c.OwnerID != p.User.ID {
return false
}
for _, d := range c.Domains {
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: write, Name: d}) {
return false
}
}
return true
}
// certFor returns the certificate in the path, if p may see it (else not
// found, so names of others' certificates don't leak).
func (s *Server) certFor(r *http.Request, p *auth.Principal, write bool) (*model.Certificate, error) {
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
if err != nil {
return nil, certErr(err)
}
if !certAllowed(p, c, write) {
if write && certAllowed(p, c, false) {
return nil, forbidden("you may only download certificate %s", c.Name)
}
return nil, notFound("no such certificate")
}
return c, nil
}
// withOwners adds owners and downloads to views (for administrators).
func (s *Server) withOwners(ctx context.Context, views []certView) error {
pulls, err := s.store.CertificatePulls(ctx)
if err != nil {
return err
}
users, err := s.store.ListUsers(ctx)
if err != nil {
return err
}
names := map[int64]string{}
for _, u := range users {
names[u.ID] = u.Username
}
for i := range views {
if id := views[i].OwnerID; id != 0 {
views[i].Owner = names[id]
if views[i].Owner == "" {
views[i].Owner = fmt.Sprintf("deleted user %d", id)
}
}
views[i].PulledBy = pulls[views[i].ID]
}
return nil
}
func certErr(err error) error {
var ie *certs.InputError
switch {
@@ -66,22 +151,40 @@ func certErr(err error) error {
return notFound("no such certificate")
case errors.Is(err, certs.ErrConflict):
return conflict("a certificate with that name exists")
case errors.Is(err, certs.ErrTaken):
return conflict("the certificate name is taken by another user's certificate (or one Jarl uses): choose another name")
}
return err
}
// certList: GET /certificates.
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsReady(p); err != nil {
if err := s.certsAccess(p); err != nil {
return err
}
all, err := s.cfg.Certs.List(r.Context())
if err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
out := []certView{}
for _, c := range all {
out = append(out, s.presentCert(c))
if !certAllowed(p, c, false) {
continue
}
v := s.presentCert(c)
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
out = append(out, v)
}
if !p.IsAdmin() {
writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
return nil
}
if err := s.withOwners(r.Context(), out); err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
return nil
@@ -89,17 +192,136 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
// certGet: GET /certificates/{name}.
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsReady(p); err != nil {
if err := s.certsAccess(p); err != nil {
return err
}
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
c, err := s.certFor(r, p, false)
if err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
v := s.presentCert(c)
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
if p.IsAdmin() {
vs := []certView{v}
if err := s.withOwners(r.Context(), vs); err != nil {
return err
}
v = vs[0]
}
writeJSON(w, http.StatusOK, v)
return nil
}
// certRequest: POST /certificates/{name}/request {"domains": [...],
// "key_type": "ec256"}, for jarl cert get: the certificate for another
// host, owned by the caller's user. A new one is created and obtained
// (202 while that runs; poll GET /certificates/{name} until
// issuing_since is gone). An existing one is the caller's, obtained again
// only when its names change; otherwise it is returned as it is (200).
func (s *Server) certRequest(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsAccess(p); err != nil {
return err
}
if p.User == nil || p.User.ID == 0 {
return forbidden("certificates for other hosts belong to a user: use an API token, not the local socket as root")
}
var in struct {
Domains []string `json:"domains"`
KeyType string `json:"key_type"`
}
if err := decodeJSON(r, &in); err != nil {
return err
}
for _, d := range in.Domains {
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: true, Name: d}) {
return forbidden("you may not get a certificate for %s: that needs certificates:write with a names pattern that matches it", d)
}
}
res, err := s.cfg.Certs.Ensure(r.Context(), r.PathValue("name"), in.Domains, in.KeyType, p)
if err != nil {
return certErr(err)
}
writeJSON(w, http.StatusOK, s.presentCert(c))
v := s.presentCert(res.Certificate)
out := map[string]any{"certificate": v, "created": res.Created, "changed": res.Changed}
if res.Ignored != "" {
out["warnings"] = []string{res.Ignored}
}
status := http.StatusOK
if v.IssuingSince != nil {
status = http.StatusAccepted
}
writeJSON(w, status, out)
return nil
}
// certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle,
// the certificate as PEM for use elsewhere: the chain (default), the
// certificate alone, the private key, or the key followed by the chain
// (one file, as HAProxy wants). Anything with the key is audited.
func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsAccess(p); err != nil {
return err
}
if _, err := s.certFor(r, p, false); err != nil {
return err
}
part := r.URL.Query().Get("part")
if part == "" {
part = "fullchain"
}
if part == "key" || part == "bundle" {
if ok, why := s.keyTransport(r); !ok {
return &httpError{http.StatusForbidden, "https_required", "a private key is only handed out over HTTPS (or to this server itself), and this request came by " + why}
}
}
c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name"))
if err != nil {
return certErr(err)
}
var out []byte
file := c.Name + "-" + part + ".pem"
switch part {
case "fullchain":
out = chain
case "cert":
if b, _ := pem.Decode(chain); b != nil {
out = pem.EncodeToMemory(b)
}
case "key":
out = key
case "bundle":
out = append(append(append([]byte{}, key...), '\n'), chain...)
default:
return badRequest("part must be fullchain, cert, key or bundle")
}
if len(out) == 0 {
return badRequest("certificate %s has no %s", c.Name, part)
}
if part == "key" || part == "bundle" {
s.eng.Audit(r.Context(), p, "certificate.export_key", "certificate", c.ID, c.Name, "success", "exported the private key ("+part+")")
}
pull := &model.CertificatePull{CertID: c.ID, Who: pullWho(p), Serial: c.Serial, IP: s.clientIP(r), At: time.Now().UTC()}
if p.User != nil {
pull.UserID = p.User.ID
}
if p.Token != nil {
pull.TokenID = p.Token.ID
}
if err := s.store.RecordCertificatePull(r.Context(), pull); err != nil {
s.log.Warn("recording a certificate download failed", "certificate", c.Name, "err", err)
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
w.Header().Set("Cache-Control", "no-store")
_, err = w.Write(out)
return err
}
// certCreate: POST /certificates. With "issue": true (the default for
// certificates Jarl obtains) it is obtained before the answer.
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
@@ -146,7 +368,10 @@ func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Prin
// certDelete: DELETE /certificates/{name}.
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsReady(p); err != nil {
if err := s.certsAccess(p); err != nil {
return err
}
if _, err := s.certFor(r, p, true); err != nil {
return err
}
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
@@ -241,3 +466,28 @@ func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
}
return out
}
// keyTransport reports whether a private key may go back over r: HTTPS
// (also through a trusted proxy), the local socket, or loopback.
func (s *Server) keyTransport(r *http.Request) (bool, string) {
https, why := s.transport(r)
if https {
return true, why
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if a, err := netip.ParseAddr(host); err == nil && a.Unmap().IsLoopback() && !s.trusted(a.Unmap()) {
return true, "plain HTTP from this server"
}
return false, why
}
// pullWho names a downloader: its token (and user), or the user.
func pullWho(p *auth.Principal) string {
switch {
case p.Token != nil:
return "token " + p.Token.Name + " (" + p.Name() + ")"
case p.User != nil && p.Method != "":
return p.Method + " " + p.Name()
}
return p.Name()
}
+16
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
@@ -284,6 +285,21 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
resp.Backup = s.backupStatus(r.Context(), now)
resp.TOTP = s.totpOverview(r.Context())
resp.Certificates = s.certSummary(r.Context())
resp.Storage = s.storage(r.Context())
}
if p.Can(auth.Action{Resource: auth.ResOptions}) {
if h := s.encryptedDNSHealth(); h != nil {
sum := &api.EncDNSSummary{Checked: h.Checked, OK: h.OK(), Problems: []string{}, Warnings: []string{}}
for _, pr := range h.Probes {
where := fmt.Sprintf("%s on %s port %d", map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[pr.Kind], pr.Address, pr.Port)
if pr.Problem != "" {
sum.Problems = append(sum.Problems, where+": "+pr.Problem)
} else if pr.Warning != "" {
sum.Warnings = append(sum.Warnings, where+": "+pr.Warning)
}
}
resp.EncryptedDNS = sum
}
}
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
resp.BlocklistErrors = s.blocklistErrors(r.Context())
+92
View File
@@ -0,0 +1,92 @@
package server
import (
"net/http"
"strings"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
)
// zoneCompare: GET /zones/{zone}/compare compares a zone with the same
// zone in other views (docs/views.md#comparing-views), from the zone's own
// view: ?with=a,b picks the other views (default: every view with a
// primary zone of that name), ?name=www[&type=A] narrows it to one name,
// ?all=1 includes what is the same. A zone named without its view is
// compared from the first view that has it. (?views=a,b, the views to
// compare with the first as the base, works too.)
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
q := r.URL.Query()
list := func(k string) []string {
var out []string
for _, v := range strings.Split(q.Get(k), ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
return out
}
with, views := list("with"), list("views")
z, err := s.zoneByPath(r, st, p)
if err != nil {
// A name in several views is what there is to compare.
ref := r.PathValue("zone")
if ref == "root" {
ref = "."
}
name, perr := engine.ParseZoneName(ref)
zs := st.ZonesNamed(name)
if perr != nil || len(zs) < 2 {
return err
}
st.SortByView(zs)
z = zs[0]
if len(views) > 0 {
for _, o := range zs {
if strings.EqualFold(st.ViewName(o), views[0]) {
z = o
}
}
}
}
readable := func(o *model.Zone) bool {
vn := st.ViewName(o)
return o.Type == model.ZonePrimary && p.CanSeeZone(o.Name, vn) && p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn})
}
if !readable(z) {
return badRequest("zone %s isn't a primary zone whose records you may read", engine.Display(z.Name))
}
zones := []*model.Zone{z}
var others []*model.Zone
for _, o := range st.ZonesNamed(z.Name) {
vn := st.ViewName(o)
if o.ID == z.ID || !readable(o) {
continue
}
if len(with) > 0 && !containsFold(with, vn) || len(views) > 0 && !containsFold(views, vn) {
continue
}
others = append(others, o)
}
st.SortByView(others)
zones = append(zones, others...)
if len(zones) < 2 {
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
}
writeJSON(w, http.StatusOK, engine.Compare(st, zones, engine.CompareFilter{Name: q.Get("name"), Type: q.Get("type"), All: queryBool(r, "all")}))
return nil
}
func containsFold(list []string, s string) bool {
for _, x := range list {
if strings.EqualFold(x, s) {
return true
}
}
return false
}
+22 -1
View File
@@ -57,6 +57,8 @@ func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Princ
for _, v := range st.OrderedViews() {
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
out = append(out, presentView(st, v, colors))
} else if lv := limitedView(st, v, colors, p); lv != nil {
out = append(out, *lv)
}
}
writeJSON(w, http.StatusOK, out)
@@ -635,7 +637,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
}
sort.Slice(recs, func(i, j int) bool {
if recs[i].Name != recs[j].Name {
return model.CanonicalLess(recs[i].Name, recs[j].Name)
return model.NameLess(recs[i].Name, recs[j].Name)
}
return recs[i].ID < recs[j].ID
})
@@ -659,3 +661,22 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
writeJSON(w, http.StatusOK, out)
return nil
}
// generatedOptions: GET /options/generated lists what Jarl adds to
// named.conf by itself (the statistics channel, its logging, blocking's
// response-policy, view keys), with why and what controls it.
func (s *Server) generatedOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
out := s.eng.GeneratedStatements(r.Context(), st)
if out == nil {
out = []render.Generated{}
}
writeJSON(w, http.StatusOK, out)
return nil
}
+49 -10
View File
@@ -36,31 +36,70 @@ func (s *Server) zoneDNSSEC(w http.ResponseWriter, r *http.Request, p *auth.Prin
return badRequest("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
}
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
if pz := parentInJarl(st, z); pz != nil {
public := map[int64]bool{}
for _, e := range s.eng.Exposures(st) {
public[e.ViewID] = e.Public
}
name := dnsname.Display(z.Name)
reg, regOK := dnsname.RegisteredDomain(z.Name)
switch pz := parentInJarl(st, z, public); {
case pz != nil:
resp.DSAt = "parent_zone"
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
for _, rec := range st.ServedRecords(pz.ID) {
if rec.Name == z.Name && rec.Type == "DS" {
resp.ParentDS = append(resp.ParentDS, rec.Data)
}
}
resp.DSPlace = "in the parent zone " + dnsname.Display(pz.Name)
if pz.ViewID != z.ViewID {
resp.DSPlace += " (view " + st.ViewName(pz) + ")"
}
case dnsname.IsReverse(z.Name):
resp.DSAt = "reverse"
resp.DSPlace = "with whoever delegates " + name + " to you (your ISP, or your RIR for an address block of your own; many ISPs don't take DS records)"
case !regOK:
resp.DSAt = "none"
resp.DSNote = "No zone on the internet delegates " + name + ", so it can't get a DS record and resolvers never validate it. Signing only matters for a zone on the internet."
case reg == name:
resp.DSAt, resp.DSPlace = "registrar", "at your registrar"
default:
resp.DSAt = "parent"
resp.DSPlace = "in its parent zone (most likely " + reg + "), wherever that zone's DNS is hosted (at Porkbun: " + reg + "'s DNS records, as a DS record named " + strings.TrimSuffix(name, "."+reg) + ")"
}
writeJSON(w, http.StatusOK, resp)
return nil
}
// parentInJarl returns the primary zone in z's view that z is delegated
// from, if Jarl has it.
func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
var best *model.Zone
for _, o := range st.ZonesInView(z.ViewID) {
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
// parentInJarl returns the primary zone in Jarl that z is delegated from:
// in z's own view, else in another view, a public one first (the one the
// internet asks).
func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone {
in := func(viewID int64) *model.Zone {
var best *model.Zone
for _, o := range st.ZonesInView(viewID) {
if o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
continue
}
if best == nil || len(o.Name) > len(best.Name) {
best = o
}
}
return best
}
if pz := in(z.ViewID); pz != nil {
return pz
}
var other *model.Zone
for _, v := range st.OrderedViews() {
if v.ID == z.ViewID {
continue
}
if best == nil || len(o.Name) > len(best.Name) {
best = o
if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) {
other = pz
}
}
return best
return other
}
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
+125 -10
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"net/http"
"net/netip"
"slices"
@@ -38,11 +39,18 @@ type encCert struct {
type encStatus struct {
encdns.Status
// Hostname is the name clients connect to (the certificate's first).
Hostname string `json:"hostname,omitempty"`
// Hostnames are the names clients connect to (the setup's, else the
// certificate's first); Hostname is the first.
Hostnames []string `json:"hostnames"`
Hostname string `json:"hostname,omitempty"`
// AddressViews are, per address, the views whose records give a host
// name that address.
AddressViews map[string][]string `json:"address_views"`
// DoTHost and DoHURL are what to give clients.
DoTHost string `json:"dot_host,omitempty"`
DoHURL string `json:"doh_url,omitempty"`
// Health is the last check of the listeners (every 10 minutes).
Health *encdns.Health `json:"health,omitempty"`
// For the setup form.
Addresses []encAddress `json:"addresses"`
Views []encView `json:"views"`
@@ -89,6 +97,15 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
}
}
}
if hs := st.Settings.EncryptedDNSHosts; len(hs) > 0 {
out.Hostname = hs[0]
out.Hostnames = hs
} else if out.Hostname != "" {
out.Hostnames = []string{out.Hostname}
}
if out.Hostnames == nil {
out.Hostnames = []string{}
}
if out.Hostname != "" {
if len(out.DoT) > 0 {
out.DoTHost = out.Hostname
@@ -104,6 +121,9 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
out.DoHURL += "/dns-query"
}
}
if out.Enabled {
out.Health = s.encryptedDNSHealth()
}
seen := map[netip.Addr]bool{}
for _, a := range localAddrs() {
a = a.Unmap()
@@ -114,17 +134,25 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
}
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".")
if host == "" {
host = out.Hostname
hosts := out.Hostnames
if q := r.URL.Query().Get("hostname"); q != "" {
hosts = nil
for _, h := range strings.Split(q, ",") {
if h = strings.TrimSuffix(strings.TrimSpace(h), "."); h != "" {
hosts = append(hosts, h)
}
}
}
// Where the records of these host names give each address now, the
// setup's or not: what the form starts from.
out.AddressViews = encdns.CurrentAddressViews(st, hosts)
for _, x := range s.eng.Exposures(st) {
if x.View == "" {
continue
}
v := encView{Name: x.View, Public: x.Public}
if host != "" {
if vv := st.ViewByName(x.View); vv != nil {
if vv := st.ViewByName(x.View); vv != nil {
for _, host := range hosts {
for _, z := range st.ZonesInView(vv.ID) {
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
v.HasZone = true
@@ -198,13 +226,17 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
case c.NotAfter == nil:
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
case in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}):
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname)
case len(in.Hosts()) > 0 && !certs.Covers(c.Names, in.Hosts()):
return badRequest("certificate %s is for %s, not all of %s: clients would reject it (add the names to it: jarl cert update %s --domain …)", c.Name, strings.Join(c.Names, ", "), strings.Join(in.Hosts(), ", "), c.Name)
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
in.PublicViews = map[string]bool{}
for _, x := range s.eng.Exposures(st) {
in.PublicViews[x.View] = x.Public
}
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
if err != nil {
return badRequest("%v", err)
@@ -216,7 +248,7 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
if in.DoH {
what = append(what, "DNS over HTTPS")
}
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes)
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+strings.Join(in.Hosts(), ", "), reqs, nil, notes)
}
// encryptedDNSDisable: DELETE /encrypted-dns.
@@ -234,3 +266,86 @@ func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *
}
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
}
// encTarget returns the host name clients connect to and the serial of
// the certificate Jarl last wrote for named.
func (s *Server) encTarget(ctx context.Context, st encdns.Status) (host, serial string) {
if s.cfg.Certs == nil || st.Certificate == "" {
return "", ""
}
c, err := s.cfg.Certs.Get(ctx, st.Certificate)
if err != nil {
return "", ""
}
for _, n := range append(c.Names, c.Domains...) {
if !strings.HasPrefix(n, "*.") {
host = n
break
}
}
return host, c.Serial
}
// checkEncryptedDNS checks the DoT and DoH listeners as a client would and
// keeps the result; a change for the worse is logged.
func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
if s.cfg.Certs == nil {
return nil
}
state, err := s.store.LoadState(ctx)
if err != nil {
return nil
}
st := encdns.GetStatus(state, s.cfg.Certs.FilesDir(""))
var h *encdns.Health
if st.Enabled {
if host, serial := s.encTarget(ctx, st); host != "" {
hosts := state.Settings.EncryptedDNSHosts
if len(hosts) > 0 {
host = hosts[0]
}
h = encdns.Check(ctx, st, host, serial, s.encRoots)
h.RequireNames(append([]string{host}, hosts...))
}
}
s.encHealth.Lock()
prev := s.encHealth.h
s.encHealth.h = h
s.encHealth.Unlock()
if h != nil {
for _, p := range h.Probes {
if p.OK {
continue
}
was := false
if prev != nil {
for _, q := range prev.Probes {
was = was || q.Kind == p.Kind && q.Address == p.Address && q.Port == p.Port && !q.OK
}
}
if !was {
s.log.Warn("encrypted DNS isn't working", "kind", p.Kind, "address", p.Address, "port", p.Port, "problem", p.Problem)
}
}
}
return h
}
func (s *Server) encryptedDNSHealth() *encdns.Health {
s.encHealth.Lock()
defer s.encHealth.Unlock()
return s.encHealth.h
}
// encryptedDNSCheckNow: POST /encrypted-dns/health checks the listeners now.
func (s *Server) encryptedDNSCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, false); err != nil {
return err
}
h := s.checkEncryptedDNS(r.Context())
if h == nil {
return conflict("encrypted DNS isn't set up")
}
writeJSON(w, http.StatusOK, h)
return nil
}
+8 -1
View File
@@ -1,6 +1,7 @@
package server
import (
"jarl/internal/mailcheck"
"net/http"
"time"
@@ -32,6 +33,11 @@ type exposureReport struct {
// RateLimiting is what rate limiting limited, or would have, in the
// last 24 hours (with query statistics on).
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
// RateLimitLog is false when stats.rate_limit_log is off: what rate
// limiting does isn't counted then.
RateLimitLog bool `json:"rate_limit_log"`
// Mail is the last check of the mail addresses' reverse DNS.
Mail *mailcheck.Report `json:"mail,omitempty"`
}
type rateLimitActivity struct {
@@ -52,7 +58,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
return err
}
problems := s.eng.Validate(st)
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}, RateLimitLog: s.cfg.RateLimitLog}
finding := func(pr validate.Problem, viewID int64) exposureFinding {
_, desc := engine.ExposureFix(st, viewID, pr.Code)
return exposureFinding{Problem: pr, Fix: desc}
@@ -96,6 +102,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
out.RateLimiting = a
}
out.Mail = s.mailReport()
writeJSON(w, http.StatusOK, out)
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package server
import (
"context"
"net/http"
"time"
"jarl/internal/auth"
"jarl/internal/mailcheck"
)
// mailCheckEvery is how often jarld checks the reverse DNS of the
// addresses that send and receive mail (docs/exposure.md#mail).
const mailCheckEvery = 6 * time.Hour
// checkMail checks the reverse DNS of the public zones' mail addresses and
// keeps the report; it logs addresses that became wrong.
func (s *Server) checkMail(ctx context.Context) *mailcheck.Report {
st, err := s.store.LoadState(ctx)
if err != nil {
s.log.Warn("mail reverse DNS check: loading state", "err", err)
return nil
}
public := map[int64]bool{}
for _, x := range s.eng.Exposures(st) {
public[x.ViewID] = x.Public
}
if len(st.Views) == 0 {
public[0] = true
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
rep := mailcheck.Check(ctx, st, func(id int64) bool { return public[id] }, mailcheck.Resolver{Servers: s.cfg.Resolvers})
s.mail.Lock()
prev := s.mail.r
s.mail.r = rep
s.mail.Unlock()
was := map[string]bool{}
if prev != nil {
for _, r := range prev.Results {
was[r.Zone+"|"+r.Address] = r.OK
}
}
for _, r := range rep.Results {
if ok, seen := was[r.Zone+"|"+r.Address]; !r.OK && (ok || !seen) {
s.log.Warn("mail reverse DNS", "zone", r.Zone, "address", r.Address, "problem", r.Message)
}
}
return rep
}
func (s *Server) mailReport() *mailcheck.Report {
s.mail.Lock()
defer s.mail.Unlock()
return s.mail.r
}
// mailCheckNow: POST /exposure/mail checks now and returns the report.
func (s *Server) mailCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
return err
}
rep := s.checkMail(r.Context())
if rep == nil {
rep = &mailcheck.Report{Checked: time.Now(), Results: []mailcheck.Result{}}
}
writeJSON(w, http.StatusOK, rep)
return nil
}
// mailLoop checks a minute after the start, then every mailCheckEvery.
func (s *Server) mailLoop(ctx context.Context) {
wait := time.Minute
for {
select {
case <-ctx.Done():
return
case <-time.After(wait):
s.checkMail(ctx)
wait = mailCheckEvery
}
}
}
+16 -1
View File
@@ -35,11 +35,16 @@ import (
)
// registrarFor names the registrar Jarl can manage z's delegation at:
// "porkbun" when a key is configured and allows the zone's name.
// "porkbun" when a key is configured and allows the zone's name, and the
// zone is a registered domain (not a subdomain or a reverse zone, which
// their parent zone delegates).
func (s *Server) registrarFor(z *model.Zone) string {
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
return ""
}
if d, ok := dnsname.RegisteredDomain(z.Name); !ok || d != dnsname.Display(z.Name) {
return ""
}
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
return ""
}
@@ -59,6 +64,16 @@ func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State
return nil, nil, "", err
}
if s.registrarFor(z) == "" {
d, ok := dnsname.RegisteredDomain(z.Name)
switch {
case s.cfg.Porkbun == nil:
case dnsname.IsReverse(z.Name):
return nil, nil, "", badRequest("%s is a reverse zone: whoever holds the addresses (your ISP, or the RIR) delegates it, not a registrar. Check its delegation on the zone's Name servers tab (jarl zone delegation ZONE)", dnsname.Display(z.Name))
case !ok:
return nil, nil, "", badRequest("%s isn't a domain registered at a registrar (its suffix isn't a public one), so there is nothing to manage at Porkbun", dnsname.Display(z.Name))
case d != dnsname.Display(z.Name):
return nil, nil, "", badRequest("%s is a subdomain of %s: the NS (and DS) records in the zone %s delegate it, not the registrar. Manage %s at Porkbun instead", dnsname.Display(z.Name), d, d, d)
}
if s.cfg.Porkbun == nil {
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
}
+59
View File
@@ -0,0 +1,59 @@
package server
import (
"net/http"
"jarl/internal/auth"
"jarl/internal/engine"
)
// zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse
// zone lacks for its view's A and AAAA records; ?other_views=1 also for
// the addresses only other views' records use (docs/reverse.md).
func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
z, err := s.zoneByPath(r, st, p)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.FillPTRInput{ZoneID: z.ID, OtherViews: queryBool(r, "other_views")})}, nil)
}
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
// zones the private views lack for their records' addresses. ?all=1 adds
// the ones not suggested unasked (public views, views with suggestions
// off, dismissed), marked hidden; ?view= narrows it to one view.
func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
out := []engine.ReverseSuggestion{}
for _, sg := range s.eng.ReverseSuggestions(st, engine.ReverseOptions{All: queryBool(r, "all"), View: r.URL.Query().Get("view")}) {
if p.CanSeeZone(sg.Zone+".", sg.View) {
out = append(out, sg)
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// reverseDismiss: POST /reverse-zones/dismiss {"view": "internal",
// "prefix": "185.199.108.153/32"} hides a suggestion; /undismiss shows it
// again.
func (s *Server) reverseDismiss(dismiss bool) handler {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.DismissInput
if err := decodeJSON(r, &in); err != nil {
return err
}
op := engine.OpReverseUndismiss
if dismiss {
op = engine.OpReverseDismiss
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
}
}
+89 -9
View File
@@ -1,6 +1,7 @@
package server
import (
"errors"
"net/http"
"net/netip"
"path/filepath"
@@ -14,14 +15,24 @@ import (
"jarl/internal/stats"
)
// statsQuerier checks permission and that statistics are enabled.
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
// statsQuerier checks permission and that statistics are enabled. With
// ?views=internal,vps the figures are for those views only.
func (s *Server) statsQuerier(r *http.Request, p *auth.Principal) (*stats.Querier, error) {
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
return nil, err
}
if s.statsQ == nil {
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
}
var views []string
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
if v = strings.TrimSpace(v); v != "" {
views = append(views, v)
}
}
if len(views) > 0 {
return s.statsQ.ForViews(views), nil
}
return s.statsQ, nil
}
@@ -46,7 +57,7 @@ func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Pri
}
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -66,7 +77,7 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
}
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -93,7 +104,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
}
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -124,7 +135,7 @@ func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.P
}
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -143,7 +154,7 @@ func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Pr
}
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -152,6 +163,9 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
return err
}
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
if errors.Is(err, stats.ErrNotPerView) {
return &httpError{http.StatusBadRequest, "not_per_view", r.URL.Query().Get("group") + ": " + err.Error()}
}
if err != nil {
return badRequest("%v", err)
}
@@ -201,7 +215,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
}
slices.SortFunc(recs, func(a, b *model.Record) int {
if a.Name != b.Name {
if model.CanonicalLess(a.Name, b.Name) {
if model.NameLess(a.Name, b.Name) {
return -1
}
return 1
@@ -247,7 +261,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
// limits (rndc status), and the space left on the file systems Jarl and
// named write to.
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if _, err := s.statsQuerier(p); err != nil {
if _, err := s.statsQuerier(r, p); err != nil {
return err
}
out := map[string]any{"server": s.cfg.Stats.Server()}
@@ -269,6 +283,72 @@ func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Pri
labels = append(labels, "statistics database")
}
out["disks"] = stats.DiskUsage(labels, dirs)
// The statistics database: what the statistics cost on disk.
if db := s.cfg.Stats.Store().Path(); db != "" && db != ":memory:" {
info := map[string]any{"path": db, "bytes": dbBytes(db)}
if keep := s.cfg.Stats.Options().RetainQueries; keep > 0 && s.cfg.Stats.Options().QueryLog {
if n, err := s.cfg.Stats.Store().QueryLogSize(r.Context()); err == nil {
info["query_log_rows"] = n
}
}
out["database"] = info
}
writeJSON(w, http.StatusOK, out)
return nil
}
// statsExplore: GET /stats/explore?client=&name=&domain=&view=&type=&rcode=
// &blocked=&since=24h explores the query log (docs/stats.md#exploring):
// totals, a series, the top values of each dimension left open, and the
// latest answers, with client host names.
func (s *Server) statsExplore(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
since, err := statsSince(r)
if err != nil {
return err
}
v := r.URL.Query()
f := stats.ExploreFilter{Client: v.Get("client"), Name: v.Get("name"), Domain: v.Get("domain"), View: v.Get("view"),
Type: v.Get("type"), RCode: v.Get("rcode"), Blocked: v.Get("blocked"), Since: since}
ex, err := q.Explore(r.Context(), f, 15, 100)
if errors.Is(err, stats.ErrNoQueryLog) {
return &httpError{http.StatusNotFound, "query_log_off", err.Error()}
}
if err != nil {
return err
}
if err := s.nameClients(r, p, ex.By["client"]); err != nil {
return err
}
// The recent answers' clients, named once each.
var es []stats.Entry
seen := map[string]int{}
for _, row := range ex.Recent {
if _, ok := seen[row.Client]; !ok {
seen[row.Client] = len(es)
es = append(es, stats.Entry{Key: row.Client})
}
}
if err := s.nameClients(r, p, es); err != nil {
return err
}
for i := range ex.Recent {
ex.Recent[i].ClientName = es[seen[ex.Recent[i].Client]].Name
}
// The filter's client, named too.
out := struct {
*stats.Exploration
ClientName string `json:"client_name,omitempty"`
}{Exploration: ex}
if f.Client != "" {
one := []stats.Entry{{Key: f.Client}}
if err := s.nameClients(r, p, one); err == nil {
out.ClientName = one[0].Name
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package server
import (
"context"
"os"
"path/filepath"
"slices"
"jarl/internal/api"
"jarl/internal/stats"
)
// storage reports the size of Jarl's databases and how full the disks
// Jarl uses are (the dashboard's Storage card).
func (s *Server) storage(ctx context.Context) *api.Storage {
out := &api.Storage{Databases: []api.Database{}, Disks: []api.Disk{}}
dbFile := func(label, path string) *api.Database {
if path == "" || path == ":memory:" {
return nil
}
d := api.Database{Label: label, Path: path, Bytes: dbBytes(path)}
out.Databases = append(out.Databases, d)
out.Total += d.Bytes
return &out.Databases[len(out.Databases)-1]
}
labels := []string{}
dirs := map[string]string{}
add := func(label, dir string) {
if dir != "" {
labels = append(labels, label)
dirs[label] = dir
}
}
const jarlDB, statsDB = "Jarl's database", "statistics database"
if p := s.store.Path(); p != "" {
dbFile(jarlDB, p)
add(jarlDB, filepath.Dir(p))
}
if s.cfg.Stats != nil {
if p := s.cfg.Stats.Store().Path(); p != "" && p != ":memory:" {
if d := dbFile(statsDB, p); d != nil && s.cfg.Stats.Options().RetainQueries > 0 {
if n, err := s.cfg.Stats.Store().QueryLogSize(ctx); err == nil {
d.QueryLogRows = &n
d.QueryLogRetention = s.cfg.Stats.Status().Retention.Queries
}
}
add(statsDB, filepath.Dir(p))
}
if lf := s.cfg.Stats.Options().LogFile; lf != "" {
add("named's logs", filepath.Dir(lf))
}
}
paths := s.eng.Config().Paths
add("zone files", paths.ZoneDir)
add("named.conf", paths.ConfigDir)
add("Jarl's cache and blocklists", paths.CacheDir)
add("backups", s.cfg.BackupDir)
for _, d := range stats.DiskUsage(labels, dirs) {
disk := api.Disk{Path: d.Path, Holds: d.Holds, Total: d.Total, Free: d.Free}
for _, db := range out.Databases {
if slices.Contains(d.Holds, db.Label) {
disk.Databases += db.Bytes
}
}
out.Disks = append(out.Disks, disk)
}
return out
}
// dbBytes is the size of a SQLite database: its file, -wal and -shm.
func dbBytes(path string) int64 {
var n int64
for _, suffix := range []string{"", "-wal", "-shm"} {
if fi, err := os.Stat(path + suffix); err == nil {
n += fi.Size()
}
}
return n
}
+1 -1
View File
@@ -34,7 +34,7 @@ func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal)
name = "."
}
var err error
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"))
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"), func(z *model.Zone) bool { return p.CanSeeZone(z.Name, st.ViewName(z)) })
if err != nil {
return nil, err
}
+14
View File
@@ -289,6 +289,20 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
}
}
if h := s.encryptedDNSHealth(); h != nil {
for _, pr := range h.Probes {
pw.gauge("jarl_encrypted_dns_up", "1 when the DNS over TLS or HTTPS listener answered with the right, current certificate at the last check (every 10 minutes).", b2f(pr.OK),
"kind", pr.Kind, "address", pr.Address, "port", strconv.Itoa(pr.Port))
}
}
if m := s.mailReport(); m != nil {
for _, r := range m.Results {
pw.gauge("jarl_mail_reverse_dns_ok", "1 when an address that sends or receives a public zone's mail has a PTR record that resolves back to it, at the last check (every 6 hours).", b2f(r.OK),
"zone", r.Zone, "address", r.Address)
}
}
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
for _, t := range tokens {
if t.ExpiresAt != nil && t.RevokedAt == nil {
+46 -1
View File
@@ -22,6 +22,35 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
}
out.Registrar = s.registrarFor(z)
if z.Type == model.ZonePrimary && z.ViewID != 0 {
for _, o := range st.ZonesNamed(z.Name) {
if o.ID != z.ID && o.Type == model.ZonePrimary {
out.OtherViews = append(out.OtherViews, st.ViewName(o))
}
}
}
if z.ViewID != 0 {
others := st.ZonesNamed(z.Name)
st.SortByView(others)
for _, o := range others {
if o.ID != z.ID {
out.InViews = append(out.InViews, api.ZoneLink{ID: o.ID, View: st.ViewName(o), Type: o.Type, Linked: z.LinkGroup != 0 && o.LinkGroup == z.LinkGroup})
}
}
}
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
out.Prefix = pf.String()
if z.Type == model.ZonePrimary {
fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false)
out.PTRMissing = len(fills)
if z.ViewID != 0 {
out.PTROtherViews = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true)) - len(fills)
}
for _, f := range fills[:min(len(fills), 25)] {
out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f))
}
}
}
for _, o := range st.LinkedWith(z) {
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
@@ -60,6 +89,22 @@ func presentView(st *model.State, v *model.View, colors map[int64]string) api.Vi
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID)), Color: colors[v.ID]}
}
// limitedView presents a view to a principal without views:read who can
// see zones in it (the zone list names the view anyway): its name and
// colour, and how many of its zones they can see. nil when they see none.
func limitedView(st *model.State, v *model.View, colors map[int64]string, p *auth.Principal) *api.View {
n := 0
for _, z := range st.ZonesInView(v.ID) {
if p.CanSeeZone(z.Name, v.Name) {
n++
}
}
if n == 0 {
return nil
}
return &api.View{View: model.View{ID: v.ID, Name: v.Name, Position: v.Position}, Zones: n, Color: colors[v.ID], Limited: true}
}
func presentACL(st *model.State, a *model.ACL) api.ACL {
used := engine.FindACLRefs(st, a.Name)
if used == nil {
@@ -111,7 +156,7 @@ func presentOption(st *model.State, o *model.Option) api.Option {
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
out.Description, out.Status = c.Description, c.Status
}
if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value {
if f := namedconf.FormatValueList(o.Name, o.Value, 3); f != o.Value {
out.Formatted = f
}
return out
+26
View File
@@ -170,3 +170,29 @@ func writeKey(t *testing.T, dir, zone string) uint16 {
os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644)
return k.KeyTag()
}
// With no porkbun.domains, only zones that are registered domains get the
// registrar: not subdomains or reverse zones, which their parent delegates.
func TestRegistrarOnlyRegisteredDomains(t *testing.T) {
fake := porkbun.NewFake("pk1_a", "sk1_b", "adbraun.lol")
pts := httptest.NewServer(fake)
t.Cleanup(pts.Close)
e := newAPIEnvConfig(t, Config{Version: "test", Porkbun: &porkbun.Client{BaseURL: pts.URL + "/api/json/v3", APIKey: "pk1_a", Secret: "sk1_b"}})
for _, z := range []string{"adbraun.lol", "lab.adbraun.lol", "10.0.10.0/24", "home.lan"} {
e.do("POST", "/zones", e.token, map[string]any{"name": z, "nameservers": []string{"ns1.example.net."}}, 201)
}
for zone, want := range map[string]string{"adbraun.lol": "porkbun", "lab.adbraun.lol": "", "10.0.10.in-addr.arpa": "", "home.lan": ""} {
got, _ := e.do("GET", "/zones/"+zone, e.token, nil, 200)["registrar"].(string)
if got != want {
t.Errorf("%s: registrar %q, want %q", zone, got, want)
}
}
for zone, msg := range map[string]string{"lab.adbraun.lol": "subdomain of adbraun.lol", "10.0.10.in-addr.arpa": "reverse zone", "home.lan": "isn't a domain registered"} {
out := e.do("GET", "/zones/"+zone+"/registrar", e.token, nil, 400)
errObj, _ := out["error"].(map[string]any)
if m, _ := errObj["message"].(string); !strings.Contains(m, msg) {
t.Errorf("%s: %v", zone, out)
}
}
e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
}
+11
View File
@@ -111,8 +111,13 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/compare", s.authed(s.zoneCompare))
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/fill-ptr", s.authed(s.zoneFillPTR))
m.HandleFunc("GET "+p+"/reverse-zones/suggestions", s.authed(s.reverseSuggestions))
m.HandleFunc("POST "+p+"/reverse-zones/dismiss", s.authed(s.reverseDismiss(true)))
m.HandleFunc("POST "+p+"/reverse-zones/undismiss", s.authed(s.reverseDismiss(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
@@ -128,6 +133,7 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/records/{id}/enable", s.authed(s.setRecordDisabled(false)))
// Options and settings
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions))
m.HandleFunc("GET "+p+"/options/generated", s.authed(s.generatedOptions))
m.HandleFunc("POST "+p+"/options", s.authed(s.createOption))
m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption))
m.HandleFunc("PATCH "+p+"/options/{id}", s.authed(s.updateOption))
@@ -168,6 +174,8 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM))
m.HandleFunc("POST "+p+"/certificates/{name}/request", s.authed(s.certRequest))
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
@@ -176,8 +184,10 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
m.HandleFunc("DELETE "+p+"/encrypted-dns", s.authed(s.encryptedDNSDisable))
m.HandleFunc("POST "+p+"/encrypted-dns/check", s.authed(s.encryptedDNSCheck))
m.HandleFunc("POST "+p+"/encrypted-dns/health", s.authed(s.encryptedDNSCheckNow))
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
m.HandleFunc("POST "+p+"/exposure/mail", s.authed(s.mailCheckNow))
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
@@ -187,6 +197,7 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
m.HandleFunc("GET "+p+"/stats/explore", s.authed(s.statsExplore))
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
})
+31
View File
@@ -4,6 +4,7 @@ package server
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io/fs"
@@ -21,7 +22,9 @@ import (
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/certs"
"jarl/internal/encdns"
"jarl/internal/engine"
"jarl/internal/mailcheck"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
"jarl/internal/stats"
@@ -61,6 +64,12 @@ type Config struct {
TOTPIssuer string
// Metrics configures /metrics (metrics.go).
Metrics Metrics
// RateLimitLog says rate limiting's log lines reach the statistics
// (stats.rate_limit_log), for the Exposure page.
RateLimitLog bool
// Resolvers are public recursive resolvers (acme.resolvers), to see
// the mail addresses' reverse DNS as receivers do.
Resolvers []string
// Certs manages TLS certificates (nil in tests that don't need it),
// including the one jarld serves (h_certs.go, sniff.go).
Certs *certs.Manager
@@ -90,6 +99,18 @@ type Server struct {
// state part of /metrics.
started time.Time
metricsCache metricsCache
// encHealth is the last check of encrypted DNS's listeners
// (h_encdns.go); encRoots the CAs it trusts (nil: the system's).
encHealth struct {
sync.Mutex
h *encdns.Health
}
encRoots *x509.CertPool
// mail is the last check of the mail addresses' reverse DNS (h_mail.go).
mail struct {
sync.Mutex
r *mailcheck.Report
}
mu sync.Mutex
srvs []*http.Server
@@ -226,6 +247,15 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
ready()
}
go s.housekeeping(ctx)
go s.mailLoop(ctx)
go func() {
// Once jarld and named have settled after a start.
select {
case <-ctx.Done():
case <-time.After(30 * time.Second):
s.checkEncryptedDNS(ctx)
}
}()
select {
case <-ctx.Done():
case err := <-errc:
@@ -300,6 +330,7 @@ func (s *Server) housekeeping(ctx context.Context) {
}
s.limiter.cleanup()
s.warnExpiringTokens(ctx, time.Now())
s.checkEncryptedDNS(ctx)
if s.certs != nil {
s.certs.reloadIfChanged()
}
+91 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
@@ -35,7 +36,10 @@ func TestStatsAPI(t *testing.T) {
minute := now.Unix() - now.Unix()%60 - 120
hour := minute - minute%3600
b := &stats.Batch{}
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10}
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10,
"view:internal": 30, "view:external": 20, stats.ViewKey("internal", "responses"): 30, stats.ViewKey("external", "responses"): 20,
stats.ViewKey("internal", "rcode:NOERROR"): 30, stats.ViewKey("external", "rcode:NOERROR"): 15, stats.ViewKey("external", "rcode:NXDOMAIN"): 5,
"answer:authoritative": 40}
b.AddCounters(stats.ResMinute, minute, vals)
b.AddCounters(stats.ResHour, hour, vals)
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
@@ -47,6 +51,16 @@ func TestStatsAPI(t *testing.T) {
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
e := newAPIEnv(t, c)
// Some views only.
if sum := e.do("GET", "/stats/summary?since=1h&views=external", e.token, nil, 200); sum["queries"].(float64) != 20 || len(sum["rcodes"].([]any)) != 2 || sum["filter_views"] == nil {
t.Errorf("external summary = %v", sum)
}
if b := e.list("/stats/breakdown?group=rcode&since=1h&views=internal,external", e.token); len(b) != 2 || b[0]["count"].(float64) != 45 {
t.Errorf("rcodes of both views = %v", b)
}
if out, status := e.req("GET", "/stats/breakdown?group=answer&since=1h&views=internal", e.token, nil); status != 400 || !strings.Contains(string(out), "not_per_view") {
t.Errorf("answers per view = %d %s", status, out)
}
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
t.Errorf("status = %v", st)
}
@@ -108,3 +122,79 @@ func TestStatsAPI(t *testing.T) {
vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
e.do("GET", "/stats/summary", vtok, nil, 200)
}
func TestStatsExploreAPI(t *testing.T) {
ctx := context.Background()
ss, err := stats.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer ss.Close()
now := time.Now().Unix()
b := &stats.Batch{}
b.AddQueries([]stats.QueryRow{
{Time: now - 60, Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"},
{Time: now - 50, Client: "10.0.0.7", View: "internal", Name: "ads.tracker.test", Type: "A", RCode: "NXDOMAIN", Blocked: "hagezi"},
{Time: now - 40, Client: "198.51.100.9", View: "external", Name: "nope.example.com", Type: "A", RCode: "NXDOMAIN"},
})
if err := ss.Commit(ctx, b); err != nil {
t.Fatal(err)
}
opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour}
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
e := newAPIEnv(t, c)
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201)
ex := e.do("GET", "/stats/explore?rcode=NXDOMAIN&since=1h", e.token, nil, 200)
if ex["total"].(float64) != 2 || ex["blocked"].(float64) != 1 {
t.Errorf("NXDOMAIN: %v", ex)
}
clients := ex["by"].(map[string]any)["client"].([]any)
if len(clients) != 2 {
t.Fatalf("clients %v", clients)
}
named := false
for _, c := range clients {
m := c.(map[string]any)
named = named || m["key"] == "10.0.0.7" && m["name"] == "laptop.example.com"
}
if !named {
t.Errorf("client names %v", clients)
}
ex = e.do("GET", "/stats/explore?client=10.0.0.7", e.token, nil, 200)
recent := ex["recent"].([]any)
if ex["total"].(float64) != 2 || ex["client_name"] != "laptop.example.com" || len(recent) != 2 || recent[0].(map[string]any)["client_name"] != "laptop.example.com" {
t.Errorf("laptop: %v", ex)
}
if ex := e.do("GET", "/stats/explore?client=10.0.0.7&views=external", e.token, nil, 200); ex["total"].(float64) != 0 {
t.Errorf("laptop in external only: %v", ex["total"])
}
// The dashboard reports the databases' size.
st := e.do("GET", "/status", e.token, nil, 200)
sg, ok := st["storage"].(map[string]any)
if !ok || len(sg["databases"].([]any)) == 0 {
t.Errorf("storage %v", st["storage"])
}
}
// /stats/server reports the statistics database's size and the query log.
func TestStatsServerDatabase(t *testing.T) {
ss, err := stats.OpenStore(filepath.Join(t.TempDir(), "stats.db"))
if err != nil {
t.Fatal(err)
}
defer ss.Close()
b := &stats.Batch{}
b.AddQueries([]stats.QueryRow{{Time: time.Now().Unix(), Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"}})
if err := ss.Commit(context.Background(), b); err != nil {
t.Fatal(err)
}
opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour}
e := newAPIEnv(t, stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil))))
sv := e.do("GET", "/stats/server", e.token, nil, 200)
db, ok := sv["database"].(map[string]any)
if !ok || db["bytes"].(float64) <= 0 || db["query_log_rows"].(float64) != 1 {
t.Errorf("database %v", sv["database"])
}
}
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more