Compare commits

..
98 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 67a829be34 Release 0.6.4-rc.2
release / release (push) Successful in 4m44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:24:45 +01:00
adamandClaude Opus 5.5 42e79af678 cert get: combined file for HAProxy, --local, saved NAME; cert list shows get and last download
- jarl cert get --combined PATH writes the key and full chain in one file
  (mode 600, atomic), kept outside --dir so it can live in HAProxy's crt
  directory; JARL_CERT_COMBINED for the hook. Turning it on, moving it or
  removing it ('') applies from the files on disk and runs the hook.
- jarl cert get --local writes the combined file and runs the hook from
  this host's files, without asking jarld.
- jarl cert saved NAME shows all of one certificate's settings.
- jarl cert list: USES "get" for certificates for other hosts, and a
  PULLED column (last download, "(old)" while a host is behind). Owners
  now see their own downloads (pulled_by), not only administrators.
- jarl cert get without a name refuses per-certificate flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:24:33 +01:00
adamandClaude Opus 5.5 3fb159cf4d Release 0.6.4-rc.1
release / release (push) Successful in 4m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:58:56 +01:00
adamandClaude Opus 5.5 71ebed2b73 Log failed logins and refused tokens, address first
release / release (push) Failing after 18s
For Guard's jarl preset and fail2ban: msg="login failed" ip=... user=...
and msg="token refused" ip=..., beside the audit records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00
adamandClaude Opus 5.5 95125e7f50 Release 0.6.3
Documentation: lego 5's commands beside lego 4's, and lego on a host
inside a split DNS (its propagation check and the ways round it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:44:01 +01:00
adamandClaude Opus 5.5 c10ebe0d38 Docs: lego 5, and split DNS with lego's propagation check
docs/acme.md and jarl-lego show lego 5's commands (lego run, which also
renews; lego migrate when upgrading) beside lego 4's. A new section
explains why lego on a host inside a split DNS fails its own propagation
check when the challenge goes into the external view only (JARL_VIEW, a
one-view token, httpreq's /view/...), and the ways round it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:44:00 +01:00
adamandClaude Opus 5.5 3d1911dd38 Release 0.6.2
release / release (push) Successful in 4m45s
A certificates role (template) for tokens and users that get
certificates for other hosts with jarl cert get.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:08:54 +01:00
adamandClaude Opus 5.5 310ed8061e Permissions: a certificates role
A template for tokens and users that get certificates for other hosts
(jarl cert get): certificates:write, to narrow with names= in the web
UI's "Insert a template…", or --role certificates in the CLI. The token
form's template filter no longer counts "*" as covering certificates,
as the server doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:07:49 +01:00
adamandClaude Opus 5.5 b367e0c830 Release 0.6.1
release / release (push) Successful in 4m42s
Certificates for other hosts (jarl cert get, with a daily timer),
certificate export, PTR record and override fixes in linked zones,
one-view tokens with linked zones, numeric order in reverse zones, and
cert get refusing to take over a name saved for another Jarl server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:42:42 +01:00
adamandClaude Opus 5.5 254867c61d CLI: cert get doesn't take over another server's saved name
Each Jarl server has its own certificate names, but a host keeps one
saved NAME.json (and, by default, one /etc/jarl/certs/NAME). Asking a
second server for the same name overwrote the first one's settings and
files, so the first certificate quietly stopped being renewed. Now that
is refused, as is a --dir holding another certificate's files;
--replace takes the name or directory over. A --dir another saved
certificate uses is always refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:42:42 +01:00
adamandClaude Opus 5.5 3ddd32f97f Release 0.6.1-rc.4
release / release (push) Successful in 4m43s
0.6.1's fourth release candidate: the Stats page's range buttons follow
the retention settings, jarl changeset show prints local time, and the CLI
no longer prints "and N more" when the server sends no PTR list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:32:19 +01:00
adamandClaude Opus 5.5 0976faba3f CLI: changeset show prints local time
release / release (push) Failing after 18s
Created and Applied were printed in UTC, while history and status print
local time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:29:43 +01:00
adamandClaude Opus 5.5 1d535c8063 Stats: range buttons follow the retention settings
The Statistics page offers only the ranges kept (longest of the minute,
hour and day retentions), adds 1y and 2y when the daily data covers them,
caps the heatmap at the hourly retention and links to Explore within
retention.queries. Status and explore send retention_seconds. The query
log error names stats.retention.queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:19:12 +01:00
adamandClaude Opus 5.5 7f3564c0a5 CLI: don't print "and N more" when the server sends no PTR list
A 0.6.1-rc.2 server sends ptr_other_views without ptr_other_views_list,
so zone show printed only "and 1 more" under "Other views:".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:05:50 +01:00
adamandClaude Opus 5.5 71d9a06e43 Release 0.6.1-rc.3
release / release (push) Successful in 4m39s
0.6.1's third release candidate: reverse zones list the addresses only
other views' records use, with clearer wording, and jarl zone show lists
missing PTR records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00
adamandClaude Opus 5.5 2fa9828bcf Reverse zones: list other views' addresses, clearer wording
The zone page and jarl zone show list the addresses only other views'
records use (ptr_other_views_list), with the name each would get and the
view it comes from, and say plainly that they aren't missing here. The
page's button reads 'Add PTR records for them in view X'. zone show
lists the missing PTRs too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00
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
adamandClaude Opus 5.5 ead749dd3d Release 0.4.1-rc.4
release / release (push) Successful in 2m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:09:14 +01:00
adamandClaude Opus 5.5 66dcf53684 DNS over TLS and HTTPS set up in one step
jarl encrypted-dns setup (and the Encrypted DNS page) finds or obtains a
certificate for the host name, checks that nothing else listens on the
DoT and DoH ports at the chosen addresses (offering a free DoH port), and
stages named's tls statement and listeners as one change, keeping port 53
where it was when there was no listen-on, plus the host name's records in
the private views (local to the view in a linked zone). Status shows what
to give Android, Linux, Firefox, Chrome and Windows clients. A tls
statement with missing files is refused (tls_file_missing), and a
certificate named uses can't be deleted. docs/encrypted-dns.md, e2e with
real DoT and DoH queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:09:14 +01:00
adamandClaude Opus 5.5 ebd3a3dfbe Release 0.4.1-rc.3
release / release (push) Successful in 2m2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:27:21 +01:00
adamandClaude Opus 5.5 fd0cbb47a9 Web UI: linked views link to their zones; pick views to link one per row, none ticked
The "linked with" badge links each view's zone (the API's zones carry links: id and view). The Link dialog lists the views one per row, with record count and any link they already have; nothing is ticked, Link waits for a choice and names every zone that will join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:27:00 +01:00
adamandClaude Opus 5.5 057433c198 Linked zones: shared records kept in step across views
Zones of the same name in different views can be linked. Shared records
are kept in step: a change in one view reaches the others in the same
changeset, as explicit operations, so the diff, permission checks,
history and undo cover every view. Records local to a view override the
shared records of that name and type there; removing the override brings
the shared ones back, and sharing a view's version makes it every view's.
Each zone keeps its own options, serial and file.

Linking existing zones changes no answer: what is the same everywhere
becomes shared, the rest stays local. Unlinking keeps every record, all
local. ACME challenges are written once per link, and --view all changes
a link once. jarl zone link/unlink, zone create --link-with, record
--local/--shared, LINK column, web UI controls, POST /zones/{zone}/link
and /unlink, validation checks linked_*, store migration 11, docs and
e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:53:58 +01:00
adamandClaude Opus 5.5 6313fa51f8 Release 0.4.1-rc.2
release / release (push) Successful in 7m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:36:00 +01:00
adamandClaude Opus 5.5 88f63ffb0a TLS certificates: Jarl obtains and renews them over ACME
Jarl is now an ACME client (lego's core, no DNS providers). It answers
DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while
Porkbun serves the domain; "auto" decides from the domain's public name
servers at every renewal, and a CNAME on _acme-challenge is followed.
Certificates are used for jarld's HTTPS (served without a restart; each
port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted
proxies still served) and/or written for named's DNS over TLS/HTTPS, with
rndc reconfig on renewal. Renewal follows the CA's ARI window or two
thirds of the lifetime, with back-off. Certificates you renew yourself
can be used too. Certificates page and dashboard card, jarl cert,
/api/v1/certificates, Prometheus gauges, docs/certificates.md, and an
e2e section against Pebble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:35:53 +01:00
adamandClaude Opus 5.5 88965096bb Web UI: option values keep the editor's layout
A shown option value wrapped long lines back to the left edge, losing
their indentation, and had different padding and width from the editor,
so the text and column moved when Edit opened. Values now don't wrap (a
long line scrolls), with the editor's padding and the same width. The
"{ } around the list are optional" hint only shows for options that end
in a list, not rate-limit, which only has one inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:24:03 +01:00
adamandClaude Opus 5.5 bad360107e docs: every rate-limit setting, with defaults and when to change them
A Settings section in docs/exposure.md: the counting model, a table of all
15 rate-limit settings from the BIND 9.20 ARM, and what was checked
against real named (TCP never limited, NXDOMAIN falls back to
responses-per-second and is counted per zone across names, all-per-second
only drops, slipped answers are TC or BADCOOKIE depending on DNS cookies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:18:17 +01:00
adamandClaude Opus 5.5 bdd7acfd3b Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.

A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.

Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:56 +01:00
adamandClaude Opus 5.5 cf550bd512 Release 0.4.1-rc.1
release / release (push) Successful in 1m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:53:31 +01:00
adamandClaude Opus 5.5 cee11e7c86 CLI: changeset commands default to JARL_CHANGESET, which is checked first
jarl changeset show/diff/apply/discard take the changeset from -C or
JARL_CHANGESET when no ID is given, and remove takes [ID] SEQ. A changeset
from JARL_CHANGESET is checked before anything uses it (staging included):
it must exist, be open and be the caller's own, so a stale export can't
stage into an applied changeset or into another user's draft that later
got its ID. Commands say on stderr which changeset they use, and apply and
discard remind to unset JARL_CHANGESET. -C and ID arguments are used as
given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:53:26 +01:00
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:14 +01:00
adamandClaude Opus 5.5 49b1c872c9 Changesets: staged deletes of gone records count as done, name stale ops
A web changeset with a staged record delete could not be applied after
the record was deleted elsewhere (e.g. with the CLI), and the error only
said "record N does not exist", so it was hard to find the operation to
remove. A delete of a record that is already gone is now a no-op with a
warning. Other operations that no longer apply fail with their number and
summary (op_summary in the API), and the Changes page marks that row and
offers to remove it from the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:59:19 +01:00
adamandClaude Opus 5.5 c37de31f4c Prometheus metrics at /metrics, configured in jarl.yaml
New metrics: section (enabled, listen, allow, token). /metrics is served on
the main listeners, the local socket (root and jarld's user) and optional
metrics-only addresses; a scrape needs an allowed address or the bearer
token. Exports health (named up, problems, drift, backups, blocklists,
DNSSEC phases, token expiry) and, with stats on, named's and Jarl's query
counters since jarld started plus named's process and cache gauges.
docs/metrics.md has example queries and alerts checked with promtool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:33:41 +01:00
adamandClaude Opus 5.5 7e6e274a89 Release 0.4.0
release / release (push) Successful in 1m59s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:58:15 +01:00
adamandClaude Opus 5.5 11ed877c3c Release 0.4.0-rc.5
release / release (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:33:22 +01:00
adamandClaude Opus 5.5 76aaaf3ef5 Different blocking for different devices: steps and client groups
Client groups put devices (addresses, networks, or ACLs of them) at a
blocking step: off (only your rules), normal (the default), or steps
added above it, each including the lists of the steps below. Lists
start at a step. In each view, a gate policy zone in front of a step's
lists lets the devices below that step through (rpz-client-ip triggers
with rpz-passthru), so every list is still loaded once. The IPv6 part
of a gate leaves the IPv4-mapped range out: named matches IPv4 clients
as ::ffff:a.b.c.d.

Web UI: a Devices panel on the Blocking page and a step on each list.
CLI: jarl block step, jarl block group, jarl blocklist --step. API:
/blocking/steps, /blocking/groups. Database schema 8.

Also: copying the state kept empty lists as empty (not nil), so
reverting a changeset no longer adds restore operations for unchanged
blocklists, rules and views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:33:16 +01:00
adamandClaude Opus 5.5 5b8f681e2a Show blocked counts on the Blocking page, and reveal + CNAME on hover
The Blocking page shows how many queries each blocklist, and the rules
together, blocked in the last 24 hours (with statistics on). The + CNAME
button on A/AAAA rows shows when the row is hovered or focused, and
always on touch screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:26:12 +01:00
adamandClaude Opus 5.5 8f0a0cc233 Release 0.4.0-rc.4
release / release (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:26 +01:00
adamandClaude Opus 5.5 ecffc2d5c1 Let jarld recognize this host's addresses under systemd
release / release (push) Successful in 1m58s
The unit's RestrictAddressFamilies lacked AF_NETLINK, which listing the
host's addresses needs, so the name server checks didn't know this
server's own addresses: the Name servers tab queried them like another
server's, and the delegation check didn't sign them with the view key.
Allow AF_NETLINK, and fall back to /proc/net without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:06 +01:00
adamandClaude Opus 5.5 9d9ae37367 Release 0.4.0-rc.3
release / release (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:19:56 +01:00
adamandClaude Opus 5.5 384f6b3060 Give every view a TSIG key that selects it
Each view's match-clients starts with "key jarl-view-NAME" (after
"!key" for the views listed after it), so a query signed with a view's
key lands in that view from any address. The secrets are derived from a
random seed in Jarl's database. The delegation check signs its queries
to this server's own addresses with the key of the zone's view: before,
they got whichever view matched the server's address, usually internal.
jarl view key / GET /views/{id}/key show a key for dig -k (tsig:write,
audited). jarl-view- key names are reserved; the importer drops view
keys from a configuration Jarl wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:19:10 +01:00
adamandClaude Opus 5.5 c696d02513 Give each view its own page, and add a CNAME from an A record
Views open a page like a zone's, with Settings, Options and Zones tabs
(and New zone in that view); the Views page lists and orders them. A
and AAAA records get a + CNAME button that fills in the add form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:12:56 +01:00
adamandClaude Opus 5.5 7be0762f48 Server statistics, multi-view record commands, and rc.3 fixes
Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
  sockets and client counts against their limits (statistics channel,
  rndc status), host memory and load, and free disk space. Memory and
  CPU are kept over time as gauges (sum and sample count, averaged in
  timelines). Statistics page Server section, jarl stats server,
  GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
  change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
  "Until I resume", {"indefinitely": true}).

Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
  when given without them.
- The dashboard lists named-checkconf's warnings about the running
  configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
  failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:03:05 +01:00
adamandClaude Opus 5.5 b097e10bef Keep options in a fixed order, and say which block a diff hunk is in
Options of a block (server options, a view, a zone) are listed and
written grouped by topic, alphabetical within a group, so the same
option is in the same place everywhere. Top-level statements keep the
order they were added in.

named.conf diffs add the enclosing blocks to each hunk header, like
git's function context: @@ -50,6 +49,8 @@ view "internal" {.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:06:22 +01:00
adamandClaude Opus 5.5 73d97ecc0a Release 0.4.0-rc.2
release / release (push) Successful in 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:51:29 +01:00
adamandClaude Opus 5.5 33060f86ce Don't count a per-view counter going down as a restart of named
After a reconfig that changed named's views, the cache and resolver
counters (summed over views) went down, which was taken for a restart:
every counter then counted its total since named started for one minute.
Only a new boot time means a restart now; a counter that goes down
counts 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:51:24 +01:00
adamandClaude Opus 5.5 89663e366b Disable zones instead of deleting them
A disabled zone stays in Jarl with its records, options and history,
still editable, but named doesn't load it; enabling it serves it as it is
then. Signed zones, and zones another view shares, can't be disabled.
jarl zone disable|enable, the zone page's Disable/Enable button.
Database schema 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:45:55 +00:00
adamandClaude Opus 5.5 59baccd636 Release 0.4.0-rc.1
release / release (push) Successful in 1m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:56:15 +00:00
adamandClaude Opus 5.5 02c393a6bb Apply pending changes from the banner
An Apply now button on the pending-changes banner applies the draft and
stays on the page; if the changes are refused, a dialog says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:56:10 +00:00
adamandClaude Opus 5.5 d4756444ac Edit blocklists and rules in the web UI
An Edit button opens a dialog for a list's name, URL, views and comment,
or a rule's action, scope, views and comment. The download-now button is
called Refresh. Checkboxes in dialogs are no longer stretched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:50:41 +00:00
adamandClaude Opus 5.5 67f14b030f Make disabled records obvious, and offer the right fix when checking a blocked name
Disabled records are hatched and badged next to their name. Checking a
name blocked by your own rule for that name offers to remove the rule,
instead of an allow rule that would be refused as a duplicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:45:10 +00:00
adamandClaude Opus 5.5 ad8eb3fc3d Show and edit option values laid out as in named.conf
Values with blocks (logging, controls, ...) are shown over several lines
with indentation, and edited that way: the editor grows with its content,
Tab indents, Enter keeps the indentation. With a draft open, option tables
show the draft's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:55:41 +00:00
adamandClaude Opus 5.5 27d1f9e538 Pause blocking, blocked clients and a blocked line in the statistics, compact view picker
jarl block pause|resume (and the Blocking page) lets every name through
for a while; jarld resumes on time. The lists stay loaded: the rules zone
just passes everything. Statistics gain the clients with the most blocked
queries and blocked queries on the queries chart. The view choice in the
blocking forms is a dropdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:10:44 +00:00
230 changed files with 29715 additions and 866 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.
+719 -3
View File
@@ -8,6 +8,639 @@ All notable changes to Jarl are documented here. The format follows
### Added
- **Failed logins in the journal.** A failed login (a wrong password, or
a wrong two-factor code) and a refused API token are logged as well as
audited, with the address first (`msg="login failed" ip=… user=…`,
`msg="token refused" ip=…`), for Guard's `jarl` preset or a fail2ban
filter to ban such addresses (docs/operations.md).
- **`jarl cert get --combined PATH`** also writes the key and the full
chain in one file (mode 600, written all at once), for HAProxy. It
belongs outside `--dir`, so a directory of combined files can be
HAProxy's `crt` directory. The hook gets it as `JARL_CERT_COMBINED`.
Adding it to a certificate this host already has writes it from the
files on disk and runs the hook; `--combined ''` removes it.
- **`jarl cert get --local`** doesn't ask jarld: it writes the combined
file and runs the hook from this host's files, to apply a changed
setting or test a hook.
- **`jarl cert saved NAME`** shows all of one certificate's saved
settings (names, key type, dir, combined file, hook, server) and this
host's copy. The token is never shown.
- `jarl cert list` has a PULLED column: when the certificate was last
downloaded, with `(old)` while a host hasn't fetched the renewed one.
The owner of a certificate obtained with `jarl cert get` now sees their
own downloads (`pulled_by`) in `jarl cert list` and `jarl cert show`, not
only administrators.
### Changed
- `jarl cert list` shows `get` as USES for a certificate obtained with
`jarl cert get`, not `-`.
- `jarl cert get` without a name refuses `--dir`, `--hook`, `--key-type`,
`--combined`, `--replace` and `--no-save`, which change one
certificate; they used to be ignored.
## [0.6.3] - 2026-10-21
### Added
- A docs/acme.md section: lego on a host inside a split DNS fails its own
propagation check when the challenge is written into the external view
only (`JARL_VIEW`, a one-view token, `.../httpreq/view/...`): why, and
the ways round it (no view limit, `--dns.propagation.disable-ans` or
`--dns.propagation.wait`, Traefik's `propagation.disableANSChecks`).
- docs/acme.md and `jarl-lego` show lego 5's commands (`lego run ...`,
which also renews; `lego migrate` when upgrading), with lego 4's
beside them. Jarl's side is the same for both.
## [0.6.2] - 2026-10-21
### Added
- A `certificates` role (template): `certificates:write`, for tokens and
users that get certificates for other hosts with `jarl cert get`. In
the web UI's user and token forms it is in "Insert a template…", to
narrow with `names=`; in the CLI, `--role certificates`.
## [0.6.1] - 2026-10-20
### 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
- The Statistics page's range buttons follow `stats.retention`: ranges
longer than any data kept are left out, and **1y** and **2y** appear
when `retention.day` (or `retention.hour`) covers them (with the
default 730d, both do). The busiest-times heatmap says how far back it
really goes (at most `retention.hour`), and links to the Explore page
keep within `retention.queries` instead of assuming 7 days.
`GET /stats/status` and `GET /stats/explore` add `retention_seconds`.
- 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. The page and `jarl zone show` list those addresses with the
name each would get, and the view it comes from
(`ptr_other_views_list`); `jarl zone show` lists the missing ones too.
- 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
- `jarl cert get NAME` no longer takes over a name this host has saved
for another Jarl server, which overwrote that certificate's settings
(and, with the default `--dir`, its files), so it stopped being
renewed. It is refused, as is a `--dir` holding another certificate's
files; `--replace` takes the name or directory over.
- The "query log is off" error named a setting that doesn't exist,
`stats.query_retention`; it's `stats.retention.queries`.
- `jarl zone show` for a reverse zone printed only "and 1 more" under
"Other views:" when the server was older than the CLI (0.6.1-rc.2
sends the count but not the addresses). It now prints just the count
line then.
- `jarl changeset show` printed its Created and Applied times in UTC,
while `jarl history` and `jarl status` print local time. It uses local
time now too.
- 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
that newly opens a public view up (an open resolver, say) now needs
`--accept-risk`, or the checkbox on the Changes page.
- Once you use address ranges, the database can't go back to 0.4: its
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
(a long line scrolls), with the same width and padding as the editor.
The "{ } around the list are optional" hint only shows for options that
end in a list (not rate-limit, which only has one inside).
- Query statistics skipped every query signed with a TSIG key (named
writes "/key NAME" after the client's port), such as queries with a
view's key: they are counted now.
- A changeset with a staged record delete could not be applied after that
record was deleted elsewhere (e.g. with the CLI), and it was hard to see
which operation to remove: the error only said "record 832 does not
exist". A staged delete of a record that is already gone now counts as
done (with a warning), and the other operations that no longer apply
(an edit or disable of a deleted record, a change to an object changed
since) are named with their number and summary. The Changes page marks
that operation and offers to remove it right in the error.
### 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,
checks that nothing else listens on port 853 and the DoH port (443 by
default) at those addresses, offering a free port, and stages named's
`tls` statement and listeners as one change, keeping port 53 where it
was, plus the host name's records in your private views (local to the
view in a linked zone). The page and `jarl encrypted-dns` say what to
give Android, Linux, Firefox, Chrome and Windows. A `tls` statement whose
files are missing is refused (`tls_file_missing`), and a certificate
named uses can't be deleted.
- Linked zones ([docs/views.md](docs/views.md#linked-zones)): zones of the
same name in different views can be linked, and Jarl keeps their shared
records in step: a change in one view reaches the others in the same
changeset (diff, permissions, history and undo cover every view). Each
view can still have records of its own (`--local`, "Only here"), which
override the shared records of that name and type there, and keeps its
own zone options. Linking existing zones changes no answer: what is the
same everywhere becomes shared, the rest stays local, and a view's
version can be shared later (`--shared`, "Share"). `jarl zone link` and
`unlink`, `zone create --link-with VIEW`, `record add|set|update
--local|--shared`, a LINK column in `record list`, "Keep them linked"
when copying a zone in the web UI, `POST /zones/{zone}/link` and
`/unlink`. ACME challenges are written once per link, and `--view all`
changes a link once.
- TLS certificates ([docs/certificates.md](docs/certificates.md)): Jarl
obtains certificates from Let's Encrypt or any ACME CA (account set up
in jarl.yaml under `acme.account`) and renews them itself, from the CA's
renewal information (ARI) or two thirds into their lifetime, with
back-off after failures. It answers the DNS-01 challenges in a zone it
serves, or through Porkbun's DNS API while Porkbun serves the domain;
`auto` decides from the domain's public name servers at every renewal,
so moving the domain to your own name servers needs no change. A CNAME
on `_acme-challenge` into a zone Jarl serves is followed, for domains
hosted elsewhere. A certificate is used for jarld's HTTPS ("web") and/or
written for named (`<bind.config_dir>/tls/NAME/`, "dns"; named rereads
it on renewal), and certificates you renew yourself can be used too
(reread when their files change). Certificates page and dashboard card
in the web UI, `jarl cert` (check, create, issue, update, delete,
account), `/api/v1/certificates`, Prometheus
`jarl_certificate_expiry_timestamp_seconds`, `jarl_certificate_failing`.
- jarld serves HTTPS and plain HTTP on the same ports once it has a
certificate (`tls.cert_file`, or one with the web use, which takes
effect without a restart): plain HTTP from the server itself and from
trusted proxies is served as before, browsers elsewhere are redirected
to HTTPS and API requests get `https_required`. Before, with
`tls.cert_file`, plain HTTP got an error even from localhost.
- Internet exposure ([docs/exposure.md](docs/exposure.md)): Jarl works out
which views are reachable from the internet (match-clients ending in a
catch-all; for a server without views, open allow-query and a
non-private listen address), with a per-view override, and checks them:
response rate limiting, open resolvers, ANY answers (minimal-any), cache
snooping, open NOTIFY and zone transfers, BIND version and host name,
management interfaces. Fixes are staged like any change, including
response rate limiting from ISC's example in log-only mode, with your
secondaries exempt, and a later "start enforcing". With statistics on,
Jarl reads named's rate limiting log lines and shows what was (or would
have been) limited: counts, client networks and names (Exposure page,
`jarl stats breakdown rrl`, top lists `ratelimited`, Prometheus
`jarl_ratelimit_logged_total`). A change that newly opens a public view
up must be confirmed (checkbox on the Changes page, `--accept-risk`, API
`accept_risk`; 409 `confirm_risk` otherwise). Findings can be
acknowledged with a reason: they stay visible as info, with who, when
and why. Exposure page and dashboard card in the web UI, `jarl
exposure` (fix, ack, unack), `GET /exposure`.
- `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the
changeset from `-C` or `JARL_CHANGESET` when no ID is given
(`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now
checked before it is used, for staging too: it must exist, be open and
be yours, so a stale export (an applied changeset, or an ID that later
turned into someone else's web UI draft) can't silently take your
changes; the error says what to do. Commands say on stderr which
changeset from `JARL_CHANGESET` they use, and `apply`/`discard` remind
you to `unset JARL_CHANGESET`. `-C` and ID arguments are used as given.
- Address ranges such as `10.0.10.101-10.0.10.199` or
`2001:db8::100-2001:db8::1ff` (also `!` negated, and with spaces around
the dash) wherever an address match list is used: ACLs, views'
match-clients and match-destinations, options such as allow-query, and
blocking's client groups ([docs/concepts.md](docs/concepts.md)). Jarl
keeps the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. The web
UI shows what a range becomes while you type it (`GET
/catalog/ranges`). Mistakes are refused when entered: an end that isn't
a full address, the start after the end, IPv4 mixed with IPv6, a range
in an option that takes no address match list (forwarders). A range
covered by an earlier element is reported as never matching, a range of
every address counts as "any" for the open resolver and open transfer
checks, and an IPv6 range in the IPv4-mapped space gets a warning. ACL
names can no longer look like a range.
- Prometheus metrics at `/metrics` ([docs/metrics.md](docs/metrics.md)),
set up in the new `metrics:` section of jarl.yaml (off by default).
`metrics.allow` lists the addresses that may scrape (loopback by
default), `metrics.token` lets anyone who sends it as a bearer token
scrape, and `metrics.listen` adds addresses that serve only `/metrics`
(for a Prometheus server elsewhere). Root and jarld's own user can also
scrape on the local socket.
- Health, always: named up, recursive and TCP clients against their
limits, adopted, open changesets, last apply, validation problems by
severity, drifted files, object counts, the last backup (and failure,
and whether it is stale), blocking paused, each blocklist's entries,
last check and change and whether it fails, the DNSSEC phase of every
signed zone, and when API tokens expire.
- With query statistics on: named's counters (queries, result codes,
record types, UDP/TCP, authoritative/recursive, rate limiting, upstream
round-trip times, DNSSEC validation, cache hits) and Jarl's (logged
responses per view and zone, blocked queries per blocklist), as
counters since jarld started, plus named's memory, CPU, threads, cache
and sockets, and the collector's health.
- Example queries and alert rules, checked with promtool.
## [0.4.0] - 2026-10-11
### Upgrading from 0.3
- Back up first (`sudo jarld backup`).
- When jarld first starts, it rewrites named's configuration: options
move into their new fixed order, and every view gets its view key in
`match-clients` and `keys.conf`. It is checked with named-checkconf
before named reloads, like any apply. Files you changed by hand are left
alone; the log then says to run `jarl resync`.
- The systemd unit changed (it allows `AF_NETLINK`). The package replaces
it; an override that adds `AF_NETLINK` (`systemctl edit jarl`) does no
harm and can be removed.
- The database moves to schema 8 (disabled zones, client groups) as
jarld starts.
- If statistics showed a spike after views were created or changed, see
"Statistics" under Fixed: the bad minutes can be removed from
`/var/lib/jarl/stats.db` (minute rows have `res = 'm'`; the hours and
days that include them are `'h'` and `'d'`).
### Added
- Different blocking for different devices (docs/blocking.md), like
Pi-hole's groups, in one view and without loading any list twice.
Client groups put devices (addresses, networks or ACLs of them) at a
blocking step: `off` (only your rules), `normal` (the default), or steps
you add above it, each including the lists of the steps below. Lists
start at a step. Jarl puts a small "gate" policy zone in front of each
step's lists that lets the devices below that step through. Web UI: a
Devices panel on the Blocking page and a step on each list. CLI: `jarl
block step`, `jarl block group`, `jarl blocklist update --step`. API:
`/blocking/steps`, `/blocking/groups`. Database schema 8.
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
its match-clients, so a query signed with it lands in that view from any
address. Jarl signs its delegation check's queries to this server's own
addresses with the key of the zone's view: before, they got whichever
view matched the server's own address (usually internal), so the check
could report the wrong serial for the external view. `jarl view key
VIEW` shows a key, for `dig -k` (docs/views.md). The first apply after
upgrading adds the keys to named.conf and keys.conf.
- Server statistics: named's memory (resident, and its own count) and
CPU use, its cache size, sockets, and recursive and TCP clients against
their limits; the host's memory and load; free space where zone files,
logs and Jarl's data are. Memory and CPU are kept over time like the
other statistics. Statistics page → Server, `jarl stats server`, API
`/stats/server`. Read from named's statistics channel, `rndc status` and
/proc, with no extra privileges.
- CLI: the record commands that change records take several views,
`--view internal,external`, or `--view all`, and make the change in each,
as one changeset (all views or none).
- Blocking can be paused until resumed, like Pi-hole's "indefinitely":
`jarl block pause indefinitely`, **Until I resume** on the Blocking page,
`{"indefinitely": true}` in the API.
- Disabled zones: a zone can be disabled instead of deleted. named stops
loading it (its zone statement is left out of named.conf), while Jarl
keeps it with its records, options and history, still editable;
enabling it serves it as it is then. Disabling says what clients get
instead (recursion, or REFUSED). Signed zones can't be disabled. Web
UI: Disable/Enable on the zone page, a badge in the zone list. CLI:
`jarl zone disable|enable`. API: `PATCH /zones/{zone}` with
`{"disabled": true}`. Database schema 7.
- Blocking (docs/blocking.md): named answers "doesn't exist" for ads,
trackers and malware, using BIND's response policy zones.
- Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld
@@ -26,13 +659,96 @@ All notable changes to Jarl are documented here. The format follows
- Web UI: a Blocking page, with well-known lists to pick and named's
memory per list. CLI: `jarl blocklist` and `jarl block`. API:
`/blocking/lists`, `/blocking/rules`, `/blocking/check`.
- Statistics: blocked queries and their share, blocked per list, most
blocked names (`jarl stats top blocked`). Blocked answers no longer
count as names that don't exist.
- Pausing: `jarl block pause [DURATION]` or the Blocking page lets every
name through for a while (at most a day); blocking resumes by itself.
The lists stay loaded, so it takes effect at once.
- Statistics: blocked queries over time (a line on the queries chart)
and their share, blocked per list, the most blocked names and the
clients with the most blocked queries (`jarl stats top blocked`,
`top blocked-clients`). Blocked answers no longer count as names that
don't exist.
- The view choice in the web UI's forms is a compact dropdown, however
many views there are.
- A `blocking` permission; database schema 6.
### Changed
- Web UI: each view has its own page, like a zone: Settings (match lists,
comment, colour), Options and Zones tabs (with **New zone in VIEW**),
and Delete. The Views page lists them in order, with the arrows that
reorder them; view links everywhere open the view's page.
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the
add form with a CNAME pointing to the record's name, leaving only the
alias's name to type. It shows when the row is hovered or focused
(always on touch screens).
- Blocking page: how many queries each blocklist blocked in the last 24
hours, and your rules together, with statistics on (linking to the
Statistics page).
- Options are kept in one fixed order, in named.conf, the web UI and the
CLI: grouped by topic (files, listening, access control, recursion and
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
within a group. The same option is now in the same place in every view
and zone. The first apply after upgrading reorders named.conf (a diff
that only moves lines). Top-level statements keep their order.
- Options that end in a list (`allow-transfer`, `forwarders`,
`listen-on`, ...) can be given without their braces, in the web UI, CLI
and API: `10.0.10.53; key xfer` becomes `{ 10.0.10.53; key xfer; }`.
Before, only some server options (those edited one element per line)
added them.
- The dashboard's configuration checks include named-checkconf's own
warnings about the running configuration (such as `both "recursion no;"
and "allow-recursion" active`), which before only showed while applying.
The CLI now prints them after an apply too.
- Statistics page: cards and tiles fill their rows instead of leaving one
alone beside an empty space, and the queries and failed answers charts
have the same margins, so their time axes line up.
- Dashboard: options have their own tile; "0 TSIG keys · 22 options" read
as if the options belonged to the keys.
- Web UI times use the 24-hour clock.
- Diffs of named.conf say which block each change is in, after the `@@`
line numbers (`@@ -50,6 +49,8 @@ view "internal" {`), like git does
with function names. Moving an option from the server's options into a
view no longer looks like two unrelated edits.
- Web UI options (server, top-level, view and zone): values with blocks,
such as logging, are shown and edited laid out as in named.conf, over
several lines with indentation. The editor grows with its content, Tab
indents and Enter keeps the indentation (Esc, then Tab, moves on). With
a draft open, the tables show the draft's values, so a staged edit stays
visible after Save.
- The pending-changes banner has an Apply now button: it applies your
draft without going to the Changes page, and stays on the page you're
on. If the changes are refused, a dialog says why and links to Changes.
- Blocking page: blocklists and rules have an Edit button (name, URL,
views, comment; a rule's action and scope). The list button that
downloads now is called Refresh, like `jarl blocklist refresh`.
- Records table: disabled records are hatched and have a "disabled" badge
next to their name, instead of only a muted colour and a badge in the
comment column.
### Fixed
- Reverting a changeset added needless "restore" operations for
blocklists, rules and views that hadn't changed: copying the state
turned their empty view lists into missing ones.
- Under the shipped systemd unit, jarld couldn't list the host's
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
didn't allow), so the name server checks didn't recognize this server's
own addresses. The Name servers tab then queried them like another
server's, and the delegation check didn't sign those queries with the
view's key: with views, both got the view matching the server's own
address (usually internal) and reported a wrong serial. The unit now
allows `AF_NETLINK`, and without it jarld reads the addresses from
/proc.
- Statistics: after a reconfig that added, removed or recreated views
(creating the first view, for instance), a minute could count everything
since named started: millions of queries in an hour. A per-view counter
(cache, upstream times, validation) going down was taken for a restart
of named. Now only a new boot time means a restart, and a counter that
goes down counts 0 for that minute.
- Blocking page, checking a name: when your own block rule is for that very
name, it offers to remove the rule instead of an allow rule, which was
refused as a second rule for the same name. It also names the rule that
matched rather than the policy zone entry.
- docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0.
## [0.3.0] - 2026-10-09
+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
+58 -4
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,
@@ -71,6 +72,19 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
queries, record types, cache hit rate. Shown in the web UI and the CLI,
and exportable as JSON. Stored as aggregates in a separate database with
retention you set.
* **Internet exposure checks**: Jarl works out which views are reachable
from the internet (a GUA-addressed internal network still counts as
yours) and checks them: response rate limiting (set up in log-only mode
first, with what it would limit shown before you enforce it), open
resolvers, ANY amplification, cache snooping, version and host name
leaks, open management interfaces. Fixes are staged for review, changes
that open a public view up must be confirmed, and deliberate exceptions
can be acknowledged with a reason.
* **Prometheus metrics** (optional): health to alert on (named down,
configuration problems, drift, failing blocklists, stale backups, DNSSEC
steps waiting at the parent, expiring tokens) and query counters per
result code, view, zone and blocklist, with example alerts. Set up in
jarl.yaml, with an address allow list and/or a bearer token.
* **Blocking** like Pi-hole, done by named itself: blocklists (Adblock,
hosts or plain domains) downloaded and kept up to date, your own block
and allow rules that beat every list, per view, with "why is this
@@ -78,6 +92,18 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
* **Let's Encrypt DNS-01** built in: `jarl acme` plus certbot, acme.sh and lego
hooks, an HTTPS-only endpoint for lego's and Traefik's `httpreq` provider,
and CNAME delegation to a dedicated ACME zone.
* **Certificates for the server itself**: Jarl obtains and renews TLS
certificates from Let's Encrypt (or any ACME CA), answering the DNS-01
challenge in its own zones or through Porkbun's API while Porkbun still
serves the domain, decided at every renewal. Use them for the web UI
(HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
rereads them on renewal), or bring your own.
* **DNS over TLS and HTTPS** for your own devices in one step: the
certificate, named's listeners on the addresses you choose (after
checking the ports are free) and the host name in your private views.
* **Linked zones for split DNS**: the same zone in several views, with the
shared records kept in step by Jarl and a few records of each view's own
(an internal address for a public name, say).
* **Adopts an existing server**: imports named.conf and zone files
(comments included), then switches named over with an automatic
fallback.
@@ -119,7 +145,12 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
| [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 |
| [docs/validation.md](docs/validation.md) | Every check Jarl makes |
| [docs/operations.md](docs/operations.md) | Backups, drift, failures, recovery, monitoring |
@@ -171,9 +202,32 @@ review step.
## Status
Version 0.3.0 is the current release. It adds disabled records,
script-friendly CLI output and client names in the statistics, on top of
0.2.0's DNSSEC signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
Version 0.6.3 is the current release; 0.6.4 is in testing (0.6.4-rc.2):
failed logins and refused tokens are logged, address first, for Guard
and fail2ban, and `jarl cert get` can write a combined file for HAProxy
(`--combined`) and apply settings without jarld (`--local`). 0.6.3 brings the lego docs up to
lego 5 and explains lego with split DNS. 0.6.2 added a `certificates` role
for tokens that get certificates for other hosts. 0.6.1 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
names in the statistics, and 0.2.0's DNSSEC signing, Porkbun support,
two-factor login and view colours. Jarl is feature-complete for one server's configuration.
Managing several servers from one jarld and dynamic updates are not
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
+1 -1
View File
@@ -1 +1 @@
0.3.0
0.6.4-rc.2
+1 -1
View File
@@ -83,7 +83,7 @@ func acmeZones(c *client.Client, name, view string) ([]acme.Zone, error) {
}
refs := make([]acme.Zone, len(zones))
for i, z := range zones {
refs[i] = acme.Zone{Name: z.Name, View: z.View}
refs[i] = acme.Zone{Name: z.Name, View: z.View, Link: z.LinkGroup}
}
out := acme.Zones(name, refs, view)
if len(out) == 0 {
+12 -3
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))
}
@@ -389,7 +392,8 @@ ACCESS is read or write, and filters take comma separated glob patterns:
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
*:read
Roles are ready-made grant sets: readonly, dns-editor, operator, acme.`
Roles are ready-made grant sets: readonly, dns-editor, operator, acme,
certificates.`
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
@@ -444,6 +448,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,13 +471,17 @@ 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) {
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator, acme or certificates")
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",
@@ -750,7 +759,7 @@ func tokenCmd() *cobra.Command {
},
}
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme")
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator, acme or certificates")
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
+112 -4
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"io"
"net/url"
@@ -42,7 +43,7 @@ named answers "doesn't exist" for the domains on them (docs/blocking.md).
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
allow rules ('jarl block') take precedence over every list.`}
var views []string
var comment string
var comment, step string
list := &cobra.Command{
Use: "list",
@@ -58,7 +59,7 @@ allow rules ('jarl block') take precedence over every list.`}
return err
}
return output(lists, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tSTATE\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
fmt.Fprintln(w, "NAME\tSTATE\tSTEP\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
for _, b := range lists {
domains, updated, mem := "-", "-", "-"
if s := b.Status; s != nil && s.UpdatedAt != nil {
@@ -67,7 +68,7 @@ allow rules ('jarl block') take precedence over every list.`}
if b.MemoryMB > 0 {
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, b.Step, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
}
for _, b := range lists {
if b.Status != nil && b.Status.Error != "" {
@@ -94,6 +95,9 @@ allow rules ('jarl block') take precedence over every list.`}
}
body := map[string]any{"name": args[0], "url": args[1]}
viewsBody(body, views)
if step != "" {
body["step"] = step
}
if comment != "" {
body["comment"] = comment
}
@@ -110,6 +114,7 @@ allow rules ('jarl block') take precedence over every list.`}
}
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
add.Flags().StringVar(&comment, "comment", "", "comment")
add.Flags().StringVar(&step, "step", "", "the blocking step it starts at (default: normal; see 'jarl block step')")
var rename, newURL string
update := &cobra.Command{
@@ -135,6 +140,9 @@ allow rules ('jarl block') take precedence over every list.`}
if f.Changed("comment") {
body["comment"] = comment
}
if f.Changed("step") {
body["step"] = step
}
viewsBody(body, views)
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
@@ -150,6 +158,7 @@ allow rules ('jarl block') take precedence over every list.`}
update.Flags().StringVar(&newURL, "url", "", "new URL")
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies to (repeat for several; "auto": every view that offers recursion)`)
update.Flags().StringVar(&comment, "comment", "", "new comment")
update.Flags().StringVar(&step, "step", "", "the blocking step it starts at (normal, or a step above it)")
toggle := func(disable bool) *cobra.Command {
verb := map[bool]string{true: "disable", false: "enable"}[disable]
@@ -309,7 +318,11 @@ names below it (docs/blocking.md).`}
if err := c.Get(ctx(), "/blocking/rules", nil, &rules); err != nil {
return err
}
note := pausedNote(c)
return output(rules, func(w io.Writer) {
if note != "" {
fmt.Fprintln(w, note)
}
fmt.Fprintln(w, "ID\tACTION\tDOMAIN\tCOVERS\tVIEWS\tCOMMENT")
for _, r := range rules {
covers := "and below"
@@ -372,7 +385,11 @@ names below it (docs/blocking.md).`}
if err := c.Get(ctx(), "/blocking/check", q("name", args[0]), &matches); err != nil {
return err
}
note := pausedNote(c)
return output(matches, func(w io.Writer) {
if note != "" {
fmt.Fprint(w, note+"While it is, every name is answered. Otherwise:\n")
}
if len(matches) == 0 {
fmt.Fprintln(w, "Blocking isn't set up in any view.")
return
@@ -385,6 +402,8 @@ names below it (docs/blocking.md).`}
switch {
case m.Result == "none":
fmt.Fprintf(w, "%snot blocked\n", where)
case m.List != "" && m.Step != "":
fmt.Fprintf(w, "%sblocked for clients at step %s and above, by blocklist %s (entry %s); not for others\n", where, m.Step, m.List, m.Entry)
case m.List != "":
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
default:
@@ -395,6 +414,95 @@ names below it (docs/blocking.md).`}
},
}
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check)
pause := &cobra.Command{
Use: "pause [DURATION | indefinitely]",
Short: "Let every name through for a while (default 10m, at most 24h) or until resumed",
Long: `Pause blocking: every name is answered normally until the time is up,
then blocking resumes by itself. Useful to see whether blocking is what
breaks a site. The lists stay loaded, so pausing and resuming take effect
at once. "indefinitely" pauses until 'jarl block resume'.
jarl block pause # 10 minutes
jarl block pause 1h
jarl block pause indefinitely
jarl block resume`,
Args: cobra.MaximumNArgs(1),
ValidArgs: []string{"indefinitely", "5m", "10m", "30m", "1h", "24h"},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 1 && args[0] == "indefinitely" {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"indefinitely": true})
if err != nil {
return err
}
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
return err
}
fmt.Println("Blocking is paused until you resume it ('jarl block resume').")
return nil
}
d := 10 * time.Minute
if len(args) == 1 {
var err error
if d, err = time.ParseDuration(args[0]); err != nil || d < time.Minute {
return fmt.Errorf("invalid duration %q (like 10m or 2h, or indefinitely)", args[0])
}
}
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"minutes": int(d.Round(time.Minute) / time.Minute)})
if err != nil {
return err
}
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
return err
}
fmt.Printf("Blocking is paused until %s.\n", time.Now().Add(d.Round(time.Minute)).Format("15:04"))
return nil
},
}
resume := &cobra.Command{
Use: "resume",
Short: "End a pause: block again now",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/resume", nil, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume, blockStepCmd(), blockGroupCmd())
return cmd
}
// pausedNote returns a line about a pause of blocking, or "".
func pausedNote(c interface {
Get(context.Context, string, url.Values, any) error
}) string {
var st struct {
PausedUntil *time.Time `json:"paused_until"`
PausedIndefinitely bool `json:"paused_indefinitely"`
}
if c.Get(ctx(), "/blocking/status", nil, &st) != nil {
return ""
}
if st.PausedIndefinitely {
return "Blocking is paused until you resume it ('jarl block resume').\n"
}
if st.PausedUntil == nil {
return ""
}
return "Blocking is paused until " + st.PausedUntil.Local().Format("2006-01-02 15:04") + " ('jarl block resume' ends it).\n"
}
+972
View File
@@ -0,0 +1,972 @@
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, writes the combined file
// (--combined) 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"`
// Combined is a file with the key and the full chain together, for
// HAProxy (--combined); kept outside Dir.
Combined string `json:"combined,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"`
// Combined is the combined file written for these files, so a changed
// --combined removes the old one.
Combined string `json:"combined,omitempty"`
// 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)
}
// checkCertClash refuses a certificate that would take over what this
// host keeps for another one: a name saved for another Jarl server
// (each server has its own names, so the server can't refuse it), or a
// directory with another certificate's files. saved[i] is sc's saved
// settings (i < 0: none).
func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error {
if i >= 0 && !replace && !sameServer(saved[i].Conn.Server, sc.Conn.Server) {
return fmt.Errorf("%s is saved here for the Jarl server %s, not %s: choose another name, or take it over with --replace", sc.Name, saved[i].Conn.Server, sc.Conn.Server)
}
for j, o := range saved {
if j != i && o.Name != sc.Name && filepath.Clean(o.Dir) == filepath.Clean(sc.Dir) {
return fmt.Errorf("%s holds the files of %s, saved for %s: give %s another --dir", sc.Dir, o.Name, o.Conn.Server, sc.Name)
}
}
if err := checkCombined(saved, i, sc); err != nil {
return err
}
if m, err := readMeta(sc.Dir); err == nil && m.Name != "" && m.Name != sc.Name && !replace {
return fmt.Errorf("%s holds the files of the certificate %s: give %s another --dir, or overwrite them with --replace", sc.Dir, m.Name, sc.Name)
}
return nil
}
// checkCombined refuses a --combined path that is a directory, one of
// the files jarl cert get writes in a certificate's directory, or another
// saved certificate's combined file.
func checkCombined(saved []savedCert, i int, sc savedCert) error {
if sc.Combined == "" {
return nil
}
if st, err := os.Stat(sc.Combined); err == nil && st.IsDir() {
return fmt.Errorf("--combined %s is a directory: give the file's path, such as %s", sc.Combined, filepath.Join(sc.Combined, sc.Name+".pem"))
}
dir, base := filepath.Split(sc.Combined)
for j, o := range append(slices.Clone(saved), sc) {
if j == i {
continue // sc itself is last
}
if filepath.Clean(dir) == filepath.Clean(o.Dir) && (base == metaFile || slices.Contains(certFiles, base)) {
return fmt.Errorf("--combined %s would overwrite %s's %s: give another path", sc.Combined, o.Name, base)
}
if j < len(saved) && o.Name != sc.Name && o.Combined != "" && filepath.Clean(o.Combined) == filepath.Clean(sc.Combined) {
return fmt.Errorf("--combined %s is the combined file of %s: give %s another path", sc.Combined, o.Name, sc.Name)
}
}
return nil
}
// sameNames reports whether two lists hold the same names, in any order.
func sameNames(a, b []string) bool {
norm := func(l []string) []string {
out := make([]string, len(l))
for i, n := range l {
out[i] = strings.TrimSuffix(strings.ToLower(n), ".")
}
slices.Sort(out)
return slices.Compact(out)
}
return slices.Equal(norm(a), norm(b))
}
// sameServer reports whether two server addresses name the same jarld.
func sameServer(a, b string) bool {
norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") }
return norm(a) == norm(b)
}
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
// Combined is the combined file; CombinedWritten is set when it was
// written now.
Combined string `json:"combined,omitempty"`
CombinedWritten bool `json:"combined_written,omitempty"`
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
// then applies it (applyCert).
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)
}
meta, _ := readMeta(sc.Dir)
have := meta != nil && meta.Serial == ci.Serial && filesPresent(sc.Dir)
renewed := meta != nil && meta.Serial != "" && meta.Serial != ci.Serial
updated := !have || force
if updated {
if err := downloadCert(c, sc, ci); err != nil {
return nil, err
}
old := ""
if meta != nil {
old = meta.Combined
}
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC(), Combined: old}
}
return applyCert(sc, meta, updated, renewed, force)
}
// localCert applies the files this host has, without asking jarld
// (--local): it writes the combined file and runs the hook.
func localCert(sc savedCert) (*getResult, error) {
meta, err := readMeta(sc.Dir)
if err != nil || !filesPresent(sc.Dir) {
return nil, fmt.Errorf("%s: this host has no files for it in %s yet: run jarl cert get %s without --local", sc.Name, sc.Dir, sc.Name)
}
if meta.Name != "" && meta.Name != sc.Name {
return nil, fmt.Errorf("%s holds the files of the certificate %s, not %s", sc.Dir, meta.Name, sc.Name)
}
return applyCert(sc, meta, false, false, true)
}
// applyCert brings what lies outside the certificate's directory in line
// with its files: it writes the combined file when the files are new, its
// path changed or it is missing (and removes one written for an older
// path), then runs the hook when anything changed, when it failed last
// time, or always.
func applyCert(sc savedCert, meta *localMeta, updated, renewed, always bool) (*getResult, error) {
res := &getResult{Name: sc.Name, Dir: sc.Dir, Serial: meta.Serial, NotAfter: meta.NotAfter, Updated: updated, Combined: sc.Combined}
changed := updated
if meta.Combined != "" && filepath.Clean(meta.Combined) != filepath.Clean(sc.Combined) {
if err := os.Remove(meta.Combined); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%s: removing the old combined file: %w", sc.Name, err)
}
meta.Combined = ""
changed = true
}
if sc.Combined != "" {
_, err := os.Stat(sc.Combined)
if updated || always || meta.Combined == "" || err != nil {
if err := writeCombined(sc); err != nil {
return nil, err
}
meta.Combined = sc.Combined
res.CombinedWritten = true
changed = true
}
}
if !changed && !always && meta.HookFailed == "" {
return res, nil
}
if sc.Hook != "" {
res.Hook = "ok"
if err := runHook(sc, meta, 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"}
// writeCombined writes the combined file from the certificate's files:
// the key, then the full chain (as jarl cert export --part bundle), mode
// 600, all at once.
func writeCombined(sc savedCert) error {
full, err := os.ReadFile(filepath.Join(sc.Dir, "fullchain.pem"))
if err != nil {
return err
}
key, err := os.ReadFile(filepath.Join(sc.Dir, "privkey.pem"))
if err != nil {
return err
}
if _, err := tls.X509KeyPair(full, key); err != nil {
return fmt.Errorf("%s: the files in %s don't match (%v): run jarl cert get %s --force", sc.Name, sc.Dir, err, sc.Name)
}
if err := os.MkdirAll(filepath.Dir(sc.Combined), 0o755); err != nil {
return err
}
data := append(append(append([]byte{}, key...), '\n'), full...)
if err := writeAtomic(sc.Combined, data, 0o600); err != nil {
return fmt.Errorf("%s: writing the combined file: %w", sc.Name, err)
}
return nil
}
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, meta *localMeta, 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_COMBINED="+sc.Combined,
"JARL_CERT_DOMAINS="+strings.Join(meta.Domains, " "),
"JARL_CERT_SERIAL="+meta.Serial,
"JARL_CERT_NOT_AFTER="+meta.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, combined, configDir string
var noSave, force, replace, local 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, writes --combined 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). Don't
point HAProxy's crt at --dir: it loads every file there.
--combined PATH also writes the key and the full chain in one file (mode
600), as HAProxy wants. Keep it outside --dir; a directory of combined
files can be HAProxy's crt directory. --combined '' stops writing it and
removes the file.
The settings (names, --dir, --combined, --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). To change one later, run 'jarl cert get NAME' with
just that option; the others stay. 'jarl cert get' without a name does every
saved certificate: run it daily (jarl cert schedule does that with a
systemd timer). Nothing is downloaded until jarld has renewed one.
A name saved here for another Jarl server isn't taken over, nor a --dir
holding another certificate's files, unless --replace is given; a --dir
another saved certificate uses is always refused.
--hook runs with sh after new files are written or the combined file
changes (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_COMBINED (empty without --combined),
JARL_CERT_DOMAINS, JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and
JARL_CERT_RENEWED (1 when it replaced an older certificate).
--local doesn't ask jarld: it uses the files this host has, writes the
combined file and runs the hook. Use it after changing --combined or
--hook, or to test a hook.`,
Example: ` jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix dovecot'
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
--combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting, apply it now
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()
if local && force {
return errors.New("--local and --force don't go together: --local always writes the combined file and runs the hook; --force downloads again from jarld")
}
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)
}
for _, n := range []string{"dir", "hook", "key-type", "combined", "replace", "no-save"} {
if f.Changed(n) {
return fmt.Errorf("--%s changes one certificate: give its name (jarl cert get NAME --%s ...)", n, n)
}
}
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 {
if local && i >= 0 && !sameNames(domains, sc.Domains) {
return fmt.Errorf("%s: new names need a new certificate from jarld: run jarl cert get %s without --local", name, name)
}
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 f.Changed("combined") {
sc.Combined = ""
if combined != "" {
abs, err := filepath.Abs(combined)
if err != nil {
return err
}
sc.Combined = abs
}
}
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
}
if err := checkCertClash(saved, i, sc, replace); err != nil {
return err
}
todo = []savedCert{sc}
}
var results []*getResult
var failed []string
var lastErr error
for _, sc := range todo {
var r *getResult
var err error
if local {
r, err = localCert(sc)
} else {
var c *client.Client
if c, err = sc.Conn.open(); err == nil {
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"
switch {
case r.Updated:
what = "new files"
case local:
what = "this host's files"
case r.CombinedWritten:
what = "combined file written"
}
if r.Combined != "" {
what += ", combined " + r.Combined
}
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, 5 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 ('' removes it)")
f.StringVar(&combined, "combined", "", "also write the key and full chain in one file at this path, for HAProxy ('' stops it)")
f.BoolVar(&local, "local", false, "don't ask jarld: write the combined file and run the hook from this host's files")
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.BoolVar(&replace, "replace", false, "take over a name saved here for another Jarl server, or a --dir holding another certificate's files")
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 [NAME]",
Short: "The certificates this host gets with jarl cert get, and when they expire",
Long: `Lists the certificates saved on this host by jarl cert get. With a name,
shows all of that certificate's settings and what this host has of it.
The token is never shown.`,
Example: ` jarl cert saved
jarl cert saved proxy
jarl cert saved -o json`,
Args: cobra.MaximumNArgs(1),
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 {
if len(args) == 1 && sc.Name != args[0] {
continue
}
sc.Conn.Token = "" // never print it
m, _ := readMeta(sc.Dir)
rows = append(rows, row{sc, m})
}
if len(args) == 1 {
if len(rows) == 0 {
return fmt.Errorf("%s isn't saved in %s (jarl cert saved lists what is)", args[0], cdir)
}
r := rows[0]
return output(r, func(w io.Writer) { printSaved(w, r.savedCert, r.Local) })
}
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\tCOMBINED\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\t%s\n", r.Name, strings.Join(r.Domains, ","), r.Dir, dash(r.Combined), exp, fetched, r.Conn.Server, firstNonEmpty(r.Hook, "-"))
}
tw.Flush()
})
},
}
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
return cmd
}
// printSaved shows one saved certificate: its settings, then this
// host's copy.
func printSaved(w io.Writer, sc savedCert, m *localMeta) {
fmt.Fprintf(w, "Name:\t%s\n", sc.Name)
fmt.Fprintf(w, "Names:\t%s\n", strings.Join(sc.Domains, " "))
fmt.Fprintf(w, "Key type:\t%s\n", firstNonEmpty(sc.KeyType, "ec256 (default)"))
fmt.Fprintf(w, "Dir:\t%s\n", sc.Dir)
fmt.Fprintf(w, "Combined:\t%s\n", dash(sc.Combined))
fmt.Fprintf(w, "Hook:\t%s\n", dash(sc.Hook))
fmt.Fprintf(w, "Server:\t%s\n", sc.Conn.Server)
if sc.Conn.CACert != "" {
fmt.Fprintf(w, "CA cert:\t%s\n", sc.Conn.CACert)
}
if sc.Conn.Insecure {
fmt.Fprintf(w, "TLS:\tnot verified (--insecure)\n")
}
if !sc.SavedAt.IsZero() {
fmt.Fprintf(w, "Saved:\t%s\n", fmtTime(&sc.SavedAt))
}
if m == nil {
fmt.Fprintf(w, "Files:\tnone yet (jarl cert get %s fetches them)\n", sc.Name)
return
}
fmt.Fprintf(w, "Serial:\t%s\n", m.Serial)
fmt.Fprintf(w, "Expires:\t%s (%dd)\n", fmtTime(&m.NotAfter), int(time.Until(m.NotAfter).Hours()/24))
fmt.Fprintf(w, "Fetched:\t%s\n", fmtTime(&m.Fetched))
if m.HookFailed != "" {
fmt.Fprintf(w, "Hook failed:\t%s (the next jarl cert get runs it again)\n", m.HookFailed)
}
}
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
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCheckCertClash(t *testing.T) {
tmp := t.TempDir()
a := connection{Server: "https://dns-a.example:8053"}
b := connection{Server: "https://dns-b.example:8053"}
saved := []savedCert{
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Conn: a},
{Name: "mail", Dir: filepath.Join(tmp, "mail"), Conn: a},
}
// mail's files are on disk; "other" has files but no saved settings.
if err := (&localMeta{Name: "mail"}).write(mkdir(t, saved[1].Dir)); err != nil {
t.Fatal(err)
}
if err := (&localMeta{Name: "other"}).write(mkdir(t, filepath.Join(tmp, "other"))); err != nil {
t.Fatal(err)
}
cases := []struct {
what string
i int
sc savedCert
replace bool
want string // "" = allowed
}{
{"same server again", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: a}, false, ""},
{"same server, other spelling", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: connection{Server: "HTTPS://dns-a.example:8053/"}}, false, ""},
{"other server", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, false, "saved here for the Jarl server"},
{"other server, replace", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, true, ""},
{"new name, saved cert's dir", -1, savedCert{Name: "web", Dir: saved[1].Dir, Conn: b}, false, "holds the files of mail"},
{"new name, saved cert's dir, replace", -1, savedCert{Name: "web", Dir: saved[1].Dir + "/", Conn: b}, true, "holds the files of mail"},
{"new name, unsaved cert's files", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, false, "certificate other"},
{"new name, unsaved cert's files, replace", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, true, ""},
{"new name, empty dir", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "web"), Conn: b}, false, ""},
}
for _, c := range cases {
err := checkCertClash(saved, c.i, c.sc, c.replace)
switch {
case c.want == "" && err != nil:
t.Errorf("%s: %v", c.what, err)
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
t.Errorf("%s: got %v, want %q", c.what, err, c.want)
}
}
}
func mkdir(t *testing.T, dir string) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
return dir
}
func TestCheckCombined(t *testing.T) {
tmp := t.TempDir()
saved := []savedCert{
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Combined: filepath.Join(tmp, "haproxy", "proxy.pem")},
{Name: "mail", Dir: filepath.Join(tmp, "mail")},
}
mkdir(t, filepath.Join(tmp, "haproxy"))
cases := []struct {
what string
i int
sc savedCert
want string
}{
{"none", 1, savedCert{Name: "mail", Dir: saved[1].Dir}, ""},
{"own again", 0, saved[0], ""},
{"beside the others", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(tmp, "haproxy", "mail.pem")}, ""},
{"inside its own dir", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[1].Dir, "combined.pem")}, ""},
{"another's combined", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: saved[0].Combined}, "combined file of proxy"},
{"own fullchain", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[1].Dir, "fullchain.pem")}, "overwrite mail's fullchain.pem"},
{"another's key", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(saved[0].Dir, "privkey.pem")}, "overwrite proxy's privkey.pem"},
{"a directory", 1, savedCert{Name: "mail", Dir: saved[1].Dir, Combined: filepath.Join(tmp, "haproxy")}, "is a directory"},
}
for _, c := range cases {
err := checkCombined(saved, c.i, c.sc)
switch {
case c.want == "" && err != nil:
t.Errorf("%s: %v", c.what, err)
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
t.Errorf("%s: got %v, want %q", c.what, err, c.want)
}
}
}
// TestApplyCert: the combined file follows --combined, and the hook runs
// only when something changed (always with --local).
func TestApplyCert(t *testing.T) {
tmp := t.TempDir()
dir := mkdir(t, filepath.Join(tmp, "proxy"))
full, key := testKeyPair(t)
for name, data := range map[string][]byte{"cert.pem": full, "chain.pem": nil, "fullchain.pem": full, "privkey.pem": key} {
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
t.Fatal(err)
}
}
if err := (&localMeta{Name: "proxy", Serial: "1", NotAfter: time.Now().Add(time.Hour)}).write(dir); err != nil {
t.Fatal(err)
}
runs := filepath.Join(tmp, "runs")
sc := savedCert{Name: "proxy", Dir: dir, Hook: `echo "$JARL_CERT_COMBINED" >> ` + runs}
hookRuns := func() []string {
b, _ := os.ReadFile(runs)
return strings.Fields(string(b))
}
apply := func(local bool) *getResult {
t.Helper()
var r *getResult
var err error
if local {
r, err = localCert(sc)
} else {
meta, _ := readMeta(dir)
r, err = applyCert(sc, meta, false, false, false)
}
if err != nil {
t.Fatal(err)
}
return r
}
if r := apply(false); r.CombinedWritten || len(hookRuns()) != 0 {
t.Fatalf("nothing changed, but: %+v, hook ran %d times", r, len(hookRuns()))
}
first := filepath.Join(tmp, "haproxy", "proxy.pem")
sc.Combined = first
if r := apply(false); !r.CombinedWritten || len(hookRuns()) != 1 {
t.Fatalf("new --combined: %+v, hook ran %d times", r, len(hookRuns()))
}
b, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
if _, err := tls.X509KeyPair(b, b); err != nil || !strings.HasPrefix(string(b), string(key)) {
t.Errorf("combined file isn't the key, then the chain: %v", err)
}
if st, _ := os.Stat(first); st.Mode().Perm() != 0o600 {
t.Errorf("combined file mode %v, want 600", st.Mode().Perm())
}
if r := apply(false); r.CombinedWritten || len(hookRuns()) != 1 {
t.Fatalf("second run changed something: %+v, hook ran %d times", r, len(hookRuns()))
}
os.Remove(first)
if r := apply(false); !r.CombinedWritten || len(hookRuns()) != 2 {
t.Fatalf("missing combined file not rewritten: %+v", r)
}
if r := apply(true); !r.CombinedWritten || len(hookRuns()) != 3 {
t.Fatalf("--local: %+v, hook ran %d times", r, len(hookRuns()))
}
second := filepath.Join(tmp, "haproxy", "adbraun.lol.pem")
sc.Combined = second
apply(false)
if _, err := os.Stat(first); !os.IsNotExist(err) {
t.Errorf("old combined file %s not removed: %v", first, err)
}
if _, err := os.Stat(second); err != nil {
t.Errorf("new combined file: %v", err)
}
sc.Combined = ""
apply(false)
if _, err := os.Stat(second); !os.IsNotExist(err) {
t.Errorf("--combined '' left %s: %v", second, err)
}
// Five runs; the last had JARL_CERT_COMBINED empty, so echo wrote no field.
if got := hookRuns(); len(got) != 4 || got[3] != second {
t.Errorf("hook runs: %q", got)
}
}
func testKeyPair(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "proxy.example"}, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour)}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &k.PublicKey, k)
if err != nil {
t.Fatal(err)
}
kd, err := x509.MarshalECPrivateKey(k)
if err != nil {
t.Fatal(err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})
}
+687
View File
@@ -0,0 +1,687 @@
package main
import (
"errors"
"fmt"
"io"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"jarl/internal/client"
)
type certStatus struct {
State string `json:"state"`
Message string `json:"message"`
}
type certInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Domains []string `json:"domains"`
CertFile string `json:"cert_file,omitempty"`
KeyFile string `json:"key_file,omitempty"`
Uses []string `json:"uses"`
Challenge string `json:"challenge,omitempty"`
KeyType string `json:"key_type,omitempty"`
Comment string `json:"comment"`
Names []string `json:"names,omitempty"`
NotBefore *time.Time `json:"not_before,omitempty"`
NotAfter *time.Time `json:"not_after,omitempty"`
Issuer string `json:"issuer,omitempty"`
Serial string `json:"serial,omitempty"`
RenewAt *time.Time `json:"renew_at,omitempty"`
LastAttempt *time.Time `json:"last_attempt,omitempty"`
LastSuccess *time.Time `json:"last_success,omitempty"`
LastError string `json:"last_error,omitempty"`
Failures int `json:"failures,omitempty"`
LastRoutes []string `json:"last_routes,omitempty"`
Status certStatus `json:"status"`
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"`
OwnerID int64 `json:"owner_id,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 {
Directory string `json:"directory"`
Email string `json:"email"`
AgreeTOS bool `json:"agree_tos"`
EAB bool `json:"eab"`
Registered bool `json:"registered"`
URI string `json:"uri"`
RegisteredAt *time.Time `json:"registered_at"`
Porkbun bool `json:"porkbun"`
Resolvers []string `json:"resolvers"`
Dir string `json:"dir"`
ActiveWebCertificate string `json:"active_web_certificate"`
}
type certRoute struct {
Domain string `json:"domain"`
Record string `json:"record"`
Target string `json:"target"`
Route string `json:"route"`
Zone string `json:"zone"`
PublicNS []string `json:"public_ns"`
Why string `json:"why"`
Error string `json:"error"`
}
func certPath(name string) string { return "/certificates/" + url.PathEscape(name) }
func certCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Aliases: []string{"certificate", "certificates"},
Short: "TLS certificates Jarl obtains and renews, or reads from files (docs/certificates.md)",
Long: `Certificates for jarld's own HTTPS (use "web") and for named's DNS over
TLS and HTTPS (use "dns": written where named can read them).
Jarl obtains them from an ACME CA (Let's Encrypt by default; the account is
set up in jarl.yaml under acme.account) and renews them itself. It answers
the DNS-01 challenges in a zone it serves, or through Porkbun's API while
Porkbun serves the domain ("auto" decides at every renewal from the
domain's public name servers). A certificate you renew yourself can be
used too (--cert-file and --key-file): Jarl rereads it when it changes.`,
Example: ` jarl cert check --domain ns1.adbraun.lol # where would the challenge go?
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns
jarl cert create wild --domain adbraun.lol --domain '*.adbraun.lol' --use dns
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 export ns1 --dir /etc/ssl/ns1 # fullchain.pem and privkey.pem, for another program`,
}
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certGetCmd(), certSavedCmd(), certForgetCmd(), certScheduleCmd(), certDeleteCmd(), certAccountCmd())
return cmd
}
func certListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List certificates",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r struct {
Certificates []certInfo `json:"certificates"`
}
if err := c.Get(ctx(), "/certificates", nil, &r); err != nil {
return err
}
return output(r.Certificates, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tSOURCE\tUSES\tNAMES\tEXPIRES\tPULLED\tSTATUS")
for _, ci := range r.Certificates {
names := ci.Names
if len(names) == 0 {
names = ci.Domains
}
state := ci.Status.State
if ci.IssuingSince != nil {
state = "obtaining"
}
uses := strings.Join(ci.Uses, ",")
if uses == "" && ci.OwnerID != 0 {
uses = "get" // for another host (jarl cert get)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(uses), strings.Join(names, " "), fmtDate(ci.NotAfter), lastPull(ci), state)
}
})
},
}
}
// lastPull is when the certificate was last downloaded, marked "(old)"
// while someone who downloaded it still has the one before its renewal.
func lastPull(ci certInfo) string {
if len(ci.PulledBy) == 0 {
return "-"
}
s := fmtDate(&ci.PulledBy[0].At) // newest first
for _, p := range ci.PulledBy {
if p.Serial != ci.Serial {
return s + " (old)"
}
}
return s
}
func fmtDate(t *time.Time) string {
if t == nil {
return "-"
}
return t.Local().Format("2006-01-02")
}
func printCert(w io.Writer, ci certInfo) {
fmt.Fprintf(w, "Name:\t%s\n", ci.Name)
switch ci.Source {
case "acme":
fmt.Fprintf(w, "Source:\tacme (Jarl obtains and renews it)\n")
fmt.Fprintf(w, "Domains:\t%s\n", strings.Join(ci.Domains, " "))
fmt.Fprintf(w, "Challenge:\t%s\n", ci.Challenge)
fmt.Fprintf(w, "Key type:\t%s\n", ci.KeyType)
default:
fmt.Fprintf(w, "Source:\tfile (you renew it)\n")
fmt.Fprintf(w, "Cert file:\t%s\n", ci.CertFile)
fmt.Fprintf(w, "Key file:\t%s\n", ci.KeyFile)
}
uses := []string{}
for _, u := range ci.Uses {
switch u {
case "web":
s := "web (jarld's HTTPS"
if ci.Serving {
s += ", serving now"
}
uses = append(uses, s+")")
case "dns":
uses = append(uses, "dns (for named)")
}
}
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
}
if ci.IssuingSince != nil {
state = "obtaining since " + fmtTime(ci.IssuingSince)
}
fmt.Fprintf(w, "Status:\t%s\n", state)
if ci.NotAfter != nil {
fmt.Fprintf(w, "Names:\t%s\n", strings.Join(ci.Names, " "))
fmt.Fprintf(w, "Valid:\t%s to %s\n", fmtTime(ci.NotBefore), fmtTime(ci.NotAfter))
fmt.Fprintf(w, "Issuer:\t%s\n", ci.Issuer)
fmt.Fprintf(w, "Serial:\t%s\n", ci.Serial)
}
if ci.Source == "acme" {
if ci.NextAttempt != nil && ci.NotAfter != nil {
fmt.Fprintf(w, "Renews:\t%s\n", fmtTime(ci.NextAttempt))
} else if ci.NextAttempt != nil {
fmt.Fprintf(w, "Next try:\t%s\n", fmtTime(ci.NextAttempt))
}
if len(ci.LastRoutes) > 0 {
fmt.Fprintf(w, "Challenges:\t%s\n", strings.Join(ci.LastRoutes, "; "))
}
}
if ci.LastSuccess != nil {
fmt.Fprintf(w, "Last success:\t%s\n", fmtTime(ci.LastSuccess))
}
if ci.LastError != "" {
fmt.Fprintf(w, "Last error:\t%s\n", ci.LastError)
}
if ci.Files != nil {
fmt.Fprintf(w, "For named:\t%s\n\t%s\n", ci.Files.Chain, ci.Files.Key)
}
}
func certShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show NAME",
Short: "Show a certificate",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var ci certInfo
if err := c.Get(ctx(), certPath(args[0]), nil, &ci); err != nil {
return err
}
return output(ci, func(w io.Writer) { printCert(w, ci) })
},
}
}
type certFlags struct {
domains []string
uses []string
challenge, keyType string
comment string
certFile, keyFile string
}
func (f *certFlags) add(cmd *cobra.Command) {
fl := cmd.Flags()
fl.StringArrayVarP(&f.domains, "domain", "d", nil, "a name on the certificate (repeat for several; *.example.com for a wildcard)")
fl.StringArrayVarP(&f.uses, "use", "u", nil, "what it is for: web (jarld's HTTPS) or dns (named's DNS over TLS/HTTPS); repeat for both")
fl.StringVar(&f.challenge, "challenge", "", "where DNS-01 challenges go: auto (from the domain's public name servers), jarl or porkbun")
fl.StringVar(&f.keyType, "key-type", "", "key type: ec256 (default), ec384, rsa2048, rsa3072 or rsa4096")
fl.StringVar(&f.comment, "comment", "", "a comment")
fl.StringVar(&f.certFile, "cert-file", "", "a certificate you renew yourself: the full chain (PEM)")
fl.StringVar(&f.keyFile, "key-file", "", "its private key (PEM)")
}
func (f *certFlags) body(cmd *cobra.Command) map[string]any {
b := map[string]any{}
ch := cmd.Flags().Changed
if ch("domain") {
b["domains"] = f.domains
}
if ch("use") {
uses := []string{}
for _, u := range f.uses {
for _, x := range strings.Split(u, ",") {
if x = strings.TrimSpace(x); x != "" && x != "none" {
uses = append(uses, x)
}
}
}
b["uses"] = uses
}
for flag, key := range map[string]string{"challenge": "challenge", "key-type": "key_type", "comment": "comment", "cert-file": "cert_file", "key-file": "key_file"} {
if ch(flag) {
v, _ := cmd.Flags().GetString(flag)
b[key] = v
}
}
return b
}
func certCreateCmd() *cobra.Command {
var f certFlags
var noIssue, noWait bool
cmd := &cobra.Command{
Use: "create NAME",
Short: "Add a certificate: obtained by Jarl (--domain), or one you renew (--cert-file, --key-file)",
Long: `Adds a certificate. With --domain, Jarl obtains it from the CA at once
(and renews it from then on); the command waits for the outcome. With
--cert-file and --key-file it is a certificate you renew yourself, which
Jarl reads (and rereads when the files change).
The name is used for the files for named (<certificates.dir>/NAME/).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
b := f.body(cmd)
b["name"] = args[0]
b["source"] = "acme"
if f.certFile != "" || f.keyFile != "" {
b["source"] = "file"
}
b["issue"] = !noIssue
var r struct {
Certificate certInfo `json:"certificate"`
Error string `json:"error"`
}
if err := c.Do(ctx(), "POST", "/certificates", nil, b, &r); err != nil {
return err
}
if r.Certificate.IssuingSince != nil && !noWait {
return waitIssue(c, args[0])
}
if r.Error != "" {
return errors.New(r.Error)
}
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
},
}
f.add(cmd)
cmd.Flags().BoolVar(&noIssue, "no-issue", false, "don't obtain it now (jarld does within minutes)")
cmd.Flags().BoolVar(&noWait, "no-wait", false, "return at once instead of waiting for the CA")
return cmd
}
func certUpdateCmd() *cobra.Command {
var f certFlags
cmd := &cobra.Command{
Use: "update NAME",
Short: "Change a certificate (--domain and --use replace the whole list)",
Long: `Changes a certificate. --domain and --use replace the whole list
(--use none for no use). New names or a new key type make Jarl obtain it
again soon (run 'jarl cert issue NAME' to do it now).`,
Example: ` jarl cert update ns1 --use web --use dns
jarl cert update ns1 --challenge auto
jarl cert update ns1 --domain ns1.adbraun.lol --domain dns.adbraun.lol`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
b := f.body(cmd)
if len(b) == 0 {
return errors.New("nothing to change")
}
var r struct {
Certificate certInfo `json:"certificate"`
}
if err := c.Do(ctx(), "PATCH", certPath(args[0]), nil, b, &r); err != nil {
return err
}
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
},
}
f.add(cmd)
return cmd
}
func certIssueCmd() *cobra.Command {
var noWait bool
cmd := &cobra.Command{
Use: "issue NAME",
Aliases: []string{"renew"},
Short: "Obtain the certificate now (reread the files of one you renew yourself)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r struct {
Certificate certInfo `json:"certificate"`
Error string `json:"error"`
}
if err := c.Do(ctx(), "POST", certPath(args[0])+"/issue", nil, map[string]any{}, &r); err != nil {
return err
}
if r.Error != "" {
return errors.New(r.Error)
}
if r.Certificate.IssuingSince != nil && !noWait {
return waitIssue(c, args[0])
}
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
},
}
cmd.Flags().BoolVar(&noWait, "no-wait", false, "return at once instead of waiting for the CA")
return cmd
}
// waitIssue polls until jarld has finished obtaining the certificate and
// reports the outcome.
func waitIssue(c *client.Client, name string) error {
tty := !flagQuiet && flagOutput == "table"
if tty {
fmt.Fprintf(os.Stderr, "Obtaining %s from the CA (this can take a few minutes while the challenges reach every name server)", name)
}
deadline := time.Now().Add(20 * time.Minute)
var ci certInfo
for {
ci = certInfo{} // a field the answer leaves out must not keep the last value
if err := c.Get(ctx(), certPath(name), nil, &ci); err != nil {
return err
}
if ci.IssuingSince == nil || time.Now().After(deadline) {
break
}
if tty {
fmt.Fprint(os.Stderr, ".")
}
time.Sleep(2 * time.Second)
}
if tty {
fmt.Fprintln(os.Stderr)
}
if ci.IssuingSince != nil {
return fmt.Errorf("jarld is still obtaining %s; check later with: jarl cert show %s", name, name)
}
if err := output(ci, func(w io.Writer) { printCert(w, ci) }); err != nil {
return err
}
if ci.LastError != "" && (ci.LastSuccess == nil || ci.LastAttempt != nil && ci.LastAttempt.After(*ci.LastSuccess)) {
return fmt.Errorf("obtaining %s failed (jarld tries again at %s)", name, fmtTime(ci.NextAttempt))
}
return nil
}
func certCheckCmd() *cobra.Command {
var f certFlags
cmd := &cobra.Command{
Use: "check [NAME]",
Short: "Show where each name's DNS-01 challenge would go, without asking the CA",
Long: `For an existing certificate, or for --domain names, shows where Jarl would
publish each DNS-01 challenge (in a zone it serves, or at Porkbun) and why,
seen from the resolvers in acme.resolvers, as the CA would see it. Nothing
is published and the CA isn't asked.`,
Example: ` jarl cert check ns1
jarl cert check --domain ns1.adbraun.lol --domain '*.adbraun.lol'`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r struct {
OK bool `json:"ok"`
Routes []certRoute `json:"routes"`
Account certAccount `json:"account"`
}
if len(args) == 1 {
err = c.Do(ctx(), "POST", certPath(args[0])+"/check", nil, map[string]any{}, &r)
} else {
b := f.body(cmd)
if b["domains"] == nil {
return errors.New("give a certificate name, or --domain")
}
err = c.Do(ctx(), "POST", "/certificates/check", nil, b, &r)
}
if err != nil {
return err
}
if err := output(r, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tROUTE\tRECORD\tWHY")
for _, rt := range r.Routes {
rec := strings.TrimSuffix(rt.Target, ".")
if rt.Target != rt.Record && rt.Target != "" {
rec += " (CNAME from " + strings.TrimSuffix(rt.Record, ".") + ")"
}
if rt.Error != "" {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Domain, "none", dash(rec), rt.Error)
continue
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Domain, rt.Route, rec, rt.Why)
}
if !r.Account.AgreeTOS {
fmt.Fprintln(w, "\nNote: acme.account.agree_tos isn't set in jarl.yaml, so Jarl can't register with the CA yet.")
}
}); err != nil {
return err
}
if !r.OK {
return errors.New("not every name can be validated")
}
return nil
},
}
cmd.Flags().StringArrayVarP(&f.domains, "domain", "d", nil, "a name to check (repeat for several)")
cmd.Flags().StringVar(&f.challenge, "challenge", "", "the challenge setting to check with: auto, jarl or porkbun")
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{
Use: "delete NAME",
Short: "Delete a certificate (and its files for named)",
Long: `Deletes a certificate. Its files for named are removed, so take it out of
any tls block in named's configuration first. If jarld served it, jarld
goes back to tls.cert_file, or to plain HTTP.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete certificate "+args[0]+"?", yes) {
return errors.New("aborted")
}
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("deleted certificate", args[0])
}
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
func certAccountCmd() *cobra.Command {
return &cobra.Command{
Use: "account",
Short: "Show the ACME account and settings (set in jarl.yaml under acme)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r struct {
Account certAccount `json:"account"`
}
if err := c.Get(ctx(), "/certificates", nil, &r); err != nil {
return err
}
a := r.Account
return output(a, func(w io.Writer) {
fmt.Fprintf(w, "Directory:\t%s\n", a.Directory)
fmt.Fprintf(w, "Email:\t%s\n", dash(a.Email))
fmt.Fprintf(w, "Terms agreed:\t%v\n", a.AgreeTOS)
if a.Registered {
fmt.Fprintf(w, "Account:\t%s (since %s)\n", a.URI, fmtTime(a.RegisteredAt))
} else {
fmt.Fprintf(w, "Account:\tnot registered yet (it is, with the first certificate)\n")
}
fmt.Fprintf(w, "EAB:\t%v\n", a.EAB)
fmt.Fprintf(w, "Porkbun:\t%v\n", a.Porkbun)
fmt.Fprintf(w, "Resolvers:\t%s\n", strings.Join(a.Resolvers, " "))
fmt.Fprintf(w, "Files for named:\t%s\n", a.Dir)
fmt.Fprintf(w, "Serving HTTPS with:\t%s\n", dash(a.ActiveWebCertificate))
})
},
}
}
+70 -31
View File
@@ -120,13 +120,17 @@ func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
// changeset given with --changeset.
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
var resp api.MutationResponse
if c.Changeset > 0 {
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), nil, map[string]any{"ops": reqs}, &resp); err != nil {
cs, err := c.UseChangeset(ctx())
if err != nil {
return err
}
if cs > 0 {
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", cs), nil, map[string]any{"ops": reqs}, &resp); err != nil {
return err
}
return report(&resp)
}
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil {
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force, AcceptRisk: c.AcceptRisk}, &resp); err != nil {
return err
}
return report(&resp)
@@ -230,8 +234,9 @@ func changesetCmd() *cobra.Command {
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
jarl record add example.com www2 A 192.0.2.20
jarl record delete example.com www A
jarl changeset diff $JARL_CHANGESET
jarl changeset apply $JARL_CHANGESET`,
jarl changeset diff # JARL_CHANGESET's
jarl changeset apply
unset JARL_CHANGESET`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
@@ -245,27 +250,31 @@ func changesetCmd() *cobra.Command {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d)\n", cs.ID, cs.ID, cs.ID)
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d), then 'jarl changeset apply'\n", cs.ID, cs.ID, cs.ID)
return nil
},
}
show := &cobra.Command{
Use: "show ID",
Short: "Show a changeset's operations",
Args: cobra.ExactArgs(1),
Use: "show [ID]",
Short: "Show a changeset's operations (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, _, err := changesetArg(c, args, false)
if err != nil {
return err
}
var cs api.Changeset
if err := c.Get(ctx(), "/changesets/"+args[0], nil, &cs); err != nil {
if err := c.Get(ctx(), "/changesets/"+id, nil, &cs); err != nil {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Local().Format("2006-01-02 15:04"), cs.Username, cs.Via)
if cs.AppliedAt != nil {
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Local().Format("2006-01-02 15:04"), cs.AppliedBy)
}
fmt.Fprintln(w, "\n#\tOPERATION")
for _, op := range cs.Ops {
@@ -280,16 +289,20 @@ func changesetCmd() *cobra.Command {
},
}
diff := &cobra.Command{
Use: "diff ID",
Short: "Validate an open changeset and show what applying it would change",
Args: cobra.ExactArgs(1),
Use: "diff [ID]",
Short: "Validate an open changeset and show what applying it would change (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
var raw json.RawMessage
if err := c.Get(ctx(), "/changesets/"+args[0]+"/preview", nil, &raw); err != nil {
if err := c.Get(ctx(), "/changesets/"+id+"/preview", nil, &raw); err != nil {
return err
}
if flagOutput != "table" {
@@ -312,56 +325,82 @@ func changesetCmd() *cobra.Command {
fmt.Println()
fmt.Print(r.Diff)
if r.Valid {
fmt.Println("\nvalid: apply with 'jarl changeset apply " + args[0] + "'")
how := "jarl changeset apply " + id
if fromEnv {
how = "jarl changeset apply"
}
fmt.Println("\nvalid: apply with '" + how + "'")
return nil
}
return fmt.Errorf("the changeset can't be applied as it is")
},
}
apply := &cobra.Command{
Use: "apply ID",
Short: "Apply an open changeset",
Args: cobra.ExactArgs(1),
Use: "apply [ID]",
Short: "Apply an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp); err != nil {
return err
}
return report(&resp)
if err := report(&resp); err != nil {
return err
}
if fromEnv && resp.Applied {
envChangesetDone(id, "applied")
}
return nil
},
}
discard := &cobra.Command{
Use: "discard ID",
Short: "Throw away an open changeset",
Args: cobra.ExactArgs(1),
Use: "discard [ID]",
Short: "Throw away an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/discard", nil, nil, nil); err != nil {
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("discarded changeset", args[0])
fmt.Println("discarded changeset", id)
}
if fromEnv {
envChangesetDone(id, "discarded")
}
return nil
},
}
remove := &cobra.Command{
Use: "remove ID SEQ",
Short: "Remove one operation from an open changeset",
Args: cobra.ExactArgs(2),
Use: "remove [ID] SEQ",
Short: "Remove one operation from an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
return c.Do(ctx(), "DELETE", "/changesets/"+args[0]+"/ops/"+args[1], nil, nil, nil)
seq := args[len(args)-1]
id, _, err := changesetArg(c, args[:len(args)-1], true)
if err != nil {
return err
}
return c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+seq, nil, nil, nil)
},
}
revert := &cobra.Command{
+271
View File
@@ -0,0 +1,271 @@
package main
import (
"fmt"
"io"
"net/url"
"strings"
"github.com/spf13/cobra"
"jarl/internal/api"
)
// Blocking steps and client groups (docs/blocking.md).
func blockStepCmd() *cobra.Command {
cmd := &cobra.Command{Use: "step", Aliases: []string{"steps"}, Short: "Blocking steps: which lists apply to which client groups",
Long: `Steps decide which blocklists a client group gets. Each step includes the
lists of the steps below it, plus its own:
off only your rules (built in)
normal your rules + the normal lists; clients not in a group (built in)
strict normal + the lists at step strict (a step you add)
...
Lists start at a step ('jarl blocklist update LIST --step strict'), client
groups are at one ('jarl block group add kids 10.10.0.50 --step strict').`}
list := &cobra.Command{
Use: "list",
Short: "List the steps, lowest first",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var steps []api.BlockStep
if err := c.Get(ctx(), "/blocking/steps", nil, &steps); err != nil {
return err
}
return output(steps, func(w io.Writer) {
fmt.Fprintln(w, "STEP\tLISTS\tGROUPS\tCOMMENT")
fmt.Fprintln(w, "off\t-\t(built in)\tonly your rules")
fmt.Fprintln(w, "normal\t-\t(built in)\tthe default")
for _, s := range steps {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, orDash(strings.Join(s.Lists, ", ")), orDash(strings.Join(s.Groups, ", ")), s.Comment)
}
})
},
}
var comment string
add := &cobra.Command{
Use: "add NAME",
Short: "Add a step above the existing ones",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps", nil, map[string]any{"name": args[0], "comment": comment})
if err != nil {
return err
}
return report(resp)
},
}
add.Flags().StringVar(&comment, "comment", "", "comment")
rename := &cobra.Command{
Use: "rename STEP NEW-NAME",
Short: "Rename a step",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/steps/"+url.PathEscape(args[0]), nil, map[string]any{"name": args[1]})
if err != nil {
return err
}
return report(resp)
},
}
order := &cobra.Command{
Use: "order STEP...",
Short: "Set the order of the steps above normal, lowest first",
Example: " jarl block step order strict very-strict",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps/order", nil, map[string]any{"steps": args})
if err != nil {
return err
}
return report(resp)
},
}
del := &cobra.Command{
Use: "delete STEP",
Short: "Delete a step (refused while lists or groups use it)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/steps/"+url.PathEscape(args[0]), nil, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.AddCommand(list, add, rename, order, del)
return cmd
}
func blockGroupCmd() *cobra.Command {
cmd := &cobra.Command{Use: "group", Aliases: []string{"groups"}, Short: "Client groups: blocking steps for some devices",
Long: `A client group puts devices at a blocking step: off (only your rules),
normal, or a step you added ('jarl block step'). Members are addresses,
prefixes, or ACLs made of them. Devices not in a group are at normal.
named must see the devices' own addresses (not a router forwarding for
them), and they need fixed addresses (DHCP reservations).`}
list := &cobra.Command{
Use: "list",
Short: "List client groups",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var groups []api.ClientGroup
if err := c.Get(ctx(), "/blocking/groups", nil, &groups); err != nil {
return err
}
return output(groups, func(w io.Writer) {
fmt.Fprintln(w, "GROUP\tSTEP\tMEMBERS\tVIEWS\tCOMMENT")
for _, g := range groups {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", g.Name, g.Step, orDash(strings.Join(g.Members, ", ")), viewsText(g.Views, g.AutoViews), g.Comment)
}
})
},
}
var step, comment string
var views []string
add := &cobra.Command{
Use: "add NAME MEMBER...",
Short: "Add a client group",
Example: ` jarl block group add work 10.10.0.20 --step off
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
jarl block group add guests guest-net --step normal # an ACL`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{"name": args[0], "members": args[1:], "comment": comment}
if step != "" {
body["step"] = step
}
viewsBody(body, views)
resp, err := c.Mutate(ctx(), "POST", "/blocking/groups", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
add.Flags().StringVar(&step, "step", "", "the group's step: off, normal (default) or one you added")
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies in (repeat for several; default: every view with blocking)")
add.Flags().StringVar(&comment, "comment", "", "comment")
var addM, removeM []string
var rename string
update := &cobra.Command{
Use: "update GROUP",
Short: "Change a group's members, step, name, views or comment",
Example: ` jarl block group update kids --add 10.10.0.52 --remove 10.10.0.50
jarl block group update kids --step very-strict`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{}
f := cmd.Flags()
if len(addM) > 0 || len(removeM) > 0 {
var g api.ClientGroup
var all []api.ClientGroup
if err := c.Get(ctx(), "/blocking/groups", nil, &all); err != nil {
return err
}
for _, x := range all {
if x.Name == strings.ToLower(args[0]) {
g = x
}
}
if g.ID == 0 {
return fmt.Errorf("client group %s not found", args[0])
}
drop := map[string]bool{}
for _, m := range removeM {
drop[m] = true
}
members := []string{}
for _, m := range g.Members {
if !drop[m] {
members = append(members, m)
}
}
body["members"] = append(members, addM...)
}
if f.Changed("step") {
body["step"] = step
}
if f.Changed("name") {
body["name"] = rename
}
if f.Changed("comment") {
body["comment"] = comment
}
viewsBody(body, views)
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
}
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/groups/"+url.PathEscape(args[0]), nil, body)
if err != nil {
return err
}
return report(resp)
},
}
update.Flags().StringArrayVar(&addM, "add", nil, "add a member (repeat for several)")
update.Flags().StringArrayVar(&removeM, "remove", nil, "remove a member (repeat for several)")
update.Flags().StringVar(&step, "step", "", "new step")
update.Flags().StringVar(&rename, "name", "", "new name")
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies in (repeat; "auto": every view with blocking)`)
update.Flags().StringVar(&comment, "comment", "", "new comment")
del := &cobra.Command{
Use: "delete GROUP",
Short: "Delete a client group (its devices go back to normal)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/groups/"+url.PathEscape(args[0]), nil, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.AddCommand(list, add, update, del)
return cmd
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
+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())
}
}
+140 -11
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 string
var adopt, cascade, yes bool
var recursiveOnly, comment, exposure string
var adopt, cascade, yes, reverseSuggest bool
viewBody := func(cmd *cobra.Command) map[string]any {
body := map[string]any{}
f := cmd.Flags()
@@ -60,6 +65,12 @@ func viewCmd() *cobra.Command {
if f.Changed("comment") {
body["comment"] = comment
}
if f.Changed("exposure") {
body["exposure"] = exposure
}
if f.Changed("reverse-suggestions") {
body["reverse_suggestions"] = reverseSuggest
}
return body
}
addViewFlags := func(c *cobra.Command) {
@@ -67,6 +78,8 @@ func viewCmd() *cobra.Command {
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
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",
@@ -201,7 +214,45 @@ func viewCmd() *cobra.Command {
return nil
},
}
cmd.AddCommand(list, create, update, del, order, color)
var keyNamed bool
key := &cobra.Command{
Use: "key VIEW",
Short: "Show the TSIG key that selects a view, to query it from anywhere",
Long: `Every view has a TSIG key (jarl-view-NAME) at the front of its
match-clients: a query signed with it lands in that view, whatever address
it comes from. Jarl signs its own checks of this server with it; use it to
see what a view answers:
jarl view key external # shows a ready dig -y command
jarl view key external --named-conf > external.key
dig -k external.key @SERVER adbraun.lol SOA
Showing it needs tsig:write and is recorded in the audit log.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var k struct {
Name string `json:"name"`
Algorithm string `json:"algorithm"`
Secret string `json:"secret"`
}
if err := c.Get(ctx(), "/views/"+url.PathEscape(args[0])+"/key", nil, &k); err != nil {
return err
}
if keyNamed {
fmt.Printf("key %q {\n\talgorithm %s;\n\tsecret %q;\n};\n", k.Name, k.Algorithm, k.Secret)
return nil
}
return output(k, func(w io.Writer) {
fmt.Fprintf(w, "Key:\t%s\nAlgorithm:\t%s\nSecret:\t%s\ndig:\tdig -y %s:%s:%s @SERVER NAME\n", k.Name, k.Algorithm, k.Secret, k.Algorithm, k.Name, k.Secret)
})
},
}
key.Flags().BoolVar(&keyNamed, "named-conf", false, "print a key statement (for dig -k FILE)")
cmd.AddCommand(list, create, update, del, order, color, key)
return cmd
}
@@ -540,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; }'
@@ -564,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 {
@@ -585,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()
@@ -599,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 {
@@ -612,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)
@@ -679,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
}
@@ -718,7 +816,7 @@ func settingsCmd() *cobra.Command {
},
}
var ns []string
var contact, policy string
var contact, policy, srvExposure string
var ttl, refresh, retry, expire, neg uint32
set := &cobra.Command{
Use: "set",
@@ -739,6 +837,9 @@ func settingsCmd() *cobra.Command {
if f.Changed("serial-policy") {
body["serial_policy"] = policy
}
if f.Changed("exposure") {
body["exposure"] = srvExposure
}
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
if f.Changed(name) {
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
@@ -761,6 +862,7 @@ func settingsCmd() *cobra.Command {
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
set.Flags().StringVar(&srvExposure, "exposure", "", "without views: is the server reachable from the internet? auto, public or private (docs/exposure.md)")
set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
@@ -769,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 {
+465
View File
@@ -0,0 +1,465 @@
package main
import (
"errors"
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/spf13/cobra"
)
type encEndpoint struct {
Address string `json:"address"`
Port int `json:"port"`
}
type encStatus struct {
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"`
} `json:"addresses"`
Views []struct {
Name string `json:"name"`
Public bool `json:"public"`
HasZone bool `json:"has_zone"`
} `json:"views"`
Certificates []struct {
Name string `json:"name"`
Names []string `json:"names"`
Domains []string `json:"domains"`
DNS bool `json:"dns"`
Obtained bool `json:"obtained"`
} `json:"certificates"`
}
type encConflict struct {
Address string `json:"address"`
Port int `json:"port"`
What string `json:"what"`
}
func endpoints(es []encEndpoint) string {
var out []string
for _, e := range es {
out = append(out, fmt.Sprintf("%s port %d", e.Address, e.Port))
}
return strings.Join(out, ", ")
}
func encryptedDNSCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "encrypted-dns",
Aliases: []string{"encdns"},
Short: "DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md)",
Long: `Shows whether named serves DNS over TLS (DoT) and DNS over HTTPS (DoH),
where, and what to give clients. 'setup' sets it up: a certificate for the
host name (obtained if needed), named's tls statement and listeners on the
addresses you choose, and the host name's address records in your private
views.`,
Example: ` jarl encrypted-dns
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 --no-doh
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
jarl encrypted-dns disable`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s encStatus
if err := c.Get(ctx(), "/encrypted-dns", nil, &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
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))
}
if len(s.DoH) > 0 {
fmt.Fprintf(w, "DNS over HTTPS:\t%s\n", endpoints(s.DoH))
}
if s.DoTHost != "" {
fmt.Fprintf(w, "For DoT clients:\t%s (Android: Private DNS hostname; Linux: DNS=ADDRESS#%s, DNSOverTLS=yes)\n", s.DoTHost, s.Hostname)
}
if s.DoHURL != "" {
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")
}
var addrs []string
for _, a := range s.Addresses {
t := a.Address
if !a.Private {
t += " (public)"
}
addrs = append(addrs, t)
}
fmt.Fprintf(w, "This server's addresses:\t%s\n", strings.Join(addrs, ", "))
})
},
}
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[=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:
- the certificate: --certificate, or one with the dns use whose names
cover the host name, or a new one Jarl obtains for it (and waits for);
- a check that nothing else listens on those ports there;
- 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 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.
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
}
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", 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 {
list = append(list, a.Address)
}
return fmt.Errorf("give --address (this server's addresses on your own networks); it has %s", strings.Join(list, ", "))
}
// The certificate.
if certName == "" {
certs:
for _, ci := range s.Certificates {
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, strings.Join(hosts, ", "))
var r struct {
Certificate certInfo `json:"certificate"`
}
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 {
return err
}
} else {
var ci certInfo
if err := c.Get(ctx(), certPath(certName), nil, &ci); err != nil {
return err
}
if !contains(ci.Uses, "dns") {
fmt.Printf("Adding the dns use to certificate %s\n", certName)
if err := c.Do(ctx(), "PATCH", certPath(certName), nil, map[string]any{"uses": append(ci.Uses, "dns")}, nil); err != nil {
return err
}
}
if ci.NotAfter == nil {
if ci.IssuingSince == nil {
if err := c.Do(ctx(), "POST", certPath(certName)+"/issue", nil, map[string]any{}, nil); err != nil {
return err
}
}
if err := waitIssue(c, certName); err != nil {
return err
}
}
}
body := map[string]any{"hostnames": hosts, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
if cmd.Flags().Changed("doh-port") {
body["doh_port"] = dohPort
}
if cmd.Flags().Changed("dot-port") {
body["dot_port"] = dotPort
}
// Nothing else may listen there.
var chk struct {
Conflicts []encConflict `json:"conflicts"`
FreeDoHPort int `json:"free_doh_port"`
Error string `json:"error"`
}
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, body, &chk); err != nil {
return err
}
if len(chk.Conflicts) > 0 {
var msgs []string
for _, cf := range chk.Conflicts {
msgs = append(msgs, fmt.Sprintf("%s listens on %s port %d", cf.What, cf.Address, cf.Port))
}
hint := ""
if chk.FreeDoHPort != 0 && !noDoH {
hint = fmt.Sprintf("; for DoH, port %d is free there (--doh-port %d)", chk.FreeDoHPort, chk.FreeDoHPort)
}
return fmt.Errorf("%s%s", strings.Join(msgs, "; "), hint)
}
if chk.Error != "" && !flagQuiet {
fmt.Println("Note:", chk.Error)
}
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
}
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
f := setup.Flags()
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, 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 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
check := &cobra.Command{
Use: "check --address ADDR...",
Short: "See whether something already listens on the DoT and DoH ports",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r struct {
Conflicts []encConflict `json:"conflicts"`
FreeDoHPort int `json:"free_doh_port"`
Error string `json:"error"`
}
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, map[string]any{"addresses": cAddrs, "dot": true, "doh": true, "doh_port": cDoH}, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
if r.Error != "" {
fmt.Fprintln(w, r.Error)
}
if len(r.Conflicts) == 0 {
fmt.Fprintln(w, "The ports are free.")
}
for _, cf := range r.Conflicts {
fmt.Fprintf(w, "%s port %d:\t%s listens there\n", cf.Address, cf.Port, cf.What)
}
if r.FreeDoHPort != 0 {
fmt.Fprintf(w, "Free for DoH:\tport %d\n", r.FreeDoHPort)
}
})
},
}
check.Flags().StringArrayVar(&cAddrs, "address", nil, "address to check (repeat for several)")
check.Flags().IntVar(&cDoH, "doh-port", 443, "port for DNS over HTTPS")
disable := &cobra.Command{
Use: "disable",
Short: "Turn DNS over TLS and HTTPS off (the certificate and records stay)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "DELETE", "/encrypted-dns", url.Values{}, nil)
if err != nil {
return err
}
return report(resp)
},
}
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
}
// coversName reports whether certificate names cover host (a wildcard
// covers one label).
func coversName(names []string, host string) bool {
for _, n := range names {
n = strings.ToLower(strings.TrimSuffix(n, "."))
if n == host {
return true
}
if base, ok := strings.CutPrefix(n, "*."); ok {
if _, rest, ok := strings.Cut(host, "."); ok && rest == base {
return true
}
}
}
return false
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
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
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"errors"
"fmt"
"os"
"strconv"
"jarl/internal/api"
"jarl/internal/client"
"jarl/internal/model"
)
// A changeset given with --changeset is used as given (jarld still refuses
// what isn't allowed). One taken from JARL_CHANGESET is checked first: an
// exported ID outlives the changeset (it gets applied), and changeset IDs
// keep growing, so a stale or mistyped one can name someone else's
// changeset later. It is used only when it is open and the caller's own,
// and the CLI says on stderr which changeset that is.
// envChangeset returns the changeset in JARL_CHANGESET (0 if unset).
func envChangeset() (int64, error) {
v := os.Getenv("JARL_CHANGESET")
if v == "" {
return 0, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid JARL_CHANGESET %q", v)
}
return n, nil
}
// checkEnvChangeset checks the changeset id from JARL_CHANGESET before it
// is used. With mustOwn, it must also be open and the caller's own (for
// staging into it, applying or discarding it).
func checkEnvChangeset(c *client.Client, id int64, mustOwn bool) (*api.Changeset, error) {
var cs api.Changeset
if err := c.Get(ctx(), fmt.Sprintf("/changesets/%d", id), nil, &cs); err != nil {
var ce *client.Error
if errors.As(err, &ce) && ce.Status == 404 {
return nil, fmt.Errorf("JARL_CHANGESET is %d, but there is no changeset %d: start one with 'export JARL_CHANGESET=$(jarl changeset create -q)', or 'unset JARL_CHANGESET' to make changes live", id, id)
}
return nil, err
}
if mustOwn {
if cs.Status != model.ChangesetOpen {
return nil, fmt.Errorf("JARL_CHANGESET is %d, but changeset %d is %s: 'unset JARL_CHANGESET' to make changes live, or start a new one with 'export JARL_CHANGESET=$(jarl changeset create -q)'", id, id, cs.Status)
}
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return nil, err
}
mine := cs.UserID == me.User.ID && (me.User.ID != 0 || cs.Username == me.User.Username)
if !mine {
return nil, fmt.Errorf("JARL_CHANGESET is %d, but changeset %d is %s's (made via %s), not yours, so it isn't used from JARL_CHANGESET; give it as -C %d to use it anyway, or 'unset JARL_CHANGESET'", id, id, cs.Username, cs.Via, id)
}
}
if !flagQuiet {
desc := cs.Description
if desc == "" {
desc = "no description"
}
fmt.Fprintf(os.Stderr, "changeset %d (%s, %d operations) from JARL_CHANGESET\n", id, desc, len(cs.Ops))
}
return &cs, nil
}
// changesetArg returns the changeset a changeset subcommand works on: the
// ID argument, else --changeset, else JARL_CHANGESET (checked; mustOwn as
// for checkEnvChangeset). fromEnv says it came from JARL_CHANGESET.
func changesetArg(c *client.Client, args []string, mustOwn bool) (id string, fromEnv bool, err error) {
if len(args) > 0 {
return args[0], false, nil
}
if flagChangeset > 0 {
return strconv.FormatInt(flagChangeset, 10), false, nil
}
n, err := envChangeset()
if err != nil {
return "", false, err
}
if n == 0 {
return "", false, errors.New("which changeset? Give its ID, or set JARL_CHANGESET (export JARL_CHANGESET=$(jarl changeset create -q))")
}
if _, err := checkEnvChangeset(c, n, mustOwn); err != nil {
return "", false, err
}
return strconv.FormatInt(n, 10), true, nil
}
// envChangesetDone reminds that JARL_CHANGESET still names a changeset
// that was just applied or discarded: later changes would fail to stage.
func envChangesetDone(id, what string) {
if !flagQuiet {
fmt.Fprintf(os.Stderr, "JARL_CHANGESET still names changeset %s, which is now %s: run 'unset JARL_CHANGESET' (or export a new one) before making more changes\n", id, what)
}
}
+271
View File
@@ -0,0 +1,271 @@
package main
import (
"errors"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
)
type exposureFinding struct {
Severity string `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
View string `json:"view"`
Fix string `json:"fix"`
}
type exposureReport struct {
Scopes []struct {
ViewID int64 `json:"view_id"`
View string `json:"view"`
Public bool `json:"public"`
Override string `json:"override"`
Reason string `json:"reason"`
RateLimit string `json:"rate_limit"`
Findings []exposureFinding `json:"findings"`
} `json:"scopes"`
Server []exposureFinding `json:"server"`
RateLimiting *struct {
Since string `json:"since"`
Counts []struct {
Key string
Count int64
} `json:"counts"`
Networks []struct {
Key string
Count int64
} `json:"networks"`
Names []struct {
Key string
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
// into a view name.
func exposureScope(s string) map[string]any {
if s == "" || strings.EqualFold(s, "server") {
return map[string]any{}
}
return map[string]any{"view": s}
}
func exposureCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "exposure",
Short: "What is reachable from the internet, and what to harden (docs/exposure.md)",
Long: `Shows which views (or the server, without views) are reachable from the
internet and why, their rate limiting, and the exposure findings with the
fix Jarl can stage for each.`,
Example: ` jarl exposure
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 ack external security_open_resolver --reason "public resolver for the club"
jarl view update external --exposure public # override the classification`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r exposureReport
if err := c.Get(ctx(), "/exposure", nil, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
fmt.Fprintln(w, "VIEW\tEXPOSURE\tRATE LIMIT\tWHY")
for _, s := range r.Scopes {
name, exp := s.View, "private"
if name == "" {
name = "(server)"
}
if s.Public {
exp = "public"
}
if s.Override != "" {
exp += " (set)"
}
rl := s.RateLimit
if !s.Public && rl == "off" {
rl = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, exp, rl, s.Reason)
}
var all []exposureFinding
for _, s := range r.Scopes {
all = append(all, s.Findings...)
}
all = append(all, r.Server...)
fmt.Fprintln(w)
if len(all) == 0 {
fmt.Fprintln(w, "No findings.")
} else {
fmt.Fprintln(w, "SEVERITY\tWHERE\tCODE\tFINDING")
for _, f := range all {
where := f.View
if where == "" {
where = "server"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Severity, where, f.Code, f.Message)
if f.Fix != "" {
fmt.Fprintf(w, "\t\t\tfix: jarl exposure fix %s %s (%s)\n", where, f.Code, f.Fix)
}
}
}
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 {
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count)
}
for _, e := range a.Networks {
fmt.Fprintf(w, "network %s\t%d\n", e.Key, e.Count)
}
for _, e := range a.Names {
fmt.Fprintf(w, "name %s\t%d\n", e.Key, e.Count)
}
}
})
},
}
fix := &cobra.Command{
Use: "fix VIEW|server CODE",
Short: "Stage (or apply) the fix Jarl suggests for a finding",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := exposureScope(args[0])
body["code"] = args[1]
resp, err := c.Mutate(ctx(), "POST", "/exposure/fix", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
var reason string
ack := &cobra.Command{
Use: "ack VIEW|server CODE --reason TEXT",
Short: "Accept a finding on purpose: it stays, as info, with who and why",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := exposureScope(args[0])
body["code"], body["reason"] = args[1], reason
resp, err := c.Mutate(ctx(), "POST", "/exposure/ack", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
ack.Flags().StringVar(&reason, "reason", "", "why the finding is accepted (required)")
unack := &cobra.Command{
Use: "unack VIEW|server CODE",
Short: "Take an acknowledgement back: the finding warns again",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := exposureScope(args[0])
body["code"] = args[1]
resp, err := c.Mutate(ctx(), "POST", "/exposure/unack", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
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
}
+71 -17
View File
@@ -9,11 +9,11 @@ import (
"errors"
"fmt"
"io"
"net/netip"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"text/tabwriter"
@@ -24,6 +24,7 @@ import (
"jarl/internal/api"
"jarl/internal/client"
"jarl/internal/dnsname"
"jarl/internal/version"
)
@@ -48,6 +49,7 @@ var (
flagChangeset int64
flagDryRun bool
flagForce bool
flagRisk bool
flagOutput string
flagInsecure bool
flagCACert string
@@ -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
}
@@ -149,17 +178,23 @@ func newClient() (*client.Client, error) {
}
c.Changeset = flagChangeset
if c.Changeset == 0 {
if v := os.Getenv("JARL_CHANGESET"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v)
}
n, err := envChangeset()
if err != nil {
return nil, err
}
if n > 0 {
// Checked when a change is first staged into it
// (envchangeset.go).
c.Changeset = n
c.CheckChangeset = func(context.Context) error {
_, err := checkEnvChangeset(c, n, true)
return err
}
}
}
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
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
}
@@ -239,6 +274,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
pf.BoolVar(&flagRisk, "accept-risk", false, "confirm a change that opens a public view up, such as an open resolver (docs/exposure.md)")
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
@@ -252,7 +288,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
}
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
add("changes", applyCmd(), changesetCmd(), historyCmd())
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
add("admin", statusCmd(), exposureCmd(), certCmd(), encryptedDNSCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
setupCompletion(root)
return root
}
@@ -530,6 +566,18 @@ func report(resp *api.MutationResponse) error {
json.Unmarshal(resp.Problems, &introduced)
}
printProblems(os.Stderr, introduced)
// named-checkconf's own warnings about the new configuration.
if r != nil && (resp.Applied || resp.DryRun) {
for _, m := range r.Check {
if !m.Error && !strings.HasPrefix(m.Text, "loaded serial") && m.Text != "OK" {
where := ""
if m.Object != "" {
where = " (" + m.Object + ")"
}
fmt.Fprintf(os.Stderr, "warning: named-checkconf: %s%s\n", m.Text, where)
}
}
}
for _, warn := range resp.Warnings {
fmt.Fprintln(os.Stderr, "warning:", warn)
}
@@ -550,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)
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"fmt"
"os"
"strings"
"jarl/internal/api"
"jarl/internal/client"
)
// zoneViews expands a --view value for a record command: "" or one view
// as it is, "a,b" to those views, and "all" to every view that has the
// zone.
// localFanOut is set by commands making local changes (--local), which
// go to every view named even when the zones are linked.
var localFanOut bool
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
if view != "all" && !strings.Contains(view, ",") {
return []string{view}, nil
}
var zones []api.Zone
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
return nil, err
}
want := strings.ToLower(strings.TrimSuffix(zone, "."))
link := map[string]int64{}
var out []string
for _, z := range zones {
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
link[z.View] = z.LinkGroup
if view == "all" {
out = append(out, z.View)
}
}
}
if view != "all" {
for _, v := range strings.Split(view, ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
}
if len(out) == 0 {
return nil, fmt.Errorf("zone %s is in no view", zone)
}
// Linked zones (docs/views.md#linked-zones) get a shared change once:
// Jarl copies it to the others. A local change goes to each.
if localFanOut {
return out, nil
}
var dedup []string
first := map[int64]string{}
for _, v := range out {
g := link[v]
if g != 0 {
if f, ok := first[g]; ok {
if !flagQuiet {
fmt.Fprintf(os.Stderr, "view %s: linked with %s, so it gets the change from there\n", v, f)
}
continue
}
first[g] = v
}
dedup = append(dedup, v)
}
return dedup, nil
}
// perView runs a record command once per view. With several views, the
// changes are staged into one changeset and applied together, so either
// every view gets the change or none does (with --changeset, they are
// staged there).
//
// do returns the server's response, or nil when there was nothing to do.
func perView(c *client.Client, views []string, description string, do func(c *client.Client, view string) (*api.MutationResponse, error)) error {
if len(views) == 1 || c.Changeset > 0 {
for _, v := range views {
resp, err := do(c, v)
if err != nil {
if len(views) > 1 {
err = fmt.Errorf("view %s: %w", v, err)
}
return err
}
if resp != nil {
if err := report(resp); err != nil {
return err
}
}
}
return nil
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": description + " (views " + strings.Join(views, ", ") + ")"}, &cs); err != nil {
return err
}
discard := func() { c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/discard", cs.ID), nil, nil, nil) }
staging := *c
staging.Changeset, staging.DryRun = cs.ID, false
staged := 0
for _, v := range views {
resp, err := do(&staging, v)
if err != nil {
discard()
fmt.Fprintf(os.Stderr, "view %s failed, so nothing was changed in any view\n", v)
return err
}
if resp != nil {
staged++
}
}
if staged == 0 {
discard()
return nil
}
var resp api.MutationResponse
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp)
if err != nil || c.DryRun {
discard()
}
if err != nil {
return err
}
return report(&resp)
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"bytes"
"strings"
"testing"
"jarl/internal/api"
)
func TestPrintPTRFills(t *testing.T) {
var b bytes.Buffer
printPTRFills(&b, []api.PTRFill{{Address: "10.0.10.20", Target: "nextcloud.adbraun.lol.", From: "internal"}}, 3)
out := b.String()
if !strings.Contains(out, "10.0.10.20 -> nextcloud.adbraun.lol (view internal)") || !strings.Contains(out, "and 2 more") {
t.Errorf("listed fills:\n%s", out)
}
// An older server sends the count without the list.
b.Reset()
printPTRFills(&b, nil, 1)
if b.Len() != 0 {
t.Errorf("no list from the server printed %q, want nothing", b.String())
}
}
+126 -13
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)
@@ -128,13 +156,15 @@ export the data.`,
var limit int
top := &cobra.Command{
Use: "top [names|clients|nxdomain|servfail|refused|blocked]",
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients|ratelimited|ratelimited-names]",
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
Example: ` jarl stats top names --since 7d
jarl stats top clients --limit 50
jarl stats top nxdomain # misconfigured hosts and typos
jarl stats top refused # who is asking for what they may not have
jarl stats top blocked # what blocking stops most (docs/blocking.md)`,
jarl stats top blocked # what blocking stops most (docs/blocking.md)
jarl stats top blocked-clients # the devices blocking stops most
jarl stats top ratelimited # client networks rate limiting limits, or would (docs/exposure.md)`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
kind := stats.KindName
@@ -143,13 +173,19 @@ export the data.`,
if kind == "nxdomain" || kind == "servfail" {
kind = strings.ToLower(args[0])
}
if kind == "blocked-client" {
kind = stats.KindBlockedClient
}
if kind == "ratelimited-name" {
kind = stats.KindRateLimitedName
}
}
c, err := newClient()
if err != nil {
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) {
@@ -157,7 +193,7 @@ export the data.`,
for _, e := range es {
total += e.Count
}
head := map[string]string{stats.KindClient: "CLIENT", stats.KindRefused: "CLIENT"}[kind]
head := map[string]string{stats.KindClient: "CLIENT", stats.KindRefused: "CLIENT", stats.KindBlockedClient: "CLIENT"}[kind]
if head == "" {
head = "NAME"
}
@@ -199,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) {
@@ -248,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) {
@@ -300,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) {
@@ -321,7 +357,61 @@ export the data.`,
}
sinceFlag(breakdown, &breakdownSince, "24h", sinceHelp)
cmd.AddCommand(status, summary, top, timeline, heatmap, breakdown)
server := &cobra.Command{
Use: "server",
Short: "Show named's memory, CPU, cache and clients, the host's memory and load, and free disk space",
Long: `Server statistics, sampled once a minute: named's memory and CPU use,
cache size, recursive and TCP clients against their limits, the host's
memory and load, and the space left where zone files, logs and Jarl's
data are. Memory and CPU over time: 'jarl stats timeline --key
g:named:rss,g:named:cpu' (averages; CPU in tenths of a percent).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp struct {
Server *stats.Server `json:"server"`
Named map[string]string `json:"named,omitempty"`
NamedError string `json:"named_error,omitempty"`
Disks []stats.Disk `json:"disks"`
}
if err := c.Get(ctx(), "/stats/server", nil, &resp); err != nil {
return err
}
return output(resp, func(w io.Writer) {
s := resp.Server
if s == nil {
fmt.Fprintln(w, "No sample yet:\tthe first is taken a minute after jarl starts")
return
}
fmt.Fprintf(w, "named:\t%s, up since %s, reconfigured %s\n", s.Version, isoLocal(s.BootTime), isoLocal(s.ConfigTime))
if s.PID > 0 {
fmt.Fprintf(w, "Memory:\t%s resident (named counts %s), %d threads, PID %d\n", sizeStr(s.RSS), sizeStr(s.MemInUse), s.Threads, s.PID)
fmt.Fprintf(w, "CPU:\t%.1f%% of one CPU over the last minute\n", s.CPUPercent)
} else {
fmt.Fprintf(w, "Memory:\tnamed counts %s (its process isn't visible to jarld)\n", sizeStr(s.MemInUse))
}
fmt.Fprintf(w, "Cache:\t%d RRsets, %s\n", s.CacheRRsets, sizeStr(s.CacheMem))
if n := resp.Named; n != nil {
fmt.Fprintf(w, "Recursive clients:\t%s (now/soft limit/limit), most at once %d\n", n["recursive_clients"], s.RecursionHighwater)
fmt.Fprintf(w, "TCP clients:\t%s (now/limit), most at once %d\n", n["tcp_clients"], s.TCPHighwater)
} else if resp.NamedError != "" {
fmt.Fprintf(w, "Clients:\tunknown: %s\n", resp.NamedError)
}
fmt.Fprintf(w, "Sockets:\t%d UDP, %d TCP\n", s.SocketsUDP, s.SocketsTCP)
if h := s.Host; h != nil && h.MemTotal > 0 {
fmt.Fprintf(w, "Host:\t%s of %s memory in use, load %v, %d CPUs, up %s\n", sizeStr(h.MemTotal-h.MemAvailable), sizeStr(h.MemTotal), h.Load, h.CPUs, (time.Duration(h.Uptime) * time.Second).String())
}
for _, d := range resp.Disks {
fmt.Fprintf(w, "Disk:\t%s free of %s for %s (%s)\n", sizeStr(d.Free), sizeStr(d.Total), strings.Join(d.Holds, ", "), d.Path)
}
})
},
}
cmd.AddCommand(status, summary, top, timeline, heatmap, breakdown, server)
return cmd
}
@@ -394,3 +484,26 @@ func sparkline(vals []int64) string {
}
return sb.String()
}
// sizeStr formats a size in binary units.
func sizeStr(n int64) string {
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
f, i := float64(n), 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i == 0 || f >= 100 {
return fmt.Sprintf("%.0f %s", f, units[i])
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
// isoLocal shows a time named reports (RFC 3339) in local time.
func isoLocal(s string) string {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return s
}
return t.Local().Format("2006-01-02 15:04")
}
+670 -51
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"net/url"
"os"
"slices"
"strconv"
"strings"
"unicode/utf8"
@@ -45,7 +46,11 @@ func zoneCmd() *cobra.Command {
if z.Type == "primary" {
serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, z.Type, recs, serial)
typ := z.Type
if z.Disabled {
typ += " (disabled)"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, typ, recs, serial)
}
})
},
@@ -73,12 +78,15 @@ func zoneCmd() *cobra.Command {
fmt.Fprintf(w, "View:\t%s\n", z.View)
}
fmt.Fprintf(w, "Type:\t%s\n", z.Type)
if z.Disabled {
fmt.Fprintf(w, "Disabled:\tyes: named doesn't load it ('jarl zone enable %s')\n", args[0])
}
if z.Type == "primary" {
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial)
fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum)
fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
if z.Disabled > 0 {
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.Disabled, args[0])
if z.DisabledRecords > 0 {
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.DisabledRecords, args[0])
} else {
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
}
@@ -86,6 +94,28 @@ 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])
printPTRFills(w, z.PTRMissingList, z.PTRMissing)
}
if z.PTROtherViews > 0 {
what := "addresses"
if z.PTROtherViews == 1 {
what = "address"
}
fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n",
z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0])
printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews)
}
}
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, ", "))
}
if z.ZoneFile != "" {
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
}
@@ -98,22 +128,28 @@ func zoneCmd() *cobra.Command {
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
var ns, records, primaries, forwarders, opts []string
var contact, comment, cloneFrom, file, inView, forward 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`,
jarl zone create example.com --view external --clone-from example.com --clone-view internal
jarl zone create example.com --view vps --link-with external # linked: shared records kept in step`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
@@ -142,6 +178,15 @@ and a network between octet boundaries, like a /22, gets one zone per /24
if inView != "" {
body["in_view"] = inView
}
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)
@@ -212,10 +257,14 @@ and a network between octet boundaries, like a /22, gets one zone per /24
create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from")
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
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)",
@@ -257,6 +306,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)")
}
@@ -281,6 +333,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{
@@ -306,6 +359,42 @@ and a network between octet boundaries, like a /22, gets one zone per /24
del.Flags().StringVar(&view, "view", "", "view of the zone")
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
toggle := func(disable bool) *cobra.Command {
verb := map[bool]string{true: "disable", false: "enable"}[disable]
long := `Load a disabled zone in named again, as it is in Jarl now (changes made
while it was disabled included).`
if disable {
long = `Disable a zone: it stays in Jarl with its records, options and history,
and can still be edited, but named no longer loads it. Its names are then
answered by recursion where the view offers it (the internet's version of
the zone, if any), and REFUSED elsewhere. Signed zones can't be disabled;
turn signing off first.
jarl zone disable old.adbraun.lol
jarl zone enable old.adbraun.lol`
}
c := &cobra.Command{
Use: verb + " ZONE",
Short: map[bool]string{true: "Stop serving a zone, keeping it in Jarl", false: "Serve a disabled zone again"}[disable],
Long: long,
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)
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"disabled": disable})
if err != nil {
return err
}
return report(resp)
},
}
c.Flags().StringVar(&view, "view", "", "view of the zone")
return c
}
fileCmd := &cobra.Command{
Use: "file ZONE",
Short: "Print the zone file Jarl generates",
@@ -373,7 +462,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, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
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
}
@@ -406,11 +495,32 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// ---- records
// viewsFlagHelp describes --view of the record commands that change
// records.
const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\" make the same change in each, together"
func recordCmd() *cobra.Command {
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
var view, name, typ, search, comment, ttlArg string
var ptr, missingOK, yes, onlyDisabled, wide bool
var ptr, missingOK, yes, onlyDisabled, wide, local, shared bool
var id int64
// linkFlags adds --local and --shared (linked zones) to a command.
linkFlags := func(c *cobra.Command, what string) {
c.Flags().BoolVar(&local, "local", false, "linked zones: "+what+" in this view only (overriding the shared records of this name and type here)")
c.Flags().BoolVar(&shared, "shared", false, "linked zones: "+what+" in every linked view (this version becomes every view's)")
}
linkBody := func(body map[string]any) error {
switch {
case local && shared:
return fmt.Errorf("give --local or --shared, not both")
case local:
body["local"] = true
localFanOut = true
case shared:
body["local"] = false
}
return nil
}
list := &cobra.Command{
Use: "list ZONE",
@@ -432,8 +542,15 @@ func recordCmd() *cobra.Command {
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
return err
}
linked := false
for _, r := range recs {
linked = linked || r.Linked != ""
}
return output(recs, func(w io.Writer) {
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
if linked {
rows[0] = []string{"ID", "NAME", "TTL", "TYPE", "LINK", "DATA", "COMMENT"}
}
for _, r := range recs {
ttl := fmt.Sprint(r.EffectiveTTL)
if r.TTL == nil {
@@ -443,10 +560,18 @@ func recordCmd() *cobra.Command {
if r.Disabled {
comment = strings.TrimSpace("[disabled] " + comment)
}
if linked {
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Linked, r.Data, comment})
continue
}
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
}
if !wide {
fitColumn(rows, 4)
col := 4
if linked {
col = 5
}
fitColumn(rows, col)
}
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
@@ -488,18 +613,24 @@ and splitting are automatic).
if ptr {
body["ptr"] = true
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "POST", path+"/records", qv, body)
if err := linkBody(body); err != nil {
return err
}
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return report(resp)
return perView(c, views, "add "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
return c.Mutate(ctx(), "POST", path+"/records", qv, body)
})
},
}
add.Flags().StringVar(&view, "view", "", "view of the zone")
add.Flags().StringVar(&view, "view", "", viewsFlagHelp)
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
add.Flags().StringVar(&comment, "comment", "", "comment")
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
linkFlags(add, "add it")
var data string
update := &cobra.Command{
@@ -533,6 +664,9 @@ and splitting are automatic).
if ptr {
body["ptr"] = true
}
if err := linkBody(body); err != nil {
return err
}
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
}
@@ -549,6 +683,8 @@ and splitting are automatic).
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
update.Flags().StringVar(&comment, "comment", "", "new comment")
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
update.Flags().BoolVar(&local, "local", false, "linked zones: make this name and type local to the record's view (an override)")
update.Flags().BoolVar(&shared, "shared", false, "linked zones: share this name and type with the linked views; this view's version becomes every view's")
del := &cobra.Command{
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
@@ -581,26 +717,28 @@ or one record by ID.
if len(args) == 3 && !confirm(fmt.Sprintf("Delete all %s records of %s?", strings.ToUpper(args[2]), args[1]), yes) {
return fmt.Errorf("canceled")
}
path, qv := zonePath(args[0], view)
qv.Set("name", args[1])
qv.Set("type", args[2])
if len(args) > 3 {
qv.Set("data", strings.Join(args[3:], " "))
}
if missingOK {
qv.Set("missing_ok", "1")
}
if ptr {
qv.Set("ptr", "1")
}
resp, err := c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return report(resp)
return perView(c, views, "delete "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
qv.Set("name", args[1])
qv.Set("type", args[2])
if len(args) > 3 {
qv.Set("data", strings.Join(args[3:], " "))
}
if missingOK {
qv.Set("missing_ok", "1")
}
if ptr {
qv.Set("ptr", "1")
}
return c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
})
},
}
del.Flags().StringVar(&view, "view", "", "view of the zone")
del.Flags().StringVar(&view, "view", "", viewsFlagHelp)
del.Flags().Int64Var(&id, "id", 0, "delete the record with this ID")
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches")
del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)")
@@ -627,17 +765,23 @@ spaces.
if comment != "" {
body["comment"] = comment
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
if err := linkBody(body); err != nil {
return err
}
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return report(resp)
return perView(c, views, "set "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
return c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
})
},
}
set.Flags().StringVar(&view, "view", "", "view of the zone")
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
linkFlags(set, "set it")
toggle := func(disable bool) *cobra.Command {
verb := "enable"
@@ -685,30 +829,37 @@ with DATA), or one record by ID.
if len(args) < 3 {
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
}
path, zq := zonePath(args[0], tview)
for k, v := range zq {
qv[k] = v
}
qv.Set("name", args[1])
qv.Set("type", args[2])
if len(args) > 3 {
qv.Set("data", strings.Join(args[3:], " "))
}
if tmissing {
qv.Set("missing_ok", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, qv, nil)
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
fmt.Println("nothing to do")
return nil
}
views, err := zoneViews(c, args[0], tview)
if err != nil {
return err
}
return report(resp)
return perView(c, views, verb+" "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, zq := zonePath(args[0], view)
vq := q()
for k, v := range qv {
vq[k] = v
}
for k, v := range zq {
vq[k] = v
}
vq.Set("name", args[1])
vq.Set("type", args[2])
if len(args) > 3 {
vq.Set("data", strings.Join(args[3:], " "))
}
if tmissing {
vq.Set("missing_ok", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, vq, nil)
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
fmt.Println("nothing to do")
return nil, nil
}
return resp, err
})
},
}
c.Flags().StringVar(&tview, "view", "", "view of the zone")
c.Flags().StringVar(&tview, "view", "", viewsFlagHelp)
c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID")
c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d")
c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)")
@@ -769,3 +920,471 @@ func fitColumn(rows [][]string, col int) {
}
}
}
// zoneLinkCmd is 'zone link' and 'zone unlink' (docs/views.md#linked-zones).
func zoneLinkCmd(link bool) *cobra.Command {
var view string
var with []string
cmd := &cobra.Command{
Use: "link ZONE --view VIEW [--with VIEW...]",
Short: "Link zones of the same name in several views: shared records are kept in step",
Long: `Links a zone with the zones of the same name in other views (--with, or
every view that has one). Jarl then keeps their shared records in step: a
change to one reaches the others in the same changeset. Each view can
still have records of its own (record add/set --local), which override
the shared records of that name and type there, and every zone keeps its
own options, so views can still differ in who they answer and how.
Linking never changes what named answers: a name and type with the same
records in every view becomes shared, and one that differs stays local to
each view. Share one later from the view whose version is right:
jarl record update ID --shared.`,
Example: ` jarl zone link adbraun.lol --view external --with vps
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
jarl zone unlink adbraun.lol --view vps`,
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)
verb := "unlink"
var body any
if link {
verb = "link"
body = map[string]any{"with": with}
}
resp, err := c.Mutate(ctx(), "POST", path+"/"+verb, qv, body)
if err != nil {
return err
}
return report(resp)
},
}
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
if link {
cmd.Flags().StringSliceVar(&with, "with", nil, "views to link with (default: every view with a zone of that name)")
return cmd
}
cmd.Use = "unlink ZONE --view VIEW"
cmd.Short = "Take a zone out of its link: its records stay, all local"
cmd.Long = `Takes a zone out of its link. Its records stay as they are, all local to
its view, and the other zones of the link keep theirs. A link left with one
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
}
// printPTRFills lists PTR records a reverse zone lacks under zone show's
// line about them: address -> name, and the view the name is from. A server
// older than the CLI may send the count without the list; then nothing is
// listed, rather than "and N more" under no addresses.
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
if len(fills) == 0 {
return
}
for _, f := range fills {
from := ""
if f.From != "" {
from = " (view " + f.From + ")"
}
fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from)
}
if total > len(fills) {
fmt.Fprintf(w, "\t and %d more\n", total-len(fills))
}
}
// fromViews names the views PTR fills take their names from: "view
// internal", "views internal and lab".
func fromViews(fills []api.PTRFill) string {
var vs []string
for _, f := range fills {
if f.From != "" && !slices.Contains(vs, f.From) {
vs = append(vs, f.From)
}
}
switch len(vs) {
case 0:
return "other views"
case 1:
return "view " + vs[0]
}
return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1]
}
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
}
+5 -1
View File
@@ -302,7 +302,11 @@ func renderCmd() *cobra.Command {
return err
}
paths := render.Paths{ConfigDir: filepath.Join(out, "config"), ZoneDir: filepath.Join(out, "zones"), CacheDir: filepath.Join(out, "cache")}
o := render.Render(st, paths, renderExtras(a.cfg))
x := renderExtras(a.cfg)
if x.ViewKeySeed, err = engine.ViewKeySeed(context.Background(), a.store); err != nil {
return err
}
o := render.Render(st, paths, x)
for _, f := range o.Files {
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
return err
+39 -5
View File
@@ -24,9 +24,11 @@ import (
"jarl/internal/auth"
"jarl/internal/bind"
"jarl/internal/certs"
"jarl/internal/config"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
"jarl/internal/render"
"jarl/internal/server"
@@ -122,6 +124,7 @@ func openApp(allowMissingConfig bool) (*app, error) {
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
NoReload: cfg.BIND.NoReload,
Extras: renderExtras(cfg),
LocalAddrs: nscheck.LocalAddrs,
}, log)
eng.Limits = engine.BlocklistLimits{
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
@@ -148,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}
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())
@@ -167,7 +171,7 @@ func zoneIndex(a *app) stats.ZoneIndex {
}
out := map[string][]string{}
for _, z := range st.Zones {
if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror {
if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror || z.Disabled {
continue
}
v := st.ViewName(z)
@@ -185,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
}
@@ -303,6 +308,24 @@ func serveCmd() *cobra.Command {
a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
}
}
if a.cfg.Metrics.Enabled && a.cfg.Metrics.Token != "" {
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
a.log.Warn("the configuration file holds metrics.token and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
}
}
cm := certs.New(certs.Config{
Directory: a.cfg.ACME.Account.DirectoryURL(), Email: a.cfg.ACME.Account.Email, AgreeTOS: a.cfg.ACME.Account.AgreeTOS,
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 != "" {
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
a.log.Warn("the configuration file holds acme.account.eab_hmac and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
}
}
srv := server.New(server.Config{
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
@@ -311,6 +334,11 @@ func serveCmd() *cobra.Command {
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
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,
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,
@@ -328,6 +356,7 @@ func serveCmd() *cobra.Command {
}()
go auditRetention(ctx, a)
go blocklistLoop(ctx, a)
go cm.Run(ctx)
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
sdNotify("STOPPING=1")
@@ -339,22 +368,27 @@ func serveCmd() *cobra.Command {
// blocklistLoop keeps blocklists up to date (docs/blocking.md): every few
// minutes it downloads the lists that are due, and at once when a list
// was added or changed.
// was added or changed. It also ends a pause of blocking on time.
func blocklistLoop(ctx context.Context, a *app) {
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second): // let jarld finish starting
}
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
a.eng.RefreshDue(ctx)
wait := 5 * time.Minute
if until := a.eng.ResumeBlocking(ctx); !until.IsZero() {
wait = min(wait, max(time.Until(until), time.Second))
}
t := time.NewTimer(wait)
select {
case <-ctx.Done():
t.Stop()
return
case <-t.C:
case <-a.eng.BlocklistKicks():
t.Stop()
}
}
}
+6 -1
View File
@@ -7,12 +7,17 @@
#
# Install it, then:
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \
# lego --email you@example.com --dns exec -d example.com -d '*.example.com' run
# lego run --accept-tos --email you@example.com --dns exec -d example.com -d '*.example.com'
# (lego 4: lego --accept-tos --email ... --dns exec -d ... run, and "renew" to renew)
#
# Options (environment):
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured)
# JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured)
# JARL_VIEW Only write into this view (optional; default every view that has the zone)
# On a host inside a split DNS, lego's own propagation check then can't
# see the record: leave it unset, or run lego with
# --dns.propagation.disable-ans (lego 5; --dns.propagation-disable-ans in lego 4)
# (docs/acme.md, "Split DNS and lego's propagation check").
# JARL_BIN Path of the jarl binary (optional; default "jarl")
JARL_BIN="${JARL_BIN:-jarl}"
+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.
+5 -1
View File
@@ -55,7 +55,9 @@ RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# AF_NETLINK lets jarld list this host's addresses, so its name server
# checks know which addresses are its own.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
SystemCallArchitectures=native
# @system-service includes @chown, which jarld needs to give files to group bind.
SystemCallFilter=@system-service
@@ -63,3 +65,5 @@ SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
# 'systemctl restart jarld' works too (the daemon's binary is jarld).
Alias=jarld.service
+96 -6
View File
@@ -1,5 +1,11 @@
# Let's Encrypt (ACME DNS-01) with Jarl
This page is about **other** ACME clients (certbot, acme.sh, lego,
Traefik) publishing their DNS-01 challenges through Jarl. For
certificates for the DNS server itself (Jarl's web UI, named's DNS over
TLS and HTTPS), Jarl can be the ACME client: see
[certificates.md](certificates.md).
The DNS-01 challenge proves that you control a domain by publishing a TXT
record at `_acme-challenge.<domain>`. Jarl does this through its API, so no
dynamic updates (`allow-update`, nsupdate) are needed. That matters because
@@ -42,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.
@@ -90,10 +98,23 @@ sudo install -m 0755 contrib/lego/jarl-lego.sh /usr/local/bin/jarl-lego # or f
export EXEC_PATH=/usr/local/bin/jarl-lego
export JARL_SERVER=https://ns1.adbraun.lol:8053
export JARL_TOKEN=@/etc/jarl/certbot.token
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run
lego run --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol'
```
Renew with the same variables and `lego ... renew` in place of `run`.
That is lego 5. `lego run` also renews: run the same command daily (a
cron job or systemd timer), and it renews once a third of the
certificate's lifetime is left. **lego 4** takes its options before the
command, and renews with `renew`:
```sh
lego --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run # first time
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' renew # then daily
```
Upgrading from lego 4 to 5, run `lego migrate` once (with `--path` if you
keep lego's data somewhere other than `./.lego`) before anything else:
lego 5 stores accounts and certificates differently. The exec and httpreq
providers, and so Jarl's side, are the same in both.
* lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`,
and the script passes them to `jarl acme set|unset --exact`. FQDN is the
@@ -106,7 +127,66 @@ Renew with the same variables and `lego ... renew` in place of `run`.
* Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`.
* lego waits until the record is visible on the zone's name servers
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only
once named serves the record, so this check passes on its first try.
once named serves the record, so this check passes on its first try,
as long as the view that answers lego's host has the record: with
`JARL_VIEW` on a host inside your network it doesn't. See
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
### Split DNS and lego's propagation check
Before lego asks the CA to look, it checks the record itself, asking the
zone's name servers (`ns1.adbraun.lol`) directly. With views, they answer
lego's host from the view that matches *it*. On a host inside your
network that is the internal view. If the challenge was written into
another view only (`JARL_VIEW=external`, `--view external`, a token
limited to `views=external`, or the httpreq endpoint
`.../httpreq/view/external`), lego never sees it, and gives up after
its propagation timeout without the CA having looked:
```
propagation: time limit exceeded: last error: authoritative nameservers:
NS ns1.adbraun.lol.:53 returned NXDOMAIN for _acme-challenge.stats.adbraun.lol.
```
A name whose authorization is still valid from an earlier run ("authorization
already valid; skipping challenge") isn't checked at all, so one name can
work and the next fail with the same settings.
The ways round it, best first:
1. **Don't limit the view.** Leave `JARL_VIEW` unset (and give the token
no `views=` filter, the httpreq endpoint no `/view/...`): the record
goes into every view that has the zone, the internal one too, and
lego's check passes at once. The challenge is a random value that is
useless once the CA has checked it, and it is removed straight after.
2. **Skip lego's check of the name servers.** The record is already
served when Jarl answers, so lego's check adds nothing:
```sh
lego run --dns exec --dns.propagation.disable-ans ... # lego 5
lego --dns exec --dns.propagation-disable-ans ... run # lego 4.20 to 4.x
lego --dns exec --dns.disable-cp ... run # older lego 4
```
or replace the checks with a fixed wait, which also gives secondary
name servers time to catch up: `--dns.propagation.wait 30s` (lego 5;
`--dns.propagation-wait 30s` in lego 4.20 and later). In lego 5 these
can also be set in the environment: `LEGO_DNS_PROPAGATION_DISABLE_ANS=true`,
`LEGO_DNS_PROPAGATION_WAIT=30s`.
3. **In Traefik**, the same under `dnsChallenge` (Traefik 3.2 and later;
older versions have `disablePropagationCheck: true`):
```yaml
dnsChallenge:
provider: httpreq
propagation:
disableANSChecks: true # or delayBeforeChecks: 30s
```
4. **Run lego where the external view answers it**, such as a host outside
your network. Rarely the easy option.
certbot (section 3) doesn't check by itself, and acme.sh (section 4)
checks through public DNS-over-HTTPS resolvers, so neither runs into this.
## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI
@@ -291,14 +371,20 @@ of the `acme` role.
export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
export HTTPREQ_USERNAME=lego
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_...
lego --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol' run
lego run --accept-tos --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol'
```
That is lego 5; in lego 4 the options go before the command (`lego
--email ... --dns httpreq -d ... run`, and `renew` to renew). See
[section 5](#5-lego).
Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which
jarld refuses with a message saying so). If jarld's certificate doesn't come from a public
CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's
`LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put
it in the endpoint: `.../api/v1/acme/httpreq/view/external`.
it in the endpoint: `.../api/v1/acme/httpreq/view/external`; on a host inside
your network, read [Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check)
first.
### 4. Traefik
@@ -404,4 +490,8 @@ the main zone is never modified by automation.
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
sure the record is in the view that matches the CA (it is by default)
and that `allow-query` lets the internet query the zone.
* lego says `propagation: time limit exceeded ... returned NXDOMAIN for
_acme-challenge...`: the record is in a view that doesn't answer lego's
host, usually `JARL_VIEW=external` on a host inside your network. See
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
* `jarl audit --user certbot` shows every attempt.
+113 -13
View File
@@ -10,6 +10,8 @@ what is documented here.
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
`relative_name`).
* `GET /healthz` (no authentication) returns `{"ok": true, "version": ...}`.
* `GET /metrics` serves Prometheus metrics when `metrics.enabled` is set.
Access is set in jarl.yaml, not with API tokens ([metrics.md](metrics.md)).
## Authentication
@@ -147,6 +149,14 @@ honours `If-Match: <version>`, and batch operations take `base_version`. If
the object changed in the meantime the request fails with 409. Staged
operations record the version they were staged against automatically.
**Stale changesets.** When an object a staged operation needs was changed
or deleted after it was staged (from the CLI, say), preview and apply fail
on that operation: the error has `op` (its number in the changeset),
`op_summary` and a message saying what happened; remove the operation
(`DELETE /changesets/{id}/ops/{seq}`) or stage it again. A staged delete of
a record that is already gone is not an error: it counts as done, with a
warning.
## Resources
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
@@ -160,7 +170,7 @@ may be the name.
| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) |
| POST | `/zones` | ZoneInput (below) |
| GET | `/zones/{zone}` | |
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change) |
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change); `{"disabled": true}` disables the zone: named stops loading it, Jarl keeps it |
| DELETE | `/zones/{zone}` | deletes the zone with its records and options |
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
| GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) |
@@ -180,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
@@ -189,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
@@ -198,6 +211,15 @@ 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),
with the first 25 of each in `ptr_missing_list` and
`ptr_other_views_list` (`address`, `name`, `target`, and `from`, the view
the name comes from).
`/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:
@@ -277,11 +299,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)).
@@ -332,6 +363,28 @@ registrar_error`.
### Records
In a linked zone ([views.md](views.md#linked-zones)), records carry
`shared_id` and `linked` (`shared` or `local`). Creating, updating
(`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). 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.
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
@@ -375,7 +428,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
@@ -386,6 +441,7 @@ Disabling a record that is already disabled is a 409; with `missing_ok=1`
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default |
| GET | `/views/{id}/key` | `{name, algorithm, secret}`: the TSIG key that selects the view (docs/views.md); needs `tsig:write`, audited |
`adopt_zones` moves zones that are not in a view into the new view. named
requires every zone to be in a view as soon as one view exists.
@@ -404,7 +460,7 @@ send the request with that changeset (`?changeset=` or
| Method | Path | Body |
|--------|------|------|
| GET | `/acls` | each with `used_by` |
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "key xfer", "localhost"], "comment": ""}` |
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "10.0.10.101-10.0.10.199", "key xfer", "localhost"], "comment": ""}` (ranges: [concepts.md](concepts.md#model-views-zones-records-options)) |
| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
| GET | `/tsig-keys` | without secrets |
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
@@ -419,14 +475,22 @@ Needs `blocking:read` (`blocking:write` to change). See
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": ""}` (`views` empty or absent: automatic); jarld downloads it right after |
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": "", "step": "normal"}` (`views` empty or absent: automatic; `step` the blocking step it starts at); jarld downloads it right after |
| GET / PATCH / DELETE | `/blocking/lists/{id or name}` | PATCH any of `name`, `url`, `views`, `disabled`, `comment` |
| POST | `/blocking/lists/{id or name}/refresh` | download now and wait; `?force=1` even when the server says nothing changed. Returns the list; a failed update is in `state` and `status.error` |
| GET | `/blocking/rules` | each with `views` and `auto_views` |
| POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
| PATCH / DELETE | `/blocking/rules/{id}` | |
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list}` |
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list, step}` (what applies to normal devices when blocking isn't paused; `step` when only devices at that step or above are blocked) |
| GET / POST | `/blocking/steps` | the steps above normal, lowest first, with the `lists` and `groups` at each; POST `{"name": "strict", "comment": ""}` adds one on top |
| PATCH / DELETE | `/blocking/steps/{id or name}` | `{"name", "position", "comment"}`; delete is refused while lists or groups use it |
| POST | `/blocking/steps/order` | `{"steps": ["strict", "very-strict"]}`: every step above normal, lowest first |
| GET / POST | `/blocking/groups` | client groups; POST `{"name": "kids", "members": ["10.10.0.50", "tablets"], "step": "strict", "views": []}` |
| PATCH / DELETE | `/blocking/groups/{id or name}` | the same fields |
| GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
| POST | `/blocking/resume` | end a pause now |
Lists and rules are configuration like the rest: they can be staged, and
listing with `?changeset=` marks them `pending`.
@@ -435,13 +499,16 @@ 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"}` |
Option values are named.conf syntax without the final semicolon. They are
checked against the BIND 9.20 grammar for that scope.
checked against the BIND 9.20 grammar for that scope. `position` only
orders top-level statements; the options of a block are always listed and
written in Jarl's fixed order (docs/concepts.md).
### Catalogs and search
@@ -450,6 +517,8 @@ checked against the BIND 9.20 grammar for that scope.
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
| GET | `/catalog/rrtypes` | record types with field hints and examples |
| GET | `/catalog/grants` | resources, roles and the grant syntax |
| GET | `/exposure` | which views are reachable from the internet, the exposure findings and their fixes ([exposure.md](exposure.md#api)); `POST /exposure/fix`, `/exposure/ack`, `/exposure/unack` |
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
| GET | `/search?q=text` | zones, records and ACLs matching the text |
### Changesets
@@ -460,7 +529,7 @@ checked against the BIND 9.20 grammar for that scope.
| 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}` |
@@ -484,6 +553,7 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`:
| `settings.update` | | settings fields |
| `blocklist.create` / `blocklist.update` / `blocklist.delete` | blocklist (update/delete) | list fields; views as `views` (names) or `view_ids` |
| `blockrule.create` / `blockrule.update` / `blockrule.delete` | rule (update/delete) | rule fields, likewise |
| `blocking.pause` | | `{"until": "2026-10-10T15:04:05Z"}`, `{"indefinitely": true}`, or `{}` to resume |
Operations later in a batch can refer to objects created earlier in it by
name (zone and view names). One request can therefore create a view, a zone
@@ -493,29 +563,59 @@ in it and its records.
| Method | Path | Notes |
|--------|------|-------|
| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`) |
| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`); `blocking_paused_until` or `blocking_paused_indefinitely` (with `blocking:read`, while paused). `problems` include named-checkconf's warnings about the running configuration (`named_warning`) |
| POST | `/system/check` | validation + named-checkconf of the current configuration (`options:read`) |
| POST | `/system/resync` | `{"dry_run": false, "force": false}`: rewrite every file from the database and reload (admin) |
| GET | `/audit` | `?user=&action=&outcome=&object_type=&changeset=&q=&since=&until=&limit=200&before=ID` |
### 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).
| Method | Path | Query | Returns |
|--------|------|-------|---------|
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, retention_seconds, warnings}`: `retention` has `minute`, `hour`, `day`, `queries` as `"7d"`, `retention_seconds` the same in seconds (`queries` 0 without a query log) |
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
| 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), `since`, `limit` (default 20) | `[{key, count}]`; client and refused entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
| 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/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` (`"7d"`) and `retention_seconds`, `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
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
```
### Encrypted DNS
See [encrypted-dns.md](encrypted-dns.md#api): `GET`, `POST` and `DELETE
/encrypted-dns`, `POST /encrypted-dns/check`.
### Certificates
Administrators only. See [certificates.md](certificates.md#api).
| Method | Path | Body |
|--------|------|------|
| GET | `/certificates` | `certificates` (with `status`, `next_attempt`, `issuing_since`, `serving`, `files`, and for one obtained with `jarl cert get` `owner_id`, `owner` and `pulled_by`: every download for an administrator, the caller's own for the owner) and `account` |
| POST | `/certificates` | `{"name", "domains", "uses", "challenge", "key_type", "comment"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`; `"issue": false` doesn't start obtaining it |
| POST | `/certificates/check` | `{"domains", "challenge"}`: the route of each challenge |
| 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`,
`serving`.
### ACME: lego's httpreq provider
Off unless `acme.httpreq.enabled` is set, and by default HTTPS only. The
@@ -537,7 +637,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 |
+90 -4
View File
@@ -17,6 +17,7 @@ does the blocking itself; jarld only prepares the lists.
- [Your rules](#your-rules)
- [Blocklists](#blocklists)
- [Checking a name](#checking-a-name)
- [Pausing](#pausing)
- [Statistics](#statistics)
- [Memory and performance](#memory-and-performance)
- [How it works](#how-it-works)
@@ -146,6 +147,56 @@ A list's state is **ok** (in use), **waiting** (not downloaded yet),
**error** (the last update failed; the previous version stays in use),
**disabled**, or **idle** (it applies to no view, so it does nothing).
## Different blocking for different devices
Client groups give some devices less or more blocking than the rest, like
Pi-hole's groups: no blocking for a work laptop, an extra list for the
kids' tablets.
**Steps** decide which lists a device gets. Each step includes the lists
of the steps below it, plus its own:
| Step | Gets |
|------|------|
| `off` (built in) | only your rules |
| `normal` (built in) | your rules and the normal lists; every device not in a group |
| `strict` (one you add) | normal, plus the lists at step `strict` |
| `very-strict` (one you add) | strict, plus the lists at step `very-strict` |
Every list starts at a step (normal unless you choose one), and every
group is at a step. Your rules apply at every step, `off` included.
```sh
jarl block step add strict
jarl blocklist update social-media --step strict
jarl block group add work 10.10.0.20 --step off
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
jarl block group list
```
In the web UI, the Blocking page's **Devices** panel holds the steps and
the groups, and each list has a step.
- **Members** are addresses, networks (`10.10.0.0/24`), ranges
(`10.10.0.20-10.10.0.49`), or ACLs made only of those (`key`, `localnets` and `!` can't be used: blocking matches
clients by address). A device is in one group: two groups with the same
address are refused, but a more specific address in another group wins
(`10.10.0.0/24` in one group, `10.10.0.20` in another).
- **Steps only add.** A group gets everything up to its step, so "kids get
list A, guests get list B, and neither gets the other's" can't be done
with steps. That needs a view per group, each with its own lists (and
their memory).
- Lists aren't copied per step: each is loaded once, and the steps cost a
small zone each. Lists at a step no group reaches block nothing (Jarl
warns, and shows them as "no effect").
- named must see the device's own address. When a router hands out itself
as DNS server and forwards to named, every query comes from the router,
and groups can't tell devices apart. Devices also need fixed addresses
(DHCP reservations); IPv6 privacy addresses change, so add a device's
network, or rely on its IPv4 address.
- `jarl block check NAME` (and the Blocking page) answers for normal
devices, and says when a list of a higher step blocks the name.
## Checking a name
`jarl block check NAME`, or the box at the top of the Blocking page, says
@@ -163,15 +214,40 @@ Views without blocking aren't listed.
In the web UI a blocked name gets an **Allow** button, and the statistics'
most blocked names link to this check.
## Pausing
To see whether blocking is what breaks a site, pause it: every name is
answered normally until the time is up (at most a day), then blocking
resumes by itself, also if jarld was restarted in between. A pause can
also last until someone resumes blocking (like Pi-hole's "indefinitely").
```sh
jarl block pause # 10 minutes; or: jarl block pause 1h
jarl block pause indefinitely
jarl block resume # end it now
```
In the web UI, **Pause blocking** is at the top of the Blocking page (with
**Until I resume** among the lengths), and the dashboard says while
blocking is paused. The lists stay loaded (the
rules zone just lets everything through), so pausing and resuming take
effect within a second. Pausing and resuming are recorded in the history
like other changes.
## Statistics
With [query statistics](stats.md) on (including `query_log`), Jarl counts
what blocking does: blocked queries over time and as a share of all
queries, which rules and lists blocked them, and the most blocked names
(`jarl stats summary`, `jarl stats top blocked`, `jarl stats breakdown
blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
what blocking does: blocked queries over time (next to all queries) and
as a share of them, which rules and lists blocked them, the most blocked
names, and the clients with the most blocked queries (`jarl stats summary`,
`jarl stats top blocked`, `jarl stats top blocked-clients`, `jarl stats
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
are about names that really don't exist.
The Blocking page shows the last 24 hours too: a **Blocked (24h)** column
for each list, and one count for your rules together (named logs which
policy zone matched, not which rule in it).
Without statistics named doesn't log what it blocks at all.
## Memory and performance
@@ -228,6 +304,16 @@ response-policy {
blocking in the same view: Jarl refuses the change and says so.
- Rewrites are logged to Jarl's response log when statistics read it, and
not at all otherwise.
- With client groups, a **gate** zone stands in front of the lists of each
step: `off.gate.jarl.invalid` before the normal lists,
`STEP.gate.jarl.invalid` before a step's. named checks the zones in
order and the first match decides; a gate matches the devices below its
step by address (`rpz-client-ip` triggers) with `rpz-passthru`, so they
never reach the lists behind it. A gate is never logged, and its file is
next to the rules zone (`/var/lib/bind/jarl/VIEW/strict.gate.jarl.invalid.rpz`).
For "everyone except these devices", a gate lists the address ranges
around them, at most a few hundred entries. named matches IPv4 clients as
IPv4-mapped IPv6 addresses, so the IPv6 part leaves `::ffff:0:0/96` out.
Lists and rules need the `blocking` permission ([permissions.md](permissions.md)).
+416
View File
@@ -0,0 +1,416 @@
# TLS certificates
Jarl can obtain TLS certificates from an ACME CA (Let's Encrypt by
default) and renew them itself, and put them where they are used:
- **web**: jarld's own HTTPS (the web UI, the API and the CLI's server).
- **dns**: files named can read, for DNS over TLS (DoT) and DNS over HTTPS
(DoH) in named's `tls` blocks.
One certificate can have both uses. Jarl answers the ACME DNS-01
challenge itself: in a zone it serves, or through Porkbun's API while
Porkbun still serves the domain. So no other ACME client, hook script or
DNS provider plugin is needed, and the server doesn't have to be
reachable on port 80 or 443 from the internet.
A certificate you renew some other way (certbot, acme.sh, your own CA)
can be used too: Jarl reads it from its files and rereads it when they
change.
In the web UI: **Certificates** (administrators). In the CLI: `jarl cert`.
In the API: `/api/v1/certificates`. All of it is for administrators only:
a certificate's key can impersonate your server.
## Quick start
1. Set up the ACME account in `/etc/jarl/jarl.yaml` and restart jarld
(`systemctl restart jarl`). Try Let's Encrypt's staging CA first: its
certificates aren't trusted by browsers, but its rate limits are
generous, so mistakes cost nothing.
```yaml
acme:
account:
directory: letsencrypt-staging # then: letsencrypt
email: you@adbraun.lol # optional
agree_tos: true # after reading https://letsencrypt.org/repository/
```
2. See where the challenges would go, without asking the CA for anything:
```sh
jarl cert check --domain ns1.adbraun.lol
```
```
NAME ROUTE RECORD WHY
ns1.adbraun.lol porkbun _acme-challenge.ns1.adbraun.lol adbraun.lol is served by Porkbun (curitiba.ns.porkbun.com, ...), so the record goes in through Porkbun's API
```
3. Create the certificate. Jarl obtains it at once; the command waits for
the outcome (usually under a minute, a few minutes through Porkbun):
```sh
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns
```
4. When it works, switch `directory` to `letsencrypt`, restart jarld and
renew: `jarl cert issue ns1`.
From then on jarld renews it by itself.
## The ACME account
| Setting | Default | |
|--|--|--|
| `acme.account.directory` | `letsencrypt` | `letsencrypt`, `letsencrypt-staging`, or the directory URL of another ACME CA (ZeroSSL, Google, step-ca, ...). |
| `acme.account.email` | `""` | Contact address for the CA (optional; Let's Encrypt no longer sends expiry mails). |
| `acme.account.agree_tos` | `false` | You accept the CA's terms of service. Jarl registers no account without it, and says so. |
| `acme.account.eab_kid`, `eab_hmac` | `""` | External Account Binding, for CAs that need it (ZeroSSL, Google): from the CA's dashboard. |
| `acme.resolvers` | `1.1.1.1:53`, `9.9.9.9:53`, `8.8.8.8:53` | Recursive resolvers Jarl uses to see a domain as the CA does (below). |
| `acme.ca_bundle` | `""` | A PEM file of extra CA certificates to trust when talking to the ACME server (a private CA). |
| `acme.check_propagation` | `true` | Before telling the CA to look, wait until every name server of the zone serves the challenge (asked directly on port 53). |
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the dns use are written for named. |
The account is registered with the first certificate, and its key is
kept in Jarl's database. Changing `directory` registers a new account with
that CA; changing `email` updates the account.
## Where the challenge goes
For each name on a certificate, the CA looks up a TXT record at
`_acme-challenge.<name>` (a wildcard `*.example.com` uses
`_acme-challenge.example.com`). Jarl first looks the name up the way the
CA will, through `acme.resolvers` (public resolvers, not this server,
which may answer from an internal view), following a CNAME on
`_acme-challenge` if there is one. Then it finds the zone that holds the
record on the internet and that zone's public name servers:
| The zone's public name servers | Route | Jarl writes the record |
|--|--|--|
| Porkbun's (`*.ns.porkbun.com`), with `porkbun.api_key` set | `porkbun` | through Porkbun's DNS API (TTL 600, Porkbun's minimum), and deletes it afterwards. The key is the one the registrar features use ([configuration.md](configuration.md#porkbun-registrar-and-dns-host)); it needs DNS record write access, "API Access" switched on for the domain, and the domain in `porkbun.domains` if you set that |
| the name servers in Jarl's own zone of that name | `jarl` | in that zone, in every view that has it, as a change by `system` (in the history) |
| anything else | none | nowhere: the certificate fails with what to do |
The certificate's `challenge` setting chooses:
- **`auto`** (the default) decides again at **every** issue and renewal.
While Porkbun serves `adbraun.lol`, the challenge goes to Porkbun. Once
you point the domain's name servers at your own servers, the next
renewal writes into Jarl's zone instead. Nothing needs changing.
- **`jarl`** or **`porkbun`** pins the route. Jarl still checks that the
CA will see the record there and refuses to try otherwise (no wasted
attempt against the CA's rate limits).
```sh
jarl cert update ns1 --challenge porkbun # or jarl, or auto
```
`jarl cert check NAME` (or **Check the names** in the web UI) shows the
route for each name, and why, at any time.
### Domains Jarl can't write to: a CNAME
For a domain whose DNS is elsewhere (Cloudflare, say), point the
challenge name at a zone this server serves on the internet, once, by
hand:
```
_acme-challenge.www.example.org. CNAME www-example-org.acme.adbraun.lol.
```
Jarl follows the CNAME and writes the record at its target, in its zone
`acme.adbraun.lol` (which must be delegated to this server). Renewals
never touch the other provider again. That is the same idea as
[acme.md section 7](acme.md#7-a-dedicated-acme-zone-cname-delegation).
## Renewal
jarld looks at every certificate every 10 minutes:
- A certificate Jarl obtains is renewed at the time the CA suggests
(ACME Renewal Information, RFC 9773; asked twice a day, so a CA that
needs to replace certificates early, e.g. after revoking them, gets its
way), or, without one, two thirds into its lifetime: day 60 of a 90-day
certificate. Each renewal makes a new key.
- When obtaining fails, jarld tries again after 1, 2, 4, 8, 16 hours, then
daily. The dashboard and the Certificates page show the error; so do
`jarl cert list` and Prometheus (`jarl_certificate_failing`).
- New names or a new key type on a certificate make it due at once.
- `jarl cert issue NAME` (**Renew** in the web UI) obtains it now. It runs
in the background: the command waits and reports, the web page updates
itself.
A certificate counts as **expiring** (a warning) when less than a sixth of
its lifetime is left, which for a renewing certificate means renewal has
been failing for a while; and **expired** after its end.
## Use: web (jarld's HTTPS)
Only one certificate can have the web use. As soon as it has been
obtained, jarld serves it on all its `listen` addresses, **without a
restart**: each port speaks HTTPS and plain HTTP side by side.
- HTTPS connections get the certificate (HTTP/2 included).
- Plain HTTP from this server itself (`127.0.0.1`, `::1`) is served as
before, so local scripts and `--server http://127.0.0.1:8053` keep
working. So is plain HTTP from `trusted_proxies`.
- Plain HTTP from anywhere else: browsers are redirected to
`https://<the same host and port>`; API requests get `400
https_required` (and, if they carried a token, a note that it crossed
the network unencrypted).
Point browsers and CLIs at the certificate's name:
`jarl config set server https://ns1.adbraun.lol:8053`. A CLI that uses the
server's IP address fails the name check.
A managed certificate takes over from `tls.cert_file`. Taking the web use
off (or deleting the certificate) goes back to `tls.cert_file`, or to
plain HTTP.
## Use: dns (for named)
Jarl writes the certificate to `<certificates.dir>/<name>/`:
```
/etc/bind/jarl/tls/ns1/fullchain.pem (0644)
/etc/bind/jarl/tls/ns1/privkey.pem (0640, group bind)
```
Debian's AppArmor profile lets named read `/etc/bind`, and group `bind`
lets it read the key. When a renewal changes the files and named's
configuration names them, Jarl runs `rndc reconfig`, and named serves the
new certificate at once (checked with BIND 9.20: DoT keeps working, with
the new certificate).
`jarl encrypted-dns setup` sets up DNS over TLS and HTTPS with it
([encrypted-dns.md](encrypted-dns.md)). By hand, a `tls` statement names
the files:
```sh
jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key-file "/etc/bind/jarl/tls/ns1/privkey.pem"; }' --toplevel
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. 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
```sh
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
```
Jarl checks that the key belongs to the certificate, warns when the file
holds only the certificate without its intermediates (use the full
chain), and rereads both files when they change (within 10 minutes, or at
once with `jarl cert issue own`). A pair that doesn't match is reported
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
jarl cert saved mail # all of mail's settings
```
* **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.
* **A combined file** (`--combined PATH`): the key, then the full chain,
in one file (mode 600), as HAProxy wants. See [HAProxy](#haproxy).
* **Saved settings**: the names, `--dir`, `--combined`, `--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 saved NAME` shows them all (the token never). `jarl cert forget
NAME` stops getting one (`--delete` deletes it on jarld too).
* **Changing a setting**: run `jarl cert get NAME` with just that option
(`--hook '...'`, `--combined PATH`, `--dir DIR`); the others stay.
`--hook ''` removes the hook, `--combined ''` stops writing the
combined file and deletes it. The run still asks jarld for the
certificate, but downloads nothing while this host has the current one.
A new or moved combined file is written from the files this host
already has, and the hook runs.
* **`--local`** doesn't ask jarld at all: it writes the combined file and
runs the hook from the files this host has. Use it to apply a changed
setting while jarld is unreachable, or to test a hook. New names still
need jarld.
* **Several Jarl servers on one host**: each server has its own
certificate names, so two servers may both have a `proxy`. The host
keeps one `proxy` per settings directory, though: asking another server
for a name saved here for the first one is refused, as is a `--dir`
holding another certificate's files. Choose another name or `--dir`, or
pass `--replace` to take the name (or directory) over. A `--dir` that
another saved certificate uses is always refused.
* **The hook** runs with `sh` after new files are written or the
combined file changes (and with `--local`), with `JARL_CERT_NAME`,
`JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_COMBINED` (empty
without `--combined`), `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. The owner's own tokens see
their own downloads there too. In `jarl cert list`, such a certificate's
USES is `get`, and PULLED is the last download, with `(old)` while a host
hasn't fetched the renewed one.
### HAProxy
HAProxy wants the key and the chain in one file. Give `jarl cert get` a
`--combined` path outside `--dir`:
```sh
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' \
--combined /etc/haproxy/certs/adbraun.lol.pem --hook 'systemctl reload haproxy'
jarl cert get webmail -d webmail.adbraun.lol \
--combined /etc/haproxy/certs/webmail.pem --hook 'systemctl reload haproxy'
```
```
frontend https
bind :443 ssl crt /etc/haproxy/certs/
```
HAProxy loads every file in a `crt` directory and picks one by SNI, so
`/etc/haproxy/certs` should hold only combined files. Don't point `crt`
at `--dir` (`/etc/jarl/certs/NAME`): HAProxy would try to load
`cert.pem`, `privkey.pem` and `jarl-cert.json` there too, and refuse
to start. A `--combined` path that would overwrite a file in a saved
certificate's directory, or another certificate's combined file, is
refused.
The combined file is mode 600, owned by the user running `jarl cert get`
(root under the timer). HAProxy reads its certificates as root before it
drops privileges, so that's fine.
To add a combined file to a certificate this host already has, run
`jarl cert get proxy --combined /etc/haproxy/certs/adbraun.lol.pem`: it's
written from the files on disk and the hook runs, with nothing
downloaded.
## 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 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
wildcards where one name will do: a wildcard's key is good for every
name in the domain.
- Using one certificate for jarld and named puts its key in two places.
On one server, where jarld already writes named's configuration, that
changes little.
## API
| Method | Path | |
|--|--|--|
| GET | `/certificates` | `certificates` (each with `status` {`state`: ok, pending, failing, expiring, expired; `message`}, `next_attempt`, `issuing_since` while being obtained, `serving`, `files`) and `account` |
| POST | `/certificates` | `{"name": "ns1", "domains": [...], "uses": ["web", "dns"], "challenge": "auto", "key_type": "ec256"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`. One Jarl obtains starts being obtained at once (`"issue": false` leaves it to the loop) |
| POST | `/certificates/check` | `{"domains": [...], "challenge": "auto"}`: where each challenge would go (`routes`: `route`, `target`, `zone`, `public_ns`, `why` or `error`) |
| 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
[Pebble](https://github.com/letsencrypt/pebble) is Let's Encrypt's test
CA. Point it at a test named, and Jarl at it:
```sh
docker run -d --name pebble --network host -e PEBBLE_VA_NOSLEEP=1 \
ghcr.io/letsencrypt/pebble -config test/config/pebble-config.json -dnsserver 127.0.0.1:53
docker cp pebble:/test/certs/pebble.minica.pem /etc/jarl/pebble.pem
```
```yaml
acme:
account: {directory: "https://localhost:14000/dir", agree_tos: true}
resolvers: ["127.0.0.1:53"]
ca_bundle: /etc/jarl/pebble.pem
```
+170 -7
View File
@@ -41,7 +41,7 @@ jarl whoami
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
| Skip TLS verification | `--insecure` | | `insecure` |
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
| Changeset to stage into (and the default for `jarl changeset diff/apply/...`) | `-C/--changeset` | `JARL_CHANGESET` (checked: open and yours, see [Changesets](#changesets-stage-review-apply)) | |
Flags win over the environment, and the environment wins over the file. The
file is `jarl/config.yaml` in the user's configuration directory, or
@@ -143,6 +143,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
| `-n, --dry-run` | Validate and show the diff without changing anything. |
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
| `--accept-risk` | Confirm a change that opens a public view up, such as an open resolver ([exposure.md](exposure.md)). Without it such a change stops with exit 5. |
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
## Exit codes
@@ -176,18 +177,35 @@ 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"
jarl zone update adbraun.lol --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200
jarl zone update adbraun.lol --move-to-view internal
jarl zone delete lab.adbraun.lol [-y]
jarl zone disable lab.adbraun.lol [--view V] # named stops serving it; kept (and editable) in Jarl
jarl zone enable lab.adbraun.lol [--view V]
jarl zone file adbraun.lol # the generated zone file
jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa]
jarl zone serials adbraun.lol # are the secondaries (or primaries) on the current serial?
jarl zone delegation adbraun.lol [--resolver 9.9.9.9] # does the parent zone delegate it correctly?
```
**Disabling a zone** leaves it out of named's configuration but keeps it in
Jarl: records, options and history, which can still be edited. Its names
are then answered by recursion where the view offers it (the internet's
version of the zone, if any), and REFUSED elsewhere; `disable` says which.
Enabling it serves it as it is then. A signed zone can't be disabled (turn
signing off first), nor a zone another view shares with `in-view`.
Reverse names follow the dots (the nibbles in IPv6), so a reverse zone
covers a /8, /16 or /24 (a multiple of /4 for IPv6). A network in between is
covered by several zones, created together in one changeset: a /22 by four
@@ -201,6 +219,20 @@ router, answers for the rest of the /24. IPv4 networks smaller than a /24
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
`zone create` sets any zone option in the same step.
Linked zones ([views.md](views.md#linked-zones)) keep their shared
records in step across views:
```sh
jarl zone link adbraun.lol --view internal --with external
jarl zone create adbraun.lol --view vps --link-with external
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
name it.
@@ -264,8 +296,20 @@ jarl record disable --id 1234 # by ID; same for enab
# reverse zones: name records by address; the host name needs no final dot
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
# a zone in several views (split horizon): the same change in each
jarl record add adbraun.lol mx2 A 203.0.113.5 --view internal,external
jarl record delete adbraun.lol old A --view all -y
```
**Several views.** When a zone exists in more than one view, the record
commands that change records (`add`, `set`, `delete`, `disable`,
`enable`) take `--view a,b` or `--view all` (every view the zone is in).
The changes are applied together as one changeset: if one view refuses
(the zone isn't in it, a CNAME conflict, ...), no view is changed. With
`--changeset` they are staged there instead. Keep split-horizon data that
differs per view (an internal address, say) to one view at a time.
Names are relative to the zone (`@` is the apex) unless they end with a
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
@@ -273,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.
@@ -324,9 +371,9 @@ reads standard input.
export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q)
jarl record set lab.adbraun.lol gw A 10.20.0.1
jarl record set lab.adbraun.lol nas A 10.20.0.10
jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff
jarl changeset remove $JARL_CHANGESET 2 # drop operation #2
jarl changeset apply $JARL_CHANGESET
jarl changeset diff # validation + named-checkconf + diff
jarl changeset remove 2 # drop operation #2
jarl changeset apply
unset JARL_CHANGESET
jarl changeset list [--status open|applied|discarded] [--mine]
@@ -336,8 +383,87 @@ jarl history [--limit 50]
jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset
```
`show`, `diff`, `apply`, `discard` and `remove` work on the changeset you
name, else the one in `-C`, else the one in `JARL_CHANGESET`.
A changeset from `JARL_CHANGESET` is checked before anything uses it,
because an exported ID easily outlives its changeset, and IDs keep growing
(every change, live ones included, gets the next one), so a stale or
mistyped ID can later name a changeset that isn't yours:
- Staging into it, and `diff`, `apply`, `discard` and `remove` on it, need
it to be open and yours. Otherwise the command stops and says why
(`there is no changeset 99`, `changeset 42 is applied`, `changeset 57 is
certbot's`) and what to do. Commands that only read (`jarl zone list`)
and `jarl changeset create` don't care, so you can always start a new one.
- Each command says on stderr which changeset it uses: `changeset 42 (renumber
lab, 2 operations) from JARL_CHANGESET` (not with `-q`).
- After `apply` or `discard`, a reminder to `unset JARL_CHANGESET`: the CLI
can't change your shell's environment, and later changes would fail to
stage.
`-C ID` and an ID argument are used as given, without these checks (jarld
still refuses a changeset that isn't open, or another user's unless you are
an administrator).
Changesets staged in the web UI show up here too, and the other way round.
## Internet exposure
```sh
jarl exposure # public views, findings, fixes, rate limiting
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
```
See [exposure.md](exposure.md).
## Encrypted DNS
```sh
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
```
See [encrypted-dns.md](encrypted-dns.md).
## Certificates
```sh
jarl cert check --domain ns1.adbraun.lol # where each challenge would go, and why
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns # obtained at once; waits
jarl cert create wild -d adbraun.lol -d '*.adbraun.lol' --use dns --key-type ec384
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 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 get proxy -s other --replace # take over a name saved here for another server
jarl cert get proxy --combined /etc/haproxy/certs/proxy.pem # also key + chain in one file (HAProxy); '' stops it
jarl cert get proxy --hook 'systemctl reload haproxy' --local # change a setting; apply it from this host's files, without jarld
jarl cert saved # this host's certificates and their expiry
jarl cert saved proxy # all of one certificate's settings (never the token)
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)
```
Administrators only. See [certificates.md](certificates.md).
## Views, ACLs, TSIG keys, options, settings
```sh
@@ -347,8 +473,10 @@ jarl view order internal external
jarl view update external --match-clients "!homelab,any"
jarl view delete old [--cascade]
jarl view color internal green # web UI colour; "default" resets it
jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
jarl acl create dhcp 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff # ranges (concepts.md)
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
jarl acl update homelab --rename lan # references are updated
jarl acl delete lan # refused while in use
@@ -362,12 +490,33 @@ jarl option list [--view V | --zone Z | --toplevel | --all]
jarl option set recursion yes
jarl option set allow-recursion '{ homelab; localhost; }'
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
jarl option set allow-transfer '{ key xfer; }' --zone adbraun.lol
jarl option set forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list
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
listed in one fixed order (see [concepts.md](concepts.md)).
```sh
jarl settings show
jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600
@@ -391,6 +540,7 @@ jarl token list --all --expiring [60d] # expiring within 14 days
jarl token create laptop --expires 365d
jarl token create backup-script --grant "*:read"
jarl token create acme --user certbot --role acme
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host (or --role certificates)
jarl token rotate laptop --save # new secret, same token and grants
jarl token rotate --current --save --grace 10m # the token this CLI uses
jarl token revoke 7
@@ -416,13 +566,17 @@ 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
jarl stats top names | clients | nxdomain | servfail | refused | blocked [--since 24h] [--limit 20]
jarl stats top names | clients | nxdomain | servfail | refused | blocked | blocked-clients [--since 24h] [--limit 20]
jarl stats timeline --since 6h [--key rcode:NXDOMAIN] [--table]
jarl stats heatmap --since 28d
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | blocklist
jarl stats server # named's memory, CPU, cache, clients; host; disk space
```
Statistics must be enabled in `jarl.yaml`, and `-o json` exports the data.
@@ -442,6 +596,15 @@ jarl block allow DOMAIN [--exact] [--view V]... # an exception that beats every
jarl block list
jarl block delete DOMAIN [--view V] | --id ID
jarl block check DOMAIN # blocked? by which rule or list?
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
jarl block pause indefinitely # ... until 'jarl block resume'
jarl block step list | add NAME | rename STEP NEW | order STEP... | delete STEP
jarl block group list
jarl block group add NAME MEMBER... [--step S] [--view V]... # addresses, networks or ACLs
jarl block group update GROUP [--add M] [--remove M] [--step S] [--name N]
jarl block group delete GROUP
jarl blocklist update LIST --step STEP # devices at that step or above get it
jarl block resume
```
See [blocking.md](blocking.md).
+48 -1
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.
@@ -68,7 +76,9 @@ both, using a single mechanism:
* `POST /api/v1/apply` (CLI: `jarl apply -f changes.yaml`) applies a list of
operations as one changeset in a single request.
* `?dry_run=1` (CLI: `--dry-run`) runs the whole pipeline except installing
files and reloading named, and shows the problems and the diff.
files and reloading named, and shows the problems and the diff. Each
part of a named.conf diff says which block it is in, after the `@@`
line numbers: `@@ -50,6 +49,8 @@ view "internal" {`.
**Why bundling matters.** Some changes only make sense together. Turning
`www` from an A record into a CNAME means deleting the A record and adding
@@ -159,6 +169,38 @@ RFC 1982 arithmetic.
Jarl checks them before named does. Statements that Jarl manages itself
(`zone`, `view`, `acl`, `key`, a zone's `type`/`file`, a view's
`match-clients`, ...) can't be set as raw options.
Options of a block are kept in one fixed order, the same in named.conf,
the web UI and the CLI: grouped (files, listening, access control,
recursion and forwarding, DNSSEC, transfers and notify, then the rest),
alphabetical within a group. So the same option is in the same place in
every view and zone. Top-level statements stay in the order they were
added.
* **Address ranges.** Wherever named takes an address match list (ACLs,
views' `match-clients` and `match-destinations`, options such as
`allow-query` or `allow-recursion`) and in blocking's client groups, Jarl
also takes a range of addresses: `10.0.10.101-10.0.10.199`,
`2001:db8::100-2001:db8::1ff`, `!10.0.10.150-10.0.10.160`. BIND only
matches addresses and prefixes, so Jarl keeps the range as you wrote it
and writes the fewest prefixes that cover exactly that range to
named.conf, with the range as a comment above them:
```
acl "dhcp" {
// 10.0.10.101-10.0.10.199
10.0.10.101;
10.0.10.102/31;
10.0.10.104/29;
10.0.10.112/28;
10.0.10.128/26;
10.0.10.192/29;
};
```
Nothing outside the range is matched (writing such lists by hand, it is
easy to round to `10.0.10.96/27` and let in more than you meant). Both
ends are full addresses of the same family, and spaces around the dash
are fine. A range takes at most 62 prefixes for IPv4 and 254 for IPv6.
The web UI shows what a range becomes while you type it. A range in the
IPv4-mapped space (`::ffff:0:0/96`) gets a warning: named can see IPv4
clients as such addresses, so it may match IPv4 clients too.
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
and timers, default TTL, serial policy.
@@ -216,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
+46 -7
View File
@@ -20,7 +20,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
|-----|---------|---------|
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. Ports below 1024 (`"192.168.10.2:443"`) work because `jarl.service` gives jarld the `CAP_NET_BIND_SERVICE` capability, and only that ([HTTPS on port 443](#example-https-on-port-443)). |
| `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. |
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. |
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. Easier: let Jarl obtain and renew one ([certificates.md](certificates.md), use `web`), which takes over from these. With a certificate, the `listen` ports serve HTTPS and plain HTTP side by side: plain HTTP is still served to this server itself and to `trusted_proxies`; anyone else is sent to HTTPS. |
| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. |
| `session.lifetime` | `12h` | A web session ends this long after login... |
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
@@ -73,6 +73,36 @@ The `stats:` section turns on query statistics (off by default). See
[stats.md](stats.md) for every setting and what it changes in named's
configuration. Changing it takes effect when jarld restarts.
### Prometheus metrics
| Key | Default | Meaning |
|-----|---------|---------|
| `metrics.enabled` | `false` | Serve Prometheus metrics at `/metrics` on `listen` and the local socket ([metrics.md](metrics.md)). |
| `metrics.listen` | `[]` | Extra `ADDRESS:PORT`s that serve only `/metrics`, over plain HTTP, e.g. `["0.0.0.0:9450"]`. |
| `metrics.allow` | `["127.0.0.1", "::1"]` | Addresses or prefixes that may scrape without a token. |
| `metrics.token` | `""` | A bearer token (at least 16 characters) that lets any address scrape. Keep jarl.yaml readable by root and jarld only when set. |
Query counters need `stats.enabled` as well. Changes take effect when jarld
restarts.
### Certificates (ACME account)
The account Jarl obtains certificates with, and where it writes them for
named. See [certificates.md](certificates.md). Changes take effect when
jarld restarts.
| Key | Default | Meaning |
|-----|---------|---------|
| `acme.account.directory` | `letsencrypt` | `letsencrypt`, `letsencrypt-staging` or an ACME directory URL (`https://...`). |
| `acme.account.email` | `""` | Contact address for the CA (optional). |
| `acme.account.agree_tos` | `false` | You accept the CA's terms of service; no account is registered without it. |
| `acme.account.eab_kid`, `acme.account.eab_hmac` | `""` | External Account Binding, for CAs that need it. Both or neither. Keep the file readable only by root and jarld. |
| `acme.resolvers` | `["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]` | Recursive resolvers (`address:port`) used to see domains as the CA does: their zone, public name servers, and a CNAME on `_acme-challenge`. Not this server: it may answer from an internal view. |
| `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)
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
@@ -114,18 +144,27 @@ there (`ReadWritePaths`). If you change them, update both
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
```
### Porkbun (registrar)
### Porkbun (registrar and DNS host)
With an API key, Jarl can show and set a domain's name servers, glue
records and DNSSEC DS records at Porkbun, after checking that the change
won't break the domain ([dnssec.md](dnssec.md#porkbun)). Without a key
(the default) nothing about Porkbun shows up.
With an API key, Jarl can:
- show and set a domain's name servers, glue records and DNSSEC DS records
at Porkbun, after checking that the change won't break the domain
([dnssec.md](dnssec.md#porkbun));
- publish the DNS-01 challenges of certificates it obtains while Porkbun
still serves the domain's DNS ([certificates.md](certificates.md#where-the-challenge-goes)):
it only adds TXT records at `_acme-challenge` names and removes them
again.
Without a key (the default) nothing about Porkbun shows up, and
certificates for domains Porkbun serves can't be obtained (`jarl cert
check` says so).
| Key | Default | Meaning |
|-----|---------|---------|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
| `porkbun.domains` | `[]` | Only these domains (default: every primary zone 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
+14 -1
View File
@@ -10,6 +10,7 @@ internal/
dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones
rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog
namedconf/ named.conf lexer, parser and printer; address match lists
iprange/ address ranges (10.0.10.101-10.0.10.199): parsing and the exact prefix cover
catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded
validate/ whole-configuration validation and security lints
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
@@ -31,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
@@ -117,7 +124,13 @@ checker tests work with the installed `bind9-utils` too.
ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks
the answers with `dig`. The Porkbun code is tested against
`porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real
one. `KEEP=1` leaves named
one. With Docker, certificates are tested end to end against
[Pebble](https://github.com/letsencrypt/pebble), Let's Encrypt's test CA
(`ghcr.io/letsencrypt/pebble`, host network, ports 14000 and 15000),
which checks the challenges against the test named: obtaining, HTTPS
from jarld, DNS over TLS from named, renewal. `PEBBLE=0` skips that; the
certs package's own tests use a fake DNS server and `porkbun.Fake`, and
never a real CA. `KEEP=1` leaves named
and jarld running afterwards. The output says where; set `JARL_CLI_CONFIG`
to that directory's `cli.yaml` to keep using the CLI against it.
+30 -3
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
@@ -175,13 +186,17 @@ Before you go public:
## Porkbun
With a Porkbun API key, Jarl manages a domain's name servers, glue records
and DS records at Porkbun, and checks each change before making it.
and DS records at Porkbun, and checks each change before making it. The
same key also lets Jarl publish the DNS-01 challenges of its own
certificates while Porkbun serves the domain's DNS
([certificates.md](certificates.md#where-the-challenge-goes)).
**Setting it up**
1. At porkbun.com, *Account → API Access*: create a key. If you can,
limit it to the domain and the permissions it needs (domains, read),
and to your server's address.
limit it to the domain and the permissions it needs, and to your
server's address. The registrar features need the domain permissions;
certificate challenges need DNS record write access too.
2. On the domain's page, switch **API Access** on (Porkbun refuses API
calls for a domain until you do).
3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld:
@@ -200,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`:
| | |
+237
View File
@@ -0,0 +1,237 @@
# DNS over TLS and HTTPS
named (BIND 9.20) can answer over encrypted connections itself: DNS over
TLS (DoT, port 853) and DNS over HTTPS (DoH, at `/dns-query`). Your
phones, laptops and browsers then ask this server without anyone on the
network seeing or changing their queries, and they get exactly what plain
DNS gives them: the same views, the same records and the same blocking.
Jarl sets it up in one step: a certificate for the host name clients
connect to (obtained and renewed by Jarl, [certificates.md](certificates.md)),
named's `tls` statement and listeners on the addresses you choose, and the
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:
1. **finds or obtains the certificate**: one Jarl manages whose names cover
the host name (`--certificate NAME` to pick one), or a new one named
after the host name's first label, which Jarl obtains from the CA (the
command waits). The certificate gets the `dns` use, so it is written
where named can read it, and named rereads it on every renewal.
2. **checks the ports**: nothing else may listen on the chosen addresses
on port 853 or the DoH port. Jarl looks at the host's listening sockets
(it binds nothing) and says what is in the way, e.g. `jarld (Jarl's web
UI and API) listens on 10.0.10.5 port 443`, and which port is free
instead (`--doh-port 8443`).
3. **stages named's configuration**, as one change you can review:
```
tls jarl-dns { cert-file ".../tls/dns/fullchain.pem"; key-file ".../tls/dns/privkey.pem"; protocols { TLSv1.2; TLSv1.3; }; };
options {
listen-on port 853 tls jarl-dns { 10.0.10.5; };
listen-on port 443 tls jarl-dns http default { 10.0.10.5; };
listen-on-v6 port 853 tls jarl-dns { 2a02:8013:7f3d:10::5; };
...
```
named listens on port 53 on every address only while there is no
`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 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.
**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` | | 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 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.
## Which view clients get
A DoT or DoH client gets the view its address matches, as over plain DNS:
your phone at home gets the internal view, with its records and blocking.
Choosing a view by key (`match-clients { key ...; }`) doesn't work over
DoT and DoH, as clients don't sign their queries.
## Clients
`jarl encrypted-dns` (and the page) shows what to give them:
```
For DoT clients: dns.adbraun.lol
For DoH clients: https://dns.adbraun.lol/dns-query
```
| Client | Where |
|--|--|
| Android 9+ | Settings → Network → Private DNS → Private DNS provider hostname: `dns.adbraun.lol` (DoT) |
| Linux (systemd-resolved) | `/etc/systemd/resolved.conf`: `DNS=10.0.10.5#dns.adbraun.lol` and `DNSOverTLS=yes` (or `opportunistic`, which falls back to plain DNS) |
| Firefox | Settings → Privacy & Security → DNS over HTTPS → Increased protection → Custom: the DoH URL |
| 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, with BIND's own `dig` (9.18 or
later; `bind9-dnsutils` on Debian):
```sh
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
VPN home (WireGuard) when you're out, or switch Private DNS to
*Automatic* when you leave. Firefox's *Increased protection* and
systemd-resolved's `opportunistic` fall back to normal DNS by themselves.
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
(`tls_file_missing`): named won't load its configuration without them.
* A certificate named uses can't be deleted, nor lose its `dns` use: turn
encrypted DNS off first.
* Listeners you wrote yourself are left alone; the page notes them.
## API
| Method | Path | Body |
|--------|------|------|
| 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` | `{"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` |
+247
View File
@@ -0,0 +1,247 @@
# Internet exposure
When a view (or the whole server) can be reached from the internet, a few
things matter that don't on your own network: it shouldn't help attackers
flood others with DNS answers (amplification), recurse for strangers, or
tell scanners its exact version. Jarl works out which views are public,
checks them, and stages the fixes for you to review.
In the web UI: **Exposure** (and its card on the dashboard). In the CLI:
`jarl exposure`. In the API: `GET /api/v1/exposure`.
## Which views are public
A view is **public** when its `match-clients` let in anyone: after ACLs
and ranges are filled in, they end in a catch-all (`any`, `0.0.0.0/0`,
`::/0`, or a prefix wider than /8 for IPv4 or /16 for IPv6), or they are
empty, which named reads as "any". A typical external view
(`match-clients { any; }` or `{ !internal; any; }`) is public.
A view that lists the networks it serves is **private**, whatever kind of
addresses those are. An internal view for your global IPv6 prefix
(`2a02:8013:7f3d:10::/64`) is as private as one for `192.168.1.0/24`:
listing a network says "these are mine". Jarl doesn't look at whether
addresses are private (RFC 1918, ULA) for this, because on IPv6 your own
devices have global addresses.
A view whose `match-destinations` list only private addresses (it answers
only on a private interface) is private too.
**A server without views** has no match list, so Jarl looks at
`allow-query` and where named listens: it is public when anyone may query
it (`allow-query` unset, or with a catch-all) **and** named listens on an
address that isn't private (a public IPv4 address or a global IPv6
address of this host). This can't see your firewall, so it is a guess.
**When Jarl gets it wrong**, override it per view: `public`, `private` or
`auto`. For example, behind a NAT port forward named only ever sees
private addresses although it is reachable from the internet.
```sh
jarl view update external --exposure public
jarl settings set --exposure private # a server without views
```
The Exposure page shows each view's classification and the reason
("match-clients ends in any", "match-clients lists only homelab, no
catch-all"), with a selector for the override.
## The checks
They run on public views only, and are warnings (they don't stop you
applying) unless acknowledged.
| Code | What | Fix Jarl can stage |
|------|------|-------------------|
| `exposure_rate_limit` | No response rate limiting: the view can be used to flood others with its answers (DNS amplification) | `rate-limit { responses-per-second 10; log-only yes; exempt-clients { your secondaries }; }` |
| `exposure_rate_limit_log_only` (info) | Rate limiting only logs | The same block without `log-only`: start enforcing |
| `security_open_resolver` | Recursion for anyone: an open resolver | `recursion no` in that view |
| `exposure_minimal_any` | ANY queries get full answers, a favourite for amplification | `minimal-any yes` |
| `exposure_cache_snooping` | `allow-query-cache` lets anyone see what your clients look up | `allow-query-cache { none; }` |
| `security_open_transfer` | Zone transfers for anyone | none: allow your secondaries or a TSIG key |
| `exposure_open_notify` | `allow-notify` accepts NOTIFY from anyone | none: allow your primaries |
| `exposure_version` | `dig CH TXT version.bind` shows the exact BIND version | `version none` |
| `exposure_hostname` | `dig CH TXT hostname.bind` shows the host name | `hostname none` |
| `exposure_management` | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone | none: use 127.0.0.1 or an allow list |
The last three are server-wide: they are checked when any view is public.
A **fix** goes into your pending changes like any edit (or is applied at
once in live mode), so you see the named.conf diff before it reaches named:
```sh
jarl exposure # classification, findings, fixes
jarl exposure fix external exposure_rate_limit
jarl exposure fix server exposure_version
```
## Response rate limiting
Response rate limiting (RRL, BIND's `rate-limit`) counts identical
responses sent to the same client network. Past the limit, named drops
some and "slips" others: it sends a short reply asking the client to retry
over TCP (or with a DNS cookie). A real client does; the spoofed victim of
an amplification attack never sees the answers it didn't ask for. ISC
recommends it for authoritative servers only, so Jarl only suggests it for
public views, never for your internal resolver.
Jarl starts from ISC's example, 10 identical responses per second per
client network, and exempts the servers your zones notify and allow
transfers to (`also-notify` and `allow-transfer` addresses, through ACLs
too): limiting your own secondaries is the classic RRL mistake.
**Rollout in two steps:**
1. The fix turns rate limiting on with `log-only yes`. named answers
everyone as before, but logs every response it *would* have limited.
With [query statistics](stats.md) on, Jarl reads those lines: the
Exposure page shows how many responses would have been limited, and
which client networks and names. Check that your secondaries,
monitoring, or a big shared resolver aren't among them (add them to
`exempt-clients` if they are).
2. When you're happy, **Start enforcing** (the fix of
`exposure_rate_limit_log_only`) removes `log-only`.
In log-only mode named's own counters don't move: what would be limited
is only in its log (category `query-errors`). That is why the activity
needs query statistics; without them, look at named's log for
`would rate limit`. When rate limiting is configured and statistics are
on, Jarl adds `query-errors` to its log channel (keeping named's syslog
destination for it). `stats.rate_limit_log: false` in jarl.yaml leaves
`query-errors` alone; the page then can't show what rate limiting does.
The Server options page lists this, and everything else Jarl adds to
named.conf by itself, under **Added by Jarl** (`jarl option generated`).
The statistics also count it: the `rrl` breakdown (`drop`, `slip`,
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
lists, and in Prometheus `jarl_ratelimit_logged_total{action}` next to
named's own `jarl_ratelimit_responses_total` ([metrics.md](metrics.md)).
### Settings
You can tune the block like any option; `option set` replaces the whole
block, so copy the current value from `jarl option list --view external`
and edit it:
```sh
jarl option set rate-limit '{ responses-per-second 20; window 15; slip 2; exempt-clients { 192.0.2.53; }; log-only yes; }' --view external
```
named counts **identical responses** to the same **client network** (a
/24 for IPv4, a /56 for IPv6) over a **window**, and limits each kind of
response separately. These are the settings (BIND 9.20; the ARM has the
full text):
| Setting | Default | What it does | When to change it |
|---------|---------|--------------|-------------------|
| `responses-per-second` | 0 (off) | Answers with data for the same name and type, per client network. Wildcard answers count as the zone's apex. This is the main limit; Jarl's fix sets 10 | Raise it if a legitimate network shows up in log-only mode, e.g. a large shared resolver (mobile carriers, public resolvers) asking for your popular names |
| `nxdomains-per-second` | same as `responses-per-second` | NXDOMAIN answers (names that don't exist), counted together for a domain whatever the name asked | `0` turns NXDOMAIN limiting off, for zones that legitimately get many lookups of missing names |
| `nodata-per-second` | same as `responses-per-second` | Empty answers (the name exists, the type doesn't), counted together whatever the type | Rarely |
| `referrals-per-second` | same as `responses-per-second` | Referrals to child zones you delegate | Only if you delegate subdomains to other servers |
| `errors-per-second` | same as `responses-per-second` | Other errors (REFUSED, SERVFAIL, FORMERR), counted together whatever the name | Rarely. REFUSED and SERVFAIL can't be truncated, so slipped ones still go out at the slip rate |
| `all-per-second` | 0 (off) | All UDP answers to a client network, of any kind, like a firewall rate limit; over it, answers are only **dropped** (`slip` doesn't apply) | Usually leave off; if you use it, ISC says at least 4 times the other limits |
| `window` | 15 seconds (1–3600) | How far back named counts. A client over the limit stays limited until its rate over the window falls again | Longer windows remember attackers longer and also punish short legitimate bursts longer |
| `slip` | 2 (0–10) | Of the limited answers, every n-th "slips": instead of being dropped it gets a short reply asking the client to retry: truncated (TC) so it retries over TCP, or, when the query carried a DNS cookie, BADCOOKIE with a server cookie that lets it through on its retry. `0` drops all, `1` slips all | `1` if you'd rather every real client gets a retry hint; `0` drops all (spoofed victims see nothing, but real clients just time out) |
| `ipv4-prefix-length` | 24 | The size of the client network answers are counted for (IPv4) | `32` counts each address on its own: fairer to neighbours, more memory |
| `ipv6-prefix-length` | 56 | The same for IPv6 | `64` if one /56 holds many independent clients; `48` to treat a whole site as one |
| `exempt-clients` | none | Address match list (addresses, prefixes, [ranges](concepts.md#model-views-zones-records-options), ACLs) that is never limited. Jarl's fix puts your secondaries here | Add monitoring, other servers of yours, and anything legitimate that log-only mode shows |
| `log-only` | no | Count and log what would be limited, answer everyone normally | Jarl starts with `yes`; the "start enforcing" fix removes it |
| `qps-scale` | off | When the total query rate is above this, all limits shrink in proportion (at a rate of 1000 with `qps-scale 250`, a limit of 20 becomes 5) | Only on busy servers that see attacks spread over many networks |
| `max-table-size`, `min-table-size` | min 500 entries | The table of client networks named tracks (40–80 bytes per entry) | Only on busy servers |
What Jarl checked against real named: TCP answers are never limited;
`nxdomains-per-second` falls back to `responses-per-second` and `0`
turns it off; NXDOMAIN answers for different missing names in a zone
share one limit; `all-per-second` only drops; with `slip 1`, clients
without DNS cookies get truncated answers, and clients with cookies (dig
sends them by default) get through on their retry with the server's
cookie; `exempt-clients` is never limited; in log-only mode nothing is
limited and only the log shows what would be.
Rate limiting is per view: put it in the external view (`--view
external`). Set in the server options, it applies to every view that has
none of its own, your internal resolver included, which RRL isn't meant
for.
## Mail
Receivers such as Gmail and Outlook check the address mail comes from: it
needs a PTR record (reverse DNS), and the name it gives must resolve back
to the address. Without that, mail is rejected or marked as spam. The PTR
records of your public addresses live in the reverse zones of whoever
owns the addresses, your hosting provider or ISP: you set them in their
panel, not in Jarl. A reverse zone you create in Jarl only answers your
own clients (unless the owner delegated it to you).
So Jarl checks them as the internet sees them, through the public
resolvers of `acme.resolvers`: the addresses of every public zone's MX
hosts (receiving) and SPF `ip4:`, `ip6:`, `a` and `a:` addresses
(sending). The Exposure page's **Mail reverse DNS** panel shows each
address, its PTR, who runs its reverse zone (the provider, from the
zone's name server) and what to do; jarld checks a minute after it starts
and every 6 hours, and logs an address that goes wrong.
```
$ jarl exposure mail
ZONE ADDRESS FOR PTR STATUS
adbraun.lol 172.235.168.106 SPF ip4, MX mail.adbraun.lol mail.adbraun.lol ok
```
It warns when an address has no PTR record, or when its name doesn't
resolve back to it; notes when the name is an alias (CNAME), or isn't the
MX name (fine if the mail server uses it in HELO). `jarl exposure mail`
exits 1 when an address isn't right; Prometheus has
`jarl_mail_reverse_dns_ok{zone,address}`.
## Confirming a change that opens a view up
A change that *introduces* an open resolver, open zone transfers, cache
snooping, open NOTIFY or an open management interface on a public view
must be confirmed:
- **Web UI:** the Changes page shows the finding with a checkbox (*I
understand: …*); **Apply** stays disabled until it is ticked.
- **CLI:** the command stops (exit 5) and says why; run it again with
`--accept-risk`.
- **API:** `accept_risk: true` in the apply body, or `?accept_risk=1` on a
change applied at once; without it the answer is 409 `confirm_risk`.
Previews (`GET /changesets/{id}/preview`) list them as `risks`.
Only changes that newly open something up ask: an unrelated change to a
view that is already open, or taking back an acknowledgement, doesn't.
## Acknowledging a finding
Some findings are deliberate: a public resolver for a club's network, a
visible version string. Acknowledge them with a reason:
```sh
jarl exposure ack external security_open_resolver --reason "public resolver for the club, monitored"
jarl exposure ack server exposure_version --reason "version shown on purpose"
jarl exposure unack external security_open_resolver
```
The finding doesn't disappear: it stays visible as **info**, with who
accepted it, when and why (*open resolver … (acknowledged by adam on 12
Oct 2026: public resolver for the club, monitored)*), on the Exposure page,
the dashboard and in `jarl check`. An acknowledgement covers that one
finding on that one view (or the server); the same finding elsewhere
warns as usual. Acknowledging is a configuration change like any other,
so it is in the history and audit log, and can be reverted.
Jarl isn't designed to run an open resolver, and an acknowledgement makes
one deliberate and visible, not safe: a public resolver also needs abuse
monitoring, response size limits and usually a machine of its own.
## API
| Method | Path | Body |
|--------|------|------|
| GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`), `rate_limit_log`, `mail` (the last mail check) |
| POST | `/exposure/mail` | Checks the mail addresses' reverse DNS now: `checked`, `results` (`zone`, `address`, `roles`, `ptr`, `ok`, `level` ok/info/warning, `message`, `reverse_zone`, `managed_by`, `local_zone`), `errors` |
| POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change |
| POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` |
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
The override is a view's `exposure` field (`PATCH /views/{id}`), or the
settings' for a server without views (`PATCH /settings`).
+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
+255
View File
@@ -0,0 +1,255 @@
# Prometheus metrics
jarld can serve metrics for [Prometheus](https://prometheus.io/) at
`/metrics`:
- **health** you can alert on: named up, validation problems, files changed
outside Jarl, blocklists that fail to download, backup age, DNSSEC zones
waiting for you at the parent, and API tokens about to expire,
- **queries**, once [query statistics](stats.md) are on: named's counters
(result codes, record types, cache, upstream times, DNSSEC validation, rate
limiting) and Jarl's own (responses per view and zone, blocked queries per
blocklist), plus named's memory, CPU, cache and sockets.
Metrics are **off by default**. Everything is set in `/etc/jarl/jarl.yaml`;
nothing is stored in the database.
## Turning it on
Prometheus on the same server needs only:
```yaml
metrics:
enabled: true
```
```sh
sudo systemctl restart jarl
curl -s http://127.0.0.1:8053/metrics | head
```
`/metrics` is served on the addresses in `listen` (and their TLS, if set)
and on the local socket. Query counters also need `stats.enabled: true`
([stats.md](stats.md)); without it you get only the health metrics.
### Prometheus on another machine
Give `/metrics` an address of its own and say who may scrape it:
```yaml
metrics:
enabled: true
listen: ["0.0.0.0:9450"] # serves only /metrics, over plain HTTP
allow: ["192.168.1.20"] # the Prometheus server
```
```yaml
# prometheus.yml
scrape_configs:
- job_name: jarl
static_configs:
- targets: ["dns.lan:9450"]
```
Or use a token instead of (or as well as) the address list. Any address
that sends it may scrape:
```yaml
metrics:
enabled: true
listen: ["0.0.0.0:9450"]
token: "…" # e.g. from: openssl rand -hex 24
```
```yaml
# prometheus.yml
scrape_configs:
- job_name: jarl
authorization:
credentials: "…" # or credentials_file: /etc/prometheus/jarl.token
static_configs:
- targets: ["dns.lan:9450"]
```
With a token in jarl.yaml, keep the file readable by root and jarld only
(`chown root:jarl /etc/jarl/jarl.yaml; chmod 640 /etc/jarl/jarl.yaml`);
jarld warns at start when everyone can read it. On `metrics.listen` the
token travels unencrypted, so use it on a network you trust, or scrape the
main listener over HTTPS instead (`scheme: https` and its port in
Prometheus).
### Settings
| Key | Default | Meaning |
|-----|---------|---------|
| `metrics.enabled` | `false` | Serve `/metrics`. |
| `metrics.listen` | `[]` | Extra `ADDRESS:PORT`s that serve only `/metrics`, over plain HTTP. |
| `metrics.allow` | `["127.0.0.1", "::1"]` | Addresses or prefixes that may scrape without a token. Behind a reverse proxy in `trusted_proxies`, this is the client's address from `X-Forwarded-For`. `[]` with a token: the token is required everywhere. |
| `metrics.token` | `""` | A bearer token (at least 16 characters) that lets any address scrape. |
A scrape is allowed when it comes from an address in `allow`, **or**
carries the token, **or** comes over the local socket from root or jarld's
own user. A wrong token is refused (and counts towards the per-address
limit on failed logins). Jarl's API tokens don't work for `/metrics`.
The firewall must let Prometheus reach the port. `allow` is checked by
jarld, not the kernel.
## What is exported
Everything is prefixed `jarl_`. The text format is checked with `promtool
check metrics`.
### Health (always)
These are read at most every 15 seconds; a scrape in between gets the
same values.
| Metric | Meaning |
|--------|---------|
| `jarl_build_info{version}` | Always 1. |
| `jarl_start_time_seconds` | When jarld started. |
| `jarl_metrics_state_ok` | 0 when Jarl's state couldn't be read (see jarld's log). |
| `jarl_adopted` | 1 when named loads Jarl's configuration. |
| `jarl_named_up` | 1 when named answers `rndc status` (missing with `bind.no_reload`). |
| `jarl_named_recursive_clients`, `…_soft_limit`, `…_limit` | Recursive clients now, and named's limits (`rndc status`). |
| `jarl_named_tcp_clients`, `…_limit` | TCP clients now, and the limit. |
| `jarl_open_changesets` | Changesets with staged changes not applied yet. |
| `jarl_last_apply_timestamp_seconds` | When the last changeset was applied. |
| `jarl_problems{severity}` | Validation problems (`error`, `warning`, `info`) as on the dashboard, including named's own warnings. |
| `jarl_drifted_files` | Managed files changed or removed outside Jarl. |
| `jarl_objects{kind}` | Views, zones, records, ACLs, TSIG keys and options. |
| `jarl_backup_last_success_timestamp_seconds`, `jarl_backup_last_size_bytes` | The newest backup by `jarld backup`. |
| `jarl_backup_last_failure_timestamp_seconds` | A failed backup newer than the last good one. |
| `jarl_backup_stale` | 1 when the newest backup is older than `backup.warn_after`. |
| `jarl_blocking_paused` | 1 while blocking is paused (only with blocklists or rules). |
| `jarl_blocking_paused_until_timestamp_seconds` | When a timed pause ends. |
| `jarl_blocklist_enabled{list}` | 1 for lists that are switched on. |
| `jarl_blocklist_entries{list}` | Domains in the last good download. |
| `jarl_blocklist_failing{list}` | 1 when the last download or check of an enabled list failed. |
| `jarl_blocklist_last_check_timestamp_seconds{list}`, `jarl_blocklist_last_change_timestamp_seconds{list}` | When the list was last checked, and last changed. |
| `jarl_dnssec_phase{zone,view,phase}` | Always 1, one per signed zone; `phase` is one of the [DNSSEC phases](dnssec.md). `ready` means: add the DS at the parent. `removing_ds`: remove it. |
| `jarl_token_expiry_timestamp_seconds{id,user,token}` | When each API token with an expiry date expires. |
| `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`)
The counters start at zero when jarld starts (Prometheus handles that).
named's counters move once a minute, when Jarl polls named's statistics
channel. Counters from the response log (`responses`, views, zones,
blocking) move as lines are read and need `stats.query_log`. Clients in
`stats.exclude_clients` are left out of those. Names and clients are not
exported, because there would be too many of them. Use the Statistics page
for them.
| Metric | Meaning |
|--------|---------|
| `jarl_queries_total` | Queries named received. |
| `jarl_responses_by_rcode_total{rcode}` | Responses by result code (`NOERROR`, `NXDOMAIN`, `SERVFAIL`, `REFUSED`, …). |
| `jarl_queries_by_type_total{type}` | Queries by record type. |
| `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. |
| `jarl_answers_total{source}` | `authoritative` or `recursive`. |
| `jarl_recursion_queries_total`, `jarl_dropped_queries_total`, `jarl_duplicate_queries_total` | Queries that caused recursion, that named dropped, and duplicates. |
| `jarl_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped` (named's counters; they don't move in log-only mode). |
| `jarl_ratelimit_logged_total{action}` | Rate limited responses from named's log: `drop`, `slip`, and in log-only mode `would-drop`, `would-slip` ([exposure.md](exposure.md#response-rate-limiting)). |
| `jarl_upstream_responses_by_rtt_total{rtt}` | Upstream answers during recursion by round-trip time (`<10ms` … `>1600ms`). |
| `jarl_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. |
| `jarl_cache_lookups_total{result}` | `hits` and `misses`. |
| `jarl_logged_responses_total` | Responses read from the response log. |
| `jarl_responses_by_view_total{view}` | Logged responses per view (`_default` without views). |
| `jarl_responses_by_zone_total{zone}` | Logged responses per local zone; `-` for names in no local zone. |
| `jarl_blocked_queries_total` | Queries blocking answered. |
| `jarl_blocked_by_list_total{list}` | Blocked queries per blocklist; `rules` for your own rules. |
| `jarl_stats_poll_ok`, `jarl_stats_last_poll_timestamp_seconds` | Whether the last poll of named's statistics channel worked, and when it was. |
| `jarl_stats_log_lines_total`, `jarl_stats_last_log_line_timestamp_seconds` | Response log lines read, and the time of the last one. |
| `jarl_stats_warnings` | Warnings on the Statistics page (e.g. no log lines while named answers queries). |
| `jarl_named_start_time_seconds`, `jarl_named_config_time_seconds` | When named started, and last loaded its configuration. |
| `jarl_named_resident_memory_bytes`, `jarl_named_threads`, `jarl_named_cpu_seconds_total` | named's process (`/proc`). |
| `jarl_named_memory_in_use_bytes` | Memory named counts as in use. |
| `jarl_named_cache_rrsets`, `jarl_named_cache_memory_bytes` | named's caches, all views together. |
| `jarl_named_sockets{proto}` | Open UDP and TCP sockets. |
| `jarl_named_recursive_clients_highwater`, `jarl_named_tcp_clients_highwater` | The most clients at once since named started. |
Host metrics (load, memory, disks) are left to
[node_exporter](https://github.com/prometheus/node_exporter).
## Example queries
```promql
# Queries per second, and the share that failed
rate(jarl_queries_total[5m])
rate(jarl_responses_by_rcode_total{rcode="SERVFAIL"}[5m]) / ignoring(rcode) rate(jarl_queries_total[5m])
# Blocked share of logged responses, per list
rate(jarl_blocked_by_list_total[1h]) / ignoring(list) group_left rate(jarl_logged_responses_total[1h])
# Busiest zones
topk(5, rate(jarl_responses_by_zone_total{zone!="-"}[1h]))
# Cache hit rate
rate(jarl_cache_lookups_total{result="hits"}[5m]) / ignoring(result) sum without(result) (rate(jarl_cache_lookups_total[5m]))
```
## Example alerts
```yaml
groups:
- name: jarl
rules:
- alert: JarlDown
expr: up{job="jarl"} == 0
for: 5m
- alert: NamedDown
expr: jarl_named_up == 0
for: 2m
- alert: JarlConfigErrors
expr: jarl_problems{severity="error"} > 0
for: 15m
- alert: JarlDrift
expr: jarl_drifted_files > 0
for: 15m
- alert: BlocklistFailing
expr: jarl_blocklist_failing == 1
for: 6h
- alert: BackupStale
expr: jarl_backup_stale == 1
- alert: DNSSECWaitingForYou
expr: jarl_dnssec_phase{phase=~"ready|removing_ds|unsigned"} == 1
for: 1h
annotations:
summary: "{{ $labels.zone }}: DNSSEC is waiting for a step at the parent ({{ $labels.phase }})"
- alert: TokenExpiresSoon
expr: jarl_token_expiry_timestamp_seconds - time() < 7 * 86400
annotations:
summary: "API token {{ $labels.token }} of {{ $labels.user }} expires within a week"
- alert: CertificateFailing
expr: jarl_certificate_failing == 1
for: 12h
annotations:
summary: "Certificate {{ $labels.certificate }} can't be renewed (see the Certificates page)"
- alert: CertificateExpiresSoon
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
- alert: NamedRecursiveClientsHigh
expr: jarl_named_recursive_clients / jarl_named_recursive_clients_soft_limit > 0.8
for: 5m
- alert: StatsNotCollecting
expr: jarl_stats_poll_ok == 0 or jarl_stats_warnings > 0
for: 15m
```
+20
View File
@@ -360,9 +360,29 @@ cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
rndc reconfig
```
## Failed logins in the journal
A failed login to the web UI or the API (a wrong password, or a wrong
two-factor code), and a refused API token, are written to the journal as
well as the audit log, with the address first:
```
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
```
Guard's `jarl` preset, or a fail2ban filter, can ban such addresses at
the firewall (with Housecarl's ban lists). The address comes before the
user name, which a caller chooses, so a pattern anchored on
`msg="login failed" ip=` can't be fooled by a name. With `log.format:
json` they are JSON objects with the same fields in the same order.
## Monitoring
* `GET /healthz` (no authentication) reports that jarld is up.
* Prometheus: `/metrics` covers named up, configuration problems, drift,
blocklist failures, backup age, DNSSEC steps waiting at the parent and
expiring tokens, with example alerts ([metrics.md](metrics.md)).
* On the server, a check can use the local socket and needs no token:
`sudo jarl status -o json`.
* `GET /api/v1/status` with a read-only token reports named reachability,
+19 -4
View File
@@ -116,8 +116,10 @@ demote or delete the last enabled administrator.
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, and use the registrar (Porkbun,
whose key can move the domain).
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)). The one
exception is certificates for other hosts, below.
A grant is written as:
@@ -136,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
@@ -145,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:
@@ -156,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
@@ -172,6 +185,7 @@ you can then edit.
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
| `operator` | `*:write` (everything except user management) |
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
| `certificates` | `certificates:write`: certificates for other hosts (`jarl cert get`) for any name in Jarl's zones. Narrow it to a host's names, e.g. `certificates:write names=web01.adbraun.lol` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
```sh
jarl user create alice --role dns-editor
@@ -197,6 +211,7 @@ The audit log shows the user and the token name.
jarl token create laptop # full rights of the user, no expiry
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
jarl token create certbot --user certbot --role acme # admin: a token for another user
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host
jarl token list --all
jarl token revoke 12
```
+204
View File
@@ -0,0 +1,204 @@
# 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 listed apart, with the name each would get: `Other views` in
`jarl zone show`, `ptr_other_views` and `ptr_other_views_list` in the
API, and a quiet note on the page with an **Add PTR records for them in
view vps** button. Filling never adds them unasked. `jarl zone show`
lists the missing ones too:
```
Missing PTRs: 1 (addresses the view's records use here; add them: jarl zone fill-ptr 10.0.10.0/24)
10.0.10.7 -> printer.adbraun.lol
Other views: 2 addresses with A or AAAA records only in view internal, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr 10.0.10.0/24 --other-views)
10.0.10.26 -> gomft.adbraun.lol (view internal)
10.0.10.101 -> claude.adbraun.lol (view internal)
```
## 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.
+110 -4
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.
@@ -103,11 +112,32 @@ lists, views, zones). named's own counters can't be filtered.
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
| `responses` | response log | lines read (a health check) |
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
| `rrl:drop`, `rrl:slip`, `rrl:would-drop`, `rrl:would-slip` | response log | responses [rate limiting](exposure.md#response-rate-limiting) limited, or would have in log-only mode (named's own `ratelimit:*` counters don't move then) |
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
the samples and `gn:KEY` their number, so a chart shows the average):
| Gauge | Source | Meaning |
|-------|--------|---------|
| `g:named:rss` | /proc | named's resident memory (what the system sees), bytes |
| `g:named:mem` | named | the memory named counts itself as using, bytes |
| `g:named:cpu` | /proc | named's CPU use over the minute, in tenths of a percent of one CPU |
| `g:cache:mem`, `g:cache:rrsets` | named | the cache's memory and RRsets, all views |
| `g:host:mem-used`, `g:host:load` | /proc | the host's memory in use, and its 1-minute load average times 100 |
Jarl finds named's process by name (the one whose command line mentions
named's main configuration file); reading its memory and CPU time needs no
privileges. With `/proc` mounted `hidepid`, it isn't visible and those
figures are left out. The current values also include named's sockets, its
recursive and TCP clients against their limits (`rndc status`), the most
at once since it started, and the space left on the file systems holding
the zone files, Jarl's cache, named.conf, the response log and this
database.
**Top lists** (per hour and per day, the `top_n` largest): `name` (most
queried names), `client`, `nxdomain` (names answered NXDOMAIN), `servfail`
(names answered SERVFAIL), `refused` (clients that were refused), `blocked`
(names blocking answered). Blocked answers are NXDOMAIN too, but they
(names blocking answered), `blocked_client` (clients by blocked queries). Blocked answers are NXDOMAIN too, but they
aren't counted as names that don't exist: not in the `nxdomain` list, and
not in the summary's NXDOMAIN ratio.
@@ -131,7 +161,9 @@ prefixes and get no name.
page follows it. Charts have a hover readout (or focus them and use the
arrow keys) and a **Table** button showing the same data as a table. The
heatmap shows the average queries per hour for each weekday and hour over at
least the last 7 days. The page refreshes every minute.
least the last 7 days. The page refreshes every minute. **Server**, at the
bottom, shows named's memory, CPU, cache and clients, the host and the
disks now, and named's memory and CPU over the range.
**CLI**:
@@ -145,23 +177,97 @@ jarl stats top refused # who asks for what they may not have
jarl stats timeline --since 6h # sparkline; --table for every bucket
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
jarl stats heatmap --since 28d
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit | rrl
jarl stats top ratelimited # client networks rate limiting limits (or would)
jarl stats server # memory, CPU, cache, clients, host, disks now
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
jarl stats summary --since 30d -o json # export (also -o yaml)
```
**API**: see [api.md](api.md#query-statistics).
**Prometheus**: with `metrics.enabled`, the counters are also served at
`/metrics` ([metrics.md](metrics.md)).
**Permissions**: the `stats` resource (`stats:read`). The `readonly` and
`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
lists for `retention.hour`, daily ones for `retention.day`. Older data is
deleted every hour.
- Charts use the finest resolution that covers the range: minutes up to 48
hours, hours up to 90 days, days beyond.
hours (and `retention.minute`), hours up to `retention.hour`, days beyond.
- The Statistics page offers only the ranges the data is kept for: up to 90
days with the defaults, plus a year and 2 years when `retention.day` (or
`retention.hour`) is that long. The Explore page offers the ranges up to
`retention.queries`.
- Top lists are exact within an hour (up to 200,000 distinct names or
clients per hour, beyond which the rarest are dropped). Daily lists are
built from the hourly top lists, so for very long tails they are
+54 -4
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 |
@@ -57,6 +57,8 @@ showing them.
| `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` |
| `zone_file_required` / `zone_file_invalid` / `zone_file_managed` | error | Hint zones need a file. Forward zones have none. Primary zone files are Jarl's |
| `zone_inview_invalid`, `zone_inview_option` | error | `in-view` zones only inside views, and only with `forward`/`forwarders` |
| `zone_inview_disabled` | error | An `in-view` zone can't share a disabled zone |
| `zone_disabled_signed` | error | A signed zone can't be disabled: turn signing off first |
| `zone_records_unmanaged` | error | Only primary zones can have records in Jarl |
| `zone_serial_policy` | error | `date`, `increment` or `unixtime` |
| `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups |
@@ -65,11 +67,12 @@ showing them.
| Code | Severity | Rule |
|------|----------|------|
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets` |
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), ranges (`10.0.10.101-10.0.10.199`: both ends full addresses of one family, start not after end), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets`. Ranges in options are checked when they're entered, and only options with address match lists take them (not `forwarders`, say) |
| `range_mapped4` | warning | An IPv6 range that reaches into `::ffff:0:0/96`: named can see IPv4 clients as such addresses, so it may match IPv4 clients too |
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
| `acl_cycle` | error | ACLs that include each other |
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match |
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak |
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, names that look like addresses or ranges, elements that can never match (a prefix or range an earlier one already covers) |
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) |
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
| `view_unreachable` | warning | A view that comes after one matching every client is never used |
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
@@ -101,6 +104,26 @@ applied.
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
## TLS
| Code | Severity | Check |
|------|----------|-------|
| `tls_file_missing` | error | The files a top-level `tls` statement names (certificate, key, CA) exist: named doesn't load its configuration without them |
## Linked zones
See [views.md](views.md#linked-zones). Jarl keeps linked zones in step
itself, so these only show when something got past it (a change staged
before the zones were linked, or the database edited by hand).
| Code | Severity | Check |
|------|----------|-------|
| `linked_zone_type` | error | Only primary zones can be linked |
| `linked_zone_name` | error | Linked zones have the same name |
| `linked_mixed` | error | A name and type in a zone is either shared or local, not both |
| `linked_out_of_sync` | error | Every view that doesn't override a shared name and type has the same records for it. Link the zones again (`jarl zone link`) to work out what is shared |
| `linked_zone_alone` | info | A zone linked with no other zone |
## Blocking
From [blocking.md](blocking.md).
@@ -113,14 +136,34 @@ From [blocking.md](blocking.md).
| `blocking_view_missing` | error | A list or rule names a view that doesn't exist |
| `blocking_no_view` | warning | A list or rule applies to no view: none offers recursion, and blocking only changes recursive answers |
| `blocking_no_recursion` | warning | A list or rule names a view that doesn't offer recursion, where it has no effect |
| `blocking_step_missing` | error | A list or client group is at a step that doesn't exist (a list can't be at `off`) |
| `client_group_member_invalid` | error | Client group members are addresses, networks, or ACLs made only of those |
| `client_group_overlap` | error | The same address or network is in two client groups of a view |
| `client_group_empty` | warning | A client group has no members |
| `blocklist_step_unused` | warning | A list is at a step that no client group reaches, so it blocks nothing |
## Security lints
These and the exposure checks below only look at views that are
reachable from the internet ([exposure.md](exposure.md)); without views,
the open resolver and open transfer checks look at the server. Any of them
can be acknowledged (it then shows as info, with who accepted it and why),
and a change that newly introduces an open resolver, open transfers, cache
snooping, open NOTIFY or an open management interface on a public view
must be confirmed (`--accept-risk`).
| Code | Severity | What it catches |
|------|----------|-----------------|
| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks |
| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key |
| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead |
| `exposure_rate_limit` | warning | A public view without response rate limiting (`rate-limit`) |
| `exposure_rate_limit_log_only` | info | Rate limiting in log-only mode: check what it would limit, then enforce |
| `exposure_minimal_any` | warning | A public view answering ANY queries in full (`minimal-any` off) |
| `exposure_cache_snooping` | warning | A public view without recursion whose cache anyone may query (`allow-query-cache`) |
| `exposure_open_notify` | warning | A public view accepting NOTIFY from anyone (`allow-notify`) |
| `exposure_version`, `exposure_hostname` | warning | The server is public and shows its BIND version or host name to anyone (`version none`, `hostname none`) |
| `exposure_management` | warning | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone, while the server is public |
## What only BIND checks
@@ -130,3 +173,10 @@ values Jarl accepts as raw named.conf syntax, such as `logging` channels
and `rrset-order`, are the main example. Their errors come back as
`validation_failed` (HTTP 422) with the checker's messages in `check`, each
tied to the object it belongs to.
named-checkconf's warnings don't block a change. They are shown with the
change, and the dashboard's configuration checks list them for as long as
they apply (`named_warning`; `named_error` if the running configuration no
longer passes, after a hand edit say). Jarl runs named-checkconf on the
running configuration for this, without loading zones, when the dashboard
asks and the configuration changed, or at most every 10 minutes.
+190 -11
View File
@@ -81,21 +81,34 @@ avoids named-checkconf's warning that `allow-recursion` is still active in a
view with recursion off. Jarl warns (`security_open_resolver`) if a view
ever offers recursion to everyone.
Jarl counts the external view as reachable from the internet (its
match-clients end in `any`) and checks it: rate limiting, ANY queries,
the version string and more. See the **Exposure** page or `jarl exposure`,
and [exposure.md](exposure.md) to set up response rate limiting.
### 4. Give the external view its zones
**Different data per view (split DNS).** Copy the zone, then change what
should differ:
**Mostly the same, a few differences (split DNS).** Create the external
copy *linked* ([below](#linked-zones)): Jarl keeps the shared records the
same in both views, and each view can have records of its own:
```sh
jarl zone create adbraun.lol --view external --link-with internal
jarl record set adbraun.lol www A 203.0.113.20 --view external --local # public address, external only
jarl record delete adbraun.lol nas-internal A --view external # gone from both: it was shared
```
Delete records you don't want outside before you link, or make them local
to the internal view first (`jarl record update ID --local`): a shared
record is in both views.
**Two separate copies.** `--clone-from` copies the zone once; from then on
the two are separate zones, and a record added to one isn't in the other:
```sh
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
jarl record list adbraun.lol --view external
jarl record update <id> --data 203.0.113.20 # public address instead of 10.10.0.20
jarl record delete adbraun.lol nas-internal A --view external
```
From then on the two copies are separate zones. A record added to one isn't
in the other, and the serials are separate.
**The same data in both views.** Use an `in-view` zone. The external view
then serves the internal view's zone, so there is one copy to maintain:
@@ -116,7 +129,168 @@ dig @<public-ip> example.com # from outside: status R
jarl check
```
On the server, `dig -b <source address>` picks which view you test.
On the server, `dig -b <source address>` picks which view you test, or,
from anywhere, sign the query with the view's key (below):
```sh
jarl view key external --named-conf > external.key
dig -k external.key @ns1.adbraun.lol nas.adbraun.lol +short # 203.0.113.20, even from the LAN
```
## View keys
named picks a view by where a query comes from, so a query this server
sends to its own public address usually lands in the internal view: its
source is one of your own networks. That makes checks of the external view
from the server itself (such as Jarl's delegation check) see the wrong
view.
So Jarl gives every view a TSIG key, `jarl-view-NAME`, and puts it at the
front of the view's `match-clients`:
```
view "internal" {
match-clients { !key jarl-view-external; key jarl-view-internal; homelab; };
...
view "external" {
match-clients { key jarl-view-external; any; };
```
A query signed with a view's key lands in that view, whatever its source;
the `!key` stops an earlier view from taking a later view's queries.
Clients don't send these keys, so for them nothing changes. Jarl signs its
own queries to this server's addresses with the key of the zone's view.
The keys live in `keys.conf` with your other keys, never in diffs or in
`jarl tsig list`; their secrets are derived from a random value in Jarl's
database (and in its backups), so they stay the same from one apply to
the next. `jarl view key VIEW`
shows one (it needs `tsig:write` and is audited). Key names starting with
`jarl-view-` are reserved, and the view keys can be used in your own
options (`allow-transfer { key jarl-view-external; }`).
**Moving between views.** A zone's page lists the other views that have a
zone of the same name ("also in external", in each view's colour). Each
one links straight to that zone, on the tab you are on.
## Linked zones
Most split-horizon zones are the same in every view apart from a few
names. Linking zones of the same name in different views lets Jarl keep
the shared records in step, while each view can still have records of its
own and its own zone options.
* **Shared records** are in every linked view. Adding, changing,
disabling or deleting one in any view does the same in the others, in
the same changeset: the diff shows every view, and history, undo and
`jarl check` treat them together.
* **Local records** are in one view only (`--local`, or **Only here** in
the web UI). A local record overrides the shared records of the same name
and type in that view: `www A 10.0.10.5` local to internal, while the
external view keeps the shared `www A 203.0.113.20`. Delete the local
records and the name and type are gone from that view only: the other
views keep their records, each local to its view now. To give the view
the shared version back instead, **Share** it from a view that has it.
* A new record joins the set of the same name and type: local if that set
is local in its view, shared otherwise.
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
web UI) makes that view's records of that name and type the shared
version: the other views' records of that name and type are replaced.
* Zones stay separate in named, so each keeps its own serial, options
(allow-transfer, DNSSEC signing, ...) and file, and the views keep their
own `match-clients`, recursion and order.
```sh
jarl zone link adbraun.lol --view internal --with external # link two existing zones
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
jarl record list adbraun.lol --view internal # LINK column: shared or local
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local
jarl record update 1234 --local # this name and type: only this view
jarl record update 1234 --shared # this view's version, everywhere
jarl zone unlink adbraun.lol --view vps # records stay, all local
```
**Linking never changes what named answers.** A name and type with the
same records in every view becomes shared; one that differs stays local to
each view (Jarl lists them). Share the right version afterwards. Unlinking
keeps every record, all local; a link left with one zone is dissolved, as
when the other zone is deleted.
`--local` also works on a zone that isn't linked (its records are local
anyway), so scripts don't need to know; `--shared` there is refused.
**Permissions** are checked in every view a change reaches. A token with
a grant for one view only (`views=internal`) can change that view's local
records and make overrides, but not a shared record, since that would
change the other views too: Jarl says so and suggests a local record. A
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
there. With `--replace`, shared records missing from the file are deleted
in every linked view.
**ACME challenges** are written once per link, and so reach every linked
view; `jarl acme`, lego's httpreq and certificates Jarl obtains all do
this.
**Compared with BIND's `in-view`**, where one view serves another's zone
as it is: linked zones can differ per view, keep their own options, and
don't depend on the view order (`in-view` can only use a view defined
before it).
## Comparing views
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
same zone in another view (or all of them), from the view you're on.
The tab starts with a view the zone is linked to, if any, else the
first other view; `jarl zone diff` compares with every other view unless
`--with` names some:
```
$ jarl zone diff adbraun.lol --view internal
adbraun.lol in internal, compared with external
linked: a difference is a view's own records, there on purpose
external: 1 answered differently, 24 only in internal, 2 only in external, 31 the same
ANSWERED DIFFERENTLY (1)
nas A
internal 10.0.10.20 [local, id 812]
external 203.0.113.20 [local, id 790]
ONLY IN INTERNAL (24)
printer A 10.0.10.30
router A 10.0.10.1
...
ONLY IN EXTERNAL (2)
_acme-challenge TXT "…"
...
31 names and types are the same (--all lists them)
```
* **Answered differently**: names and types more than one view has, with
different records. In linked zones that is a view's own (local)
records, there on purpose; between zones that aren't linked, it is a
difference that crept in. **Use this everywhere** gives every view one
view's records (in linked zones by sharing them: `jarl record update ID
--shared`).
* **Only in …**: names and types some views lack. **Add to external**
gives that view the same records, its own.
* **The same**: how many; `--all` (or "List them") shows them.
One line per record: the CLI cuts long values to fit the terminal
(`--wide` shows them whole); the web UI shows the whole value on hover.
The apex NS records are left out, since views often list their name
servers differently on purpose.
**One name.** `jarl zone diff adbraun.lol nas` (and a type: `... @ TXT`)
shows just that name in every view, the same records included. In the
web UI, a record's **Compare** button does the same. `--with external`
(repeat it) picks the views to compare with.
## Living with views
@@ -125,12 +299,17 @@ On the server, `dig -b <source address>` picks which view you test.
guess.
* `jarl acme` writes challenge records into **every** view that has the
zone, so Let's Encrypt (external) and internal clients see the same
thing. Use `--view` to limit it.
thing (once per link, for linked zones). Use `--view` to limit it.
* `--view all` (or a list) on record commands changes each linked zone
once: the change reaches the others through the link. With `--local` it
goes to each view named.
* `jarl view order internal external` changes the match order, and
`jarl view list` shows it.
* Deleting a view is refused while it still has zones, unless you pass
`--cascade`.
* Give each view a colour (Views → Edit → Colour, or
* Each view has its own page (Views → the view's name): its settings,
options and zones.
* Give each view a colour (the view's page → Settings → Colour, or
`jarl view color internal green`) to tell the zones of each view apart
in the zone list. The colour only affects the web UI, so it applies at
once, with no changeset or deploy. `jarl view color internal default`
+3
View File
@@ -4,6 +4,7 @@ go 1.27.1
require (
github.com/aymanbagabas/go-udiff v0.4.1
github.com/go-acme/lego/v4 v4.35.2
github.com/miekg/dns v1.1.73
github.com/spf13/cobra v1.10.2
go.yaml.in/yaml/v3 v3.0.5
@@ -15,7 +16,9 @@ require (
)
require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
+10
View File
@@ -1,8 +1,16 @@
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -24,6 +32,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+17 -1
View File
@@ -21,6 +21,10 @@ const Comment = "ACME DNS-01 challenge"
type Zone struct {
Name string // as Jarl stores it, e.g. "adbraun.lol."
View string // "" when views are not used
// Link is the zone's link group (0: not linked). A record written
// into one zone of a link reaches the others (docs/views.md#linked-zones),
// so only one of them is written to.
Link int64
}
// Zones returns the zones that hold name: the longest matching zone, in
@@ -46,7 +50,19 @@ func Zones(name string, zones []Zone, view string) []Zone {
out = append(out, z)
}
}
return out
// One zone per link: Jarl writes the shared record into the others.
var dedup []Zone
links := map[int64]bool{}
for _, z := range out {
if z.Link != 0 {
if links[z.Link] {
continue
}
links[z.Link] = true
}
dedup = append(dedup, z)
}
return dedup
}
// Requests returns the changes that set (or, with set false, remove) the
+168 -5
View File
@@ -22,13 +22,38 @@ type Zone struct {
Display string `json:"display_name"`
View string `json:"view,omitempty"`
Records int `json:"record_count"`
// Disabled is how many of the records are disabled.
Disabled int `json:"disabled_count,omitempty"`
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
RName string `json:"rname_email,omitempty"`
// DisabledRecords is how many of the records are disabled.
DisabledRecords int `json:"disabled_count,omitempty"`
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
RName string `json:"rname_email,omitempty"`
// Registrar names the registrar Jarl can manage the domain at
// ("porkbun"), when one is configured for it.
Registrar string `json:"registrar,omitempty"`
// LinkedWith are the views of the zones this one is linked with
// (docs/views.md#linked-zones).
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 adds them, naming them from there. PTROtherViewsList
// are the first 25 (From says which view).
PTROtherViews int `json:"ptr_other_views,omitempty"`
PTROtherViewsList []PTRFill `json:"ptr_other_views_list,omitempty"`
// Pending is set when listing with a changeset: add, change or delete.
Pending string `json:"pending,omitempty"`
}
@@ -40,6 +65,8 @@ type Record struct {
View string `json:"view,omitempty"`
RelativeName string `json:"relative_name"`
EffectiveTTL uint32 `json:"effective_ttl"`
// Linked is "shared" or "local" for a record of a linked zone ("" otherwise).
Linked string `json:"linked,omitempty"`
// Text is the decoded value of TXT and SPF records.
Text *string `json:"text,omitempty"`
// Pending is set when listing with a changeset: add, change, delete,
@@ -52,6 +79,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.
@@ -77,6 +119,10 @@ type Option struct {
Where string `json:"where"` // e.g. "view internal"
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty"`
// Formatted is the value laid out over several lines, as in
// named.conf, when it has a block (for display and editing; Value is
// the same on one line).
Formatted string `json:"formatted,omitempty"`
}
// User is a user with formatted grants.
@@ -204,6 +250,9 @@ type BatchRequest struct {
Ops []ChangeRequest `json:"ops"`
DryRun bool `json:"dry_run,omitempty"`
Force bool `json:"force,omitempty"`
// AcceptRisk confirms a change that opens the server up
// (docs/exposure.md).
AcceptRisk bool `json:"accept_risk,omitempty"`
// Apply (for POST /changesets) applies the new changeset at once.
Apply bool `json:"apply,omitempty"`
}
@@ -245,6 +294,67 @@ type StatusResponse struct {
// BlocklistErrors are blocklists whose last update failed (for those
// who may see blocking).
BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"`
// BlockingPausedUntil is set while blocking is paused for a time,
// BlockingPausedIndefinitely while it is paused until resumed.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
// 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.
type CertSummary struct {
Total int `json:"total"`
// Problems are the certificates that aren't fine: failing, expiring,
// expired (not ones still being obtained for the first time).
Problems []CertProblem `json:"problems"`
// Next is the certificate that expires first, and when.
Next string `json:"next,omitempty"`
NextAfter *time.Time `json:"next_after,omitempty"`
// Serving is the certificate jarld serves HTTPS with ("" for none or tls.cert_file).
Serving string `json:"serving,omitempty"`
}
// CertProblem is a certificate that needs attention.
type CertProblem struct {
Name string `json:"name"`
State string `json:"state"`
Message string `json:"message"`
}
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
@@ -316,6 +426,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.
@@ -382,8 +495,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"`
@@ -498,6 +623,27 @@ type Blocklist struct {
// MemoryMB estimates what named needs for the list, in all its views.
MemoryMB int `json:"memory_mb,omitempty"`
Pending string `json:"pending,omitempty"`
// Step is the name of the step the list starts at ("normal" or one
// above it).
Step string `json:"step"`
}
// BlockStep is a blocking step above normal as returned by the API, with
// what uses it.
type BlockStep struct {
model.BlockStep
Lists []string `json:"lists"`
Groups []string `json:"groups"`
Pending string `json:"pending,omitempty"`
}
// ClientGroup is a client group as returned by the API.
type ClientGroup struct {
model.ClientGroup
Step string `json:"step"`
Views []string `json:"views"`
AutoViews bool `json:"auto_views,omitempty"`
Pending string `json:"pending,omitempty"`
}
// BlockRule is a block or allow rule as returned by the API.
@@ -507,3 +653,20 @@ type BlockRule struct {
AutoViews bool `json:"auto_views,omitempty"`
Pending string `json:"pending,omitempty"`
}
// ZoneLink is a zone a zone is linked with.
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"`
}
+30
View File
@@ -134,3 +134,33 @@ func TestRoleTemplatesValid(t *testing.T) {
}
}
}
func TestCertificatesRole(t *testing.T) {
p := &Principal{User: &model.User{Grants: RoleTemplates["certificates"]}}
if !p.Can(Action{Resource: ResCerts, Write: true, Name: "web01.adbraun.lol"}) {
t.Error("the certificates role can't get a certificate")
}
if p.Can(Action{Resource: ResRecords, Write: true, Zone: "adbraun.lol."}) || p.Can(Action{Resource: ResZones, Zone: "adbraun.lol."}) {
t.Error("the certificates role reaches beyond certificates")
}
}
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")
}
}
+16 -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
}
@@ -382,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{
},
"operator": {{Resource: ResAll, Access: Write}},
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
// certificates for other hosts (jarl cert get): narrow it with names=
// to the host's own.
"certificates": {{Resource: ResCerts, Access: Write}},
}
+16 -1
View File
@@ -340,7 +340,14 @@ type ServerStatus struct {
ConfigFile string `json:"config_file"`
Zones string `json:"zones"`
Running bool `json:"running"`
Raw string `json:"raw"`
// RecursiveClients is "now/soft limit/hard limit" (the soft limit is
// where named starts dropping the oldest queries), TCPClients
// "now/limit".
RecursiveClients string `json:"recursive_clients,omitempty"`
TCPClients string `json:"tcp_clients,omitempty"`
CPUs string `json:"cpus,omitempty"`
WorkerThreads string `json:"worker_threads,omitempty"`
Raw string `json:"raw"`
}
// Status returns named's status.
@@ -363,6 +370,14 @@ func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) {
st.ConfigFile = v
case ok && k == "number of zones":
st.Zones = v
case ok && k == "recursive clients":
st.RecursiveClients = v
case ok && k == "tcp clients":
st.TCPClients = v
case ok && k == "CPUs found":
st.CPUs = v
case ok && k == "worker threads":
st.WorkerThreads = v
case strings.Contains(line, "server is up and running"):
st.Running = true
}
+180
View File
@@ -0,0 +1,180 @@
package blocking
import (
"fmt"
"math"
"net/netip"
"sort"
"strings"
)
// Client steps (docs/blocking.md): named checks policy zones in order and
// the first match decides, so a "gate" zone that lets some clients through
// (rpz-passthru, matched on the client's address) keeps them from every
// zone after it. Jarl puts a gate in front of the lists of each step,
// letting through the clients whose step is below it.
// GateSuffix follows a gate's name: off.gate.jarl.invalid,
// strict.gate.jarl.invalid.
const GateSuffix = ".gate.jarl.invalid"
// v4Mapped holds the IPv4-mapped IPv6 addresses.
var v4Mapped = netip.MustParsePrefix("::ffff:0:0/96")
// GateZone returns the policy zone name of the gate in front of a step's
// lists ("off" for the gate in front of the normal lists).
func GateZone(step string) string { return step + GateSuffix }
// Assignment gives the addresses in Prefix a step rank (-1 off, 0 normal,
// 1, 2, ... the steps above it).
type Assignment struct {
Prefix netip.Prefix
Rank int
}
// PassBelow returns the fewest prefixes that cover exactly the addresses whose
// rank is below k. An address has the rank of the most specific
// assignment containing it, and rank 0 (normal) when none does. When two
// assignments have the same prefix, the higher rank counts (validation
// refuses that anyway).
func PassBelow(assigns []Assignment, k int) []netip.Prefix {
var out []netip.Prefix
for _, root := range []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")} {
var mine []Assignment
if !root.Addr().Is4() {
// named matches IPv4 clients as IPv4-mapped IPv6 addresses:
// keep IPv6 prefixes clear of them, so only the IPv4 ones
// decide for IPv4 clients.
mine = append(mine, Assignment{v4Mapped, math.MaxInt})
}
rank := 0
for _, a := range assigns {
p := a.Prefix.Masked()
if p.Addr().Is4() != root.Addr().Is4() {
continue
}
if p.Bits() == 0 {
rank = max(rank, a.Rank)
continue
}
mine = append(mine, Assignment{p, a.Rank})
}
for _, p := range cover(root, rank, mine, k) {
if p.Bits() == 0 {
// A trigger needs at least one bit: use both halves.
lo, hi := halves(p)
out = append(out, lo, hi)
continue
}
out = append(out, p)
}
}
return out
}
// cover returns the prefixes inside p whose addresses have a rank below k.
// rank is p's own; inside are the assignments strictly inside p.
func cover(p netip.Prefix, rank int, inside []Assignment, k int) []netip.Prefix {
if len(inside) == 0 {
if rank < k {
return []netip.Prefix{p}
}
return nil
}
var out []netip.Prefix
lo, hi := halves(p)
full := 0
for _, h := range []netip.Prefix{lo, hi} {
var sub []Assignment
for _, a := range inside {
if a.Prefix.Bits() > h.Bits() && h.Contains(a.Prefix.Addr()) {
sub = append(sub, a)
}
}
c := cover(h, ownRank(inside, h, rank), sub, k)
if len(c) == 1 && c[0] == h {
full++
}
out = append(out, c...)
}
if full == 2 {
return []netip.Prefix{p}
}
return out
}
// ownRank is the rank of h: its own assignments' (the highest), or the
// inherited one without any.
func ownRank(inside []Assignment, h netip.Prefix, inherited int) int {
r, found := 0, false
for _, a := range inside {
if a.Prefix == h {
if !found || a.Rank > r {
r = a.Rank
}
found = true
}
}
if !found {
return inherited
}
return r
}
// halves splits a prefix into its two halves.
func halves(p netip.Prefix) (netip.Prefix, netip.Prefix) {
bits := p.Bits() + 1
lo := netip.PrefixFrom(p.Addr(), bits).Masked()
b := lo.Addr().AsSlice()
i := (bits - 1) / 8
b[i] |= 0x80 >> ((bits - 1) % 8)
a, _ := netip.AddrFromSlice(b)
return lo, netip.PrefixFrom(a, bits)
}
// ClientIPOwner returns the policy zone owner name that matches clients
// in p (an rpz-client-ip trigger): 32.37.0.10.10.rpz-client-ip for
// 10.10.0.37/32, 64.zz.3d7f.8013.2a02.rpz-client-ip for 2a02:8013:3d7f::/64.
func ClientIPOwner(p netip.Prefix) string {
p = p.Masked()
a := p.Addr()
var labels []string
if a.Is4() {
b := a.As4()
labels = []string{fmt.Sprint(b[3]), fmt.Sprint(b[2]), fmt.Sprint(b[1]), fmt.Sprint(b[0])}
} else {
// RFC 5952 text, with "zz" for the "::" and the words reversed.
s := a.String()
var words []string
if left, right, ok := strings.Cut(s, "::"); ok {
if left != "" {
words = append(words, strings.Split(left, ":")...)
}
words = append(words, "zz")
if right != "" {
words = append(words, strings.Split(right, ":")...)
}
} else {
words = strings.Split(s, ":")
}
for i := len(words) - 1; i >= 0; i-- {
labels = append(labels, words[i])
}
}
return fmt.Sprint(p.Bits()) + "." + strings.Join(labels, ".") + ".rpz-client-ip"
}
// GateZoneFile renders a gate: every client in prefixes passes.
func GateZoneFile(title string, prefixes []netip.Prefix) string {
owners := make([]string, 0, len(prefixes))
for _, p := range prefixes {
owners = append(owners, ClientIPOwner(p))
}
sort.Strings(owners)
var sb strings.Builder
sb.WriteString(header(title, 1))
for _, o := range owners {
sb.WriteString(o + "\tCNAME\trpz-passthru.\n")
}
return sb.String()
}
+96
View File
@@ -0,0 +1,96 @@
package blocking
import (
"net/netip"
"slices"
"strings"
"testing"
)
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
// rankOf is the rank PassBelow's model gives an address.
func rankOf(assigns []Assignment, a netip.Addr) int {
best, bits := 0, -1
for _, x := range assigns {
if x.Prefix.Contains(a) && x.Prefix.Bits() >= bits {
if x.Prefix.Bits() > bits || x.Rank > best {
best = x.Rank
}
bits = x.Prefix.Bits()
}
}
return best
}
func covered(ps []netip.Prefix, a netip.Addr) bool {
for _, p := range ps {
if p.Contains(a) {
return true
}
}
return false
}
func TestPassBelow(t *testing.T) {
assigns := []Assignment{
{pfx("10.10.0.20/32"), -1}, // work laptop: off
{pfx("10.10.0.0/24"), 2}, // the kids' network: very strict
{pfx("10.10.0.50/32"), 1}, // except one tablet: strict
{pfx("2a02:db8::/64"), 1}, // an IPv6 network: strict
{pfx("2a02:db8::99/128"), 0}, // one device in it: normal
}
probe := []string{"10.10.0.20", "10.10.0.21", "10.10.0.50", "10.10.0.51", "10.10.1.1", "192.168.1.1", "0.0.0.1", "255.255.255.255",
"2a02:db8::1", "2a02:db8::99", "2a02:db8:0:1::1", "::1", "ffff::1"}
for k := 0; k <= 3; k++ {
gate := PassBelow(assigns, k)
for _, s := range probe {
a := netip.MustParseAddr(s)
if want := rankOf(assigns, a) < k; covered(gate, a) != want {
t.Errorf("k=%d %s: passes=%v, want %v (rank %d)", k, s, !want, want, rankOf(assigns, a))
}
}
for _, p := range gate {
if p.Bits() == 0 {
t.Errorf("k=%d: a /0 trigger", k)
}
// named matches IPv4 clients as ::ffff:a.b.c.d: no IPv6
// prefix may reach them.
if p.Addr().Is6() && p.Overlaps(v4Mapped) {
t.Errorf("k=%d: %s covers IPv4-mapped addresses", k, p)
}
}
}
// Off clients only, before the normal lists.
if got := PassBelow(assigns, 0); !slices.Equal(got, []netip.Prefix{pfx("10.10.0.20/32")}) {
t.Errorf("off gate = %v", got)
}
// Nobody above normal: everyone passes the gate of step 1: two IPv4
// halves, and IPv6 around the IPv4-mapped /96 (96 prefixes).
if got := PassBelow(nil, 1); len(got) != 2+96 {
t.Errorf("empty step-1 gate = %v", got)
}
// The complement of one IPv4 address is 32 prefixes (plus IPv6's 96).
if got := PassBelow([]Assignment{{pfx("10.10.0.50/32"), 1}}, 1); len(got) != 32+96 {
t.Errorf("complement of one address: %d prefixes", len(got))
}
}
func TestClientIPOwner(t *testing.T) {
for p, want := range map[string]string{
"10.10.0.37/32": "32.37.0.10.10.rpz-client-ip",
"10.10.0.0/24": "24.0.0.10.10.rpz-client-ip",
"2a02:db8::/64": "64.zz.db8.2a02.rpz-client-ip",
"2a02:db8::1/128": "128.1.zz.db8.2a02.rpz-client-ip",
"::/1": "1.zz.rpz-client-ip",
"2001:db8:1:2:3:4:5:6/128": "128.6.5.4.3.2.1.db8.2001.rpz-client-ip",
} {
if got := ClientIPOwner(pfx(p)); got != want {
t.Errorf("%s: %s, want %s", p, got, want)
}
}
z := GateZoneFile("t", []netip.Prefix{pfx("10.10.0.20/32")})
if !strings.Contains(z, "32.20.0.10.10.rpz-client-ip\tCNAME\trpz-passthru.\n") {
t.Errorf("gate zone:\n%s", z)
}
}
+10
View File
@@ -40,6 +40,9 @@ type Match struct {
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
RuleID int64 `json:"rule_id,omitempty"`
List string `json:"list,omitempty"` // the blocklist, when a list matched
// Step is the list's step when it is above normal: only clients at
// that step or above get this answer (others: none).
Step string `json:"step,omitempty"`
}
// Actions of rules.
@@ -139,6 +142,13 @@ func RuleEntries(rules []Rule) map[string]string {
return e
}
// PausedZoneFile renders a rules zone that lets every name through, for
// while blocking is paused: the rules zone comes first, and its match
// stops named from looking at the lists.
func PausedZoneFile(title string) string {
return header(title+" (blocking is paused)", 1) + "*\tCNAME\trpz-passthru.\n"
}
// RulesZoneFile renders the rules zone of a view.
func RulesZoneFile(title string, rules []Rule) string {
e := RuleEntries(rules)
+84
View File
@@ -427,3 +427,87 @@ func classify(name, grammar string) (string, []string) {
}
return KindRaw, nil
}
// listParam matches an optional parameter before a statement's list, such
// as "[ port <integer> ]" in allow-transfer's grammar.
var listParam = regexp.MustCompile(`\[ ([a-z0-9-]+) `)
// WrapList adds the braces to a list value given without them, for
// statements that end in a list ("allow-transfer [ port <integer> ] {
// <address_match_element>; ... }"): "10.0.0.53" becomes "{ 10.0.0.53; }",
// "port 5353 10.0.0.53; key xfer" becomes "port 5353 { 10.0.0.53; key
// xfer; }". Elements are separated by ";", "," or new lines, or by spaces
// when they are all addresses. Values with a brace, and values of other
// statements, are returned as they are.
func (st *Statement) WrapList(v string) string {
g, _, _ := strings.Cut(st.Grammar, "\n")
open := strings.Index(g, " {")
if strings.Contains(v, "{") || open < 0 || !strings.HasSuffix(g, "; ... };") {
return v
}
params := map[string]bool{}
for _, m := range listParam.FindAllStringSubmatch(g[:open], -1) {
params[m[1]] = true
}
fields := strings.Fields(v)
i := 0
for i+1 < len(fields) && params[strings.ToLower(fields[i])] {
i += 2
}
head := strings.Join(fields[:i], " ")
rest := strings.Join(fields[i:], " ")
var elems []string
if !strings.ContainsAny(rest, ";,\n") {
elems = spaceElements(fields[i:])
} else {
for _, e := range strings.FieldsFunc(rest, func(r rune) bool { return r == ';' || r == ',' || r == '\n' }) {
if e = strings.TrimSpace(e); e != "" {
elems = append(elems, e)
}
}
}
if len(elems) == 0 {
return v
}
list := "{ " + strings.Join(elems, "; ") + "; }"
if head != "" {
return head + " " + list
}
return list
}
// spaceElements splits list elements separated by spaces: "key NAME"
// is one element, and "port N" and "tls NAME" belong to the address
// before them (forwarders { 192.0.2.1 port 53; }).
func spaceElements(fields []string) []string {
var out []string
for i := 0; i < len(fields); i++ {
f := fields[i]
switch strings.ToLower(f) {
case "key", "!key", "!":
if i+1 < len(fields) {
f += " " + fields[i+1]
i++
}
if f[0] == '!' && strings.HasPrefix(strings.ToLower(f), "! key") && i+1 < len(fields) {
f += " " + fields[i+1]
i++
}
case "-":
// A range with spaces around the dash: 10.0.0.1 - 10.0.0.9.
if len(out) > 0 && i+1 < len(fields) {
out[len(out)-1] += " - " + fields[i+1]
i++
continue
}
case "port", "tls":
if len(out) > 0 && i+1 < len(fields) {
out[len(out)-1] += " " + f + " " + fields[i+1]
i++
continue
}
}
out = append(out, f)
}
return out
}
+25
View File
@@ -77,3 +77,28 @@ func TestCatalog(t *testing.T) {
t.Errorf("zone types = %s", types)
}
}
func TestWrapList(t *testing.T) {
c := Default()
for _, tc := range []struct{ scope, name, in, want string }{
{ScopeOptions, "allow-transfer", "10.0.10.53", "{ 10.0.10.53; }"},
{ScopeOptions, "allow-transfer", "localhost 10.0.10.53 key xfer ! key old !10.0.0.1", "{ localhost; 10.0.10.53; key xfer; ! key old; !10.0.0.1; }"},
{ScopeOptions, "forwarders", "1.1.1.1 port 853 tls cf 9.9.9.9", "{ 1.1.1.1 port 853 tls cf; 9.9.9.9; }"},
{ScopeOptions, "allow-transfer", "localhost; 10.0.10.53", "{ localhost; 10.0.10.53; }"},
{ScopeOptions, "allow-transfer", "port 5353 key xfer, !10.0.0.1", "port 5353 { key xfer; !10.0.0.1; }"},
{ScopeOptions, "allow-transfer", "{ any; }", "{ any; }"},
{ScopeOptions, "allow-query", "10.0.0.0/8 192.168.1.0/24", "{ 10.0.0.0/8; 192.168.1.0/24; }"},
{ScopeOptions, "forwarders", "1.1.1.1 9.9.9.9", "{ 1.1.1.1; 9.9.9.9; }"},
{ScopeOptions, "listen-on", "port 53 10.0.10.5", "port 53 { 10.0.10.5; }"},
{ScopeOptions, "recursion", "yes", "yes"},
{ScopeOptions, "directory", `"/var/cache/bind"`, `"/var/cache/bind"`},
} {
st, ok := c.Lookup(tc.scope, tc.name)
if !ok {
t.Fatalf("%s not found", tc.name)
}
if got := st.WrapList(tc.in); got != tc.want {
t.Errorf("%s %q = %q, want %q", tc.name, tc.in, got, tc.want)
}
}
}
+406
View File
@@ -0,0 +1,406 @@
package certs
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
"jarl/internal/acme"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
)
// Account is the ACME account certificates are obtained with. Its key is
// kept in Jarl's database (and so in backups), like TSIG secrets.
type Account struct {
Directory string `json:"directory"`
Email string `json:"email,omitempty"`
URI string `json:"uri,omitempty"` // the account at the CA
RegisteredAt time.Time `json:"registered_at"`
KeyPEM string `json:"key_pem,omitempty"`
}
const metaAccount = "acme.account"
// account returns the stored account (nil when there is none).
func (m *Manager) account(ctx context.Context) *Account {
raw, err := m.store.GetMeta(ctx, metaAccount)
if err != nil {
return nil
}
var a Account
if json.Unmarshal([]byte(raw), &a) != nil {
return nil
}
return &a
}
type legoUser struct {
email string
reg *registration.Resource
key crypto.PrivateKey
}
func (u *legoUser) GetEmail() string { return u.email }
func (u *legoUser) GetRegistration() *registration.Resource { return u.reg }
func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key }
// ErrTermsNotAgreed means acme.account.agree_tos isn't set.
var ErrTermsNotAgreed = errors.New("acme.account.agree_tos isn't set")
func (m *Manager) httpClient() (*http.Client, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
if m.cfg.CABundle != "" {
pem, err := os.ReadFile(m.cfg.CABundle)
if err != nil {
return nil, fmt.Errorf("acme.ca_bundle: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("acme.ca_bundle: no certificates in %s", m.cfg.CABundle)
}
tr.TLSClientConfig.RootCAs = pool
}
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
}
// client returns a lego client for the account (registering it when
// needed) that makes keys of keyType.
func (m *Manager) client(ctx context.Context, keyType string) (*lego.Client, error) {
dir := m.cfg.Directory
hc, err := m.httpClient()
if err != nil {
return nil, err
}
a := m.account(ctx)
user := &legoUser{email: m.cfg.Email}
if a != nil && a.Directory == dir && a.KeyPEM != "" {
if user.key, err = parseKey(a.KeyPEM); err != nil {
return nil, fmt.Errorf("the stored ACME account key: %w", err)
}
if a.URI != "" {
user.reg = &registration.Resource{URI: a.URI}
}
} else {
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
user.key = k
}
cfg := lego.NewConfig(user)
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "jarl/"+m.cfg.Version
cfg.Certificate.KeyType = legoKeyType(keyType)
cfg.Certificate.Timeout = 90 * time.Second
c, err := lego.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("ACME server %s: %w", dir, err)
}
if user.reg != nil && (a.Email == m.cfg.Email) {
return c, nil
}
if user.reg != nil {
// Only the email changed: update the account.
if reg, err := c.Registration.UpdateRegistration(registration.RegisterOptions{TermsOfServiceAgreed: true}); err == nil {
user.reg = reg
return c, m.saveAccount(ctx, user, dir)
}
}
if !m.cfg.AgreeTOS {
tos := c.GetToSURL()
if tos == "" {
tos = "on the CA's website"
}
return nil, fmt.Errorf("%w: Jarl registers an account with %s only after you accept its terms of service (%s): set acme.account.agree_tos: true in jarl.yaml and restart jarld", ErrTermsNotAgreed, dir, tos)
}
var reg *registration.Resource
if m.cfg.EABKeyID != "" {
reg, err = c.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{TermsOfServiceAgreed: true, Kid: m.cfg.EABKeyID, HmacEncoded: m.cfg.EABHMAC})
} else {
if c.GetExternalAccountRequired() {
return nil, fmt.Errorf("%s needs External Account Binding: set acme.account.eab_kid and eab_hmac in jarl.yaml (from the CA's dashboard)", dir)
}
reg, err = c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
}
if err != nil {
return nil, fmt.Errorf("registering the ACME account: %w", err)
}
user.reg = reg
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
return c, m.saveAccount(ctx, user, dir)
}
func (m *Manager) saveAccount(ctx context.Context, u *legoUser, dir string) error {
der, err := x509.MarshalPKCS8PrivateKey(u.key)
if err != nil {
return err
}
a := Account{Directory: dir, Email: u.email, URI: u.reg.URI, RegisteredAt: time.Now().UTC(),
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))}
b, _ := json.Marshal(a)
return m.store.SetMeta(ctx, metaAccount, string(b))
}
func parseKey(s string) (crypto.PrivateKey, error) {
b, _ := pem.Decode([]byte(s))
if b == nil {
return nil, errors.New("not PEM")
}
return x509.ParsePKCS8PrivateKey(b.Bytes)
}
func legoKeyType(t string) certcrypto.KeyType {
switch t {
case "ec384":
return certcrypto.EC384
case "rsa2048":
return certcrypto.RSA2048
case "rsa3072":
return certcrypto.RSA3072
case "rsa4096":
return certcrypto.RSA4096
}
return certcrypto.EC256
}
// KeyTypes are the key types a certificate Jarl obtains can have.
var KeyTypes = []string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}
// provider answers DNS-01 challenges for one issue: in a zone Jarl serves,
// or at Porkbun, as route decides for each name.
type provider struct {
m *Manager
ctx context.Context
cert *model.Certificate
mu sync.Mutex
cleanups map[string]func() error
routes []Route
}
func (p *provider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
st, err := p.m.store.LoadState(p.ctx)
if err != nil {
return err
}
r := p.m.route(p.ctx, st, p.cert.Challenge, domain, info.EffectiveFQDN)
p.mu.Lock()
p.routes = append(p.routes, r)
p.mu.Unlock()
if r.Error != "" {
return errors.New(r.Error)
}
key := info.EffectiveFQDN + " " + info.Value
var cleanup func() error
switch r.Route {
case model.ChallengeJarl:
if err := p.m.publish(p.ctx, p.cert, info.EffectiveFQDN, info.Value, true); err != nil {
return err
}
cleanup = func() error {
return p.m.publish(context.WithoutCancel(p.ctx), p.cert, info.EffectiveFQDN, info.Value, false)
}
case model.ChallengePorkbun:
zone := strings.TrimSuffix(r.Zone, ".")
id, err := p.m.cfg.Porkbun.CreateTXT(p.ctx, zone, strings.TrimSuffix(info.EffectiveFQDN, "."), info.Value)
if err != nil {
return fmt.Errorf("publishing the challenge for %s at Porkbun: %w", domain, err)
}
cleanup = func() error { return p.m.cfg.Porkbun.DeleteRecord(context.WithoutCancel(p.ctx), zone, id) }
}
p.mu.Lock()
p.cleanups[key] = cleanup
p.mu.Unlock()
return nil
}
func (p *provider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
key := info.EffectiveFQDN + " " + info.Value
p.mu.Lock()
f := p.cleanups[key]
delete(p.cleanups, key)
p.mu.Unlock()
if f == nil {
return nil
}
if err := f(); err != nil {
p.m.log.Warn("removing an ACME challenge record failed", "certificate", p.cert.Name, "record", info.EffectiveFQDN, "err", err)
return err
}
return nil
}
// Timeout is how long lego waits for the record to reach every name
// server of the zone: Porkbun's take a few minutes.
func (p *provider) Timeout() (timeout, interval time.Duration) {
if p.m.cfg.PropagationTimeout > 0 {
return p.m.cfg.PropagationTimeout, 2 * time.Second
}
return 10 * time.Minute, 10 * time.Second
}
// publish writes (or removes) a challenge TXT record in the Jarl zone that
// holds name, in every view that has it, and returns once named serves it.
func (m *Manager) publish(ctx context.Context, c *model.Certificate, name, value string, set bool) error {
st, err := m.store.LoadState(ctx)
if err != nil {
return err
}
zones := acme.Zones(name, primaryZones(st), "")
if len(zones) == 0 {
return fmt.Errorf("no primary zone in Jarl contains %s", strings.TrimSuffix(name, "."))
}
var reqs []engine.Request
for _, r := range acme.Requests(name, zones, []string{value}, set, acme.DefaultTTL) {
reqs = append(reqs, engine.Request{Op: r.Op, ID: r.ID, BaseVersion: r.BaseVersion, Data: r.Data})
}
desc := acme.Description(name, set) + " (certificate " + c.Name + ")"
if _, err := m.eng.Submit(ctx, reqs, auth.System, "acme", desc, engine.ApplyOptions{}); err != nil {
return fmt.Errorf("writing the challenge record %s: %w", strings.TrimSuffix(name, "."), err)
}
return nil
}
// obtain gets a new certificate for c from the CA. old is the certificate
// it replaces (nil for the first), so the CA can link them (ARI).
func (m *Manager) obtain(ctx context.Context, c *model.Certificate, old *x509.Certificate) (*certificate.Resource, []Route, error) {
m.legoMu.Lock()
defer m.legoMu.Unlock()
client, err := m.client(ctx, c.KeyType)
if err != nil {
return nil, nil, err
}
p := &provider{m: m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
opts := []dns01.ChallengeOption{dns01.AddRecursiveNameservers(m.cfg.Resolvers), dns01.AddDNSTimeout(10 * time.Second)}
if m.cfg.SkipPropagationCheck {
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
}
if err := client.Challenge.SetDNS01Provider(p, opts...); err != nil {
return nil, nil, err
}
req := certificate.ObtainRequest{Domains: c.Domains, Bundle: true}
if old != nil {
if id, err := certificate.MakeARICertID(old); err == nil {
req.ReplacesCertID = id
}
}
res, err := client.Certificate.Obtain(req)
if err != nil && req.ReplacesCertID != "" && len(p.routes) == 0 && ctx.Err() == nil {
// The CA refused the order itself, before any challenge: it may
// not link the new certificate to the old one (another CA, one
// already replaced, or its own bug). Ask for a plain new one.
m.log.Info("the CA refused to link the renewal to the old certificate; asking for a new one", "certificate", c.Name, "err", cleanLegoError(err))
req.ReplacesCertID = ""
res, err = client.Certificate.Obtain(req)
}
if err != nil {
return nil, p.routes, cleanLegoError(err)
}
return res, p.routes, nil
}
// renewalInfo asks the CA when to renew (ARI, RFC 9773). nil, nil when
// it doesn't say.
func (m *Manager) renewalInfo(ctx context.Context, c *model.Certificate, leaf *x509.Certificate) (*time.Time, error) {
m.legoMu.Lock()
defer m.legoMu.Unlock()
client, err := m.client(ctx, c.KeyType)
if err != nil {
return nil, err
}
ri, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{Cert: leaf})
if err != nil {
if strings.Contains(err.Error(), "does not advertise a renewal info endpoint") {
return nil, nil
}
return nil, err
}
// A time within the CA's window (now if it has started); never later
// than two thirds into the lifetime, in case the window is odd.
at := ri.ShouldRenewAt(time.Now(), 10*365*24*time.Hour)
if at == nil {
return nil, nil
}
t := at.UTC().Truncate(time.Second)
if limit := twoThirds(leaf.NotBefore, leaf.NotAfter); t.After(limit) {
t = limit
}
return &t, nil
}
func twoThirds(nb, na time.Time) time.Time {
return nb.Add(na.Sub(nb) * 2 / 3).UTC().Truncate(time.Second)
}
// cleanLegoError shortens lego's errors, which wrap each name's problem in
// several prefixes and the whole ACME problem document:
//
// error: one or more domains had a problem:
// [a.example.com] [a.example.com] acme: error presenting token: MSG
// [b.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG
//
// becomes "a.example.com: MSG; b.example.com: the CA refused (unauthorized): MSG".
func cleanLegoError(err error) error {
msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1))
var out []string
for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
domain := ""
for strings.HasPrefix(line, "[") {
end := strings.Index(line, "] ")
if end < 0 {
break
}
if domain == "" {
domain = line[1:end]
}
line = line[end+2:]
}
for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} {
line = strings.TrimPrefix(line, p)
}
if m := acmeProblemRe.FindStringSubmatch(line); m != nil {
line = "the CA refused (" + m[2] + "): " + m[3]
}
if domain != "" {
line = domain + ": " + line
}
out = append(out, line)
}
if len(out) == 0 {
return err
}
return errors.New(strings.Join(out, "; "))
}
// acmeProblemRe matches "403 :: urn:ietf:params:acme:error:unauthorized :: detail".
var acmeProblemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
+522
View File
@@ -0,0 +1,522 @@
package certs
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"io"
"log/slog"
"math/big"
"net"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/miekg/dns"
"jarl/internal/auth"
"jarl/internal/encdns"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/porkbun"
"jarl/internal/render"
"jarl/internal/store"
)
// fakeDNS answers like the internet would: zones with their public name
// servers, CNAME records, and TXT records (Porkbun's, from the fake API).
type fakeDNS struct {
mu sync.Mutex
zones map[string][]string // zone -> NS
cnames map[string]string
txt func(name string) []string
addr string
}
func newFakeDNS(t *testing.T) *fakeDNS {
f := &fakeDNS{zones: map[string][]string{}, cnames: map[string]string{}, txt: func(string) []string { return nil }}
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &dns.Server{PacketConn: pc, Handler: f}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
f.addr = pc.LocalAddr().String()
return f
}
func (f *fakeDNS) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
f.mu.Lock()
defer f.mu.Unlock()
m := new(dns.Msg)
m.SetReply(r)
q := r.Question[0]
name := strings.ToLower(q.Name)
zone := ""
for z := range f.zones {
if dns.IsSubDomain(z, name) && len(z) > len(zone) {
zone = z
}
}
if zone == "" {
m.Rcode = dns.RcodeNameError
w.WriteMsg(m)
return
}
soa := &dns.SOA{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 60}, Ns: f.zones[zone][0], Mbox: "h." + zone, Serial: 1, Refresh: 1, Retry: 1, Expire: 1, Minttl: 60}
switch {
case q.Qtype == dns.TypeSOA && name == zone:
m.Answer = append(m.Answer, soa)
case q.Qtype == dns.TypeNS && name == zone:
for _, ns := range f.zones[zone] {
m.Answer = append(m.Answer, &dns.NS{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 60}, Ns: ns})
}
case f.cnames[name] != "":
m.Answer = append(m.Answer, &dns.CNAME{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: f.cnames[name]})
case q.Qtype == dns.TypeTXT && len(f.txt(name)) > 0:
for _, v := range f.txt(name) {
m.Answer = append(m.Answer, &dns.TXT{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, Txt: []string{v}})
}
default:
m.Ns = append(m.Ns, soa)
}
w.WriteMsg(m)
}
type env struct {
t *testing.T
m *Manager
st *store.Store
eng *engine.Engine
dns *fakeDNS
pb *porkbun.Fake
dir string
}
// newEnv sets up Jarl with zone example.com (name server
// ns1.example.com, which the internet also uses), a fake internet and a
// fake Porkbun account with pb.test.
func newEnv(t *testing.T, withPorkbun bool) *env {
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "jarl.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
eng := engine.New(st, nil, engine.Config{
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
}, quiet)
e := &env{t: t, st: st, eng: eng, dns: newFakeDNS(t), dir: dir}
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com")}))
e.dns.mu.Lock()
e.dns.zones["example.com."] = []string{"ns1.example.net."}
e.dns.zones["pb.test."] = []string{"curitiba.ns.porkbun.com.", "fortaleza.ns.porkbun.com."}
e.dns.zones["other.test."] = []string{"a.ns.cloudflare.com."}
e.dns.mu.Unlock()
cfg := Config{Resolvers: []string{e.dns.addr}, Dir: filepath.Join(dir, "tls"), Directory: "https://acme.invalid/dir"}
if withPorkbun {
e.pb = porkbun.NewFake("pk1_test", "sk1_test", "pb.test")
ts := httptest.NewServer(e.pb)
t.Cleanup(ts.Close)
cfg.Porkbun = &porkbun.Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_test", Secret: "sk1_test"}
e.dns.mu.Lock()
e.dns.txt = func(name string) []string { return e.pb.TXT(name) }
e.dns.mu.Unlock()
}
e.m = New(cfg, st, eng, quiet)
// lego's own lookups (CNAME following) use the fake internet too.
dns01.AddRecursiveNameservers([]string{e.dns.addr})(nil)
return e
}
func (e *env) submit(reqs ...engine.Request) {
e.t.Helper()
if _, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{}); err != nil {
e.t.Fatal(err)
}
}
func (e *env) state() *model.State {
st, err := e.st.LoadState(context.Background())
if err != nil {
e.t.Fatal(err)
}
return st
}
func TestRoute(t *testing.T) {
e := newEnv(t, true)
ctx := context.Background()
e.dns.mu.Lock()
e.dns.cnames["_acme-challenge.www.other.test."] = "www-other.acme.example.com."
e.dns.mu.Unlock()
cases := []struct {
challenge, domain string
route string // "" for an error
target string
errHas string
}{
{"auto", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
{"auto", "*.example.com", "jarl", "_acme-challenge.example.com.", ""},
{"auto", "ns1.pb.test", "porkbun", "_acme-challenge.ns1.pb.test.", ""},
{"auto", "www.other.test", "jarl", "www-other.acme.example.com.", ""},
{"auto", "mail.other.test", "", "", "Point _acme-challenge.mail.other.test with a CNAME"},
{"porkbun", "www.example.com", "", "", "not Porkbun"},
{"jarl", "ns1.pb.test", "", "", "no primary zone in Jarl is pb.test"},
{"jarl", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
}
for _, c := range cases {
r := e.m.route(ctx, e.state(), c.challenge, c.domain, "")
if r.Route != c.route || c.target != "" && r.Target != c.target || c.errHas != "" && !strings.Contains(r.Error, c.errHas) {
t.Errorf("%s %s: route %q target %q error %q", c.challenge, c.domain, r.Route, r.Target, r.Error)
}
}
// The move from Porkbun to Jarl: while the internet still asks
// Porkbun, auto goes there even though Jarl has the zone; once the
// name servers change, it comes to Jarl.
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("pb.test")}))
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "porkbun" {
t.Errorf("before the move: %+v", r)
}
if r := e.m.route(ctx, e.state(), "jarl", "ns1.pb.test", ""); !strings.Contains(r.Error, "not by the name servers in Jarl's zone (ns1.example.net)") {
t.Errorf("jarl before the move: %+v", r)
}
e.dns.mu.Lock()
e.dns.zones["pb.test."] = []string{"ns1.example.net."}
e.dns.mu.Unlock()
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "jarl" {
t.Errorf("after the move: %+v", r)
}
if r := e.m.route(ctx, e.state(), "porkbun", "ns1.pb.test", ""); !strings.Contains(r.Error, "not Porkbun") {
t.Errorf("porkbun after the move: %+v", r)
}
// Without a Porkbun key, a Porkbun domain says what is missing.
e2 := newEnv(t, false)
if r := e2.m.route(ctx, e2.state(), "auto", "ns1.pb.test", ""); !strings.Contains(r.Error, "no porkbun.api_key") {
t.Errorf("without a key: %+v", r)
}
}
func TestProvider(t *testing.T) {
e := newEnv(t, true)
ctx := context.Background()
c := &model.Certificate{Name: "t", Challenge: "auto"}
p := &provider{m: e.m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
// Porkbun: the record goes in through the API and is removed again.
info := dns01.GetChallengeInfo("ns1.pb.test", "key-auth-1")
if err := p.Present("ns1.pb.test", "tok", "key-auth-1"); err != nil {
t.Fatal(err)
}
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 1 || got[0] != info.Value {
t.Fatalf("Porkbun TXT = %v, want %s", got, info.Value)
}
if err := p.CleanUp("ns1.pb.test", "tok", "key-auth-1"); err != nil {
t.Fatal(err)
}
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 0 {
t.Fatalf("Porkbun TXT after cleanup = %v", got)
}
// Jarl: the record goes into the zone, in a changeset by the system.
txt := func() []string {
var out []string
st := e.state()
for _, r := range st.Records {
if r.Type == "TXT" && r.Name == "_acme-challenge.www.example.com." {
out = append(out, r.Data)
}
}
return out
}
info = dns01.GetChallengeInfo("www.example.com", "key-auth-2")
if err := p.Present("www.example.com", "tok", "key-auth-2"); err != nil {
t.Fatal(err)
}
if got := txt(); len(got) != 1 || !strings.Contains(got[0], info.Value) {
t.Fatalf("Jarl TXT = %v, want %s", got, info.Value)
}
if err := p.CleanUp("www.example.com", "tok", "key-auth-2"); err != nil {
t.Fatal(err)
}
if got := txt(); len(got) != 0 {
t.Fatalf("Jarl TXT after cleanup = %v", got)
}
if len(p.routes) != 2 || p.routes[0].Route != "porkbun" || p.routes[1].Route != "jarl" {
t.Errorf("routes = %+v", p.routes)
}
// A name nobody can answer for fails before anything is published.
if err := p.Present("mail.other.test", "tok", "k"); err == nil || !strings.Contains(err.Error(), "CNAME") {
t.Errorf("other.test: %v", err)
}
}
// makeCert returns a certificate for names (signed by a CA unless
// selfSigned) and its key, as PEM; withCA appends the CA certificate.
func makeCert(t *testing.T, names []string, notBefore, notAfter time.Time, withCA bool) (certPEM, keyPEM []byte) {
t.Helper()
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA", Organization: []string{"Jarl"}},
NotBefore: notBefore, NotAfter: notAfter.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
caDER, _ := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey)
caCert, _ := x509.ParseCertificate(caDER)
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
leaf := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
NotBefore: notBefore, NotAfter: notAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
der, err := x509.CreateCertificate(rand.Reader, leaf, caCert, &key.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if withCA {
certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...)
}
kd, _ := x509.MarshalPKCS8PrivateKey(key)
return certPEM, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd})
}
func TestInspect(t *testing.T) {
now := time.Now()
cert, key := makeCert(t, []string{"ns1.example.com", "*.example.com"}, now, now.Add(90*24*time.Hour), true)
in, pair, err := Inspect(cert, key)
if err != nil || pair == nil {
t.Fatal(err)
}
if strings.Join(in.Names, " ") != "ns1.example.com *.example.com" || in.Issuer != "Jarl Test CA" || in.KeyType != "ec256" || len(in.Warnings) != 0 {
t.Errorf("info = %+v", in)
}
leafOnly, _ := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
_, otherKey := makeCert(t, []string{"b.example.com"}, now, now.Add(time.Hour), false)
if _, _, err := Inspect(cert, otherKey); err == nil || !strings.Contains(err.Error(), "doesn't belong") {
t.Errorf("mismatched key: %v", err)
}
leafCert, leafKey := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
if in, _, err := Inspect(leafCert, leafKey); err != nil || len(in.Warnings) != 1 || !strings.Contains(in.Warnings[0], "full chain") {
t.Errorf("leaf only: %+v %v", in, err)
}
_ = leafOnly
if !Covers([]string{"*.example.com"}, []string{"www.example.com"}) || Covers([]string{"*.example.com"}, []string{"a.b.example.com"}) || Covers([]string{"example.com"}, []string{"www.example.com"}) {
t.Error("Covers")
}
}
func TestDueAndStatus(t *testing.T) {
m := &Manager{now: time.Now}
now := time.Now().UTC().Truncate(time.Second)
nb, na := now.Add(-80*24*time.Hour), now.Add(10*24*time.Hour)
c := &model.Certificate{Source: model.CertSourceACME, CreatedAt: now.Add(-time.Hour)}
if got := m.Due(c); !got.Equal(c.CreatedAt) {
t.Errorf("never obtained: due %v", got)
}
if s := m.StatusOf(c); s.State != "pending" {
t.Errorf("pending: %+v", s)
}
c.CertPEM, c.NotBefore, c.NotAfter = "x", &nb, &na
if got, want := m.Due(c), twoThirds(nb, na); !got.Equal(want) {
t.Errorf("due %v, want two thirds %v", got, want)
}
ari := now.Add(time.Hour)
c.RenewAt = &ari
if got := m.Due(c); !got.Equal(ari) {
t.Errorf("due %v, want the CA's %v", got, ari)
}
// 10 days left of 90 is less than a sixth: expiring.
if s := m.StatusOf(c); s.State != "expiring" {
t.Errorf("status %+v", s)
}
// After three failures it waits four hours from the last attempt.
last := now.Add(-time.Hour)
c.Failures, c.LastAttempt, c.LastError = 3, &last, "boom"
if got := m.Due(c); !got.Equal(last.Add(4 * time.Hour)) {
t.Errorf("backoff: due %v", got)
}
if s := m.StatusOf(c); s.State != "failing" || !strings.Contains(s.Message, "boom") {
t.Errorf("failing: %+v", s)
}
c.Failures = 30
if got := m.Due(c); !got.Equal(last.Add(24 * time.Hour)) {
t.Errorf("backoff is at most a day: due %v", got)
}
old := now.Add(-time.Hour)
c.NotAfter = &old
if s := m.StatusOf(c); s.State != "expired" {
t.Errorf("expired: %+v", s)
}
}
func TestInput(t *testing.T) {
e := newEnv(t, false)
ctx := context.Background()
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
bad := []Input{
{Name: model.Ptr("Bad Name"), Domains: &[]string{"a.example.com"}},
{Name: model.Ptr("x"), Domains: &[]string{"localhost"}},
{Name: model.Ptr("x"), Domains: &[]string{"a.*.example.com"}},
{Name: model.Ptr("x"), Domains: &[]string{}},
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"mail"}},
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Challenge: model.Ptr("http")},
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, KeyType: model.Ptr("rsa1024")},
{Name: model.Ptr("x"), Source: model.Ptr("file"), CertFile: model.Ptr("rel.pem"), KeyFile: model.Ptr("/k.pem")},
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, CertFile: model.Ptr("/c.pem")},
}
for _, in := range bad {
if _, err := e.m.Create(ctx, in, admin); err == nil {
t.Errorf("accepted %+v", in)
}
}
c, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"WWW.Example.com.", "www.example.com", "*.example.com"}, Uses: &[]string{"web", "dns", "web"}}, admin)
if err != nil {
t.Fatal(err)
}
if strings.Join(c.Domains, " ") != "www.example.com *.example.com" || strings.Join(c.Uses, ",") != "dns,web" || c.Challenge != "auto" || c.KeyType != "ec256" {
t.Errorf("normalized: %+v", c)
}
// Only one certificate serves jarld's HTTPS.
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web2"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"web"}}, admin); err == nil || !strings.Contains(err.Error(), "already used for jarld's HTTPS") {
t.Errorf("second web certificate: %v", err)
}
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"a.example.com"}}, admin); err != ErrConflict {
t.Errorf("duplicate name: %v", err)
}
if _, err := e.m.Update(ctx, "web", Input{Name: model.Ptr("renamed")}, admin); err == nil {
t.Error("rename accepted")
}
}
func TestFileSource(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, "fullchain.pem"), filepath.Join(e.dir, "privkey.pem")
cert, key := makeCert(t, []string{"ns1.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
os.WriteFile(certFile, cert, 0o600)
os.WriteFile(keyFile, key, 0o600)
c, err := e.m.Create(ctx, Input{Name: model.Ptr("own"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"web", "dns"}}, admin)
if err != nil {
t.Fatal(err)
}
c, _ = e.m.Get(ctx, "own")
if strings.Join(c.Domains, " ") != "ns1.example.com" || c.NotAfter == nil || c.CertPEM != "" {
t.Errorf("read: %+v", c)
}
if e.m.WebCertificate() == nil || e.m.WebName() != "own" {
t.Error("not served for HTTPS")
}
chain := filepath.Join(e.m.FilesDir("own"), ChainFile)
if b, err := os.ReadFile(chain); err != nil || string(b) != string(cert) {
t.Fatalf("files for named: %v", err)
}
if fi, err := os.Stat(filepath.Join(e.m.FilesDir("own"), KeyFile)); err != nil || fi.Mode().Perm() != 0o640 {
t.Errorf("key file: %v %v", fi, err)
}
// Renewed by its owner: the loop notices and puts the new one in place.
cert2, key2 := makeCert(t, []string{"ns1.example.com", "dns.example.com"}, now, now.Add(90*24*time.Hour), true)
os.WriteFile(certFile, cert2, 0o600)
os.WriteFile(keyFile, key2, 0o600)
future := now.Add(time.Minute)
os.Chtimes(certFile, future, future)
e.m.Tick(ctx)
c, _ = e.m.Get(ctx, "own")
if strings.Join(c.Domains, " ") != "ns1.example.com dns.example.com" {
t.Errorf("not reread: %v", c.Domains)
}
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
t.Error("files for named not updated")
}
if got := e.m.WebCertificate(); got == nil || len(got.Leaf.DNSNames) != 2 {
t.Error("HTTPS still serves the old certificate")
}
// A broken renewal keeps the last good certificate in place.
os.WriteFile(keyFile, key, 0o600)
os.Chtimes(keyFile, future.Add(time.Minute), future.Add(time.Minute))
e.m.Tick(ctx)
c, _ = e.m.Get(ctx, "own")
if !strings.Contains(c.LastError, "doesn't belong") || e.m.StatusOf(c).State != "failing" {
t.Errorf("mismatch not reported: %+v", c)
}
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
t.Error("files for named replaced by a broken pair")
}
// Without the dns use the files go; deleting stops HTTPS.
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
// The pair is broken now; put the good key back first.
t.Log(err)
}
os.WriteFile(keyFile, key2, 0o600)
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(e.m.FilesDir("own")); !os.IsNotExist(err) {
t.Errorf("files for named left behind: %v", err)
}
if err := e.m.Delete(ctx, "own", admin); err != nil {
t.Fatal(err)
}
if e.m.WebCertificate() != nil {
t.Error("still serving a deleted certificate")
}
}
func TestCleanLegoError(t *testing.T) {
in := errors.New("error: one or more domains had a problem:\n[www.example.org] [www.example.org] acme: error presenting token: example.org is served by x, which Jarl can't write to\n[a.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: Incorrect TXT record \"abc\" found at _acme-challenge.a.example.com\n")
want := `www.example.org: example.org is served by x, which Jarl can't write to; a.example.com: the CA refused (unauthorized): Incorrect TXT record "abc" found at _acme-challenge.a.example.com`
if got := cleanLegoError(in).Error(); got != want {
t.Errorf("got %s\nwant %s", got, want)
}
if got := cleanLegoError(errors.New("acme: error: 429 :: urn:ietf:params:acme:error:rateLimited :: too many")).Error(); got != "the CA refused (rateLimited): too many" {
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)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
// Package certs manages TLS certificates (docs/certificates.md): ones Jarl
// obtains and renews itself over ACME, answering the DNS-01 challenges in
// its own zones or at Porkbun, and ones you renew yourself that Jarl only
// reads. A certificate is used for jarld's HTTPS ("web") and/or written
// where named can read it ("dns", for DNS over TLS and HTTPS).
package certs
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"strings"
"time"
)
// Info is what a certificate says about itself.
type Info struct {
Names []string
NotBefore time.Time
NotAfter time.Time
Issuer string
Serial string
KeyType string // ec256, ec384, rsa2048, ...
Leaf *x509.Certificate
// Warnings are problems that don't stop it working everywhere (a
// missing intermediate certificate).
Warnings []string
}
// Inspect checks that certPEM (the certificate, then its intermediates)
// and keyPEM belong together and returns what the certificate says, and
// the pair ready to serve.
func Inspect(certPEM, keyPEM []byte) (*Info, *tls.Certificate, error) {
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "private key does not match public key"):
return nil, nil, errors.New("the key doesn't belong to the certificate")
case strings.Contains(msg, "failed to find any PEM data in certificate"):
return nil, nil, errors.New("no certificate found (expected PEM: -----BEGIN CERTIFICATE-----)")
case strings.Contains(msg, "failed to find any PEM data in key"), strings.Contains(msg, "failed to find PEM block with type ending in \"PRIVATE KEY\""):
return nil, nil, errors.New("no private key found (expected PEM: -----BEGIN ... PRIVATE KEY-----)")
}
return nil, nil, err
}
leaf := pair.Leaf
if leaf == nil {
if leaf, err = x509.ParseCertificate(pair.Certificate[0]); err != nil {
return nil, nil, err
}
pair.Leaf = leaf
}
in := &Info{NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, Serial: fmt.Sprintf("%x", leaf.SerialNumber), Leaf: leaf, KeyType: keyType(leaf)}
in.Names = append(in.Names, leaf.DNSNames...)
for _, ip := range leaf.IPAddresses {
in.Names = append(in.Names, ip.String())
}
if len(in.Names) == 0 && leaf.Subject.CommonName != "" {
in.Names = []string{leaf.Subject.CommonName}
}
in.Issuer = leaf.Issuer.CommonName
if len(leaf.Issuer.Organization) > 0 {
if in.Issuer == "" {
in.Issuer = leaf.Issuer.Organization[0]
} else {
in.Issuer = leaf.Issuer.Organization[0] + " " + in.Issuer
}
}
selfSigned := leaf.Issuer.String() == leaf.Subject.String()
if len(pair.Certificate) == 1 && !selfSigned {
in.Warnings = append(in.Warnings, "the file holds only the certificate, without its intermediate certificate: many clients will reject it; use the full chain (fullchain.pem)")
}
return in, &pair, nil
}
func keyType(c *x509.Certificate) string {
switch k := c.PublicKey.(type) {
case *ecdsa.PublicKey:
return fmt.Sprintf("ec%d", k.Curve.Params().BitSize)
case *rsa.PublicKey:
return fmt.Sprintf("rsa%d", k.N.BitLen())
case ed25519.PublicKey:
return "ed25519"
}
return "unknown"
}
// Covers reports whether the certificate names cover every name in want
// (a wildcard covers one label).
func Covers(names, want []string) bool {
for _, w := range want {
if !covered(names, w) {
return false
}
}
return true
}
func covered(names []string, want string) bool {
want = strings.ToLower(strings.TrimSuffix(want, "."))
for _, n := range names {
n = strings.ToLower(n)
if n == want {
return true
}
if base, ok := strings.CutPrefix(n, "*."); ok && !strings.HasPrefix(want, "*.") {
if _, rest, ok := strings.Cut(want, "."); ok && rest == base {
return true
}
}
}
return false
}
+128
View File
@@ -0,0 +1,128 @@
package certs
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/miekg/dns"
)
// resolver asks recursive resolvers (acme.resolvers: public ones by
// default), to see a domain as the CA does rather than as this server's
// own views answer it.
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.SetEdns0(1232, false)
m.RecursionDesired = true
timeout := r.timeout
if timeout == 0 {
timeout = 5 * time.Second
}
var errs []error
for _, s := range r.servers {
c := &dns.Client{Timeout: timeout}
in, _, err := c.ExchangeContext(ctx, m, s)
if err == nil && in.Truncated {
c.Net = "tcp"
in, _, err = c.ExchangeContext(ctx, m, s)
}
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", s, err))
continue
}
if in.Rcode == dns.RcodeServerFailure || in.Rcode == dns.RcodeRefused {
errs = append(errs, fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode]))
continue
}
return in, nil
}
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], errors.Join(errs...))
}
// zoneOf returns the zone that name is in on the internet (the owner of
// the SOA record that answers for it).
func (r resolver) zoneOf(ctx context.Context, name string) (string, error) {
name = dns.Fqdn(strings.ToLower(name))
for cand := name; ; {
in, err := r.query(ctx, cand, dns.TypeSOA)
if err != nil {
return "", err
}
for _, sec := range [][]dns.RR{in.Answer, in.Ns} {
for _, rr := range sec {
if soa, ok := rr.(*dns.SOA); ok && dns.IsSubDomain(soa.Hdr.Name, name) {
return strings.ToLower(soa.Hdr.Name), nil
}
}
}
i, end := dns.NextLabel(cand, 0)
if end {
return "", fmt.Errorf("no zone found for %s", strings.TrimSuffix(name, "."))
}
cand = cand[i:]
}
}
// nameServers returns the zone's name servers on the internet (lowercase,
// without trailing dots, sorted).
func (r resolver) nameServers(ctx context.Context, zone string) ([]string, error) {
in, err := r.query(ctx, zone, dns.TypeNS)
if err != nil {
return nil, err
}
var out []string
for _, rr := range in.Answer {
if ns, ok := rr.(*dns.NS); ok && strings.EqualFold(ns.Hdr.Name, dns.Fqdn(zone)) {
out = append(out, normName(ns.Ns))
}
}
if len(out) == 0 {
return nil, fmt.Errorf("%s has no name servers on the internet (%s)", strings.TrimSuffix(zone, "."), dns.RcodeToString[in.Rcode])
}
slices.Sort(out)
return slices.Compact(out), nil
}
// follow returns where name leads after its CNAME records (name itself
// when it has none).
func (r resolver) follow(ctx context.Context, name string) (string, error) {
name = dns.Fqdn(strings.ToLower(name))
for range 10 {
in, err := r.query(ctx, name, dns.TypeCNAME)
if err != nil {
return "", err
}
next := ""
for _, rr := range in.Answer {
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(c.Hdr.Name, name) {
next = strings.ToLower(c.Target)
}
}
if next == "" {
return name, nil
}
name = next
}
return "", fmt.Errorf("%s: more than 10 CNAME records in a row", name)
}
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
// challengeName is the DNS-01 record name for a certificate name (a
// wildcard shares its base name's).
func challengeName(domain string) string {
return "_acme-challenge." + dns.Fqdn(strings.TrimPrefix(strings.ToLower(domain), "*."))
}
File diff suppressed because it is too large. Load diff
+146
View File
@@ -0,0 +1,146 @@
package certs
import (
"context"
"fmt"
"slices"
"strings"
"jarl/internal/acme"
"jarl/internal/model"
)
// Route says where the DNS-01 challenge for one certificate name goes,
// and why.
type Route struct {
Domain string `json:"domain"` // the name on the certificate
Record string `json:"record"` // _acme-challenge.<domain>
// Target is where the TXT record is written: Record, or where its
// CNAME points.
Target string `json:"target"`
Route string `json:"route,omitempty"` // jarl or porkbun; "" when there is none
Zone string `json:"zone,omitempty"` // the zone on the internet that holds Target
PublicNS []string `json:"public_ns,omitempty"`
Why string `json:"why"`
Error string `json:"error,omitempty"`
}
func (r Route) String() string {
s := r.Domain + ": " + r.Route
if r.Target != r.Record {
s += " via CNAME " + strings.TrimSuffix(r.Target, ".")
}
return s + " (zone " + strings.TrimSuffix(r.Zone, ".") + ")"
}
func isPorkbunNS(ns []string) bool {
for _, n := range ns {
if strings.HasSuffix(n, ".porkbun.com") {
return true
}
}
return false
}
// route decides where the challenge for domain goes. challenge is the
// certificate's setting: auto picks from the zone's public name servers.
// target is the record name after CNAMEs ("" to look it up).
func (m *Manager) route(ctx context.Context, st *model.State, challenge, domain, target string) Route {
r := Route{Domain: domain, Record: challengeName(domain)}
fail := func(format string, a ...any) Route {
r.Error = fmt.Sprintf(format, a...)
return r
}
if target == "" {
t, err := m.res.follow(ctx, r.Record)
if err != nil {
return fail("%v", err)
}
target = t
}
r.Target = target
zone, err := m.res.zoneOf(ctx, target)
if err != nil {
return fail("%v", err)
}
r.Zone = zone
ns, err := m.res.nameServers(ctx, zone)
if err != nil {
return fail("%v", err)
}
r.PublicNS = ns
zoneName, nsList := strings.TrimSuffix(zone, "."), strings.Join(ns, ", ")
// Jarl can answer when it has the zone and the internet asks the name
// servers listed in it.
var jarlNS []string
jarlHas := false
for _, z := range st.SortedZones() {
if z.Type != model.ZonePrimary || z.Disabled || normName(z.Name) != normName(zone) {
continue
}
jarlHas = true
for _, rec := range st.ZoneRecords(z.ID) {
if rec.Type == "NS" && !rec.Disabled && normName(rec.Name) == normName(zone) {
jarlNS = append(jarlNS, normName(rec.Data))
}
}
}
slices.Sort(jarlNS)
jarlNS = slices.Compact(jarlNS)
servedByJarl := jarlHas && slices.ContainsFunc(ns, func(n string) bool { return slices.Contains(jarlNS, n) })
porkbunOK := m.cfg.Porkbun != nil && (m.cfg.PorkbunAllows == nil || m.cfg.PorkbunAllows(zoneName))
porkbun := func() Route {
r.Route, r.Why = model.ChallengePorkbun, fmt.Sprintf("%s is served by Porkbun (%s), so the record goes in through Porkbun's API", zoneName, nsList)
return r
}
jarl := func() Route {
r.Route, r.Why = model.ChallengeJarl, fmt.Sprintf("%s is served by this server's name servers (%s), so Jarl writes the record itself", zoneName, nsList)
return r
}
switch challenge {
case model.ChallengePorkbun:
switch {
case m.cfg.Porkbun == nil:
return fail("the challenge is set to porkbun, but jarl.yaml has no porkbun.api_key")
case !porkbunOK:
return fail("the challenge is set to porkbun, but %s isn't in porkbun.domains in jarl.yaml", zoneName)
case !isPorkbunNS(ns):
return fail("the challenge is set to porkbun, but on the internet %s is served by %s, not Porkbun: the CA would never see the record (set the challenge to auto)", zoneName, nsList)
}
return porkbun()
case model.ChallengeJarl:
switch {
case !jarlHas:
return fail("the challenge is set to jarl, but no primary zone in Jarl is %s (where %s is on the internet)", zoneName, strings.TrimSuffix(target, "."))
case !servedByJarl:
return fail("the challenge is set to jarl, but on the internet %s is served by %s, not by the name servers in Jarl's zone (%s): the CA would never see the record (set the challenge to auto)", zoneName, nsList, strings.Join(jarlNS, ", "))
}
return jarl()
}
switch {
case isPorkbunNS(ns) && porkbunOK:
return porkbun()
case servedByJarl:
return jarl()
case isPorkbunNS(ns) && m.cfg.Porkbun == nil:
return fail("%s is served by Porkbun (%s) and jarl.yaml has no porkbun.api_key. Add the key, or point %s with a CNAME at a name in a zone this server serves on the internet", zoneName, nsList, strings.TrimSuffix(r.Record, "."))
case isPorkbunNS(ns):
return fail("%s is served by Porkbun (%s) but isn't in porkbun.domains in jarl.yaml", zoneName, nsList)
case jarlHas:
return fail("on the internet %s is served by %s, not by the name servers in Jarl's zone (%s), so the CA would never see a record Jarl writes. Delegate the zone to this server, or point %s with a CNAME at a name in a zone it serves", zoneName, nsList, strings.Join(jarlNS, ", "), strings.TrimSuffix(r.Record, "."))
}
return fail("%s is served by %s, which Jarl can't write to. Point %s with a CNAME at a name in a zone this server serves on the internet (for example %s.acme.<your domain>, in a zone acme.<your domain> delegated here): Jarl follows the CNAME and answers there", zoneName, nsList, strings.TrimSuffix(r.Record, "."), strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(r.Record, "_acme-challenge."), "."), ".", "-"))
}
// primaryZones lists the zones Jarl can write challenge records into.
func primaryZones(st *model.State) []acme.Zone {
var out []acme.Zone
for _, z := range st.SortedZones() {
if z.Type == model.ZonePrimary && !z.Disabled {
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z), Link: z.LinkGroup})
}
}
return out
}
+34 -5
View File
@@ -30,8 +30,17 @@ type Client struct {
// Changeset, if set, stages changes into this changeset instead of
// applying them.
Changeset int64
DryRun bool
Force bool
// CheckChangeset, if set, runs once before Changeset is first used,
// and its error stops the request. The CLI checks a changeset taken
// from JARL_CHANGESET with it.
CheckChangeset func(ctx context.Context) error
csChecked bool
csErr error
DryRun bool
Force bool
// AcceptRisk confirms changes that open the server up
// (docs/exposure.md).
AcceptRisk bool
// Preflight lets only requests through that can't change anything:
// reads and dry runs. Running a command on several servers uses it to
// check the command everywhere before running it anywhere.
@@ -243,6 +252,19 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values, out any
return c.Do(ctx, http.MethodGet, path, query, nil, out)
}
// UseChangeset returns the changeset to stage into (0 for none), once
// CheckChangeset has accepted it.
func (c *Client) UseChangeset(ctx context.Context) (int64, error) {
if c.Changeset == 0 {
return 0, nil
}
if c.CheckChangeset != nil && !c.csChecked {
c.csChecked = true
c.csErr = c.CheckChangeset(ctx)
}
return c.Changeset, c.csErr
}
// Mutate sends a configuration change, honoring Changeset, DryRun and
// Force.
func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) {
@@ -250,8 +272,12 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
for k, v := range query {
q[k] = v
}
if c.Changeset > 0 {
q.Set("changeset", fmt.Sprint(c.Changeset))
cs, err := c.UseChangeset(ctx)
if err != nil {
return &api.MutationResponse{}, err
}
if cs > 0 {
q.Set("changeset", fmt.Sprint(cs))
}
if c.DryRun {
q.Set("dry_run", "1")
@@ -259,8 +285,11 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
if c.Force {
q.Set("force", "1")
}
if c.AcceptRisk {
q.Set("accept_risk", "1")
}
var out api.MutationResponse
err := c.Do(ctx, method, path, q, body, &out)
err = c.Do(ctx, method, path, q, body, &out)
return &out, err
}
+234 -14
View File
@@ -4,6 +4,7 @@ package config
import (
"bytes"
"fmt"
"net"
"net/netip"
"net/url"
"os"
@@ -38,6 +39,71 @@ type Config struct {
ACME ACME `yaml:"acme"`
Porkbun Porkbun `yaml:"porkbun"`
Blocking Blocking `yaml:"blocking"`
Metrics Metrics `yaml:"metrics"`
// Certificates configures TLS certificates Jarl manages
// (docs/certificates.md); the ACME account is under acme.
Certificates Certificates `yaml:"certificates"`
}
// Certificates configures the certificates Jarl manages.
type Certificates struct {
// Dir is where certificates are written for named (DNS over TLS and
// 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).
// A scrape is allowed from an address in Allow, or with Token as a bearer
// token; root and jarld's own user may also scrape on the local socket.
type Metrics struct {
Enabled bool `yaml:"enabled"`
// Listen are extra addresses that serve only /metrics, over plain
// HTTP. /metrics is always served on the main listeners too.
Listen []string `yaml:"listen"`
// Allow are the addresses or prefixes that may scrape without a token.
Allow []string `yaml:"allow"`
// Token, when set, lets any address that sends it as
// "Authorization: Bearer TOKEN" scrape.
Token string `yaml:"token"`
}
func (m *Metrics) check() error {
if !m.Enabled {
return nil
}
for _, a := range m.Listen {
if _, port, err := net.SplitHostPort(a); err != nil || port == "" {
return fmt.Errorf("listen %q must be ADDRESS:PORT, e.g. 0.0.0.0:9450", a)
}
}
for _, p := range m.Allow {
if _, err := ParsePrefix(p); err != nil {
return fmt.Errorf("allow: %v", err)
}
}
if m.Token != "" && (len(m.Token) < 16 || strings.ContainsAny(m.Token, " \t\r\n")) {
return fmt.Errorf("token must be at least 16 characters, without spaces (e.g. from 'openssl rand -hex 24')")
}
if len(m.Allow) == 0 && m.Token == "" {
return fmt.Errorf("set allow, token or both, or nobody can scrape")
}
return nil
}
// AllowPrefixes returns the parsed allow list.
func (m *Metrics) AllowPrefixes() []netip.Prefix {
var out []netip.Prefix
for _, p := range m.Allow {
if pf, err := ParsePrefix(p); err == nil {
out = append(out, pf.Masked())
}
}
return out
}
// Blocking configures blocklist downloads (docs/blocking.md). The lists
@@ -132,9 +198,85 @@ type Backup struct {
WarnAfter Duration `yaml:"warn_after"`
}
// ACME configures the ACME helpers.
// ACME configures the ACME helpers and the account Jarl uses to obtain
// certificates itself.
type ACME struct {
HTTPReq HTTPReq `yaml:"httpreq"`
HTTPReq HTTPReq `yaml:"httpreq"`
Account ACMEAccount `yaml:"account"`
// Resolvers are the recursive resolvers used to see a domain as the
// CA does: its zone, its public name servers and any CNAME on
// _acme-challenge. Public resolvers by default, because this server's
// own answers may come from an internal view.
Resolvers []string `yaml:"resolvers"`
// CABundle is a PEM file of extra CA certificates to trust when
// talking to the ACME server (a private CA such as step-ca).
CABundle string `yaml:"ca_bundle"`
// CheckPropagation waits until every name server of the zone serves
// a challenge before telling the CA to look.
CheckPropagation bool `yaml:"check_propagation"`
}
// ACMEAccount is the ACME account certificates are obtained with.
type ACMEAccount struct {
// Directory is letsencrypt, letsencrypt-staging or an ACME directory URL.
Directory string `yaml:"directory"`
Email string `yaml:"email"`
// AgreeTOS says you accept the CA's terms of service. Jarl doesn't
// register an account without it.
AgreeTOS bool `yaml:"agree_tos"`
// EABKeyID and EABHMAC are External Account Binding credentials, for
// CAs that need them (ZeroSSL, Google).
EABKeyID string `yaml:"eab_kid"`
EABHMAC string `yaml:"eab_hmac"`
}
// Directory URLs for the names acme.account.directory accepts.
const (
LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory"
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
)
// DirectoryURL is the account's ACME directory URL.
func (a ACMEAccount) DirectoryURL() string {
switch a.Directory {
case "", "letsencrypt":
return LetsEncrypt
case "letsencrypt-staging":
return LetsEncryptStaging
}
return a.Directory
}
func (a *ACME) check() error {
if d := a.Account.Directory; d != "" && d != "letsencrypt" && d != "letsencrypt-staging" {
u, err := url.Parse(d)
if err != nil || u.Scheme != "https" || u.Host == "" {
return fmt.Errorf("account.directory must be letsencrypt, letsencrypt-staging or an https:// URL")
}
}
if a.Account.Email != "" && !strings.Contains(a.Account.Email, "@") {
return fmt.Errorf("account.email %q isn't an email address", a.Account.Email)
}
if (a.Account.EABKeyID == "") != (a.Account.EABHMAC == "") {
return fmt.Errorf("account: set both eab_kid and eab_hmac, or neither")
}
for _, r := range a.Resolvers {
if _, _, err := net.SplitHostPort(r); err != nil {
return fmt.Errorf("resolvers: %q must be address:port, e.g. 1.1.1.1:53 or [2606:4700:4700::1111]:53", r)
}
}
if a.CABundle != "" && !filepath.IsAbs(a.CABundle) {
return fmt.Errorf("ca_bundle must be an absolute path")
}
return nil
}
// CertDir is certificates.dir, or <bind.config_dir>/tls.
func (c *Config) CertDir() string {
if c.Certificates.Dir != "" {
return c.Certificates.Dir
}
return filepath.Join(c.BIND.ConfigDir, "tls")
}
// HTTPReq configures the endpoint for lego's httpreq DNS provider
@@ -226,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"`
@@ -253,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
@@ -313,16 +462,20 @@ 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,
},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
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},
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
}
}
@@ -394,6 +547,15 @@ func (c *Config) check() error {
if err := c.Blocking.check(); err != nil {
return fmt.Errorf("blocking: %w", err)
}
if err := c.Metrics.check(); err != nil {
return fmt.Errorf("metrics: %w", err)
}
if err := c.ACME.check(); err != nil {
return fmt.Errorf("acme: %w", err)
}
if d := c.Certificates.Dir; d != "" && !filepath.IsAbs(d) {
return fmt.Errorf("certificates.dir must be an absolute path")
}
switch c.Log.Format {
case "text", "json":
default:
@@ -450,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")
}
@@ -497,8 +662,10 @@ const Example = `# Jarl daemon configuration (/etc/jarl/jarl.yaml).
listen:
- "127.0.0.1:8053"
# HTTPS. The certificate is reread when the file changes (or on SIGHUP),
# so renewed Let's Encrypt certificates are picked up automatically.
# HTTPS from a certificate file you renew yourself. The certificate is
# reread when the file changes (or on SIGHUP). Easier: let Jarl obtain one
# ('jarl cert create ... --use web', docs/certificates.md), which then
# takes over from this.
tls:
cert_file: ""
key_file: ""
@@ -585,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
@@ -594,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
@@ -612,15 +784,44 @@ acme:
# this server. Leave false if a reverse proxy runs on this server but
# isn't in trusted_proxies: its requests would look local.
allow_loopback_http: false
# The account Jarl obtains certificates with (docs/certificates.md).
# Nothing is registered until you create a certificate. Read the CA's
# terms (https://letsencrypt.org/repository/) and set agree_tos: true.
# Try letsencrypt-staging first: its certificates aren't trusted, but
# its rate limits are generous.
account:
directory: letsencrypt # letsencrypt, letsencrypt-staging or a directory URL
email: "" # optional
agree_tos: false
eab_kid: "" # External Account Binding, only for CAs that need it
eab_hmac: ""
# Resolvers to see your domains as the CA does (their public name
# servers, a CNAME on _acme-challenge). Not this server: it may answer
# from an internal view.
resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]
ca_bundle: "" # extra CA certificates for a private ACME server
# Wait until every name server of the zone serves a challenge before
# telling the CA to look (they are asked directly, on port 53).
check_propagation: true
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
# show and set a domain's name servers, glue records and DNSSEC DS records
# there, after checking that the change won't break the domain. Off without
# a key. Create the key at porkbun.com (Account -> API Access), switch on
# "API Access" for the domain, and limit the key to that domain if you can.
# The key can move your domain elsewhere: it lives only in this file (keep
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
# Jarl's database or backups.
# Where certificates with the "dns" use are written for named, one
# directory per certificate (fullchain.pem, privkey.pem), readable by
# bind.file_group. Default: <bind.config_dir>/tls.
certificates:
dir: ""
# Porkbun, as registrar and as DNS host. With an API key, Jarl can:
# - show and set a domain's name servers, glue records and DNSSEC DS
# records there, after checking that the change won't break the domain
# (docs/dnssec.md#porkbun);
# - publish the DNS-01 challenges of certificates Jarl obtains while
# Porkbun still serves the domain's DNS (docs/certificates.md): it adds
# and removes TXT records at _acme-challenge names only.
# Off without a key. Create the key at porkbun.com (Account -> API Access),
# switch on "API Access" for the domain, and limit the key to that domain
# if you can. The key can move your domain elsewhere: it lives only in this
# file (keep it readable by root and jarld only: chown root:jarl, chmod
# 640), never in Jarl's database or backups.
porkbun:
api_key: "" # pk1_...
secret_api_key: "" # sk1_...
@@ -633,4 +834,23 @@ blocking:
refresh: 24h # check each list for changes this often (at least 1h)
max_download_mb: 64 # refuse larger downloads
max_entries: 1000000 # refuse lists with more domains
# Prometheus metrics at /metrics (docs/metrics.md): named's counters and
# Jarl's own (blocking, zones, views), plus health gauges to alert on
# (named up, validation problems, blocklist errors, backup age, DNSSEC
# steps, token expiry). Off by default. /metrics is served on the
# addresses in 'listen' at the top of this file; a scrape is allowed from
# an address in 'allow' below, or with the token. Query counters need
# stats.enabled.
metrics:
enabled: false
# Extra addresses that serve only /metrics, over plain HTTP, e.g.
# ["0.0.0.0:9450"] for a Prometheus server elsewhere on the network.
listen: []
# Addresses or prefixes that may scrape without a token.
allow: ["127.0.0.1", "::1"]
# A bearer token (at least 16 characters) that lets any address scrape:
# in Prometheus, 'authorization: { credentials: TOKEN }'. Keep this file
# readable by root and jarld only when you set it.
token: ""
`
+30 -1
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
@@ -18,7 +19,7 @@ func TestExampleParses(t *testing.T) {
if c.BIND.ZoneDir != d.BIND.ZoneDir || time.Duration(c.Session.IdleTimeout) != 2*time.Hour || c.Listen[0] != "127.0.0.1:8053" {
t.Errorf("example differs from defaults: %+v", c)
}
if c.ACME != d.ACME || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
if !reflect.DeepEqual(c.ACME, d.ACME) || !reflect.DeepEqual(c.Certificates, d.Certificates) || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
t.Errorf("acme = %+v, want the defaults %+v", c.ACME, d.ACME)
}
}
@@ -87,3 +88,31 @@ func TestStatsConfig(t *testing.T) {
}
}
}
func TestMetricsConfig(t *testing.T) {
d := Default()
if d.Metrics.Enabled || len(d.Metrics.AllowPrefixes()) != 2 || d.Metrics.Token != "" {
t.Errorf("defaults = %+v", d.Metrics)
}
path := filepath.Join(t.TempDir(), "jarl.yaml")
for _, tc := range []struct {
yaml string
ok bool
}{
{"metrics: {enabled: true}", true},
{"metrics: {enabled: true, listen: [\"0.0.0.0:9450\", \"[::]:9450\"], allow: [10.0.0.5, 192.168.1.0/24]}", true},
{"metrics: {enabled: true, allow: [], token: 0123456789abcdef}", true},
{"metrics: {enabled: true, allow: []}", false}, // nobody could scrape
{"metrics: {enabled: true, token: short}", false},
{"metrics: {enabled: true, token: \"has space 0123456789\"}", false},
{"metrics: {enabled: true, listen: [\"9450\"]}", false},
{"metrics: {enabled: true, allow: [prometheus.lan]}", false},
{"metrics: {enabled: false, listen: [nonsense]}", true}, // only checked when enabled
} {
os.WriteFile(path, []byte(tc.yaml+"\n"), 0o644)
_, err := Load(path, false)
if (err == nil) != tc.ok {
t.Errorf("%s: err = %v, want ok=%v", tc.yaml, err, tc.ok)
}
}
}
+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)
}
}
}
+559
View File
@@ -0,0 +1,559 @@
// Package encdns sets up DNS over TLS (DoT) and DNS over HTTPS (DoH) in
// named (docs/encrypted-dns.md): a tls statement naming a certificate Jarl
// manages, and listen-on statements for port 853 (DoT) and the DoH port.
// Its state is read from named's configuration, so what the setup stages
// stays ordinary, visible configuration.
package encdns
import (
"fmt"
"net/netip"
"path/filepath"
"slices"
"strconv"
"strings"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// Prefix starts the names of the tls statements the setup writes.
const Prefix = "jarl-"
// DoTPort is the port for DNS over TLS (RFC 7858).
const DoTPort = 853
// Listener is a listen-on or listen-on-v6 statement.
type Listener struct {
OptionID int64 `json:"option_id"`
V6 bool `json:"v6"`
Port int `json:"port"` // as written; 0 when it isn't
TLS string `json:"tls,omitempty"` // tls statement ("" for plain DNS)
HTTP string `json:"http,omitempty"` // http endpoints: DNS over HTTPS
Addrs []string `json:"addresses"` // the address match list
}
// EffectivePort is the port named listens on: as written, or its
// default for the kind of listener.
func (l Listener) EffectivePort() int {
switch {
case l.Port != 0:
return l.Port
case l.HTTP != "" && l.TLS != "" && l.TLS != "none":
return 443
case l.HTTP != "":
return 80
case l.TLS != "":
return DoTPort
}
return 53
}
// Kind is plain, dot or doh.
func (l Listener) Kind() string {
switch {
case l.HTTP != "":
return "doh"
case l.TLS != "" && l.TLS != "none":
return "dot"
}
return "plain"
}
// Managed reports whether the setup wrote the listener.
func (l Listener) Managed() bool { return strings.HasPrefix(l.TLS, Prefix) }
// Listeners returns the server's listen-on and listen-on-v6 statements.
func Listeners(st *model.State) []Listener {
var out []Listener
for _, o := range st.ScopedOptions(model.ScopeOptions, 0) {
if o.Name != "listen-on" && o.Name != "listen-on-v6" {
continue
}
l, err := parseListener(o.Name, o.Value)
if err != nil {
continue
}
l.OptionID, l.V6 = o.ID, o.Name == "listen-on-v6"
out = append(out, l)
}
return out
}
func parseListener(name, value string) (Listener, error) {
var l Listener
stmt, err := namedconf.ParseOptionValue(name, value)
if err != nil {
return l, err
}
items := stmt.Items[1:]
for i := 0; i < len(items); i++ {
it := items[i]
if it.Block != nil {
for _, s := range it.Block.Stmts {
l.Addrs = append(l.Addrs, namedconf.Compact(s.Items))
}
continue
}
if it.Value == nil || i+1 >= len(items) || items[i+1].Value == nil {
continue
}
arg := items[i+1].Value.Text
switch strings.ToLower(it.Value.Text) {
case "port":
l.Port, _ = strconv.Atoi(arg)
case "tls":
l.TLS = arg
case "http":
l.HTTP = arg
}
i++
}
return l, nil
}
// TLSBlock is a top-level tls statement.
type TLSBlock struct {
OptionID int64 `json:"option_id"`
Name string `json:"name"`
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
}
// TLSBlocks returns the top-level tls statements.
func TLSBlocks(st *model.State) []TLSBlock {
var out []TLSBlock
for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) {
if o.Name != "tls" {
continue
}
stmt, err := namedconf.ParseOptionValue("tls", o.Value)
if err != nil {
continue
}
b := TLSBlock{OptionID: o.ID, Name: stmt.Arg(0)}
if blk := stmt.FirstBlock(); blk != nil {
for _, s := range blk.Stmts {
switch s.Keyword() {
case "cert-file":
b.CertFile = s.Arg(0)
case "key-file":
b.KeyFile = s.Arg(0)
}
}
}
out = append(out, b)
}
return out
}
// Endpoint is an address and port named serves DoT or DoH on.
type Endpoint struct {
Address string `json:"address"`
Port int `json:"port"`
}
// Status is what is set up.
type Status struct {
Enabled bool `json:"enabled"`
// TLS is the tls statement the setup wrote, and Certificate the
// certificate it names (a Jarl certificate's name, when it is one).
TLS *TLSBlock `json:"tls,omitempty"`
Certificate string `json:"certificate,omitempty"`
DoT []Endpoint `json:"dot"`
DoH []Endpoint `json:"doh"`
// Listeners are all listen-on statements, the setup's and others.
Listeners []Listener `json:"listeners"`
// Other are DoT or DoH listeners written by hand.
Other bool `json:"other,omitempty"`
}
// GetStatus reads the setup from the configuration. certDir is where Jarl
// writes certificates for named (certificates.dir).
func GetStatus(st *model.State, certDir string) Status {
s := Status{Listeners: Listeners(st), DoT: []Endpoint{}, DoH: []Endpoint{}}
if s.Listeners == nil {
s.Listeners = []Listener{}
}
for _, b := range TLSBlocks(st) {
if strings.HasPrefix(b.Name, Prefix) {
b := b
s.TLS = &b
if dir := filepath.Dir(b.CertFile); filepath.Dir(dir) == filepath.Clean(certDir) {
s.Certificate = filepath.Base(dir)
}
break
}
}
for _, l := range s.Listeners {
if l.Kind() == "plain" {
continue
}
if !l.Managed() {
s.Other = true
continue
}
for _, a := range l.Addrs {
e := Endpoint{Address: a, Port: l.EffectivePort()}
if l.Kind() == "doh" {
s.DoH = append(s.DoH, e)
} else {
s.DoT = append(s.DoT, e)
}
}
}
s.Enabled = len(s.DoT)+len(s.DoH) > 0
return s
}
// Setup is what to set up.
type Setup struct {
// 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.
Addresses []string `json:"addresses"`
DoT bool `json:"dot"`
DoH bool `json:"doh"`
DoHPort int `json:"doh_port"` // default 443
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
// 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,
// replacing an earlier setup, and notes for the user. certDir is where
// the certificate's files are.
func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string, error) {
var notes []string
if !s.DoT && !s.DoH {
return nil, nil, fmt.Errorf("choose DNS over TLS, DNS over HTTPS or both")
}
if s.DoHPort == 0 {
s.DoHPort = 443
}
if s.DoTPort == 0 {
s.DoTPort = DoTPort
}
for _, p := range []int{s.DoHPort, s.DoTPort} {
if p < 1 || p > 65535 || p == 53 {
return nil, nil, fmt.Errorf("port %d: use a free port other than 53", p)
}
}
if s.DoT && s.DoH && s.DoHPort == s.DoTPort {
return nil, nil, fmt.Errorf("DoT and DoH need different ports (both are %d)", s.DoHPort)
}
if s.Certificate == "" {
return nil, nil, fmt.Errorf("choose the certificate to serve")
}
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))
if err != nil {
return nil, nil, fmt.Errorf("%q isn't an address of this server", a)
}
ip = ip.Unmap()
if ip.IsUnspecified() {
return nil, nil, fmt.Errorf("listen on this server's addresses, not on %s (that would include public ones)", ip)
}
if ip.Is4() {
v4 = append(v4, ip.String())
} else {
v6 = append(v6, ip.String())
}
}
if len(v4)+len(v6) == 0 {
return nil, nil, fmt.Errorf("choose at least one address to listen on")
}
tlsName := Prefix + s.Certificate
tlsValue := TLSValue(s.Certificate, certDir)
var reqs []engine.Request
// Take out the earlier setup's listeners and tls statements.
current := Listeners(st)
for _, l := range current {
if l.Managed() {
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
}
}
haveTLS := false
for _, b := range TLSBlocks(st) {
switch {
case b.Name == tlsName:
haveTLS = true
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(tlsValue)}))
case strings.HasPrefix(b.Name, Prefix):
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
}
}
if !haveTLS {
reqs = append(reqs, option(model.ScopeTopLevel, "tls", tlsValue))
}
// named listens on port 53 everywhere only while there is no
// listen-on statement at all: adding the first one for DoT would end
// that, so keep it explicitly.
plain := func(v6 bool) bool {
for _, l := range current {
if l.V6 == v6 && !l.Managed() && l.Kind() == "plain" {
return true
}
}
return false
}
add := func(name string, addrs []string) {
if len(addrs) == 0 {
return
}
list := "{ " + strings.Join(addrs, "; ") + "; }"
if !plain(name == "listen-on-v6") {
reqs = append(reqs, option(model.ScopeOptions, name, "{ any; }"))
notes = append(notes, fmt.Sprintf("named listened on port 53 on every address by default; %s { any; } keeps that, as the first %s statement would otherwise end it", name, name))
}
if s.DoT {
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s %s", s.DoTPort, tlsName, list)))
}
if s.DoH {
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s http default %s", s.DoHPort, tlsName, list)))
}
}
add("listen-on", v4)
add("listen-on-v6", v6)
// 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)
}
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, ", ")))
}
}
}
}
// 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
}
}
}
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
}
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
}
func option(scope, name, value string) engine.Request {
return engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(scope), Name: model.Ptr(name), Value: model.Ptr(value)})
}
// zoneFor returns the primary zone in a view that holds name.
func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
var best *model.Zone
for _, z := range st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && (name == z.Name || strings.HasSuffix(name, "."+z.Name)) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
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 {
var reqs []engine.Request
for _, l := range Listeners(st) {
if l.Managed() {
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
}
}
for _, b := range TLSBlocks(st) {
if strings.HasPrefix(b.Name, Prefix) {
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
}
// Private reports whether an address is private (RFC 1918, ULA, ...).
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"))
}
+255
View File
@@ -0,0 +1,255 @@
package encdns
import (
"context"
"io"
"log/slog"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/store"
)
type env struct {
t *testing.T
eng *engine.Engine
st *store.Store
certDir string
}
func newEnv(t *testing.T) *env {
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "jarl.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
eng := engine.New(st, nil, engine.Config{
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
e := &env{t: t, eng: eng, st: st, certDir: filepath.Join(dir, "tls")}
os.MkdirAll(filepath.Join(e.certDir, "dns"), 0o755)
os.WriteFile(filepath.Join(e.certDir, "dns", "fullchain.pem"), []byte("x"), 0o644)
os.WriteFile(filepath.Join(e.certDir, "dns", "privkey.pem"), []byte("x"), 0o600)
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
return e
}
func (e *env) submit(reqs ...engine.Request) *engine.Result {
e.t.Helper()
res, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{})
if err != nil {
e.t.Fatalf("%v (%+v)", err, res)
}
return res
}
func (e *env) state() *model.State {
st, err := e.st.LoadState(context.Background())
if err != nil {
e.t.Fatal(err)
}
return st
}
func values(st *model.State, scope, name string) []string {
var out []string
for _, o := range st.ScopedOptions(scope, 0) {
if o.Name == name {
out = append(out, o.Value)
}
}
return out
}
func TestSetup(t *testing.T) {
e := newEnv(t)
setup := Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5", "2001:db8::5"}, DoT: true, DoH: true, Views: []string{"internal", "external"}}
reqs, notes, err := Plan(e.state(), setup, e.certDir)
if err != nil {
t.Fatal(err)
}
e.submit(reqs...)
st := e.state()
tls := values(st, model.ScopeTopLevel, "tls")
if len(tls) != 1 || !strings.HasPrefix(tls[0], "jarl-dns {") || !strings.Contains(tls[0], filepath.Join(e.certDir, "dns", "fullchain.pem")) {
t.Errorf("tls = %q", tls)
}
// The plain listeners named had by default are kept explicitly.
want4 := []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }", "port 443 tls jarl-dns http default { 10.0.10.5; }"}
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, want4) {
t.Errorf("listen-on = %q, want %q", got, want4)
}
if got := values(st, model.ScopeOptions, "listen-on-v6"); len(got) != 3 || got[0] != "{ any; }" {
t.Errorf("listen-on-v6 = %q", got)
}
if len(notes) != 3 || !strings.Contains(strings.Join(notes, " "), "external has no zone") {
t.Errorf("notes = %q", notes)
}
// The host name's addresses in the internal view.
z := st.FindZone("example.com.", st.ViewByName("internal").ID)
var addrs []string
for _, r := range st.ZoneRecords(z.ID) {
if r.Name == "dns.example.com." {
addrs = append(addrs, r.Type+" "+r.Data)
}
}
if !slices.Equal(addrs, []string{"A 10.0.10.5", "AAAA 2001:db8::5"}) {
t.Errorf("records = %q", addrs)
}
s := GetStatus(st, e.certDir)
if !s.Enabled || s.Certificate != "dns" || len(s.DoT) != 2 || len(s.DoH) != 2 || s.DoH[0].Port != 443 || s.Other {
t.Errorf("status = %+v", s)
}
// Setting up again replaces the setup: DoT only, one address, and the
// plain listeners aren't doubled.
reqs, _, err = Plan(st, Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, e.certDir)
if err != nil {
t.Fatal(err)
}
e.submit(reqs...)
st = e.state()
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }"}) {
t.Errorf("after the second setup: %q", got)
}
if got := values(st, model.ScopeOptions, "listen-on-v6"); !slices.Equal(got, []string{"{ any; }"}) {
t.Errorf("listen-on-v6 after the second setup: %q", got)
}
if got := values(st, model.ScopeTopLevel, "tls"); len(got) != 1 {
t.Errorf("tls statements: %q", got)
}
// Off: the setup's listeners and tls statement go, the rest stays.
e.submit(Disable(st)...)
st = e.state()
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }"}) || len(values(st, model.ScopeTopLevel, "tls")) != 0 || GetStatus(st, e.certDir).Enabled {
t.Errorf("after disabling: listen-on %q tls %q", got, values(st, model.ScopeTopLevel, "tls"))
}
}
func TestSetupRefuses(t *testing.T) {
e := newEnv(t)
st := e.state()
for _, s := range []Setup{
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}}, // neither DoT nor DoH
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"0.0.0.0"}, DoT: true}, // every address
{Hostname: "dns.example.com", Certificate: "dns", DoT: true}, // no address
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoH: true, DoHPort: 53}, // port 53
{Hostname: "dns", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, // not a host name
} {
if _, _, err := Plan(st, s, e.certDir); err == nil {
t.Errorf("accepted %+v", s)
}
}
// A tls statement whose files are missing is refused by validation.
_, err := e.eng.Submit(context.Background(), []engine.Request{engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{
Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(`x { cert-file "/nonexistent/c.pem"; key-file "/nonexistent/k.pem"; }`)})},
auth.System, "test", "", engine.ApplyOptions{})
if err == nil || !strings.Contains(err.Error(), "doesn't exist") {
t.Errorf("missing files: %v", err)
}
}
func TestPorts(t *testing.T) {
s, err := parseProcAddr("0100007F:0035")
if err != nil || s.Addr != netip.MustParseAddr("127.0.0.1") || s.Port != 53 {
t.Errorf("v4: %+v %v", s, err)
}
s, err = parseProcAddr("00000000000000000000000001000000:01BB")
if err != nil || s.Addr != netip.MustParseAddr("::1") || s.Port != 443 {
t.Errorf("v6: %+v %v", s, err)
}
e := newEnv(t)
old := ListeningSockets
t.Cleanup(func() { ListeningSockets = old })
ListeningSockets = func() ([]Socket, error) {
return []Socket{{netip.MustParseAddr("0.0.0.0"), 443}, {netip.MustParseAddr("10.0.10.5"), 8443}, {netip.MustParseAddr("::"), 853}}, nil
}
c, err := PortCheck(e.state(), []string{"10.0.10.5"}, []int{853, 443, 8443}, []string{"0.0.0.0:443"})
if err != nil || len(c) != 3 || c[1].What != "jarld (Jarl's web UI and API)" || c[2].What != "another program" {
t.Errorf("conflicts = %+v %v", c, err)
}
if p := FreeDoHPort(e.state(), []string{"10.0.10.5"}, nil); p != 4443 {
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])
}
}
+149
View File
@@ -0,0 +1,149 @@
package encdns
import (
"bufio"
"encoding/hex"
"fmt"
"net/netip"
"os"
"strconv"
"strings"
"jarl/internal/model"
)
// Socket is a listening TCP socket on this host.
type Socket struct {
Addr netip.Addr
Port int
}
// ListeningSockets reads the host's listening TCP sockets from
// /proc/net/tcp and tcp6 (Linux; nil elsewhere). It needs no privileges
// and binds nothing. Tests replace it.
var ListeningSockets = func() ([]Socket, error) {
var out []Socket
for _, f := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
socks, err := parseProcNet(f)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
out = append(out, socks...)
}
return out, nil
}
func parseProcNet(path string) ([]Socket, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []Socket
sc := bufio.NewScanner(f)
sc.Scan() // header
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 4 || fields[3] != "0A" { // 0A: LISTEN
continue
}
s, err := parseProcAddr(fields[1])
if err == nil {
out = append(out, s)
}
}
return out, sc.Err()
}
// parseProcAddr parses "0100007F:0035": the address in 32-bit words of
// host byte order (little-endian here), then the port.
func parseProcAddr(s string) (Socket, error) {
ha, hp, ok := strings.Cut(s, ":")
if !ok {
return Socket{}, fmt.Errorf("bad address %q", s)
}
b, err := hex.DecodeString(ha)
if err != nil || (len(b) != 4 && len(b) != 16) {
return Socket{}, fmt.Errorf("bad address %q", s)
}
for i := 0; i+4 <= len(b); i += 4 {
b[i], b[i+1], b[i+2], b[i+3] = b[i+3], b[i+2], b[i+1], b[i]
}
addr, _ := netip.AddrFromSlice(b)
port, err := strconv.ParseUint(hp, 16, 16)
if err != nil {
return Socket{}, err
}
return Socket{Addr: addr.Unmap(), Port: int(port)}, nil
}
// Conflict is something else listening where a listener would go.
type Conflict struct {
Address string `json:"address"`
Port int `json:"port"`
What string `json:"what"` // who it is, when Jarl knows
}
// PortCheck says which of addresses × ports something else listens on
// already. named's own listeners (the setup's, now) and jarld's (known
// listen addresses) are told apart.
func PortCheck(st *model.State, addresses []string, ports []int, jarld []string) ([]Conflict, error) {
socks, err := ListeningSockets()
if err != nil {
return nil, err
}
// What named listens on now with DoT or DoH (it will again).
ours := map[string]bool{}
for _, l := range Listeners(st) {
if l.Kind() != "plain" {
for _, a := range l.Addrs {
ours[a+"#"+strconv.Itoa(l.EffectivePort())] = true
}
}
}
jarldOn := map[string]bool{}
for _, l := range jarld {
jarldOn[l] = true
}
var out []Conflict
for _, as := range addresses {
a, err := netip.ParseAddr(as)
if err != nil {
continue
}
a = a.Unmap()
for _, p := range ports {
if ours[a.String()+"#"+strconv.Itoa(p)] {
continue
}
for _, s := range socks {
if s.Port != p || !(s.Addr == a || s.Addr.IsUnspecified() && s.Addr.Is4() == a.Is4() || s.Addr.IsUnspecified() && s.Addr.Is6()) {
continue
}
what := "another program"
for l := range jarldOn {
if strings.HasSuffix(l, ":"+strconv.Itoa(p)) {
what = "jarld (Jarl's web UI and API)"
}
}
out = append(out, Conflict{Address: a.String(), Port: p, What: what})
break
}
}
}
return out, nil
}
// FreeDoHPort returns the first of the usual DoH ports nothing listens on
// at the addresses (0 when none is free).
func FreeDoHPort(st *model.State, addresses []string, jarld []string) int {
for _, p := range []int{443, 8443, 4443, 9443} {
if c, err := PortCheck(st, addresses, []int{p}, jarld); err == nil && len(c) == 0 {
return p
}
}
return 0
}
+68
View File
@@ -10,6 +10,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
"jarl/internal/blocking"
"jarl/internal/model"
@@ -229,3 +230,70 @@ func TestBlocklistRefresh(t *testing.T) {
t.Error("the deleted list's status is still there")
}
}
func TestBlockingPause(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, blocklist("ads", "https://example.org/list"))
rulesFile := filepath.Join(env.dir, "zones/_default/rules.jarl.invalid.rpz")
if _, err := os.Stat(rulesFile); !os.IsNotExist(err) {
t.Fatal("a rules zone without rules or a pause")
}
until := env.e.Now().Add(10 * time.Minute)
res := env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
if !strings.HasPrefix(res.Ops[0].Summary, "pause blocking until ") {
t.Errorf("summary = %q", res.Ops[0].Summary)
}
f, err := os.ReadFile(rulesFile)
if err != nil || !strings.HasSuffix(string(f), "NS\tlocalhost.\n*\tCNAME\trpz-passthru.\n") {
t.Fatalf("paused rules zone: %v\n%s", err, f)
}
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
if !strings.Contains(string(conf), "zone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.") {
t.Errorf("the paused rules zone must come first:\n%s", conf)
}
// Not yet time: nothing happens.
if got := env.e.ResumeBlocking(context.Background()); !got.Equal(until) {
t.Errorf("ResumeBlocking before the end = %v", got)
}
// Time is up: resumed, and the rules zone is gone again.
env.submit(env.admin, blockRule("ads.example", "block", false))
env.e.Now = func() time.Time { return until.Add(time.Second) }
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || env.state().Settings.BlockingPausedUntil != nil {
t.Fatalf("after the pause: %v, %v", got, env.state().Settings.BlockingPausedUntil)
}
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || strings.Contains(string(f), "passthru") {
t.Errorf("rules zone after the pause:\n%s", f)
}
// Reverting a pause resumes blocking.
until = env.e.Now().Add(time.Hour)
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Settings.BlockingPausedUntil != nil {
t.Errorf("revert of a pause: %v, %v", err, env.state().Settings.BlockingPausedUntil)
}
// Until resumed: jarld never ends it, a resume does.
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Indefinitely: true}))
if res.Ops[0].Summary != "pause blocking until resumed" || !env.state().Settings.BlockingPaused() {
t.Errorf("indefinite pause: %q", res.Ops[0].Summary)
}
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*\tCNAME\trpz-passthru.\n") {
t.Errorf("rules zone while paused indefinitely:\n%s", f)
}
env.e.Now = func() time.Time { return until.Add(1000 * time.Hour) }
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || !env.state().Settings.BlockingPausedIndefinitely {
t.Errorf("an indefinite pause ended by itself: %v", got)
}
env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{}))
if env.state().Settings.BlockingPaused() {
t.Error("resume didn't end an indefinite pause")
}
// At most a day.
long := env.e.Now().Add(48 * time.Hour)
if e := env.submitErr(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &long})); e.Kind != KindBadRequest {
t.Errorf("a two-day pause: %+v", e)
}
}
+11 -5
View File
@@ -78,7 +78,7 @@ func (e *Engine) RefreshDue(ctx context.Context) {
e.log.Warn("blocklists: loading their status failed", "err", err)
return
}
out := render.Render(st, e.cfg.Paths, e.cfg.Extras)
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
if err := e.ensureBlocklistFiles(out.Blocklists); err != nil {
e.log.Warn("blocklists: creating empty zone files failed", "err", err)
}
@@ -335,7 +335,9 @@ func (l *limitedReader) Read(p []byte) (int, error) {
// CheckBlocking reports, for every view with blocking, whether name is
// blocked or allowed there, and by which rule or list. It looks names up
// the way named does: rules first, then the lists in order.
// the way named does: rules first, then the lists in order. The answer is
// for clients at the normal step; a list of a higher step that blocks the
// name says so (Match.Step), and clients at step off skip every list.
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
n, err := blocking.NormalizeName(name)
if err != nil {
@@ -349,8 +351,8 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
out = append(out, blocking.Match{View: z.View, Result: "none"})
cur = &out[len(out)-1]
}
if cur.Result != "none" {
continue // an earlier zone decided
if cur.Result != "none" || z.IsGate() {
continue // an earlier zone decided, or a gate (it never blocks)
}
if z.ListID == 0 {
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
@@ -372,7 +374,11 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
feeds[z.File] = entry
}
if entry != "" {
cur.Result, cur.Entry, cur.List = "blocked", entry, st.Blocklists[z.ListID].Name
b := st.Blocklists[z.ListID]
cur.Result, cur.Entry, cur.List = "blocked", entry, b.Name
if b.StepID != model.StepNormal {
cur.Step = st.StepName(b.StepID)
}
}
}
return out, nil
+55
View File
@@ -1,12 +1,14 @@
package engine
import (
"context"
"encoding/json"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"time"
"jarl/internal/auth"
"jarl/internal/blocking"
@@ -115,6 +117,16 @@ func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
if in.Disabled != nil {
b.Disabled = *in.Disabled
}
step, given, err := stepRef(st, in.Step, in.StepID)
if err != nil {
return err
}
if given {
if step == model.StepOff {
return errf(KindBadRequest, "a blocklist starts at normal or a step above it, not off")
}
b.StepID = step
}
if in.Comment != nil {
b.Comment = *in.Comment
}
@@ -224,6 +236,11 @@ func blockingUsers(st *model.State, viewID int64) []string {
out = append(out, ruleLabel(r))
}
}
for _, g := range st.SortedClientGroups() {
if slices.Contains(g.ViewIDs, viewID) {
out = append(out, "client group "+g.Name)
}
}
return out
}
@@ -270,3 +287,41 @@ func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) {
return ruleLabel(r), nil
})
}
func applyBlockingPause(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlockingPauseInput
if err := decode(op.Data, &in); err != nil {
return err
}
if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute {
return errf(KindBadRequest, "blocking can be paused for at most %s, or until it is resumed", MaxBlockingPause)
}
if in.Indefinitely && in.Until != nil {
return errf(KindBadRequest, "a pause is either until a time or until resumed, not both")
}
st.Settings.BlockingPausedUntil = in.Until
st.Settings.BlockingPausedIndefinitely = in.Indefinitely
return nil
}
// ResumeBlocking ends a pause whose time is up, and returns when the
// current pause ends (zero when blocking isn't paused).
func (e *Engine) ResumeBlocking(ctx context.Context) time.Time {
st, err := e.store.LoadState(ctx)
if err != nil || st.Settings.BlockingPausedUntil == nil {
return time.Time{}
}
until := *st.Settings.BlockingPausedUntil
if e.Now().Before(until) {
return until
}
if _, err := e.Submit(ctx, []Request{NewRequest(OpBlockingPause, 0, BlockingPauseInput{})}, auth.System, "system", "resume blocking after the pause", ApplyOptions{}); err != nil {
e.log.Warn("resuming blocking after the pause failed; it will be tried again", "err", err)
return e.Now().Add(time.Minute)
}
e.log.Info("blocking resumed after the pause")
return time.Time{}
}
+270
View File
@@ -0,0 +1,270 @@
package engine
import (
"encoding/json"
"net/netip"
"strings"
"jarl/internal/iprange"
"jarl/internal/model"
)
// ---- blocking steps and client groups (docs/blocking.md)
// stepRef resolves a step given by name or ID. ok is false when neither
// is given.
func stepRef(st *model.State, name *string, id *int64) (int64, bool, error) {
switch {
case name != nil:
s, found := st.StepByName(*name)
if !found {
return 0, true, errf(KindBadRequest, "blocking step %q does not exist (steps: %s)", *name, stepNames(st))
}
return s, true, nil
case id != nil:
if _, found := st.StepRank(*id); !found {
return 0, true, errf(KindBadRequest, "blocking step %d does not exist", *id)
}
return *id, true, nil
}
return 0, false, nil
}
// stepNames lists the steps for messages: "off, normal, strict".
func stepNames(st *model.State) string {
names := []string{"off", "normal"}
for _, s := range st.OrderedBlockSteps() {
names = append(names, s.Name)
}
return strings.Join(names, ", ")
}
// NormalizeMember checks a client group member: an address, a prefix or
// an ACL name (an ACL's own elements are checked by validation). It
// returns the canonical form.
func NormalizeMember(st *model.State, m string) (string, error) {
m = strings.TrimSpace(m)
if a, err := netip.ParseAddr(m); err == nil {
if a.Zone() != "" {
return "", errf(KindBadRequest, "%q: an address with a zone can't be matched", m)
}
return a.Unmap().String(), nil
}
if p, err := netip.ParsePrefix(m); err == nil {
return p.Masked().String(), nil
}
if r, ok, err := iprange.Parse(m); ok {
if err != nil {
return "", errf(KindBadRequest, "%v", err)
}
return r.String(), nil
}
if a := st.ACLByName(m); a != nil {
return a.Name, nil
}
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24), a range (10.10.0.20-10.10.0.49) or an ACL", m)
}
func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlockStepInput
if err := decode(op.Data, &in); err != nil {
return err
}
var s *model.BlockStep
if op.Kind == OpBlockStepCreate {
if op.ID <= 0 || st.BlockSteps[op.ID] != nil {
return errf(KindConflict, "step ID %d is already in use", op.ID)
}
if in.Name == nil {
return errf(KindBadRequest, "a blocking step needs a name")
}
pos := 0
for _, o := range st.BlockSteps {
pos = max(pos, o.Position+1)
}
s = &model.BlockStep{ID: op.ID, Position: pos}
} else {
s = st.BlockSteps[op.ID]
if s == nil {
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
}
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
return err
}
}
if in.Name != nil {
name := strings.ToLower(strings.TrimSpace(*in.Name))
if !blocklistNameRe.MatchString(name) || name == "off" || name == "normal" {
return errf(KindBadRequest, "step name %q: use 1 to 40 lowercase letters, digits and hyphens, like strict (off and normal are built in)", *in.Name)
}
if id, found := st.StepByName(name); found && id != s.ID {
return errf(KindConflict, "step %s already exists", name)
}
s.Name = name
}
if in.Position != nil {
s.Position = *in.Position
}
if in.Comment != nil {
s.Comment = *in.Comment
}
s.Touch(c.user(), c.now)
st.BlockSteps[s.ID] = s
return nil
}
func applyBlockStepDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
s := st.BlockSteps[op.ID]
if s == nil {
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
}
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
return err
}
if users := stepUsers(st, s.ID); len(users) > 0 {
return errf(KindConflict, "step %s is used by %s; move them to another step first", s.Name, strings.Join(users, ", "))
}
delete(st.BlockSteps, s.ID)
return nil
}
// stepUsers lists the blocklists and client groups at a step.
func stepUsers(st *model.State, id int64) []string {
var out []string
for _, b := range st.SortedBlocklists() {
if b.StepID == id {
out = append(out, "blocklist "+b.Name)
}
}
for _, g := range st.SortedClientGroups() {
if g.StepID == id {
out = append(out, "client group "+g.Name)
}
}
return out
}
func applyClientGroupPut(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in ClientGroupInput
if err := decode(op.Data, &in); err != nil {
return err
}
var g *model.ClientGroup
if op.Kind == OpClientGroupCreate {
if op.ID <= 0 || st.ClientGroups[op.ID] != nil {
return errf(KindConflict, "client group ID %d is already in use", op.ID)
}
if in.Name == nil {
return errf(KindBadRequest, "a client group needs a name")
}
g = &model.ClientGroup{ID: op.ID, Members: []string{}, StepID: model.StepNormal, ViewIDs: []int64{}}
} else {
g = st.ClientGroups[op.ID]
if g == nil {
return errf(KindNotFound, "client group %d does not exist", op.ID)
}
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
return err
}
}
if in.Name != nil {
name := strings.ToLower(strings.TrimSpace(*in.Name))
if !blocklistNameRe.MatchString(name) {
return errf(KindBadRequest, "client group name %q: use 1 to 40 lowercase letters, digits and hyphens, like kids", *in.Name)
}
if other := st.ClientGroupByName(name); other != nil && other.ID != g.ID {
return errf(KindConflict, "client group %s already exists", name)
}
g.Name = name
}
if in.Members != nil {
members := []string{}
seen := map[string]bool{}
for _, m := range *in.Members {
if strings.TrimSpace(m) == "" {
continue
}
n, err := NormalizeMember(st, m)
if err != nil {
return err
}
if !seen[n] {
seen[n] = true
members = append(members, n)
}
}
g.Members = members
}
step, given, err := stepRef(st, in.Step, in.StepID)
if err != nil {
return err
}
if given {
g.StepID = step
}
ids, err := viewIDs(st, in.Views, in.ViewIDs)
if err != nil {
return err
}
if ids != nil {
g.ViewIDs = ids
}
if in.Comment != nil {
g.Comment = *in.Comment
}
g.Touch(c.user(), c.now)
st.ClientGroups[g.ID] = g
return nil
}
func applyClientGroupDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
g := st.ClientGroups[op.ID]
if g == nil {
return errf(KindNotFound, "client group %d does not exist", op.ID)
}
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
return err
}
delete(st.ClientGroups, g.ID)
return nil
}
// ---- expansion
func (x *expander) blockStepCreate(req Request) ([]model.Op, error) {
return x.simpleCreate(req, OpBlockStepCreate, func(data json.RawMessage) (string, error) {
var in BlockStepInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil {
return "", errf(KindBadRequest, "a blocking step needs a name")
}
return "add blocking step " + strings.ToLower(*in.Name), nil
})
}
func (x *expander) clientGroupCreate(req Request) ([]model.Op, error) {
return x.simpleCreate(req, OpClientGroupCreate, func(data json.RawMessage) (string, error) {
var in ClientGroupInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil {
return "", errf(KindBadRequest, "a client group needs a name")
}
return "add client group " + strings.ToLower(*in.Name), nil
})
}
+123
View File
@@ -0,0 +1,123 @@
package engine
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
)
func clientGroup(name, step string, members ...string) Request {
return NewRequest(OpClientGroupCreate, 0, ClientGroupInput{Name: &name, Step: &step, Members: &members})
}
func TestClientGroups(t *testing.T) {
env := newEnv(t)
setupZone(env)
_, srv := newListServer(t, "||ads.example^\n")
env.submit(env.admin,
NewRequest(OpBlockStepCreate, 0, BlockStepInput{Name: model.Ptr("strict")}),
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("tablets"), Elements: &[]string{"10.0.0.50", "10.0.0.51"}}),
)
env.submit(env.admin,
blocklist("ads", srv.URL+"/a.txt"),
NewRequest(OpBlocklistCreate, 0, BlocklistInput{Name: model.Ptr("social"), URL: model.Ptr(srv.URL + "/s.txt"), Step: model.Ptr("strict")}),
clientGroup("work", "off", "10.0.0.20"),
clientGroup("kids", "strict", "tablets", "2001:db8:1::/64"),
)
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
c := string(conf)
// Off clients pass before the normal lists, everyone below strict
// before the strict ones; gates aren't logged.
want := "response-policy {\n" +
"\t\tzone \"off.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
"\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n" +
"\t\tzone \"strict.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
"\t\tzone \"social.blocklist.jarl.invalid\" min-update-interval 0 log no;\n"
if !strings.Contains(c, want) {
t.Errorf("response-policy:\n%s", c)
}
gate := func(name string) string {
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".gate.jarl.invalid.rpz"))
if err != nil {
t.Fatal(err)
}
return string(b)
}
if g := gate("off"); !strings.Contains(g, "32.20.0.0.10.rpz-client-ip\tCNAME\trpz-passthru.\n") || strings.Count(g, "rpz-client-ip") != 1 {
t.Errorf("off gate:\n%s", g)
}
// Everyone but the kids' tablets (via the ACL) and network passes.
sg := gate("strict")
for _, notIn := range []string{"32.50.0.0.10.rpz-client-ip", "32.51.0.0.10.rpz-client-ip", "64.zz.1.db8.2001.rpz-client-ip"} {
if strings.Contains(sg, notIn) {
t.Errorf("strict gate lets %s through:\n%s", notIn, sg)
}
}
// 10.0.0.0/27 holds the work laptop, 10.0.0.52/30 the next addresses
// after the tablets, 128.0.0.0/1 the upper half of IPv4.
for _, in := range []string{"27.0.0.0.10.rpz-client-ip", "30.52.0.0.10.rpz-client-ip", "1.0.0.0.128.rpz-client-ip"} {
if !strings.Contains(sg, in) {
t.Errorf("strict gate lacks %s:\n%s", in, sg)
}
}
st := env.state()
strict, _ := st.StepByName("strict")
// A step that lists or groups use can't be deleted.
if e := env.submitErr(env.admin, NewRequest(OpBlockStepDelete, strict, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist social") {
t.Errorf("deleting a used step: %+v", e)
}
// One client, one group; ACLs must hold addresses only.
if e := env.submitErr(env.admin, clientGroup("dup", "normal", "10.0.0.20")); e.Kind != KindValidation || !strings.Contains(e.Message, "10.0.0.20/32 is in client groups") {
t.Errorf("an address in two groups: %+v", e)
}
env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("keyed"), Elements: &[]string{"localnets"}}))
if e := env.submitErr(env.admin, clientGroup("bad", "normal", "keyed")); e.Kind != KindValidation || !strings.Contains(e.Message, "isn't an address") {
t.Errorf("an ACL with a key: %+v", e)
}
for _, req := range []Request{clientGroup("x", "nope", "10.1.1.1"), clientGroup("y", "normal", "not-an-acl"), clientGroup("Bad Name", "normal")} {
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
t.Errorf("%s: %+v", req.Data, e)
}
}
// A more specific address in another group is fine: it wins.
env.submit(env.admin, clientGroup("guest", "normal", "2001:db8:1::9"))
if sg := gate("strict"); !strings.Contains(sg, "128.9.zz.1.db8.2001.rpz-client-ip") {
t.Errorf("the guest inside the kids' network passes the strict gate:\n%s", sg)
}
// The kids' group gone: the strict list blocks nobody (a warning).
kids := env.state().ClientGroupByName("kids")
res := env.submit(env.admin, NewRequest(OpClientGroupDelete, kids.ID, nil))
found := false
for _, p := range res.Problems {
found = found || p.Code == "blocklist_step_unused"
}
if !found {
t.Errorf("no warning for a step no group reaches: %+v", res.Problems)
}
// Reverting brings it back.
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if len(rev.Ops) != 1 {
t.Errorf("revert ops: %+v (only the deleted group should come back)", rev.Ops)
}
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().ClientGroupByName("kids") == nil {
t.Fatalf("revert: %v", err)
}
// Renaming the ACL renames the member; deleting it is refused.
tablets := env.state().ACLByName("tablets")
env.submit(env.admin, NewRequest(OpACLUpdate, tablets.ID, ACLInput{Name: model.Ptr("pads")}))
if g := env.state().ClientGroupByName("kids"); g.Members[0] != "pads" {
t.Errorf("ACL rename: members %v", g.Members)
}
if e := env.submitErr(env.admin, NewRequest(OpACLDelete, tablets.ID, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "client group kids") {
t.Errorf("deleting an ACL a group uses: %+v", e)
}
}
+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
}
+3
View File
@@ -335,5 +335,8 @@ func fileDiff(path string, old []byte, existed bool, new []byte, removed, secret
if d != "" && !strings.HasSuffix(d, "\n") {
d += "\n"
}
if strings.HasSuffix(path, ".conf") {
d = labelHunks(d, string(old), string(new))
}
return d
}
+136
View File
@@ -0,0 +1,136 @@
package engine
import (
"regexp"
"strconv"
"strings"
)
// hunkHeader matches a unified diff hunk header; the line counts are left
// out when they are 1.
var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@$`)
// labelHunks adds the blocks a hunk's first change is in to its header,
// like git does with function names, so a change to named.conf says where
// it is:
//
// @@ -50,6 +49,8 @@ view "internal" {
//
// Removed lines are looked up in the old file, added ones in the new.
func labelHunks(diff, old, new string) string {
if !strings.Contains(diff, "\n@@ ") {
return diff
}
oldCtx, newCtx := blockContext(old), blockContext(new)
lines := strings.SplitAfter(diff, "\n")
for i, line := range lines {
m := hunkHeader.FindStringSubmatch(strings.TrimSuffix(line, "\n"))
if m == nil {
continue
}
o, _ := strconv.Atoi(m[1])
n, _ := strconv.Atoi(m[2])
label := ""
hunk:
for _, l := range lines[i+1:] {
switch {
case strings.HasPrefix(l, "-"):
label = at(oldCtx, o)
break hunk
case strings.HasPrefix(l, "+"):
label = at(newCtx, n)
break hunk
case strings.HasPrefix(l, " "):
o, n = o+1, n+1
case strings.HasPrefix(l, `\`):
default:
break hunk
}
}
if label != "" {
lines[i] = strings.TrimSuffix(line, "\n") + " " + label + "\n"
}
}
return strings.Join(lines, "")
}
// at returns the context of line n (1-based), or "".
func at(ctx []string, n int) string {
if n < 1 || n > len(ctx) {
return ""
}
return ctx[n-1]
}
// blockContext returns, for each line of a named.conf file, the blocks
// open at its start: `view "internal" { zone "example.com" {`, or "" at
// the top level. It knows named.conf's strings and comments.
func blockContext(text string) []string {
var (
out []string
stack []string
stmt strings.Builder // the current statement, up to a { or ;
inStr bool
inBlock bool // in a /* */ comment
)
label := func() string {
if len(stack) == 0 {
return ""
}
return strings.Join(stack, " { ") + " {"
}
for _, line := range strings.SplitAfter(text, "\n") {
if line == "" {
continue
}
out = append(out, label())
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case inBlock:
if c == '*' && i+1 < len(line) && line[i+1] == '/' {
inBlock = false
i++
}
case inStr:
stmt.WriteByte(c)
if c == '\\' && i+1 < len(line) {
stmt.WriteByte(line[i+1])
i++
} else if c == '"' {
inStr = false
}
case c == '"':
inStr = true
stmt.WriteByte(c)
case c == '#' || c == '/' && i+1 < len(line) && line[i+1] == '/':
i = len(line) // a comment to the end of the line
case c == '/' && i+1 < len(line) && line[i+1] == '*':
inBlock = true
i++
case c == '{':
stack = append(stack, shortLabel(stmt.String()))
stmt.Reset()
case c == '}':
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
stmt.Reset()
case c == ';':
stmt.Reset()
default:
stmt.WriteByte(c)
}
}
}
return out
}
// shortLabel tidies the text before a { for a hunk header.
func shortLabel(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 60 {
s = s[:57] + "..."
}
return s
}
+63
View File
@@ -0,0 +1,63 @@
package engine
import (
"strings"
"testing"
)
func TestLabelHunks(t *testing.T) {
old := `// header
options {
directory "/var/cache/bind";
forwarders { 1.1.1.1; 9.9.9.9; };
listen-on { any; };
recursion no;
version "a { b"; # a { in a string and comments: /* {
dnssec-validation auto;
};
view "internal" {
match-clients { 10.0.0.0/8; };
recursion yes;
allow-transfer { localhost; };
/* a { comment
over lines */
zone "example.com" {
type primary;
file "example.com.db";
notify yes;
};
zone "other.example" { type primary; file "o.db"; };
};
`
new := strings.Replace(old, "\tforwarders { 1.1.1.1; 9.9.9.9; };\n", "", 1)
new = strings.Replace(new, "\tallow-transfer { localhost; };\n", "\tallow-transfer { localhost; };\n\tforward first;\n\tforwarders { 1.1.1.1; 9.9.9.9; };\n", 1)
new = strings.Replace(new, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
d := fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new), false, false)
var headers []string
for _, l := range strings.Split(d, "\n") {
if strings.HasPrefix(l, "@@") {
headers = append(headers, l)
}
}
want := []string{
`@@ -1,7 +1,6 @@ options {`,
`@@ -12,12 +11,14 @@ view "internal" {`,
}
if strings.Join(headers, "\n") != strings.Join(want, "\n") {
t.Errorf("headers:\n%s\n\ndiff:\n%s", strings.Join(headers, "\n"), d)
}
// The first change of a hunk decides, and a change inside a zone
// names both blocks.
new2 := strings.Replace(old, "\t\tnotify yes;\n", "\t\tnotify no;\n", 1)
d = fileDiff("/etc/bind/named.conf", []byte(old), true, []byte(new2), false, false)
if !strings.Contains(d, ` @@ view "internal" { zone "example.com" {`+"\n") {
t.Errorf("zone change:\n%s", d)
}
// Zone files aren't named.conf syntax.
d = fileDiff("/var/lib/bind/example.com.db", []byte(old), true, []byte(new2), false, false)
if strings.Contains(d, "@@ view") {
t.Errorf("zone file labelled:\n%s", d)
}
}
+43 -5
View File
@@ -3,6 +3,7 @@ package engine
import (
"fmt"
"reflect"
"slices"
"sort"
"jarl/internal/dnsname"
@@ -17,10 +18,15 @@ func DiffOps(from, to *model.State) []model.Op {
add := func(kind string, id int64, base int, data any, summary string) {
ops = append(ops, newOp(kind, id, base, data, summary))
}
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) || from.Settings.BlockingPausedIndefinitely != to.Settings.BlockingPausedIndefinitely {
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil, Indefinitely: to.Settings.BlockingPausedIndefinitely}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPaused()])
}
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
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}, "restore settings")
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)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse)),
EncryptedDNSHosts: model.Ptr(slices.Clone(s.EncryptedDNSHosts))}, "restore settings")
}
for _, k := range sortedKeys(to.Keys) {
@@ -44,7 +50,9 @@ func DiffOps(from, to *model.State) []model.Op {
for _, k := range sortedKeys(to.Views) {
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)}
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)),
ReverseSuggestions: model.Ptr(!n.ReverseSuggestionsOff), DismissedReverse: model.Ptr(slices.Clone(n.DismissedReverse))}
if n.MatchRecursiveOnly != nil {
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
} else {
@@ -60,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}
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}
}
@@ -88,7 +96,7 @@ func DiffOps(from, to *model.State) []model.Op {
}
for _, k := range sortedKeys(to.Records) {
n := to.Records[k]
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled, SharedID: model.Ptr(n.SharedID)}
if n.TTL != nil {
in.TTL = model.Some(*n.TTL)
} else {
@@ -101,9 +109,19 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
}
}
// Steps before the lists and groups that use them.
for _, k := range sortedKeys(to.BlockSteps) {
n := to.BlockSteps[k]
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
if o, ok := from.BlockSteps[k]; !ok {
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
} else if !sameObject(o, n) {
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
}
}
for _, k := range sortedKeys(to.Blocklists) {
n := to.Blocklists[k]
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment}
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment, StepID: model.Ptr(n.StepID)}
if o, ok := from.Blocklists[k]; !ok {
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
} else if !sameObject(o, n) {
@@ -119,7 +137,21 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
}
}
for _, k := range sortedKeys(to.ClientGroups) {
n := to.ClientGroups[k]
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
if o, ok := from.ClientGroups[k]; !ok {
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
} else if !sameObject(o, n) {
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
}
}
// Deletions, most dependent objects first.
for _, k := range sortedKeys(from.ClientGroups) {
if _, ok := to.ClientGroups[k]; !ok {
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
}
}
for _, k := range sortedKeys(from.BlockRules) {
if _, ok := to.BlockRules[k]; !ok {
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
@@ -130,6 +162,11 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
}
}
for _, k := range sortedKeys(from.BlockSteps) {
if _, ok := to.BlockSteps[k]; !ok {
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[k].Name)
}
}
for _, k := range sortedKeys(from.Records) {
if _, ok := to.Records[k]; !ok {
o := from.Records[k]
@@ -184,6 +221,7 @@ func DiffOps(from, to *model.State) []model.Op {
func stripVersion(s model.Settings) model.Settings {
s.Version = 0
s.BlockingPausedUntil, s.BlockingPausedIndefinitely = nil, false // their own operation
return s
}
+110 -25
View File
@@ -15,6 +15,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"net/netip"
"os"
"path/filepath"
"slices"
@@ -56,6 +57,10 @@ type Config struct {
NoReload bool
// SkipCheck disables named-checkconf (only for tests without BIND).
SkipCheck bool
// LocalAddrs returns this host's addresses, for working out whether a
// server without views is reachable from the internet
// (docs/exposure.md). nil: none known.
LocalAddrs func() []netip.Addr
}
// Engine applies changesets.
@@ -73,6 +78,11 @@ type Engine struct {
blKick chan struct{} // a list was added or changed
HTTP *http.Client // for downloads; nil: a default client
Limits BlocklistLimits
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go)
seedMu sync.Mutex
seed []byte // the view keys' seed (viewkeys.go)
}
// New creates an engine.
@@ -105,17 +115,20 @@ type Result struct {
DryRun bool `json:"dry_run,omitempty"`
Valid bool `json:"valid"`
Problems []validate.Problem `json:"problems"` // problems this change introduces
Existing []validate.Problem `json:"existing_problems,omitempty"`
Check []CheckMessage `json:"check,omitempty"`
CheckOK bool `json:"check_ok"`
Diff string `json:"diff"`
Files []string `json:"files_changed"`
Serials []SerialChange `json:"serials,omitempty"`
Reloaded []string `json:"reloaded,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Drift []DriftEntry `json:"drift,omitempty"`
Ops []model.Op `json:"ops,omitempty"`
Error *Error `json:"error,omitempty"`
// Risks are the introduced problems that open the server up; applying
// needs AcceptRisk (docs/exposure.md).
Risks []validate.Problem `json:"risks,omitempty"`
Existing []validate.Problem `json:"existing_problems,omitempty"`
Check []CheckMessage `json:"check,omitempty"`
CheckOK bool `json:"check_ok"`
Diff string `json:"diff"`
Files []string `json:"files_changed"`
Serials []SerialChange `json:"serials,omitempty"`
Reloaded []string `json:"reloaded,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Drift []DriftEntry `json:"drift,omitempty"`
Ops []model.Op `json:"ops,omitempty"`
Error *Error `json:"error,omitempty"`
}
// stored is what is kept in a changeset's result column.
@@ -133,6 +146,9 @@ type ApplyOptions struct {
Force bool
// NoReload writes the files without reloading named.
NoReload bool
// AcceptRisk confirms a change that opens the server up, such as an
// open resolver on a public view (validate.RiskCodes).
AcceptRisk bool
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
wipeZone int64
}
@@ -175,7 +191,46 @@ func ValidateOptions(st *model.State) validate.Options {
}
func (e *Engine) validate(st *model.State) []validate.Problem {
return validate.State(st, ValidateOptions(st))
return validate.State(st, e.validateOptions(st))
}
func (e *Engine) validateOptions(st *model.State) validate.Options {
opts := ValidateOptions(st)
if e.cfg.LocalAddrs != nil {
opts.LocalAddrs = e.cfg.LocalAddrs()
}
return opts
}
// Validate checks a state as applying it would.
func (e *Engine) Validate(st *model.State) []validate.Problem { return e.validate(st) }
// Exposures says which views (or the server, without views) are
// reachable from the internet, and why (docs/exposure.md).
func (e *Engine) Exposures(st *model.State) []validate.Exposure {
return validate.Exposures(st, e.validateOptions(st))
}
// risks returns the introduced problems that open a public view (or a
// public server) up: risky warnings that weren't there before at all. One
// that was there already, acknowledged (taking the acknowledgement back)
// or not, opens nothing new.
func risks(before, introduced []validate.Problem, public map[string]bool) []validate.Problem {
type key struct {
code, objType, view string
id int64
}
had := map[key]bool{}
for _, p := range before {
had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] = true
}
var out []validate.Problem
for _, p := range introduced {
if p.Severity == validate.Warning && validate.RiskCodes[p.Code] && public[p.View] && !had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] {
out = append(out, p)
}
}
return out
}
// ---- changeset management
@@ -230,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 {
@@ -275,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)}
@@ -384,6 +442,8 @@ type plan struct {
base, work *model.State
introduced []validate.Problem
existing []validate.Problem
before []validate.Problem // the problems before the change
public map[string]bool // public views by name ("": the server), after the change
serials []SerialChange
out *render.Output
changed []*render.File
@@ -414,6 +474,13 @@ func (pl *plan) files() []string {
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
before := e.validate(base)
pl.before = before
pl.public = map[string]bool{}
for _, x := range e.Exposures(work) {
pl.public[x.View] = x.Public
// Server-wide findings (no view) count when anything is public.
pl.public[""] = pl.public[""] || x.Public
}
after := e.validate(work)
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
introducedKeys := map[string]bool{}
@@ -435,7 +502,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
pl.introduced = append(pl.introduced, problems...)
return pl, nil
}
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
pl.out = render.Render(work, e.cfg.Paths, e.extras(ctx))
deployed, err := e.store.DeployedFiles(ctx)
if err != nil {
return nil, err
@@ -471,18 +538,18 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
pl.diff = diff.String()
// Which zones named must reload, and which serials to verify.
baseOut := render.Render(base, e.cfg.Paths, e.cfg.Extras)
baseOut := render.Render(base, e.cfg.Paths, e.extras(ctx))
changedPaths := map[string]bool{}
for _, f := range pl.changed {
changedPaths[f.Path] = true
}
for _, z := range work.SortedZones() {
if z.Type != model.ZonePrimary {
continue
if z.Type != model.ZonePrimary || z.Disabled {
continue // disabled zones aren't in named: nothing to reload
}
path := pl.out.ZoneFiles[z.ID]
old := base.Zones[z.ID]
existedInNamed := old != nil && old.Type == model.ZonePrimary && baseOut.ZoneFiles[old.ID] == path
existedInNamed := old != nil && old.Type == model.ZonePrimary && !old.Disabled && baseOut.ZoneFiles[old.ID] == path
if !existedInNamed {
pl.newZones = true
pl.verify[z.ID] = z.SOA.Serial
@@ -496,13 +563,15 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
if len(work.Zones) != len(base.Zones) {
pl.configChanged = true
}
// Rules zones whose content changed. New ones come with a change to
// named.conf, and reconfig loads them.
// Rules zones and gates whose content changed. New ones come with a
// change to named.conf, and reconfig loads them.
for _, z := range pl.out.Policy {
if z.ListID != 0 || !changedPaths[z.File] {
continue
}
if slices.Contains(baseOut.Policy, z) {
if slices.ContainsFunc(baseOut.Policy, func(o render.PolicyZone) bool {
return o.Name == z.Name && o.ViewID == z.ViewID && o.File == z.File
}) {
pl.reloadPolicy = append(pl.reloadPolicy, z)
}
}
@@ -527,7 +596,7 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
}
defer os.RemoveAll(dir)
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
sout := render.Render(pl.work, sp, e.cfg.Extras)
sout := render.Render(pl.work, sp, e.extras(ctx))
for _, d := range sout.Dirs {
if err := os.MkdirAll(d, 0o750); err != nil {
return err
@@ -645,6 +714,7 @@ func (pl *plan) result() *Result {
Files: pl.files(),
Serials: pl.serials,
Drift: pl.drift,
Risks: risks(pl.before, pl.introduced, pl.public),
}
r.Valid = r.Valid && r.CheckOK
if r.Files == nil {
@@ -774,6 +844,13 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
if opts.DryRun {
return res, nil
}
if rs := risks(pl.before, pl.introduced, pl.public); len(rs) > 0 && !opts.AcceptRisk {
msg := rs[0].Message
if len(rs) > 1 {
msg += fmt.Sprintf(" (and %d more)", len(rs)-1)
}
return res, &Error{Kind: KindRisk, Message: "this change opens the server up: " + msg + "; apply it again with accept_risk to confirm (jarl: --accept-risk), or acknowledge the finding for good", Problems: rs}
}
if len(pl.drift) > 0 && !opts.Force {
var paths []string
for _, d := range pl.drift {
@@ -829,7 +906,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
res.Applied = true
e.removeDerived(pl)
for _, op := range cs.Ops {
if strings.HasPrefix(op.Kind, "blocklist.") {
if strings.HasPrefix(op.Kind, "blocklist.") || op.Kind == OpBlockingPause {
e.KickBlocklists()
break
}
@@ -1054,7 +1131,7 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
pl.configChanged = true
pl.reloadZones = nil
for _, z := range work.SortedZones() {
if z.Type == model.ZonePrimary {
if z.Type == model.ZonePrimary && !z.Disabled {
pl.reloadZones = append(pl.reloadZones, z)
pl.verify[z.ID] = z.SOA.Serial
}
@@ -1175,6 +1252,14 @@ type Status struct {
MainConfig string `json:"main_config"`
}
// NamedStatus asks named for its status (rndc status).
func (e *Engine) NamedStatus(ctx context.Context) (*bind.ServerStatus, error) {
if e.cfg.NoReload || e.tools == nil {
return nil, errors.New("named is not managed by this jarld (no_reload)")
}
return e.tools.Status(ctx)
}
// Status returns the current status.
func (e *Engine) Status(ctx context.Context) (*Status, error) {
st, err := e.store.LoadState(ctx)
@@ -1218,7 +1303,7 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
}
}
}
s.Problems = nonNilProblems(e.validate(st))
s.Problems = nonNilProblems(append(e.validate(st), e.namedProblems(ctx, st, deployed)...))
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
return s, nil
}
+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)
}
}
+13 -6
View File
@@ -21,19 +21,26 @@ const (
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
KindDrift ErrorKind = "drift" // files were changed outside Jarl
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
KindRisk ErrorKind = "confirm_risk" // the change opens the server up and needs confirming
)
// Error is an engine error with details for the caller.
type Error struct {
Kind ErrorKind `json:"code"`
Message string `json:"message"`
OpSeq int `json:"op,omitempty"` // the operation that failed
Problems []validate.Problem `json:"problems,omitempty"`
Check []CheckMessage `json:"check,omitempty"`
Diff string `json:"diff,omitempty"`
Kind ErrorKind `json:"code"`
Message string `json:"message"`
OpSeq int `json:"op,omitempty"` // the operation that failed
// OpSummary describes that operation, so people can find it in the
// changeset (record IDs mean little to them).
OpSummary string `json:"op_summary,omitempty"`
Problems []validate.Problem `json:"problems,omitempty"`
Check []CheckMessage `json:"check,omitempty"`
Diff string `json:"diff,omitempty"`
}
func (e *Error) Error() string {
if e.OpSeq > 0 && e.OpSummary != "" {
return fmt.Sprintf("operation %d (%s): %s", e.OpSeq, e.OpSummary, e.Message)
}
if e.OpSeq > 0 {
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
}
+715 -97
View File
File diff suppressed because it is too large. Load diff
+163
View File
@@ -0,0 +1,163 @@
package engine
import (
"net/netip"
"slices"
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// ExposureFix returns the requests that fix an exposure finding of a view
// (viewID 0: the server), and what they do, or nil when there is no
// automatic fix: what to allow instead (your secondaries, your hosts)
// is for you to decide. The requests go through a changeset like any
// edit (docs/exposure.md).
func ExposureFix(st *model.State, viewID int64, code string) ([]Request, string) {
v := st.Views[viewID]
scope, scopeID := model.ScopeOptions, int64(0)
if v != nil {
scope, scopeID = model.ScopeView, v.ID
}
set := func(name, value string) Request {
if o := st.FindOption(scope, scopeID, name); o != nil {
return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})
}
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, ScopeID: &scopeID, Name: &name, Value: &value})
}
server := func(name, value string) Request {
if o := st.FindOption(model.ScopeOptions, 0, name); o != nil {
return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})
}
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value})
}
switch code {
case "exposure_rate_limit":
value := RecommendedRateLimit(st, v)
return []Request{set("rate-limit", value)}, "start rate limiting in log-only mode: rate-limit " + value
case "exposure_rate_limit_log_only":
o := st.FindOption(scope, scopeID, "rate-limit")
if o == nil {
o = st.FindOption(model.ScopeOptions, 0, "rate-limit")
}
if o == nil {
return nil, ""
}
value := withoutLogOnly(o.Value)
return []Request{NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})}, "start enforcing the rate limit: rate-limit " + value
case "exposure_minimal_any":
return []Request{set("minimal-any", "yes")}, "answer ANY queries minimally: minimal-any yes"
case "exposure_cache_snooping":
return []Request{set("allow-query-cache", "{ none; }")}, "close the cache to everyone: allow-query-cache { none; }"
case "exposure_version":
return []Request{server("version", "none")}, "hide BIND's version: version none"
case "exposure_hostname":
return []Request{server("hostname", "none")}, "hide the host name: hostname none"
case "security_open_resolver":
if v == nil {
return nil, "" // a server without views is also your own resolver
}
return []Request{set("recursion", "no")}, "turn recursion off in view " + v.Name + ": recursion no"
}
return nil, ""
}
// RecommendedRateLimit is the rate-limit block Jarl suggests for a public
// view (v nil: the server): ISC's example of 10 identical responses per
// second per client network, in log-only mode until you have looked at
// what it would limit, with your secondaries exempt so zone transfers
// and their queries are never limited.
func RecommendedRateLimit(st *model.State, v *model.View) string {
parts := []string{"responses-per-second 10;", "log-only yes;"}
if ex := secondaries(st, v); len(ex) > 0 {
parts = append(parts, "exempt-clients { "+strings.Join(ex, "; ")+"; };")
}
return "{ " + strings.Join(parts, " ") + " }"
}
// secondaries returns the addresses the view's zones notify and allow
// transfers to (ACLs resolved), the servers that rate limiting must leave
// alone.
func secondaries(st *model.State, v *model.View) []string {
var opts []*model.Option
add := func(scope string, id int64) {
for _, name := range []string{"also-notify", "allow-transfer"} {
if o := st.FindOption(scope, id, name); o != nil {
opts = append(opts, o)
}
}
}
add(model.ScopeOptions, 0)
vid := int64(0)
if v != nil {
vid = v.ID
add(model.ScopeView, v.ID)
}
for _, z := range st.SortedZones() {
if z.ViewID == vid {
add(model.ScopeZone, z.ID)
}
}
seen := map[string]bool{}
var out []string
var addElems func(list []string, depth int)
addElems = func(list []string, depth int) {
for _, s := range list {
e, err := namedconf.ParseAMElement(s)
if err != nil || e.Negated {
continue
}
switch e.Kind {
case namedconf.AMPrefix, namedconf.AMRange:
if t := e.String(); !seen[t] {
seen[t] = true
out = append(out, t)
}
case namedconf.AMName:
if a := st.ACLByName(e.Name); a != nil && depth < 10 {
addElems(a.Elements, depth+1)
}
}
}
}
for _, o := range opts {
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil || stmt.FirstBlock() == nil {
continue
}
var list []string
for _, s := range stmt.FirstBlock().Stmts {
if o.Name == "also-notify" {
// "ADDRESS [port N] [key K]": the address only.
if len(s.Items) > 0 && s.Items[0].Value != nil {
if _, err := netip.ParseAddr(s.Items[0].Value.Text); err == nil {
list = append(list, s.Items[0].Value.Text)
}
}
continue
}
list = append(list, namedconf.Compact(s.Items))
}
addElems(list, 0)
}
slices.Sort(out)
return out
}
// withoutLogOnly removes "log-only ...;" from a rate-limit block.
func withoutLogOnly(value string) string {
st, err := namedconf.ParseOptionValue("rate-limit", value)
if err != nil || st.FirstBlock() == nil {
return value
}
b := st.FirstBlock()
var keep []*namedconf.Stmt
for _, s := range b.Stmts {
if s.Keyword() != "log-only" {
keep = append(keep, s)
}
}
b.Stmts = keep
return namedconf.Compact(st.Items[1:])
}
+142
View File
@@ -0,0 +1,142 @@
package engine
import (
"net/netip"
"strings"
"testing"
"jarl/internal/model"
"jarl/internal/validate"
)
func problemFor(ps []validate.Problem, code, view string) *validate.Problem {
for i, p := range ps {
if p.Code == code && (view == "" || p.View == view) {
return &ps[i]
}
}
return nil
}
func TestExposure(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin,
// An internal network with a global IPv6 prefix is still yours.
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("internal-clients"), Elements: &[]string{"2a02:8013:7f3d:10::/64", "192.168.1.0/24"}}),
optionReq("allow-transfer", "{ 192.0.2.53; }"),
)
env.submit(env.admin,
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"internal-clients", "localhost"}, AdoptZones: true}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"!internal-clients", "any"}}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("dmz"), MatchClients: &[]string{"any"}, MatchDestinations: &[]string{"10.9.0.53"}}),
)
st := env.state()
exps := map[string]validate.Exposure{}
for _, x := range env.e.Exposures(st) {
exps[x.View] = x
}
if x := exps["internal"]; x.Public || !strings.Contains(x.Reason, "no catch-all") {
t.Errorf("internal = %+v", x)
}
if x := exps["external"]; !x.Public || x.Reason != "match-clients ends in any" {
t.Errorf("external = %+v", x)
}
if x := exps["dmz"]; x.Public || !strings.Contains(x.Reason, "match-destinations") {
t.Errorf("dmz = %+v", x)
}
ps := env.e.validate(st)
for _, code := range []string{"exposure_rate_limit", "exposure_minimal_any"} {
if problemFor(ps, code, "external") == nil {
t.Errorf("no %s for external", code)
}
if problemFor(ps, code, "internal") != nil || problemFor(ps, code, "dmz") != nil {
t.Errorf("%s for a private view", code)
}
}
for _, code := range []string{"exposure_version", "exposure_hostname"} {
if problemFor(ps, code, "") == nil {
t.Errorf("no %s", code)
}
}
// The rate limit fix starts in log-only mode and exempts the
// secondaries.
ext := st.ViewByName("external")
reqs, desc := ExposureFix(st, ext.ID, "exposure_rate_limit")
if len(reqs) != 1 || !strings.Contains(desc, "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.53; }; }") {
t.Fatalf("fix = %v %q", reqs, desc)
}
env.submit(env.admin, reqs...)
ps = env.e.validate(env.state())
if problemFor(ps, "exposure_rate_limit", "external") != nil {
t.Error("rate limit warning stays after the fix")
}
if p := problemFor(ps, "exposure_rate_limit_log_only", "external"); p == nil || p.Severity != validate.Info {
t.Errorf("log-only = %+v", p)
}
reqs, desc = ExposureFix(env.state(), ext.ID, "exposure_rate_limit_log_only")
if !strings.Contains(desc, "{ responses-per-second 10; exempt-clients { 192.0.2.53; }; }") {
t.Errorf("enforce = %q", desc)
}
env.submit(env.admin, reqs...)
if p := problemFor(env.e.validate(env.state()), "exposure_rate_limit_log_only", "external"); p != nil {
t.Errorf("still log-only: %+v", p)
}
// Opening the server up needs confirming.
open := optionReqView("allow-recursion", "{ any; }", ext.ID)
if e := env.submitErr(env.admin, open); e.Kind != KindRisk || !strings.Contains(e.Message, "open resolver") {
t.Fatalf("open resolver without confirming: %+v", e)
}
if _, err := env.e.Submit(env.ctx, []Request{open}, env.admin, "test", "", ApplyOptions{AcceptRisk: true}); err != nil {
t.Fatalf("with accept risk: %v", err)
}
// Acknowledged, it stays as info, with who and why.
env.submit(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver", Reason: "public resolver for the club"}))
p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external")
if p == nil || p.Severity != validate.Info || p.Acknowledged == nil || !strings.Contains(p.Message, "acknowledged by admin on 7 Oct 2026: public resolver for the club") {
t.Errorf("acknowledged = %+v", p)
}
if e := env.submitErr(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver"})); e.Kind != KindBadRequest {
t.Errorf("ack without a reason: %+v", e)
}
// Reverting the acknowledgement brings the warning back.
env.submit(env.admin, NewRequest(OpExposureUnack, 0, AckInput{View: "external", Code: "security_open_resolver"}))
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p == nil || p.Severity != validate.Warning {
t.Errorf("after unack = %+v", p)
}
// Marking a view private takes it out of the checks.
env.submit(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("private")}))
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p != nil {
t.Errorf("private view still checked: %+v", p)
}
if e := env.submitErr(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("internet")})); e.Kind != KindBadRequest {
t.Errorf("bad exposure: %+v", e)
}
}
// A server without views is public only when named listens on an
// address that isn't private.
func TestServerExposure(t *testing.T) {
env := newEnv(t)
setupZone(env)
if x := env.e.Exposures(env.state())[0]; x.Public {
t.Errorf("no addresses known = %+v", x)
}
env.e.cfg.LocalAddrs = func() []netip.Addr {
return []netip.Addr{netip.MustParseAddr("192.168.1.2"), netip.MustParseAddr("2a02:8013:7f3d:10::53")}
}
if x := env.e.Exposures(env.state())[0]; !x.Public || !strings.Contains(x.Reason, "2a02:8013:7f3d:10::53") {
t.Errorf("GUA address = %+v", x)
}
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Exposure: model.Ptr("private")}))
if x := env.e.Exposures(env.state())[0]; x.Public || x.Override != "private" {
t.Errorf("marked private = %+v", x)
}
}
func optionReqView(name, value string, viewID int64) Request {
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &viewID, Name: &name, Value: &value})
}
+22
View File
@@ -0,0 +1,22 @@
package engine
import (
"context"
"io/fs"
)
// WriteFile writes a file outside the rendered configuration (a
// certificate for named, docs/certificates.md) the way deployed files are
// written: atomically, with bind.file_group, creating its directory.
func (e *Engine) WriteFile(path string, content []byte, mode fs.FileMode) error {
return e.deploy.writeAtomic(path, content, mode)
}
// Reconfig makes named reread its configuration, and with it the files
// its tls blocks name (nothing with bind.no_reload).
func (e *Engine) Reconfig(ctx context.Context) error {
if e.cfg.NoReload {
return nil
}
return e.tools.Reconfig(ctx)
}
+149 -2
View File
@@ -2,6 +2,7 @@ package engine
import (
"encoding/json"
"time"
"jarl/internal/model"
)
@@ -36,19 +37,62 @@ const (
OpBlockRuleCreate = "blockrule.create"
OpBlockRuleUpdate = "blockrule.update"
OpBlockRuleDelete = "blockrule.delete"
OpBlockingPause = "blocking.pause"
OpBlockStepCreate = "blockstep.create"
OpBlockStepUpdate = "blockstep.update"
OpBlockStepDelete = "blockstep.delete"
OpClientGroupCreate = "clientgroup.create"
OpClientGroupUpdate = "clientgroup.update"
OpClientGroupDelete = "clientgroup.delete"
// Requests only: disabling and enabling records, by ID or by match
// (zone, name, type and optionally data), become record.update
// operations.
OpRecordDisable = "record.disable"
OpRecordEnable = "record.enable"
// Acknowledging an exposure finding (docs/exposure.md), and taking it
// back, become view.update or settings.update operations.
OpExposureAck = "exposure.ack"
OpExposureUnack = "exposure.unack"
// Linked zones (docs/views.md#linked-zones): requests that expand
// 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 {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Code string `json:"code"`
Reason string `json:"reason,omitempty"`
}
// OpKinds lists all operation kinds.
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete}
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause,
OpBlockStepCreate, OpBlockStepUpdate, OpBlockStepDelete, OpClientGroupCreate, OpClientGroupUpdate, OpClientGroupDelete}
// Request is an operation as submitted by a client. Requests are expanded
// into one or more stored operations (for example an A record with "ptr"
@@ -106,6 +150,24 @@ type ZoneInput struct {
// finished, off ("").
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
Comment *string `json:"comment,omitempty"`
// Disabled keeps the zone in Jarl but out of named's configuration.
Disabled *bool `json:"disabled,omitempty"`
// LinkGroup is the zone's link group (0: not linked). Set by zone.link
// and zone.unlink; requests use those.
LinkGroup *int64 `json:"link_group,omitempty"`
// LinkWith (create only) links the new zone with the zone of the same
// 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
@@ -138,10 +200,38 @@ 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"`
// Local (requests, linked zones): true keeps the record in this view
// only (an override: the whole name and type becomes local here);
// false shares it with the linked zones. Unset: like the records of
// the same name and type, shared when there are none.
Local *bool `json:"local,omitempty"`
// SharedID (stored operations) ties the copies of a shared record
// together; 0 makes it local.
SharedID *int64 `json:"shared_id,omitempty"`
}
// LinkInput links zones of the same name in several views, or (unlink)
// takes one out of its link.
type LinkInput struct {
ZoneID int64 `json:"zone_id,omitempty"`
Zone string `json:"zone,omitempty"`
View string `json:"view,omitempty"`
// With are the views whose zone of the same name to link with (link
// only; default: every view that has one).
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.
@@ -156,6 +246,11 @@ type RRSetInput struct {
Comment *string `json:"comment,omitempty"`
// NewIDs are allocated for records that need to be created.
NewIDs []int64 `json:"new_ids,omitempty"`
// Local (requests): like RecordInput.Local, for the whole set.
Local *bool `json:"local,omitempty"`
// Shared (stored operations, linked zones): the records are shared
// (true) or local (false).
Shared *bool `json:"shared,omitempty"`
}
// ViewInput creates or updates a view.
@@ -172,6 +267,14 @@ type ViewInput struct {
AdoptZones bool `json:"adopt_zones,omitempty"`
// Cascade (delete) also deletes the view's zones.
Cascade bool `json:"cascade,omitempty"`
// 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.
@@ -215,6 +318,14 @@ type SettingsInput struct {
Expire *uint32 `json:"expire,omitempty"`
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
SerialPolicy *string `json:"serial_policy,omitempty"`
// 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.
@@ -232,6 +343,31 @@ type BlocklistInput struct {
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
// Step is the step the list starts at, by name ("normal" or a step
// above it) or ID (StepID; 0 is normal).
Step *string `json:"step,omitempty"`
StepID *int64 `json:"step_id,omitempty"`
}
// BlockStepInput creates or updates a blocking step above Normal.
// Position orders the steps (new steps go on top).
type BlockStepInput struct {
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ClientGroupInput creates or updates a client group. Members are
// addresses, prefixes or ACL names; the step is given by name ("off",
// "normal" or a step) or ID; views as for BlocklistInput.
type ClientGroupInput struct {
Name *string `json:"name,omitempty"`
Members *[]string `json:"members,omitempty"`
Step *string `json:"step,omitempty"`
StepID *int64 `json:"step_id,omitempty"`
Views *[]string `json:"views,omitempty"`
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// BlockRuleInput creates or updates a block or allow rule. Views as for
@@ -244,3 +380,14 @@ type BlockRuleInput struct {
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// BlockingPauseInput pauses blocking until a time, or resumes it (null).
type BlockingPauseInput struct {
Until *time.Time `json:"until"`
// Indefinitely pauses until someone resumes blocking (Until is nil).
Indefinitely bool `json:"indefinitely,omitempty"`
}
// MaxBlockingPause is the longest pause: blocking shouldn't stay off by
// mistake (disable lists to stop using them).
const MaxBlockingPause = 24 * time.Hour
+514
View File
@@ -0,0 +1,514 @@
package engine
// Linked zones (docs/views.md#linked-zones). Zones of the same name in
// different views can be linked: their shared records are kept in step,
// and each zone can override a name and type with records local to its
// view. Every zone keeps its own copy of each shared record (with the
// same SharedID), so the rest of Jarl sees ordinary zones.
//
// Keeping them in step happens when requests are expanded: the request's
// operations are applied to a scratch copy, and linkSync adds explicit
// operations for the other zones of the link. So a change to a shared
// record is one changeset with one operation per zone: the diff, the
// permission checks, history and undo all see every zone.
import (
"fmt"
"slices"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
)
type rrKey struct {
zone int64
name, typ string
}
// linkSync returns ops followed by the operations that bring the other
// zones of each affected link in step.
func (x *expander) linkSync(ops []model.Op) ([]model.Op, error) {
if !x.st.AnyLinked() {
return ops, 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)
var extra []model.Op
for _, k := range keys {
z := work.Zones[k.zone]
if z == nil || z.LinkGroup == 0 {
continue
}
more, err := x.syncKey(work, z, k.name, k.typ)
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...)
}
return append(ops, extra...), nil
}
// affectedKeys lists the (zone, name, type) sets that ops touch, before
// and after.
func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
var out []rrKey
seen := map[rrKey]bool{}
add := func(zone int64, name, typ string) {
k := rrKey{zone, name, typ}
if zone != 0 && name != "" && !seen[k] {
seen[k] = true
out = append(out, k)
}
}
fromRecord := func(id int64) {
for _, st := range []*model.State{pre, post} {
if r := st.Records[id]; r != nil {
add(r.ZoneID, r.Name, r.Type)
}
}
}
for _, op := range ops {
switch op.Kind {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete:
if op.ID != 0 {
fromRecord(op.ID)
}
if op.Kind == OpRecordDelete && op.ID == 0 {
var in RecordInput
if decode(op.Data, &in) == nil && in.Name != nil && in.Type != nil {
add(in.ZoneID, *in.Name, *in.Type)
}
}
case OpRRSetReplace:
var in RRSetInput
if decode(op.Data, &in) == nil {
add(in.ZoneID, in.Name, in.Type)
}
}
}
return out
}
// syncKey brings the other zones of z's link in step with z for one name
// and type, after z was changed:
//
// - z overrides it (local records): z's shared copies go; the others
// keep theirs.
// - 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).
func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]model.Op, error) {
var ops []model.Op
where := func(o *model.Zone) string { return zoneDesc(st, o) }
if st.HasLocal(z.ID, name, typ) {
for _, r := range st.SharedRecords(z.ID, name, typ) {
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil,
fmt.Sprintf("linked: %s %s is local in %s now, so its shared %s goes there", name, typ, where(z), r.Data)))
}
return ops, nil
}
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) {
// 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
}
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))))
}
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))
}
ops = append(ops, more...)
}
return ops, nil
}
for _, o := range members {
if st.HasLocal(o.ID, name, typ) {
continue // o overrides this name and type
}
copies := map[int64]*model.Record{}
for _, r := range st.SharedRecords(o.ID, name, typ) {
copies[r.SharedID] = r
}
var more []model.Op
for _, t := range truth {
cp := copies[t.SharedID]
delete(copies, t.SharedID)
if cp == nil {
op, err := x.copyRecord(st, o, t, fmt.Sprintf("linked: add %s %s %s in %s", name, typ, t.Data, where(o)))
if err != nil {
return nil, err
}
more = append(more, op)
continue
}
in, changes := recordDiff(cp, t)
if len(changes) > 0 {
more = append(more, newOp(OpRecordUpdate, cp.ID, cp.Version, in,
fmt.Sprintf("linked: change %s %s %s in %s: %s", name, typ, cp.Data, where(o), strings.Join(changes, ", "))))
}
}
for _, id := range sortedKeys(copies) {
cp := copies[id]
more = append(more, newOp(OpRecordDelete, cp.ID, cp.Version, nil, fmt.Sprintf("linked: delete %s %s %s in %s", name, typ, cp.Data, where(o))))
}
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
return nil, errf(KindForbidden, "%s %s is shared with %s, which you may not change; to change it in %s only, make it local to that view (local: true, or --local)",
dnsname.Display(name), typ, where(o), where(z))
}
ops = append(ops, more...)
}
return ops, nil
}
// copyRecord returns an operation that creates a copy of shared record r
// in zone z.
func (x *expander) copyRecord(st *model.State, z *model.Zone, r *model.Record, summary string) (model.Op, error) {
if !x.p.Can(recordAction(st, z, r.Name, r.Type, true)) {
return model.Op{}, errf(KindForbidden, "%s %s is shared with %s, which you may not change", dnsname.Display(r.Name), r.Type, zoneDesc(st, z))
}
ids, err := x.ids(1)
if err != nil {
return model.Op{}, err
}
return newOp(OpRecordCreate, ids[0], 0, recordCopyInput(z.ID, r), summary), nil
}
func recordCopyInput(zoneID int64, r *model.Record) RecordInput {
in := RecordInput{ZoneID: zoneID, Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data),
Comment: model.Ptr(r.Comment), Disabled: model.Ptr(r.Disabled), SharedID: model.Ptr(r.SharedID)}
if r.TTL != nil {
in.TTL = model.Some(*r.TTL)
} else {
in.TTL = model.Opt[uint32]{Set: true, Null: true}
}
return in
}
// recordDiff returns the update that makes cp like t, and what changes.
func recordDiff(cp, t *model.Record) (RecordInput, []string) {
var in RecordInput
var changes []string
if cp.Data != t.Data {
in.Data = model.Ptr(t.Data)
changes = append(changes, "data "+t.Data)
}
if !sameTTL(cp.TTL, t.TTL) {
if t.TTL != nil {
in.TTL = model.Some(*t.TTL)
changes = append(changes, fmt.Sprintf("ttl %d", *t.TTL))
} else {
in.TTL = model.Opt[uint32]{Set: true, Null: true}
changes = append(changes, "ttl default")
}
}
if cp.Comment != t.Comment {
in.Comment = model.Ptr(t.Comment)
changes = append(changes, "comment")
}
if cp.Disabled != t.Disabled {
in.Disabled = model.Ptr(t.Disabled)
changes = append(changes, enableWord(t.Disabled)+"d")
}
return in, changes
}
// linkTarget resolves the zone a link request names.
func (x *expander) linkTarget(in LinkInput) (*model.Zone, error) {
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
if z.Type != model.ZonePrimary {
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, z), z.Type)
}
return z, nil
}
// zoneLink links zones of the same name in several views. It never
// changes what named answers: a name and type with the same records in
// every zone becomes shared; anything that differs stays local to each
// view (and can be shared later, from the view whose version is right).
func (x *expander) zoneLink(req Request) ([]model.Op, error) {
var in LinkInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
z, err := x.linkTarget(in)
if err != nil {
return nil, err
}
members := []*model.Zone{z}
add := func(o *model.Zone) {
if !slices.ContainsFunc(members, func(m *model.Zone) bool { return m.ID == o.ID }) {
members = append(members, o)
}
}
if len(in.With) == 0 {
for _, o := range x.st.ZonesNamed(z.Name) {
if o.Type == model.ZonePrimary && o.ID != z.ID {
add(o)
}
}
}
for _, vn := range in.With {
v := x.st.ViewByName(vn)
if v == nil {
return nil, errf(KindNotFound, "view %q does not exist", vn)
}
o := x.st.FindZone(z.Name, v.ID)
if o == nil {
return nil, errf(KindNotFound, "view %s has no zone %s (create it linked: jarl zone create %s --view %s --link-with %s)", v.Name, dnsname.Display(z.Name), dnsname.Display(z.Name), v.Name, x.st.ViewName(z))
}
if o.Type != model.ZonePrimary {
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, o), o.Type)
}
add(o)
}
// Zones already linked with any of them join too.
for _, m := range slices.Clone(members) {
for _, o := range x.st.LinkMembers(m.LinkGroup) {
add(o)
}
}
if len(members) < 2 {
return nil, errf(KindBadRequest, "zone %s exists in no other view to link it with", dnsname.Display(z.Name))
}
x.st.SortByView(members)
group := members[0].ID
for _, m := range members {
if m.LinkGroup != 0 {
group = m.LinkGroup
break
}
}
// What is the same everywhere?
type key struct{ name, typ string }
sig := func(rs []*model.Record) string {
var parts []string
for _, r := range rs {
ttl := "-"
if r.TTL != nil {
ttl = fmt.Sprint(*r.TTL)
}
parts = append(parts, fmt.Sprintf("%s|%s|%v", strings.ToLower(r.Data), ttl, r.Disabled))
}
slices.Sort(parts)
return strings.Join(parts, "\n")
}
keys := map[key]bool{}
var order []key
for _, m := range members {
for _, r := range x.st.ZoneRecords(m.ID) {
k := key{r.Name, r.Type}
if !keys[k] {
keys[k] = true
order = append(order, k)
}
}
}
var ops []model.Op
var local []string
shared := 0
for _, k := range order {
first := x.st.RRset(members[0].ID, k.name, k.typ)
same := len(first) > 0
for _, m := range members[1:] {
if rs := x.st.RRset(m.ID, k.name, k.typ); len(rs) == 0 || sig(rs) != sig(first) {
same = false
break
}
}
if same {
shared++
} else {
var in []string
for _, m := range members {
if len(x.st.RRset(m.ID, k.name, k.typ)) > 0 {
in = append(in, x.st.ViewName(m))
}
}
local = append(local, fmt.Sprintf("%s %s (%s)", dnsname.Display(k.name), k.typ, strings.Join(in, ", ")))
}
// Pair the copies: record i of the first zone's set (by data) with
// the same data in the others.
byData := func(rs []*model.Record) []*model.Record {
out := slices.Clone(rs)
slices.SortStableFunc(out, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
return out
}
ref := byData(first)
for _, m := range members {
rs := byData(x.st.RRset(m.ID, k.name, k.typ))
for i, r := range rs {
want := int64(0)
var comment *string
if same {
want = ref[i].ID
if r.Comment != ref[i].Comment && ref[i].Comment != "" {
comment = model.Ptr(ref[i].Comment)
}
}
if r.SharedID == want && comment == nil {
continue
}
in := RecordInput{SharedID: model.Ptr(want), Comment: comment}
what := "local to view " + x.st.ViewName(m)
if same {
what = "shared"
}
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s %s: %s", r.Name, r.Type, r.Data, what)))
}
}
}
var views []string
for _, m := range members {
views = append(views, x.st.ViewName(m))
}
var zoneOps []model.Op
for _, m := range members {
if m.LinkGroup != group {
zoneOps = append(zoneOps, newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(group)},
fmt.Sprintf("link zone %s in views %s", zoneDesc(x.st, m), strings.Join(views, ", "))))
}
}
if len(local) > 0 {
x.warnings = append(x.warnings, fmt.Sprintf("%d names differ between the views and stay local to each: %s. Share one from the view whose version is right (jarl record update ID --shared, or Share in the web UI).", len(local), strings.Join(local, "; ")))
}
x.warnings = append(x.warnings, fmt.Sprintf("%d names and types are shared between views %s", shared, strings.Join(views, ", ")))
return append(zoneOps, ops...), nil
}
// zoneUnlink takes a zone out of its link: its records stay as they are,
// all local. A link left with one zone is dissolved.
func (x *expander) zoneUnlink(req Request) ([]model.Op, error) {
var in LinkInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
if z.LinkGroup == 0 {
return nil, errf(KindConflict, "zone %s isn't linked", zoneDesc(x.st, z))
}
out := []*model.Zone{z}
if rest := x.st.LinkedWith(z); len(rest) == 1 {
out = append(out, rest[0])
}
var ops []model.Op
for _, m := range out {
ops = append(ops, x.unlinkOps(m, "unlink zone "+zoneDesc(x.st, m))...)
}
return ops, nil
}
// unlinkOps returns the operations that take zone m out of its link.
func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
ops := []model.Op{newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(int64(0))}, summary)}
for _, r := range x.st.ZoneRecords(m.ID) {
if r.SharedID != 0 {
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))}, fmt.Sprintf("%s %s %s: no longer shared", r.Name, r.Type, r.Data)))
}
}
return ops
}
// 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) {
if 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
// joins group and all its records become shared.
func (x *expander) shareAllOps(z *model.Zone, group int64) []model.Op {
ops := []model.Op{newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{LinkGroup: model.Ptr(group)}, "link zone "+zoneDesc(x.st, z))}
for _, r := range x.st.ZoneRecords(z.ID) {
if r.SharedID == 0 {
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(r.ID)}, fmt.Sprintf("%s %s %s: shared", r.Name, r.Type, r.Data)))
}
}
return ops
}
// dropOverrides returns the operations that delete the other linked
// zones' local records of a name and type, so the shared version reaches
// them (sharing a version makes it every view's).
func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
var ops []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))))
}
}
}
return ops
}
+449
View File
@@ -0,0 +1,449 @@
package engine
import (
"slices"
"strings"
"testing"
"jarl/internal/auth"
"jarl/internal/model"
"jarl/internal/validate"
)
// linkedEnv has views internal and external and zone example.com in
// internal with a few records.
func linkedEnv(t *testing.T) *testEnv {
env := newEnv(t)
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
env.submit(env.admin,
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
env.submit(env.admin,
recIn("internal", "www", "A", "192.0.2.10"),
recIn("internal", "mail", "A", "192.0.2.25"),
recIn("internal", "@", "MX", "10 mail"))
return env
}
func recIn(view, name, typ, data string) Request {
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: view, Name: &name, Type: &typ, Data: &data})
}
// view returns a view's records of example.com as "name type data S|L",
// S for shared, L for local.
func (env *testEnv) view(name string) []string {
env.t.Helper()
s := env.state()
v := s.ViewByName(name)
z := s.FindZone("example.com.", v.ID)
var out []string
for _, r := range s.ZoneRecords(z.ID) {
if r.Type == "NS" {
continue
}
mark := "L"
if r.SharedID != 0 {
mark = "S"
}
if r.Disabled {
mark += " disabled"
}
out = append(out, strings.TrimSuffix(r.Name, ".example.com.")+" "+r.Type+" "+r.Data+" "+mark)
}
return out
}
func (env *testEnv) recordID(view, name, typ string) int64 {
env.t.Helper()
s := env.state()
z := s.FindZone("example.com.", s.ViewByName(view).ID)
for _, r := range s.ZoneRecords(z.ID) {
if r.Name == name+".example.com." && r.Type == typ {
return r.ID
}
}
env.t.Fatalf("no %s %s in %s", name, typ, view)
return 0
}
func want(t *testing.T, what string, got []string, w ...string) {
t.Helper()
if !slices.Equal(got, w) {
t.Errorf("%s:\n got %q\n want %q", what, got, w)
}
}
func TestLinkedZones(t *testing.T) {
env := linkedEnv(t)
// A zone created linked copies everything; both are linked and shared.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
all := []string{"mail A 192.0.2.25 S", "www A 192.0.2.10 S", "example.com. MX 10 mail.example.com. S"}
slices.Sort(all)
got := env.view("external")
slices.Sort(got)
want(t, "linked copy", got, all...)
s := env.state()
if zs := s.ZonesNamed("example.com."); zs[0].LinkGroup == 0 || zs[0].LinkGroup != zs[1].LinkGroup {
t.Fatalf("not linked: %+v %+v", zs[0], zs[1])
}
// Adding, changing, disabling and deleting a shared record reaches the other view.
env.submit(env.admin, recIn("external", "ftp", "CNAME", "www"))
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S") {
t.Errorf("shared add didn't reach internal: %q", env.view("internal"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("192.0.2.26")}))
if !slices.Contains(env.view("external"), "mail A 192.0.2.26 S") {
t.Errorf("shared change didn't reach external: %q", env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordDisable, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S disabled") {
t.Errorf("disable didn't reach internal: %q", env.view("internal"))
}
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
for _, v := range []string{"internal", "external"} {
for _, r := range env.view(v) {
if strings.HasPrefix(r, "ftp ") {
t.Errorf("delete by match left %s in %s", r, v)
}
}
}
// A local override: www in internal points elsewhere; external keeps
// the shared one.
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.10"), Local: model.Ptr(true)}))
if !slices.Contains(env.view("internal"), "www A 10.0.0.10 L") || slices.Contains(env.view("internal"), "www A 192.0.2.10 S") {
t.Errorf("override: internal %q", env.view("internal"))
}
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
t.Errorf("override changed external: %q", env.view("external"))
}
_ = res
// A new record of an overridden set joins it as local.
env.submit(env.admin, recIn("internal", "www", "A", "10.0.0.11"))
if !slices.Contains(env.view("internal"), "www A 10.0.0.11 L") || len(env.view("external")) != 3 {
t.Errorf("join override: internal %q external %q", env.view("internal"), env.view("external"))
}
// Changing the shared www in external doesn't touch internal's override.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"192.0.2.11"}}))
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 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.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.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(true)}))
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.25")}))
if !slices.Contains(env.view("external"), "mail A 198.51.100.25 L") || !slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
t.Errorf("local edit: internal %q external %q", env.view("internal"), env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(false)}))
if !slices.Contains(env.view("internal"), "mail A 198.51.100.25 S") || slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
t.Errorf("share external's version: internal %q", env.view("internal"))
}
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
t.Errorf("validation: %+v", probs)
}
// Undoing a changeset undoes it in both views.
r := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.99")}))
cs, err := env.e.Revert(env.ctx, r.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err != nil {
t.Fatal(err)
}
for _, v := range []string{"internal", "external"} {
if !slices.Contains(env.view(v), "mail A 198.51.100.25 S") {
t.Errorf("revert in %s: %q", v, env.view(v))
}
}
// Unlinking keeps every record, all local.
env.submit(env.admin, NewRequest(OpZoneUnlink, 0, LinkInput{Zone: "example.com", View: "external"}))
for _, z := range env.state().ZonesNamed("example.com.") {
if z.LinkGroup != 0 {
t.Errorf("still linked: %s", zoneDesc(env.state(), z))
}
}
for _, v := range []string{"internal", "external"} {
for _, r := range env.view(v) {
if strings.HasSuffix(r, " S") {
t.Errorf("still shared after unlink in %s: %s", v, r)
}
}
}
env.submit(env.admin, recIn("internal", "solo", "A", "10.0.0.5"))
if slices.Contains(env.view("external"), "solo A 10.0.0.5 L") {
t.Error("an unlinked zone was changed")
}
// Linking again works out what is shared: same in both, or not.
env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "internal"}))
in, ex := env.view("internal"), env.view("external")
if !slices.Contains(in, "mail A 198.51.100.25 S") || !slices.Contains(ex, "mail A 198.51.100.25 S") {
t.Errorf("relink: same records not shared: internal %q external %q", in, ex)
}
if !slices.Contains(in, "solo A 10.0.0.5 L") {
t.Errorf("relink: internal-only record should be local: %q", in)
}
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
t.Errorf("validation after relink: %+v", probs)
}
// Deleting one zone of two dissolves the link.
z := env.state().FindZone("example.com.", env.state().ViewByName("external").ID)
env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
if zs := env.state().ZonesNamed("example.com."); len(zs) != 1 || zs[0].LinkGroup != 0 {
t.Errorf("after deleting external: %+v", zs)
}
for _, r := range env.view("internal") {
if strings.HasSuffix(r, " S") {
t.Errorf("shared record left in an unlinked zone: %s", r)
}
}
}
func hasCode(probs []validate.Problem, code string) bool {
for _, p := range probs {
if p.Code == code {
return true
}
}
return false
}
func TestLinkedPermissions(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: "lan", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Views: []string{"internal"}}, {Resource: auth.ResZones, Access: "read"}}}
if err := env.st.CreateUser(env.ctx, u); err != nil {
t.Fatal(err)
}
lan := &auth.Principal{User: u, Method: "token"}
// A shared change would reach external, which this user may not change.
e := env.submitErr(lan, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Data: model.Ptr("10.9.9.9")}))
if e.Kind != KindForbidden || !strings.Contains(e.Message, "shared with example.com [external]") || !strings.Contains(e.Message, "local") {
t.Errorf("shared change: %v", e)
}
// A local override is theirs to make.
env.submit(lan, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.9.9.9"), Local: model.Ptr(true)}))
if !slices.Contains(env.view("internal"), "www A 10.9.9.9 L") || !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
t.Errorf("override by a view-restricted user: internal %q external %q", env.view("internal"), env.view("external"))
}
// Records of a zone that isn't linked can't be "local".
e = env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.org"), View: model.Ptr("internal"), LinkWith: "external"}))
if e.Kind != KindNotFound {
t.Errorf("link_with a view without the zone: %v", e)
}
}
func TestLinkExisting(t *testing.T) {
env := linkedEnv(t)
// external was made by hand: the same apart from www.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external")}))
env.submit(env.admin,
recIn("external", "www", "A", "203.0.113.10"),
recIn("external", "mail", "A", "192.0.2.25"),
recIn("external", "@", "MX", "10 mail"))
before := []string{}
for _, v := range []string{"internal", "external"} {
before = append(before, strings.Join(env.view(v), ";"))
}
res := env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "external", With: []string{"internal"}}))
in, ex := env.view("internal"), env.view("external")
if !slices.Contains(in, "www A 192.0.2.10 L") || !slices.Contains(ex, "www A 203.0.113.10 L") {
t.Errorf("different www should stay local: internal %q external %q", in, ex)
}
if !slices.Contains(in, "mail A 192.0.2.25 S") || !slices.Contains(ex, "mail A 192.0.2.25 S") {
t.Errorf("same mail should be shared: internal %q external %q", in, ex)
}
if !strings.Contains(strings.Join(res.Warnings, " "), "www.example.com A (internal, external)") {
t.Errorf("warnings: %q", res.Warnings)
}
// Linking never changes what named answers: the same data as before.
for i, v := range []string{"internal", "external"} {
var data []string
for _, r := range env.view(v) {
data = append(data, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
}
var was []string
for _, r := range strings.Split(before[i], ";") {
was = append(was, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
}
want(t, "answers in "+v, data, was...)
}
// Sharing internal's www replaces external's.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
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)
}
}
+77
View File
@@ -0,0 +1,77 @@
package engine
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/validate"
)
// namedCheckTTL is how long the result of checking named's running
// configuration is kept when nothing changed.
const namedCheckTTL = 10 * time.Minute
// namedCheck caches the warnings of named-checkconf on the configuration
// named runs, keyed by what it depends on.
type namedCheck struct {
mu sync.Mutex
key string
at time.Time
problems []validate.Problem
}
// namedProblems runs named-checkconf on the configuration named runs
// (without loading zones, which is quick) and returns what it reports as
// problems. An apply shows these too, but they are named's own checks,
// which Jarl doesn't repeat ("both recursion no and allow-recursion
// active"): the dashboard lists them with Jarl's so they don't go unseen
// after the apply. The result is reused while the deployed files and
// named's main file are unchanged.
func (e *Engine) namedProblems(ctx context.Context, st *model.State, deployed map[string]*model.DeployedFile) []validate.Problem {
if e.tools == nil || e.cfg.SkipCheck || e.cfg.MainConfig == "" || len(deployed) == 0 {
return nil
}
key := ""
if fi, err := os.Stat(e.cfg.MainConfig); err == nil {
key = fi.ModTime().String()
}
if f := deployed[e.cfg.Paths.MainConfig()]; f != nil {
key += "|" + f.SHA256
}
c := &e.namedCheck
c.mu.Lock()
defer c.mu.Unlock()
if c.key == key && e.Now().Sub(c.at) < namedCheckTTL {
return c.problems
}
res, err := e.tools.CheckConfig(ctx, e.cfg.MainConfig, false)
if err != nil {
return nil // named-checkconf couldn't run; an apply reports that
}
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
var ps []validate.Problem
for _, m := range res.Messages {
p := validate.Problem{Severity: validate.Warning, Code: "named_warning", View: m.View, Zone: m.Zone}
if m.Error {
p.Severity, p.Code = validate.Error, "named_error"
}
where := ""
if m.File != "" {
where = fmt.Sprintf(" (%s:%d)", m.File, m.Line)
}
if span, ok := out.Lookup(m.File, m.Line); ok {
p.ObjectType, p.ObjectID = span.ObjectType, span.ObjectID
where = " (" + span.Label + ", " + where[2:]
}
p.Message = "named-checkconf: " + strings.TrimSpace(m.Text) + where
ps = append(ps, p)
}
c.key, c.at, c.problems = key, e.Now(), ps
return ps
}
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more