Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e5bbb628 | ||
|
|
740f3cc328 | ||
|
|
5fcf4082c9 | ||
|
|
1cec6cc393 | ||
|
|
921bdd6c95 | ||
|
|
12820b17bb | ||
|
|
ead749dd3d | ||
|
|
66dcf53684 | ||
|
|
ebd3a3dfbe | ||
|
|
fd0cbb47a9 | ||
|
|
057433c198 | ||
|
|
6313fa51f8 | ||
|
|
88f63ffb0a | ||
|
|
88965096bb | ||
|
|
bad360107e | ||
|
|
bdd7acfd3b | ||
|
|
cf550bd512 | ||
|
|
cee11e7c86 | ||
|
|
e2e5007f94 | ||
|
|
49b1c872c9 | ||
|
|
c37de31f4c | ||
|
|
7e6e274a89 |
No files matched your search
+180
@@ -6,6 +6,186 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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
|
### Upgrading from 0.3
|
||||||
|
|
||||||
- Back up first (`sudo jarld backup`).
|
- Back up first (`sudo jarld backup`).
|
||||||
|
|||||||
@@ -71,6 +71,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,
|
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
|
and exportable as JSON. Stored as aggregates in a separate database with
|
||||||
retention you set.
|
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,
|
* **Blocking** like Pi-hole, done by named itself: blocklists (Adblock,
|
||||||
hosts or plain domains) downloaded and kept up to date, your own block
|
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
|
and allow rules that beat every list, per view, with "why is this
|
||||||
@@ -78,6 +91,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
|
* **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,
|
hooks, an HTTPS-only endpoint for lego's and Traefik's `httpreq` provider,
|
||||||
and CNAME delegation to a dedicated ACME zone.
|
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
|
* **Adopts an existing server**: imports named.conf and zone files
|
||||||
(comments included), then switches named over with an automatic
|
(comments included), then switches named over with an automatic
|
||||||
fallback.
|
fallback.
|
||||||
@@ -119,7 +144,11 @@ 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/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/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/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/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
||||||
|
| [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/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/validation.md](docs/validation.md) | Every check Jarl makes |
|
||||||
| [docs/operations.md](docs/operations.md) | Backups, drift, failures, recovery, monitoring |
|
| [docs/operations.md](docs/operations.md) | Backups, drift, failures, recovery, monitoring |
|
||||||
@@ -171,12 +200,17 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.3.0 is the current release, and 0.4.0 is in testing
|
Version 0.4.0 is the current release, and 0.5.0 is in testing
|
||||||
(0.4.0-rc.5): it adds blocking (blocklists and your own rules, like
|
(0.5.0-rc.1): it adds Prometheus metrics, address ranges in ACLs and
|
||||||
Pi-hole, done by named, with different blocking for different devices),
|
other address match lists, safer changeset handling, internet exposure
|
||||||
disabling zones, server statistics and view keys. 0.3.0 added disabled records, script-friendly
|
checks with response rate limiting, TLS certificates Jarl obtains and
|
||||||
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC
|
renews itself, linked zones for split DNS, DNS over TLS and HTTPS, and statistics
|
||||||
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
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
|
Managing several servers from one jarld and dynamic updates are not
|
||||||
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ func acmeZones(c *client.Client, name, view string) ([]acme.Zone, error) {
|
|||||||
}
|
}
|
||||||
refs := make([]acme.Zone, len(zones))
|
refs := make([]acme.Zone, len(zones))
|
||||||
for i, z := range 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)
|
out := acme.Zones(name, refs, view)
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
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"`
|
||||||
|
Files *struct {
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
} `json:"files,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)`,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), 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\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"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(strings.Join(ci.Uses, ",")), strings.Join(names, " "), fmtDate(ci.NotAfter), state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ", ")))
|
||||||
|
if ci.Comment != "" {
|
||||||
|
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
|
||||||
|
}
|
||||||
|
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 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))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
-29
@@ -120,13 +120,17 @@ func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
|
|||||||
// changeset given with --changeset.
|
// changeset given with --changeset.
|
||||||
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
|
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
|
||||||
var resp api.MutationResponse
|
var resp api.MutationResponse
|
||||||
if c.Changeset > 0 {
|
cs, err := c.UseChangeset(ctx())
|
||||||
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), nil, map[string]any{"ops": reqs}, &resp); err != nil {
|
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 err
|
||||||
}
|
}
|
||||||
return report(&resp)
|
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 err
|
||||||
}
|
}
|
||||||
return report(&resp)
|
return report(&resp)
|
||||||
@@ -230,8 +234,9 @@ func changesetCmd() *cobra.Command {
|
|||||||
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
|
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
|
||||||
jarl record add example.com www2 A 192.0.2.20
|
jarl record add example.com www2 A 192.0.2.20
|
||||||
jarl record delete example.com www A
|
jarl record delete example.com www A
|
||||||
jarl changeset diff $JARL_CHANGESET
|
jarl changeset diff # JARL_CHANGESET's
|
||||||
jarl changeset apply $JARL_CHANGESET`,
|
jarl changeset apply
|
||||||
|
unset JARL_CHANGESET`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -245,21 +250,25 @@ func changesetCmd() *cobra.Command {
|
|||||||
fmt.Println(cs.ID)
|
fmt.Println(cs.ID)
|
||||||
return nil
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
show := &cobra.Command{
|
show := &cobra.Command{
|
||||||
Use: "show ID",
|
Use: "show [ID]",
|
||||||
Short: "Show a changeset's operations",
|
Short: "Show a changeset's operations (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
id, _, err := changesetArg(c, args, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var cs api.Changeset
|
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 err
|
||||||
}
|
}
|
||||||
return output(cs, func(w io.Writer) {
|
return output(cs, func(w io.Writer) {
|
||||||
@@ -280,16 +289,20 @@ func changesetCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
diff := &cobra.Command{
|
diff := &cobra.Command{
|
||||||
Use: "diff ID",
|
Use: "diff [ID]",
|
||||||
Short: "Validate an open changeset and show what applying it would change",
|
Short: "Validate an open changeset and show what applying it would change (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
id, fromEnv, err := changesetArg(c, args, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var raw json.RawMessage
|
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
|
return err
|
||||||
}
|
}
|
||||||
if flagOutput != "table" {
|
if flagOutput != "table" {
|
||||||
@@ -312,56 +325,82 @@ func changesetCmd() *cobra.Command {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Print(r.Diff)
|
fmt.Print(r.Diff)
|
||||||
if r.Valid {
|
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 nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("the changeset can't be applied as it is")
|
return fmt.Errorf("the changeset can't be applied as it is")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
apply := &cobra.Command{
|
apply := &cobra.Command{
|
||||||
Use: "apply ID",
|
Use: "apply [ID]",
|
||||||
Short: "Apply an open changeset",
|
Short: "Apply an open changeset (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
id, fromEnv, err := changesetArg(c, args, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var resp api.MutationResponse
|
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 err
|
||||||
}
|
}
|
||||||
return report(&resp)
|
if err := report(&resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fromEnv && resp.Applied {
|
||||||
|
envChangesetDone(id, "applied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
discard := &cobra.Command{
|
discard := &cobra.Command{
|
||||||
Use: "discard ID",
|
Use: "discard [ID]",
|
||||||
Short: "Throw away an open changeset",
|
Short: "Throw away an open changeset (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
if !flagQuiet {
|
if !flagQuiet {
|
||||||
fmt.Println("discarded changeset", args[0])
|
fmt.Println("discarded changeset", id)
|
||||||
|
}
|
||||||
|
if fromEnv {
|
||||||
|
envChangesetDone(id, "discarded")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
remove := &cobra.Command{
|
remove := &cobra.Command{
|
||||||
Use: "remove ID SEQ",
|
Use: "remove [ID] SEQ",
|
||||||
Short: "Remove one operation from an open changeset",
|
Short: "Remove one operation from an open changeset (default: -C or JARL_CHANGESET)",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.RangeArgs(1, 2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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{
|
revert := &cobra.Command{
|
||||||
|
|||||||
+10
-2
@@ -36,7 +36,7 @@ func viewCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
var clients, dests []string
|
var clients, dests []string
|
||||||
var recursiveOnly, comment string
|
var recursiveOnly, comment, exposure string
|
||||||
var adopt, cascade, yes bool
|
var adopt, cascade, yes bool
|
||||||
viewBody := func(cmd *cobra.Command) map[string]any {
|
viewBody := func(cmd *cobra.Command) map[string]any {
|
||||||
body := map[string]any{}
|
body := map[string]any{}
|
||||||
@@ -60,6 +60,9 @@ func viewCmd() *cobra.Command {
|
|||||||
if f.Changed("comment") {
|
if f.Changed("comment") {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
if f.Changed("exposure") {
|
||||||
|
body["exposure"] = exposure
|
||||||
|
}
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
addViewFlags := func(c *cobra.Command) {
|
addViewFlags := func(c *cobra.Command) {
|
||||||
@@ -67,6 +70,7 @@ func viewCmd() *cobra.Command {
|
|||||||
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
|
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(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
||||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
|
||||||
}
|
}
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
@@ -756,7 +760,7 @@ func settingsCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
var ns []string
|
var ns []string
|
||||||
var contact, policy string
|
var contact, policy, srvExposure string
|
||||||
var ttl, refresh, retry, expire, neg uint32
|
var ttl, refresh, retry, expire, neg uint32
|
||||||
set := &cobra.Command{
|
set := &cobra.Command{
|
||||||
Use: "set",
|
Use: "set",
|
||||||
@@ -777,6 +781,9 @@ func settingsCmd() *cobra.Command {
|
|||||||
if f.Changed("serial-policy") {
|
if f.Changed("serial-policy") {
|
||||||
body["serial_policy"] = 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} {
|
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
|
||||||
if f.Changed(name) {
|
if f.Changed(name) {
|
||||||
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
|
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
|
||||||
@@ -799,6 +806,7 @@ func settingsCmd() *cobra.Command {
|
|||||||
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
|
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
|
||||||
set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
|
set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
|
||||||
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
|
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(&ttl, "ttl", 0, "default TTL")
|
||||||
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
|
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
|
||||||
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
|
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"`
|
||||||
|
DoTHost string `json:"dot_host"`
|
||||||
|
DoHURL string `json:"doh_url"`
|
||||||
|
DoT []encEndpoint `json:"dot"`
|
||||||
|
DoH []encEndpoint `json:"doh"`
|
||||||
|
Other bool `json:"other"`
|
||||||
|
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, "Certificate:\t%s\n", s.Certificate)
|
||||||
|
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.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 host, certName string
|
||||||
|
var addrs, views []string
|
||||||
|
var noDoT, noDoH bool
|
||||||
|
var dohPort, dotPort int
|
||||||
|
setup := &cobra.Command{
|
||||||
|
Use: "setup --hostname NAME --address ADDR...",
|
||||||
|
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 name's A and AAAA records, in the private views that have its
|
||||||
|
zone (or --view), local to the view when the zone is linked.
|
||||||
|
|
||||||
|
It is one change, staged or applied like any other. Running it again
|
||||||
|
replaces the earlier setup.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||||
|
if host == "" {
|
||||||
|
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
|
||||||
|
}
|
||||||
|
var s encStatus
|
||||||
|
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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 == "" {
|
||||||
|
for _, ci := range s.Certificates {
|
||||||
|
if coversName(append(ci.Names, ci.Domains...), host) {
|
||||||
|
certName = ci.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if certName == "" {
|
||||||
|
certName = strings.SplitN(host, ".", 2)[0]
|
||||||
|
fmt.Printf("Creating certificate %s for %s\n", certName, host)
|
||||||
|
var r struct {
|
||||||
|
Certificate certInfo `json:"certificate"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": []string{host}, "uses": []string{"dns"}}, &r); err != nil {
|
||||||
|
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{"hostname": host, "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)
|
||||||
|
}
|
||||||
|
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.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)")
|
||||||
|
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
|
||||||
|
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on (repeat for several)")
|
||||||
|
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
|
||||||
|
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
|
||||||
|
f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS")
|
||||||
|
f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
|
||||||
|
f.StringArrayVar(&views, "view", nil, "views to add the host name's records to (default: the private views that have its zone)")
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(setup, check, disable)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(fix, ack, unack)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+15
-8
@@ -13,7 +13,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
@@ -48,6 +47,7 @@ var (
|
|||||||
flagChangeset int64
|
flagChangeset int64
|
||||||
flagDryRun bool
|
flagDryRun bool
|
||||||
flagForce bool
|
flagForce bool
|
||||||
|
flagRisk bool
|
||||||
flagOutput string
|
flagOutput string
|
||||||
flagInsecure bool
|
flagInsecure bool
|
||||||
flagCACert string
|
flagCACert string
|
||||||
@@ -149,15 +149,21 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
c.Changeset = flagChangeset
|
c.Changeset = flagChangeset
|
||||||
if c.Changeset == 0 {
|
if c.Changeset == 0 {
|
||||||
if v := os.Getenv("JARL_CHANGESET"); v != "" {
|
n, err := envChangeset()
|
||||||
n, err := strconv.ParseInt(v, 10, 64)
|
if err != nil {
|
||||||
if err != nil {
|
return nil, err
|
||||||
return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v)
|
}
|
||||||
}
|
if n > 0 {
|
||||||
|
// Checked when a change is first staged into it
|
||||||
|
// (envchangeset.go).
|
||||||
c.Changeset = n
|
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 {
|
if !c.Preflight {
|
||||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
||||||
}
|
}
|
||||||
@@ -239,6 +245,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.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.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(&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.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.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")
|
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||||
@@ -252,7 +259,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("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
||||||
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
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)
|
setupCompletion(root)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-12
@@ -12,34 +12,60 @@ import (
|
|||||||
// zoneViews expands a --view value for a record command: "" or one view
|
// 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
|
// as it is, "a,b" to those views, and "all" to every view that has the
|
||||||
// zone.
|
// 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) {
|
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
|
||||||
if view != "all" && !strings.Contains(view, ",") {
|
if view != "all" && !strings.Contains(view, ",") {
|
||||||
return []string{view}, nil
|
return []string{view}, nil
|
||||||
}
|
}
|
||||||
if view != "all" {
|
|
||||||
var out []string
|
|
||||||
for _, v := range strings.Split(view, ",") {
|
|
||||||
if v = strings.TrimSpace(v); v != "" {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
var zones []api.Zone
|
var zones []api.Zone
|
||||||
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
||||||
|
link := map[string]int64{}
|
||||||
var out []string
|
var out []string
|
||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
||||||
out = append(out, 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 {
|
if len(out) == 0 {
|
||||||
return nil, fmt.Errorf("zone %s is in no view", zone)
|
return nil, fmt.Errorf("zone %s is in no view", zone)
|
||||||
}
|
}
|
||||||
return out, nil
|
// 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
|
// perView runs a record command once per view. With several views, the
|
||||||
@@ -90,7 +116,7 @@ func perView(c *client.Client, views []string, description string, do func(c *cl
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var resp api.MutationResponse
|
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}, &resp)
|
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 {
|
if err != nil || c.DryRun {
|
||||||
discard()
|
discard()
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-11
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,8 +22,22 @@ func statsCmd() *cobra.Command {
|
|||||||
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
||||||
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
|
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
|
Ranges are given with --since: 90m, 24h, 7d, 2w. --view limits the figures
|
||||||
export the data.`,
|
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
|
// Each command has its own --since variable: their defaults differ, and
|
||||||
// a shared variable would take the last default for all of them.
|
// a shared variable would take the last default for all of them.
|
||||||
@@ -77,14 +92,27 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var s stats.Summary
|
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 err
|
||||||
}
|
}
|
||||||
return output(s, func(w io.Writer) {
|
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, "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 {
|
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 {
|
if s.CacheHitRatio != nil {
|
||||||
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
||||||
@@ -128,14 +156,15 @@ export the data.`,
|
|||||||
|
|
||||||
var limit int
|
var limit int
|
||||||
top := &cobra.Command{
|
top := &cobra.Command{
|
||||||
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]",
|
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",
|
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
|
||||||
Example: ` jarl stats top names --since 7d
|
Example: ` jarl stats top names --since 7d
|
||||||
jarl stats top clients --limit 50
|
jarl stats top clients --limit 50
|
||||||
jarl stats top nxdomain # misconfigured hosts and typos
|
jarl stats top nxdomain # misconfigured hosts and typos
|
||||||
jarl stats top refused # who is asking for what they may not have
|
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 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),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
kind := stats.KindName
|
kind := stats.KindName
|
||||||
@@ -147,13 +176,16 @@ export the data.`,
|
|||||||
if kind == "blocked-client" {
|
if kind == "blocked-client" {
|
||||||
kind = stats.KindBlockedClient
|
kind = stats.KindBlockedClient
|
||||||
}
|
}
|
||||||
|
if kind == "ratelimited-name" {
|
||||||
|
kind = stats.KindRateLimitedName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var es []stats.Entry
|
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 err
|
||||||
}
|
}
|
||||||
return output(es, func(w io.Writer) {
|
return output(es, func(w io.Writer) {
|
||||||
@@ -203,7 +235,7 @@ export the data.`,
|
|||||||
qv.Add("key", k)
|
qv.Add("key", k)
|
||||||
}
|
}
|
||||||
var tl stats.Timeline
|
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 err
|
||||||
}
|
}
|
||||||
return output(tl, func(w io.Writer) {
|
return output(tl, func(w io.Writer) {
|
||||||
@@ -252,7 +284,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var hm stats.Heatmap
|
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 err
|
||||||
}
|
}
|
||||||
return output(hm, func(w io.Writer) {
|
return output(hm, func(w io.Writer) {
|
||||||
@@ -304,7 +336,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var es []stats.Entry
|
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 err
|
||||||
}
|
}
|
||||||
return output(es, func(w io.Writer) {
|
return output(es, func(w io.Writer) {
|
||||||
|
|||||||
+113
-5
@@ -93,6 +93,9 @@ func zoneCmd() *cobra.Command {
|
|||||||
if z.InView != "" {
|
if z.InView != "" {
|
||||||
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
||||||
}
|
}
|
||||||
|
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 != "" {
|
if z.ZoneFile != "" {
|
||||||
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
|
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,7 @@ func zoneCmd() *cobra.Command {
|
|||||||
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
|
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
|
||||||
|
|
||||||
var ns, records, primaries, forwarders, opts []string
|
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 ttl uint32
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
@@ -120,7 +123,8 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
||||||
jarl zone create partner.example --type secondary --primary 192.0.2.53
|
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 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
@@ -149,6 +153,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
if inView != "" {
|
if inView != "" {
|
||||||
body["in_view"] = inView
|
body["in_view"] = inView
|
||||||
}
|
}
|
||||||
|
if linkWith != "" {
|
||||||
|
body["link_with"] = linkWith
|
||||||
|
}
|
||||||
var recs []map[string]any
|
var recs []map[string]any
|
||||||
for _, r := range records {
|
for _, r := range records {
|
||||||
f := strings.Fields(r)
|
f := strings.Fields(r)
|
||||||
@@ -219,6 +226,7 @@ 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(&cloneView, "clone-view", "", "view of --clone-from")
|
||||||
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
|
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(&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().StringVar(&comment, "comment", "", "comment")
|
||||||
|
|
||||||
var refresh, retry, expire, minimum, serial uint32
|
var refresh, retry, expire, minimum, serial uint32
|
||||||
@@ -416,7 +424,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(&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")
|
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
||||||
|
|
||||||
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
|
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false))
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,8 +464,25 @@ const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\"
|
|||||||
func recordCmd() *cobra.Command {
|
func recordCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
||||||
var view, name, typ, search, comment, ttlArg string
|
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
|
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{
|
list := &cobra.Command{
|
||||||
Use: "list ZONE",
|
Use: "list ZONE",
|
||||||
@@ -479,8 +504,15 @@ func recordCmd() *cobra.Command {
|
|||||||
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
linked := false
|
||||||
|
for _, r := range recs {
|
||||||
|
linked = linked || r.Linked != ""
|
||||||
|
}
|
||||||
return output(recs, func(w io.Writer) {
|
return output(recs, func(w io.Writer) {
|
||||||
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
||||||
|
if linked {
|
||||||
|
rows[0] = []string{"ID", "NAME", "TTL", "TYPE", "LINK", "DATA", "COMMENT"}
|
||||||
|
}
|
||||||
for _, r := range recs {
|
for _, r := range recs {
|
||||||
ttl := fmt.Sprint(r.EffectiveTTL)
|
ttl := fmt.Sprint(r.EffectiveTTL)
|
||||||
if r.TTL == nil {
|
if r.TTL == nil {
|
||||||
@@ -490,10 +522,18 @@ func recordCmd() *cobra.Command {
|
|||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
comment = strings.TrimSpace("[disabled] " + comment)
|
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})
|
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
||||||
}
|
}
|
||||||
if !wide {
|
if !wide {
|
||||||
fitColumn(rows, 4)
|
col := 4
|
||||||
|
if linked {
|
||||||
|
col = 5
|
||||||
|
}
|
||||||
|
fitColumn(rows, col)
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||||
@@ -535,6 +575,9 @@ and splitting are automatic).
|
|||||||
if ptr {
|
if ptr {
|
||||||
body["ptr"] = true
|
body["ptr"] = true
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
views, err := zoneViews(c, args[0], view)
|
views, err := zoneViews(c, args[0], view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -549,6 +592,7 @@ and splitting are automatic).
|
|||||||
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
||||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
||||||
|
linkFlags(add, "add it")
|
||||||
|
|
||||||
var data string
|
var data string
|
||||||
update := &cobra.Command{
|
update := &cobra.Command{
|
||||||
@@ -582,6 +626,9 @@ and splitting are automatic).
|
|||||||
if ptr {
|
if ptr {
|
||||||
body["ptr"] = true
|
body["ptr"] = true
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return fmt.Errorf("nothing to change (see --help)")
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
}
|
}
|
||||||
@@ -598,6 +645,8 @@ and splitting are automatic).
|
|||||||
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
|
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
|
||||||
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||||
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
|
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{
|
del := &cobra.Command{
|
||||||
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
|
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
|
||||||
@@ -678,6 +727,9 @@ spaces.
|
|||||||
if comment != "" {
|
if comment != "" {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
views, err := zoneViews(c, args[0], view)
|
views, err := zoneViews(c, args[0], view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -691,6 +743,7 @@ spaces.
|
|||||||
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
||||||
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
||||||
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
||||||
|
linkFlags(set, "set it")
|
||||||
|
|
||||||
toggle := func(disable bool) *cobra.Command {
|
toggle := func(disable bool) *cobra.Command {
|
||||||
verb := "enable"
|
verb := "enable"
|
||||||
@@ -829,3 +882,58 @@ 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
|
||||||
|
}
|
||||||
@@ -24,9 +24,11 @@ import (
|
|||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/bind"
|
"jarl/internal/bind"
|
||||||
|
"jarl/internal/certs"
|
||||||
"jarl/internal/config"
|
"jarl/internal/config"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/nscheck"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
"jarl/internal/render"
|
"jarl/internal/render"
|
||||||
"jarl/internal/server"
|
"jarl/internal/server"
|
||||||
@@ -122,6 +124,7 @@ func openApp(allowMissingConfig bool) (*app, error) {
|
|||||||
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
|
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
|
||||||
NoReload: cfg.BIND.NoReload,
|
NoReload: cfg.BIND.NoReload,
|
||||||
Extras: renderExtras(cfg),
|
Extras: renderExtras(cfg),
|
||||||
|
LocalAddrs: nscheck.LocalAddrs,
|
||||||
}, log)
|
}, log)
|
||||||
eng.Limits = engine.BlocklistLimits{
|
eng.Limits = engine.BlocklistLimits{
|
||||||
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
|
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
|
||||||
@@ -303,6 +306,23 @@ 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)
|
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,
|
||||||
|
}, 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{
|
srv := server.New(server.Config{
|
||||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
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),
|
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
||||||
@@ -311,6 +331,9 @@ func serveCmd() *cobra.Command {
|
|||||||
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
||||||
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||||
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
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,
|
||||||
HTTPReq: server.HTTPReq{
|
HTTPReq: server.HTTPReq{
|
||||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||||
@@ -328,6 +351,7 @@ func serveCmd() *cobra.Command {
|
|||||||
}()
|
}()
|
||||||
go auditRetention(ctx, a)
|
go auditRetention(ctx, a)
|
||||||
go blocklistLoop(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)
|
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") })
|
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
|
||||||
sdNotify("STOPPING=1")
|
sdNotify("STOPPING=1")
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Let's Encrypt (ACME DNS-01) with Jarl
|
# 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
|
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
|
record at `_acme-challenge.<domain>`. Jarl does this through its API, so no
|
||||||
dynamic updates (`allow-update`, nsupdate) are needed. That matters because
|
dynamic updates (`allow-update`, nsupdate) are needed. That matters because
|
||||||
|
|||||||
+54
-1
@@ -10,6 +10,8 @@ what is documented here.
|
|||||||
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
|
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
|
||||||
`relative_name`).
|
`relative_name`).
|
||||||
* `GET /healthz` (no authentication) returns `{"ok": true, "version": ...}`.
|
* `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
|
## 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
|
the object changed in the meantime the request fails with 409. Staged
|
||||||
operations record the version they were staged against automatically.
|
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
|
## Resources
|
||||||
|
|
||||||
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
|
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
|
||||||
@@ -332,6 +342,21 @@ registrar_error`.
|
|||||||
|
|
||||||
### Records
|
### 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).
|
||||||
|
|
||||||
|
| 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` takes `"link_with": "VIEW"` to create a zone linked with
|
||||||
|
that view's zone of the same name.
|
||||||
|
|
||||||
| Method | Path | Body / query |
|
| Method | Path | Body / query |
|
||||||
|--------|------|--------------|
|
|--------|------|--------------|
|
||||||
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
||||||
@@ -405,7 +430,7 @@ send the request with that changeset (`?changeset=` or
|
|||||||
| Method | Path | Body |
|
| Method | Path | Body |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/acls` | each with `used_by` |
|
| 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 / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
|
||||||
| GET | `/tsig-keys` | without secrets |
|
| 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) |
|
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
|
||||||
@@ -461,6 +486,8 @@ written in Jarl's fixed order (docs/concepts.md).
|
|||||||
| 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/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/rrtypes` | record types with field hints and examples |
|
||||||
| GET | `/catalog/grants` | resources, roles and the grant syntax |
|
| 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 |
|
| GET | `/search?q=text` | zones, records and ACLs matching the text |
|
||||||
|
|
||||||
### Changesets
|
### Changesets
|
||||||
@@ -512,6 +539,10 @@ in it and its records.
|
|||||||
|
|
||||||
### Query statistics
|
### 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
|
Needs `stats:read`. When statistics are disabled, every endpoint but
|
||||||
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
||||||
|
|
||||||
@@ -529,6 +560,28 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
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 `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 |
|
||||||
|
|
||||||
|
`GET /status` includes `certificates` for administrators: `total`,
|
||||||
|
`problems` (failing, expiring, expired), `next` and `next_after`,
|
||||||
|
`serving`.
|
||||||
|
|
||||||
### ACME: lego's httpreq provider
|
### ACME: lego's httpreq provider
|
||||||
|
|
||||||
Off unless `acme.httpreq.enabled` is set, and by default HTTPS only. The
|
Off unless `acme.httpreq.enabled` is set, and by default HTTPS only. The
|
||||||
|
|||||||
+2
-2
@@ -177,8 +177,8 @@ jarl block group list
|
|||||||
In the web UI, the Blocking page's **Devices** panel holds the steps and
|
In the web UI, the Blocking page's **Devices** panel holds the steps and
|
||||||
the groups, and each list has a step.
|
the groups, and each list has a step.
|
||||||
|
|
||||||
- **Members** are addresses, networks (`10.10.0.0/24`), or ACLs made only
|
- **Members** are addresses, networks (`10.10.0.0/24`), ranges
|
||||||
of those (`key`, `localnets` and `!` can't be used: blocking matches
|
(`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
|
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
|
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).
|
(`10.10.0.0/24` in one group, `10.10.0.20` in another).
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
A certificate named uses can't be deleted or lose its `dns` use until
|
||||||
|
named no longer names its files.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Certificates Jarl obtains, their keys and the ACME account key are kept
|
||||||
|
in Jarl's database, so they are in its backups (like TSIG secrets). The
|
||||||
|
API never returns a key.
|
||||||
|
- 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 |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
+91
-5
@@ -41,7 +41,7 @@ jarl whoami
|
|||||||
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
|
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
|
||||||
| Skip TLS verification | `--insecure` | | `insecure` |
|
| Skip TLS verification | `--insecure` | | `insecure` |
|
||||||
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
|
| 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
|
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
|
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. |
|
| `-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. |
|
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
|
||||||
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
|
| `--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`). |
|
| `-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
|
## Exit codes
|
||||||
@@ -210,6 +211,18 @@ 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
|
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
|
||||||
`zone create` sets any zone option in the same step.
|
`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
|
||||||
|
```
|
||||||
|
|
||||||
When a zone exists in several views, add `--view NAME` to commands that
|
When a zone exists in several views, add `--view NAME` to commands that
|
||||||
name it.
|
name it.
|
||||||
|
|
||||||
@@ -294,7 +307,9 @@ 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
|
host name (a bare `nas` is refused, since it would mean
|
||||||
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
||||||
keeps the PTR record in the reverse zone in step (when that zone is managed
|
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 zones of every linked view count. Every record type BIND supports can be used. Jarl knows the
|
||||||
syntax of the common ones and checks them strictly, and the rest are
|
syntax of the common ones and checks them strictly, and the rest are
|
||||||
validated by parsing.
|
validated by parsing.
|
||||||
|
|
||||||
@@ -345,9 +360,9 @@ reads standard input.
|
|||||||
export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q)
|
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 gw A 10.20.0.1
|
||||||
jarl record set lab.adbraun.lol nas A 10.20.0.10
|
jarl record set lab.adbraun.lol nas A 10.20.0.10
|
||||||
jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff
|
jarl changeset diff # validation + named-checkconf + diff
|
||||||
jarl changeset remove $JARL_CHANGESET 2 # drop operation #2
|
jarl changeset remove 2 # drop operation #2
|
||||||
jarl changeset apply $JARL_CHANGESET
|
jarl changeset apply
|
||||||
unset JARL_CHANGESET
|
unset JARL_CHANGESET
|
||||||
|
|
||||||
jarl changeset list [--status open|applied|discarded] [--mine]
|
jarl changeset list [--status open|applied|discarded] [--mine]
|
||||||
@@ -357,8 +372,74 @@ jarl history [--limit 50]
|
|||||||
jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset
|
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.
|
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 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 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 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
|
## Views, ACLs, TSIG keys, options, settings
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -371,6 +452,7 @@ jarl view color internal green # web UI colour; "default" resets
|
|||||||
jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md
|
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 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 --add 10.20.0.0/16 --remove 10.10.0.0/16
|
||||||
jarl acl update homelab --rename lan # references are updated
|
jarl acl update homelab --rename lan # references are updated
|
||||||
jarl acl delete lan # refused while in use
|
jarl acl delete lan # refused while in use
|
||||||
@@ -386,6 +468,7 @@ jarl option set allow-recursion '{ homelab; localhost; }'
|
|||||||
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
||||||
jarl option set forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list
|
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-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 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 logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
||||||
jarl option unset forwarders
|
jarl option unset forwarders
|
||||||
@@ -447,6 +530,9 @@ working.
|
|||||||
|
|
||||||
## Query statistics
|
## Query statistics
|
||||||
|
|
||||||
|
`--view internal,vps` (or repeated) limits any `jarl stats` command to those
|
||||||
|
views ([stats.md](stats.md#views)).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl stats status
|
jarl stats status
|
||||||
jarl stats summary --since 7d
|
jarl stats summary --since 7d
|
||||||
|
|||||||
@@ -167,6 +167,32 @@ RFC 1982 arithmetic.
|
|||||||
alphabetical within a group. So the same option is in the same place in
|
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
|
every view and zone. Top-level statements stay in the order they were
|
||||||
added.
|
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
|
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
|
||||||
and timers, default TTL, serial policy.
|
and timers, default TTL, serial policy.
|
||||||
|
|
||||||
|
|||||||
+45
-7
@@ -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)). |
|
| `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. |
|
| `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. |
|
| `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.lifetime` | `12h` | A web session ends this long after login... |
|
||||||
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
|
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
|
||||||
@@ -73,6 +73,35 @@ 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
|
[stats.md](stats.md) for every setting and what it changes in named's
|
||||||
configuration. Changing it takes effect when jarld restarts.
|
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`. |
|
||||||
|
|
||||||
### ACME (lego httpreq endpoint)
|
### ACME (lego httpreq endpoint)
|
||||||
|
|
||||||
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
|
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
|
||||||
@@ -114,18 +143,27 @@ there (`ReadWritePaths`). If you change them, update both
|
|||||||
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
|
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
|
With an API key, Jarl can:
|
||||||
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
|
- show and set a domain's name servers, glue records and DNSSEC DS records
|
||||||
(the default) nothing about Porkbun shows up.
|
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 |
|
| Key | Default | Meaning |
|
||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
||||||
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
| `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). |
|
||||||
|
|
||||||
The key can move your domain anywhere, so it is only ever read from this
|
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
|
file: it isn't stored in the database or backups, and there is no API to
|
||||||
|
|||||||
+8
-1
@@ -10,6 +10,7 @@ internal/
|
|||||||
dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones
|
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
|
rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog
|
||||||
namedconf/ named.conf lexer, parser and printer; address match lists
|
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
|
catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded
|
||||||
validate/ whole-configuration validation and security lints
|
validate/ whole-configuration validation and security lints
|
||||||
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
|
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
|
||||||
@@ -117,7 +118,13 @@ checker tests work with the installed `bind9-utils` too.
|
|||||||
ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks
|
ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks
|
||||||
the answers with `dig`. The Porkbun code is tested against
|
the answers with `dig`. The Porkbun code is tested against
|
||||||
`porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real
|
`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`
|
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.
|
to that directory's `cli.yaml` to keep using the CLI against it.
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -175,13 +175,17 @@ Before you go public:
|
|||||||
## Porkbun
|
## Porkbun
|
||||||
|
|
||||||
With a Porkbun API key, Jarl manages a domain's name servers, glue records
|
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**
|
**Setting it up**
|
||||||
|
|
||||||
1. At porkbun.com, *Account → API Access*: create a key. If you can,
|
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),
|
limit it to the domain and the permissions it needs, and to your
|
||||||
and to your server's address.
|
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
|
2. On the domain's page, switch **API Access** on (Porkbun refuses API
|
||||||
calls for a domain until you do).
|
calls for a domain until you do).
|
||||||
3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld:
|
3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld:
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## Setting it up
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
|
||||||
|
```
|
||||||
|
|
||||||
|
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 name's A and AAAA records** with the chosen addresses,
|
||||||
|
in the private views that have a zone for it (`--view` to choose). In a
|
||||||
|
linked zone they are local to the view, so the name doesn't reach your
|
||||||
|
external view.
|
||||||
|
|
||||||
|
Running it again replaces the earlier setup (other addresses, ports, DoT
|
||||||
|
or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable`
|
||||||
|
takes the listeners and the `tls` statement out; the certificate and the
|
||||||
|
records stay.
|
||||||
|
|
||||||
|
| Option | Default | |
|
||||||
|
|--|--|--|
|
||||||
|
| `--hostname` | | The name clients connect to; the certificate is for it |
|
||||||
|
| `--address` | | This server's addresses to listen on (repeat). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there |
|
||||||
|
| `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
|
||||||
|
| `--no-dot`, `--no-doh` | both on | |
|
||||||
|
| `--dot-port` | 853 | Clients expect 853 |
|
||||||
|
| `--doh-port` | 443 | Any free port; clients take it in the URL |
|
||||||
|
| `--view` | private views with the zone | Where the host name's records go |
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com
|
||||||
|
dig +https @10.0.10.5 example.com
|
||||||
|
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 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`, `hostname`, `dot` and `doh` (address and port), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone` for `?hostname=`), `certificates` |
|
||||||
|
| POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
|
||||||
|
| POST | `/encrypted-dns` | `{"hostname", "certificate", "addresses", "dot", "doh", "dot_port", "doh_port", "views"}`: staged or applied like any change; 409 while the certificate hasn't been obtained |
|
||||||
|
| DELETE | `/encrypted-dns` | Turns it off |
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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`) |
|
||||||
|
| 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`).
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
# 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. |
|
||||||
|
|
||||||
|
### 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: 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
|
||||||
|
```
|
||||||
@@ -363,6 +363,9 @@ rndc reconfig
|
|||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
* `GET /healthz` (no authentication) reports that jarld is up.
|
* `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:
|
* On the server, a check can use the local socket and needs no token:
|
||||||
`sudo jarl status -o json`.
|
`sudo jarl status -o json`.
|
||||||
* `GET /api/v1/status` with a read-only token reports named reachability,
|
* `GET /api/v1/status` with a read-only token reports named reachability,
|
||||||
|
|||||||
+3
-2
@@ -116,8 +116,9 @@ demote or delete the last enabled administrator.
|
|||||||
|
|
||||||
A user's permissions are a list of grants. Administrators (`admin: true`)
|
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,
|
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,
|
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
|
||||||
whose key can move the domain).
|
whose key can move the domain), and manage TLS certificates (whose keys
|
||||||
|
can impersonate the server; [certificates.md](certificates.md)).
|
||||||
|
|
||||||
A grant is written as:
|
A grant is written as:
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -103,6 +103,7 @@ 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 |
|
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
|
||||||
| `responses` | response log | lines read (a health check) |
|
| `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) |
|
| `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
|
**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):
|
the samples and `gn:KEY` their number, so a chart shows the average):
|
||||||
@@ -167,7 +168,8 @@ 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 6h # sparkline; --table for every bucket
|
||||||
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
|
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
|
||||||
jarl stats heatmap --since 28d
|
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 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 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)
|
jarl stats summary --since 30d -o json # export (also -o yaml)
|
||||||
@@ -175,10 +177,33 @@ jarl stats summary --since 30d -o json # export (also -o yaml)
|
|||||||
|
|
||||||
**API**: see [api.md](api.md#query-statistics).
|
**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
|
**Permissions**: the `stats` resource (`stats:read`). The `readonly` and
|
||||||
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
||||||
addresses and what they look up, so grant it with care.
|
addresses and what they look up, so grant it with care.
|
||||||
|
|
||||||
|
## 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
|
## How data is kept
|
||||||
|
|
||||||
- Minute counters are kept for `retention.minute`, hourly counters and top
|
- Minute counters are kept for `retention.minute`, hourly counters and top
|
||||||
|
|||||||
+38
-2
@@ -67,10 +67,11 @@ showing them.
|
|||||||
|
|
||||||
| Code | Severity | Rule |
|
| 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_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_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 |
|
| `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`) |
|
| `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_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 |
|
| `view_unreachable` | warning | A view that comes after one matching every client is never used |
|
||||||
@@ -103,6 +104,26 @@ applied.
|
|||||||
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
| `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) |
|
| `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
|
## Blocking
|
||||||
|
|
||||||
From [blocking.md](blocking.md).
|
From [blocking.md](blocking.md).
|
||||||
@@ -123,11 +144,26 @@ From [blocking.md](blocking.md).
|
|||||||
|
|
||||||
## Security lints
|
## 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 |
|
| 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_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 |
|
| `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 |
|
| `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
|
## What only BIND checks
|
||||||
|
|
||||||
|
|||||||
+86
-9
@@ -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
|
view with recursion off. Jarl warns (`security_open_resolver`) if a view
|
||||||
ever offers recursion to everyone.
|
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
|
### 4. Give the external view its zones
|
||||||
|
|
||||||
**Different data per view (split DNS).** Copy the zone, then change what
|
**Mostly the same, a few differences (split DNS).** Create the external
|
||||||
should differ:
|
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
|
```sh
|
||||||
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
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
|
**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:
|
then serves the internal view's zone, so there is one copy to maintain:
|
||||||
|
|
||||||
@@ -155,6 +168,67 @@ 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
|
`jarl-view-` are reserved, and the view keys can be used in your own
|
||||||
options (`allow-transfer { key jarl-view-external; }`).
|
options (`allow-transfer { key jarl-view-external; }`).
|
||||||
|
|
||||||
|
## 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 view gets the shared ones back.
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
**Permissions** are checked in every view a change reaches. A token with
|
||||||
|
a grant for one view only (`views=internal`) can change that view's local
|
||||||
|
records and make overrides, but not a shared record, since that would
|
||||||
|
change the other views too: Jarl says so and suggests a local record. A
|
||||||
|
grant without a view (the usual `zones=adbraun.lol`) covers every view.
|
||||||
|
|
||||||
|
**Importing a zone file** into a linked zone (`jarl zone import`) adds
|
||||||
|
the records like any others: shared, unless the name and type is local
|
||||||
|
there. With `--replace`, shared records missing from the file are deleted
|
||||||
|
in every linked view.
|
||||||
|
|
||||||
|
**ACME challenges** are written once per link, and so reach every linked
|
||||||
|
view; `jarl acme`, lego's httpreq and certificates Jarl obtains all do
|
||||||
|
this.
|
||||||
|
|
||||||
|
**Compared with BIND's `in-view`**, where one view serves another's zone
|
||||||
|
as it is: linked zones can differ per view, keep their own options, and
|
||||||
|
don't depend on the view order (`in-view` can only use a view defined
|
||||||
|
before it).
|
||||||
|
|
||||||
## Living with views
|
## Living with views
|
||||||
|
|
||||||
* Commands that name a zone that exists in several views need `--view`
|
* Commands that name a zone that exists in several views need `--view`
|
||||||
@@ -162,7 +236,10 @@ options (`allow-transfer { key jarl-view-external; }`).
|
|||||||
guess.
|
guess.
|
||||||
* `jarl acme` writes challenge records into **every** view that has the
|
* `jarl acme` writes challenge records into **every** view that has the
|
||||||
zone, so Let's Encrypt (external) and internal clients see the same
|
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 order internal external` changes the match order, and
|
||||||
`jarl view list` shows it.
|
`jarl view list` shows it.
|
||||||
* Deleting a view is refused while it still has zones, unless you pass
|
* Deleting a view is refused while it still has zones, unless you pass
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.27.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1
|
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/miekg/dns v1.1.73
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
go.yaml.in/yaml/v3 v3.0.5
|
go.yaml.in/yaml/v3 v3.0.5
|
||||||
@@ -15,7 +16,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
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/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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
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=
|
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/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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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.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 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
|||||||
+17
-1
@@ -21,6 +21,10 @@ const Comment = "ACME DNS-01 challenge"
|
|||||||
type Zone struct {
|
type Zone struct {
|
||||||
Name string // as Jarl stores it, e.g. "adbraun.lol."
|
Name string // as Jarl stores it, e.g. "adbraun.lol."
|
||||||
View string // "" when views are not used
|
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
|
// 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)
|
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
|
// Requests returns the changes that set (or, with set false, remove) the
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ type Zone struct {
|
|||||||
// Registrar names the registrar Jarl can manage the domain at
|
// Registrar names the registrar Jarl can manage the domain at
|
||||||
// ("porkbun"), when one is configured for it.
|
// ("porkbun"), when one is configured for it.
|
||||||
Registrar string `json:"registrar,omitempty"`
|
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"`
|
||||||
// Pending is set when listing with a changeset: add, change or delete.
|
// Pending is set when listing with a changeset: add, change or delete.
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -40,6 +45,8 @@ type Record struct {
|
|||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
RelativeName string `json:"relative_name"`
|
RelativeName string `json:"relative_name"`
|
||||||
EffectiveTTL uint32 `json:"effective_ttl"`
|
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 is the decoded value of TXT and SPF records.
|
||||||
Text *string `json:"text,omitempty"`
|
Text *string `json:"text,omitempty"`
|
||||||
// Pending is set when listing with a changeset: add, change, delete,
|
// Pending is set when listing with a changeset: add, change, delete,
|
||||||
@@ -208,6 +215,9 @@ type BatchRequest struct {
|
|||||||
Ops []ChangeRequest `json:"ops"`
|
Ops []ChangeRequest `json:"ops"`
|
||||||
DryRun bool `json:"dry_run,omitempty"`
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
Force bool `json:"force,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 (for POST /changesets) applies the new changeset at once.
|
||||||
Apply bool `json:"apply,omitempty"`
|
Apply bool `json:"apply,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -253,6 +263,29 @@ type StatusResponse struct {
|
|||||||
// BlockingPausedIndefinitely while it is paused until resumed.
|
// BlockingPausedIndefinitely while it is paused until resumed.
|
||||||
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
||||||
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
|
||||||
@@ -536,3 +569,9 @@ type BlockRule struct {
|
|||||||
AutoViews bool `json:"auto_views,omitempty"`
|
AutoViews bool `json:"auto_views,omitempty"`
|
||||||
Pending string `json:"pending,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"`
|
||||||
|
}
|
||||||
@@ -493,6 +493,13 @@ func spaceElements(fields []string) []string {
|
|||||||
f += " " + fields[i+1]
|
f += " " + fields[i+1]
|
||||||
i++
|
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":
|
case "port", "tls":
|
||||||
if len(out) > 0 && i+1 < len(fields) {
|
if len(out) > 0 && i+1 < len(fields) {
|
||||||
out[len(out)-1] += " " + f + " " + fields[i+1]
|
out[len(out)-1] += " " + f + " " + fields[i+1]
|
||||||
|
|||||||
@@ -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 = ®istration.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+) :: (.*)$`)
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
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/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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), "*."))
|
||||||
|
}
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
legolog "github.com/go-acme/lego/v4/log"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/porkbun"
|
||||||
|
"jarl/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures the manager (acme and certificates in jarl.yaml).
|
||||||
|
type Config struct {
|
||||||
|
Directory string // ACME directory URL
|
||||||
|
Email string
|
||||||
|
AgreeTOS bool
|
||||||
|
EABKeyID string
|
||||||
|
EABHMAC string
|
||||||
|
Resolvers []string
|
||||||
|
CABundle string
|
||||||
|
// Dir is where certificates with the dns use are written for named.
|
||||||
|
Dir string
|
||||||
|
Version string
|
||||||
|
// Porkbun publishes challenges for domains Porkbun serves (nil
|
||||||
|
// without an API key); PorkbunAllows limits it to porkbun.domains.
|
||||||
|
Porkbun *porkbun.Client
|
||||||
|
PorkbunAllows func(domain string) bool
|
||||||
|
// CheckPropagation makes lego wait until every name server of the
|
||||||
|
// zone serves the challenge before telling the CA (acme.check_propagation).
|
||||||
|
CheckPropagation bool
|
||||||
|
// PropagationTimeout is how long to wait for that (default 10 minutes).
|
||||||
|
PropagationTimeout time.Duration
|
||||||
|
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||||
|
SkipPropagationCheck bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager keeps certificates current and serves jarld's own.
|
||||||
|
type Manager struct {
|
||||||
|
cfg Config
|
||||||
|
store *store.Store
|
||||||
|
eng *engine.Engine
|
||||||
|
log *slog.Logger
|
||||||
|
res resolver
|
||||||
|
|
||||||
|
// legoMu: lego keeps its DNS settings in package variables, so one
|
||||||
|
// client works at a time. issueMu: one issue at a time.
|
||||||
|
legoMu sync.Mutex
|
||||||
|
issueMu sync.Mutex
|
||||||
|
|
||||||
|
web atomic.Pointer[webCert]
|
||||||
|
mu sync.Mutex
|
||||||
|
files map[int64]fileStamp // file-source certificates: what was read last
|
||||||
|
kick chan struct{}
|
||||||
|
now func() time.Time
|
||||||
|
// issuing holds the names being obtained now, with when it started.
|
||||||
|
issuing sync.Map
|
||||||
|
// base is the context background issues run in (jarld's lifetime).
|
||||||
|
base context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
type webCert struct {
|
||||||
|
name string
|
||||||
|
pair *tls.Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileStamp struct {
|
||||||
|
certMod, keyMod time.Time
|
||||||
|
certSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a manager. Call Start before serving, then Run.
|
||||||
|
func New(cfg Config, st *store.Store, eng *engine.Engine, logger *slog.Logger) *Manager {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
cfg.SkipPropagationCheck = !cfg.CheckPropagation
|
||||||
|
m := &Manager{cfg: cfg, store: st, eng: eng, log: logger, res: resolver{servers: cfg.Resolvers},
|
||||||
|
files: map[int64]fileStamp{}, kick: make(chan struct{}, 1), now: time.Now, base: context.Background()}
|
||||||
|
// lego logs every step; keep it at debug level.
|
||||||
|
legolog.Logger = log.New(slogWriter{logger}, "", 0)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
type slogWriter struct{ l *slog.Logger }
|
||||||
|
|
||||||
|
func (w slogWriter) Write(p []byte) (int, error) {
|
||||||
|
w.l.Debug("lego: " + strings.TrimSpace(string(p)))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors for the API.
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("no such certificate")
|
||||||
|
ErrConflict = errors.New("a certificate with that name exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputError is a request Jarl refuses.
|
||||||
|
type InputError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *InputError) Error() string { return e.Msg }
|
||||||
|
|
||||||
|
func inputErr(format string, a ...any) error { return &InputError{Msg: fmt.Sprintf(format, a...)} }
|
||||||
|
|
||||||
|
// Input creates or changes a certificate; nil fields are left alone.
|
||||||
|
type Input struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Source *string `json:"source"`
|
||||||
|
Domains *[]string `json:"domains"`
|
||||||
|
CertFile *string `json:"cert_file"`
|
||||||
|
KeyFile *string `json:"key_file"`
|
||||||
|
Uses *[]string `json:"uses"`
|
||||||
|
Challenge *string `json:"challenge"`
|
||||||
|
KeyType *string `json:"key_type"`
|
||||||
|
Comment *string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||||
|
var labelRe = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$`)
|
||||||
|
|
||||||
|
func normDomains(in []string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, d := range in {
|
||||||
|
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
|
||||||
|
if d == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
labels := strings.Split(d, ".")
|
||||||
|
if len(labels) < 2 {
|
||||||
|
return nil, inputErr("%q isn't a domain name a CA will sign (it needs at least two labels)", d)
|
||||||
|
}
|
||||||
|
for i, l := range labels {
|
||||||
|
if l == "*" && i == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !labelRe.MatchString(l) {
|
||||||
|
return nil, inputErr("%q isn't a valid domain name (a wildcard is only allowed as the first label: *.example.com)", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(d) > 253 {
|
||||||
|
return nil, inputErr("%q is too long", d)
|
||||||
|
}
|
||||||
|
if !slices.Contains(out, d) {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, inputErr("give at least one domain name")
|
||||||
|
}
|
||||||
|
if len(out) > 100 {
|
||||||
|
return nil, inputErr("at most 100 names per certificate")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
|
||||||
|
if in.Name != nil {
|
||||||
|
if !creating && *in.Name != c.Name {
|
||||||
|
return inputErr("a certificate can't be renamed (its files for named are named after it): create a new one and delete this one")
|
||||||
|
}
|
||||||
|
c.Name = *in.Name
|
||||||
|
}
|
||||||
|
if !nameRe.MatchString(c.Name) {
|
||||||
|
return inputErr("name %q: use lowercase letters, digits, - and _ (at most 63)", c.Name)
|
||||||
|
}
|
||||||
|
if in.Source != nil {
|
||||||
|
if !creating && *in.Source != c.Source {
|
||||||
|
return inputErr("a certificate's source can't change: create a new one")
|
||||||
|
}
|
||||||
|
c.Source = *in.Source
|
||||||
|
}
|
||||||
|
if c.Source == "" {
|
||||||
|
c.Source = model.CertSourceACME
|
||||||
|
}
|
||||||
|
if in.Uses != nil {
|
||||||
|
c.Uses = nil
|
||||||
|
for _, u := range *in.Uses {
|
||||||
|
u = strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if u != model.CertUseWeb && u != model.CertUseDNS {
|
||||||
|
return inputErr("unknown use %q: web (jarld's HTTPS) or dns (for named: DNS over TLS and HTTPS)", u)
|
||||||
|
}
|
||||||
|
if !slices.Contains(c.Uses, u) {
|
||||||
|
c.Uses = append(c.Uses, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(c.Uses)
|
||||||
|
}
|
||||||
|
if c.Uses == nil {
|
||||||
|
c.Uses = []string{}
|
||||||
|
}
|
||||||
|
if in.Comment != nil {
|
||||||
|
c.Comment = *in.Comment
|
||||||
|
}
|
||||||
|
switch c.Source {
|
||||||
|
case model.CertSourceACME:
|
||||||
|
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
||||||
|
return inputErr("cert_file and key_file are for --source file; Jarl keeps the files of certificates it obtains itself")
|
||||||
|
}
|
||||||
|
if in.Domains != nil {
|
||||||
|
d, err := normDomains(*in.Domains)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Domains = d
|
||||||
|
}
|
||||||
|
if len(c.Domains) == 0 {
|
||||||
|
return inputErr("give the domain names the certificate is for")
|
||||||
|
}
|
||||||
|
if in.Challenge != nil {
|
||||||
|
c.Challenge = *in.Challenge
|
||||||
|
}
|
||||||
|
switch c.Challenge {
|
||||||
|
case "":
|
||||||
|
c.Challenge = model.ChallengeAuto
|
||||||
|
case model.ChallengeAuto, model.ChallengeJarl, model.ChallengePorkbun:
|
||||||
|
default:
|
||||||
|
return inputErr("challenge must be auto, jarl or porkbun")
|
||||||
|
}
|
||||||
|
if in.KeyType != nil {
|
||||||
|
c.KeyType = *in.KeyType
|
||||||
|
}
|
||||||
|
if c.KeyType == "" {
|
||||||
|
c.KeyType = "ec256"
|
||||||
|
}
|
||||||
|
if !slices.Contains(KeyTypes, c.KeyType) {
|
||||||
|
return inputErr("key type must be one of %s", strings.Join(KeyTypes, ", "))
|
||||||
|
}
|
||||||
|
case model.CertSourceFile:
|
||||||
|
if in.Challenge != nil && *in.Challenge != "" || in.KeyType != nil && *in.KeyType != "" {
|
||||||
|
return inputErr("challenge and key type are for certificates Jarl obtains (--source acme)")
|
||||||
|
}
|
||||||
|
if in.CertFile != nil {
|
||||||
|
c.CertFile = *in.CertFile
|
||||||
|
}
|
||||||
|
if in.KeyFile != nil {
|
||||||
|
c.KeyFile = *in.KeyFile
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(c.CertFile) || !filepath.IsAbs(c.KeyFile) {
|
||||||
|
return inputErr("cert_file and key_file must be absolute paths (the full chain, and its key)")
|
||||||
|
}
|
||||||
|
c.Challenge, c.KeyType = "", ""
|
||||||
|
default:
|
||||||
|
return inputErr("source must be acme (Jarl obtains and renews it) or file (you renew it)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUses refuses a second certificate for jarld's HTTPS.
|
||||||
|
func (m *Manager) checkUses(ctx context.Context, c *model.Certificate) error {
|
||||||
|
if !c.HasUse(model.CertUseWeb) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
all, err := m.store.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, o := range all {
|
||||||
|
if o.ID != c.ID && o.HasUse(model.CertUseWeb) {
|
||||||
|
return inputErr("certificate %s is already used for jarld's HTTPS; take the web use off it first (jarl cert update %s --use ...)", o.Name, o.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns every certificate.
|
||||||
|
func (m *Manager) List(ctx context.Context) ([]*model.Certificate, error) {
|
||||||
|
return m.store.Certificates(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one certificate.
|
||||||
|
func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, error) {
|
||||||
|
c, err := m.store.CertificateByName(ctx, name)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create adds a certificate. A file-source certificate is read at once;
|
||||||
|
// one Jarl obtains is left for Issue (or the background loop).
|
||||||
|
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||||
|
c := &model.Certificate{CreatedBy: p.Name(), UpdatedBy: p.Name()}
|
||||||
|
if err := m.apply(c, in, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.checkUses(ctx, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
if _, err := m.readFiles(c); err != nil {
|
||||||
|
return nil, inputErr("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.store.CreateCertificate(ctx, c); err != nil {
|
||||||
|
if errors.Is(err, store.ErrConflict) {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.eng.Audit(ctx, p, "certificate.create", "certificate", c.ID, c.Name, "ok", describe(c))
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
m.refreshFile(ctx, c, true)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update changes a certificate. New names or a new key type make one Jarl
|
||||||
|
// obtains due at once.
|
||||||
|
func (m *Manager) Update(ctx context.Context, name string, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
before := *c
|
||||||
|
if err := m.apply(c, in, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.checkUses(ctx, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if before.HasUse(model.CertUseDNS) && !c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
||||||
|
return nil, inputErr("named's configuration uses certificate %s (a tls statement names its files), so it keeps the dns use: take it out of named first (jarl encrypted-dns disable)", c.Name)
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
|
||||||
|
if _, err := m.readFiles(c); err != nil {
|
||||||
|
return nil, inputErr("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceACME && c.CertPEM != "" && (!slices.Equal(c.Domains, before.Domains) || c.KeyType != before.KeyType) {
|
||||||
|
now := m.now().UTC()
|
||||||
|
c.RenewAt, c.Failures = &now, 0
|
||||||
|
}
|
||||||
|
c.UpdatedBy = p.Name()
|
||||||
|
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.eng.Audit(ctx, p, "certificate.update", "certificate", c.ID, c.Name, "ok", describe(c))
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.files, c.ID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.refreshFile(ctx, c, true)
|
||||||
|
} else if c.CertPEM != "" {
|
||||||
|
if err := m.deploy(ctx, c, []byte(c.CertPEM), []byte(c.KeyPEM)); err != nil {
|
||||||
|
m.log.Warn("writing certificate files failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if before.HasUse(model.CertUseWeb) && !c.HasUse(model.CertUseWeb) {
|
||||||
|
m.dropWeb(c.Name)
|
||||||
|
}
|
||||||
|
m.Kick()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a certificate and its files for named.
|
||||||
|
func (m *Manager) Delete(ctx context.Context, name string, p *auth.Principal) error {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
||||||
|
return inputErr("named's configuration uses certificate %s (a tls statement names its files): take it out first (jarl encrypted-dns disable, or remove the tls statement)", c.Name)
|
||||||
|
}
|
||||||
|
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.dropWeb(c.Name)
|
||||||
|
if err := m.removeFiles(c.Name); err != nil {
|
||||||
|
m.log.Warn("removing certificate files failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.files, c.ID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.eng.Audit(ctx, p, "certificate.delete", "certificate", c.ID, c.Name, "ok", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func describe(c *model.Certificate) string {
|
||||||
|
s := c.Source
|
||||||
|
if c.Source == model.CertSourceACME {
|
||||||
|
s += " " + strings.Join(c.Domains, " ") + ", challenge " + c.Challenge
|
||||||
|
} else {
|
||||||
|
s += " " + c.CertFile
|
||||||
|
}
|
||||||
|
if len(c.Uses) > 0 {
|
||||||
|
s += ", uses " + strings.Join(c.Uses, ",")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview checks a new certificate's fields without saving it.
|
||||||
|
func (m *Manager) Preview(in Input) (*model.Certificate, error) {
|
||||||
|
c := &model.Certificate{}
|
||||||
|
if err := m.apply(c, in, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check works out where each name's challenge would go, without asking
|
||||||
|
// the CA for anything.
|
||||||
|
func (m *Manager) Check(ctx context.Context, c *model.Certificate) ([]Route, error) {
|
||||||
|
if c.Source != model.CertSourceACME {
|
||||||
|
return nil, inputErr("certificate %s is renewed by you (source file); there is nothing to check", c.Name)
|
||||||
|
}
|
||||||
|
st, err := m.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Route
|
||||||
|
for _, d := range c.Domains {
|
||||||
|
out = append(out, m.route(ctx, st, c.Challenge, d, ""))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueResult is the outcome of an issue.
|
||||||
|
type IssueResult struct {
|
||||||
|
Certificate *model.Certificate `json:"certificate"`
|
||||||
|
Routes []Route `json:"routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuing reports since when the certificate is being obtained (zero when
|
||||||
|
// it isn't).
|
||||||
|
func (m *Manager) Issuing(name string) time.Time {
|
||||||
|
if v, ok := m.issuing.Load(name); ok {
|
||||||
|
return v.(time.Time)
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueAsync starts obtaining a certificate in the background and returns
|
||||||
|
// at once: talking to the CA and waiting for every name server to serve
|
||||||
|
// the challenge can take minutes. Issuing tells when it is done; the
|
||||||
|
// outcome is in the certificate (last_success, last_error).
|
||||||
|
func (m *Manager) IssueAsync(ctx context.Context, name string, p *auth.Principal) (*model.Certificate, error) {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
res, err := m.Issue(ctx, name, p)
|
||||||
|
if res != nil {
|
||||||
|
return res.Certificate, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, busy := m.issuing.LoadOrStore(name, m.now()); busy {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer m.issuing.Delete(name)
|
||||||
|
m.issue(m.base, c, p)
|
||||||
|
}()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue obtains a new certificate for c now (a file-source certificate is
|
||||||
|
// reread instead).
|
||||||
|
func (m *Manager) Issue(ctx context.Context, name string, p *auth.Principal) (*IssueResult, error) {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.files, c.ID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err := m.refreshFile(ctx, c, true); err != nil {
|
||||||
|
return &IssueResult{Certificate: c}, err
|
||||||
|
}
|
||||||
|
return &IssueResult{Certificate: c}, nil
|
||||||
|
}
|
||||||
|
return m.issue(ctx, c, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) issue(ctx context.Context, c *model.Certificate, p *auth.Principal) (*IssueResult, error) {
|
||||||
|
m.issueMu.Lock()
|
||||||
|
defer m.issueMu.Unlock()
|
||||||
|
// Reload: another issue may have finished while this one waited.
|
||||||
|
if fresh, err := m.Get(ctx, c.Name); err == nil {
|
||||||
|
c = fresh
|
||||||
|
}
|
||||||
|
var old *x509.Certificate
|
||||||
|
if c.CertPEM != "" {
|
||||||
|
if in, _, err := Inspect([]byte(c.CertPEM), []byte(c.KeyPEM)); err == nil {
|
||||||
|
old = in.Leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.log.Info("obtaining certificate", "certificate", c.Name, "domains", strings.Join(c.Domains, " "), "by", p.Name())
|
||||||
|
now := m.now().UTC()
|
||||||
|
c.LastAttempt = &now
|
||||||
|
res, routes, err := m.obtain(ctx, c, old)
|
||||||
|
c.LastRoutes = nil
|
||||||
|
for _, r := range routes {
|
||||||
|
if r.Error == "" {
|
||||||
|
c.LastRoutes = append(c.LastRoutes, r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var info *Info
|
||||||
|
if err == nil {
|
||||||
|
info, _, err = Inspect(res.Certificate, res.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("the CA's certificate is unusable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.Failures++
|
||||||
|
c.LastError = err.Error()
|
||||||
|
if serr := m.store.UpdateCertificate(ctx, c); serr != nil {
|
||||||
|
m.log.Error("saving certificate state failed", "certificate", c.Name, "err", serr)
|
||||||
|
}
|
||||||
|
m.log.Warn("obtaining certificate failed", "certificate", c.Name, "failures", c.Failures, "err", err)
|
||||||
|
m.eng.Audit(ctx, p, "certificate.issue", "certificate", c.ID, c.Name, "error", err.Error())
|
||||||
|
return &IssueResult{Certificate: c, Routes: routes}, err
|
||||||
|
}
|
||||||
|
c.CertPEM, c.KeyPEM = string(res.Certificate), string(res.PrivateKey)
|
||||||
|
setInfo(c, info)
|
||||||
|
done := m.now().UTC()
|
||||||
|
c.LastSuccess, c.Failures, c.LastError = &done, 0, ""
|
||||||
|
at := twoThirds(info.NotBefore, info.NotAfter)
|
||||||
|
c.RenewAt, c.ARICheckedAt = &at, nil
|
||||||
|
if t, err := m.renewalInfo(ctx, c, info.Leaf); err == nil && t != nil {
|
||||||
|
c.RenewAt, c.ARICheckedAt = t, &done
|
||||||
|
}
|
||||||
|
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.log.Info("certificate obtained", "certificate", c.Name, "expires", info.NotAfter.Format(time.DateOnly), "renew_at", c.RenewAt.Format(time.RFC3339), "routes", strings.Join(c.LastRoutes, "; "))
|
||||||
|
m.eng.Audit(ctx, p, "certificate.issue", "certificate", c.ID, c.Name, "ok", fmt.Sprintf("%s, valid until %s, from %s", strings.Join(info.Names, " "), info.NotAfter.Format(time.DateOnly), info.Issuer))
|
||||||
|
if err := m.deploy(ctx, c, res.Certificate, res.PrivateKey); err != nil {
|
||||||
|
m.log.Error("writing certificate files failed", "certificate", c.Name, "err", err)
|
||||||
|
return &IssueResult{Certificate: c, Routes: routes}, fmt.Errorf("the certificate was obtained, but writing it for named failed: %w", err)
|
||||||
|
}
|
||||||
|
return &IssueResult{Certificate: c, Routes: routes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInfo(c *model.Certificate, in *Info) {
|
||||||
|
nb, na := in.NotBefore.UTC(), in.NotAfter.UTC()
|
||||||
|
c.Names, c.NotBefore, c.NotAfter, c.Issuer, c.Serial = in.Names, &nb, &na, in.Issuer, in.Serial
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFiles reads a file-source certificate and checks it.
|
||||||
|
func (m *Manager) readFiles(c *model.Certificate) (*Info, error) {
|
||||||
|
cert, err := os.ReadFile(c.CertFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fileErr(err)
|
||||||
|
}
|
||||||
|
key, err := os.ReadFile(c.KeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fileErr(err)
|
||||||
|
}
|
||||||
|
in, _, err := Inspect(cert, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s and %s: %w", c.CertFile, c.KeyFile, err)
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileErr(err error) error {
|
||||||
|
if errors.Is(err, fs.ErrPermission) {
|
||||||
|
return fmt.Errorf("%w: jarld runs as its own user (jarl) and must be able to read the certificate and its key; e.g. give the files group jarl (chgrp jarl FILE; chmod 640 FILE), or copy them somewhere it can read in your renewal hook", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshFile rereads a file-source certificate when its files changed
|
||||||
|
// (always with force) and deploys it.
|
||||||
|
func (m *Manager) refreshFile(ctx context.Context, c *model.Certificate, force bool) error {
|
||||||
|
cs, err1 := os.Stat(c.CertFile)
|
||||||
|
ks, err2 := os.Stat(c.KeyFile)
|
||||||
|
var stamp fileStamp
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
stamp = fileStamp{certMod: cs.ModTime(), keyMod: ks.ModTime(), certSize: cs.Size()}
|
||||||
|
m.mu.Lock()
|
||||||
|
prev, ok := m.files[c.ID]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if ok && prev == stamp && !force {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in, err := m.readFiles(c)
|
||||||
|
now := m.now().UTC()
|
||||||
|
c.LastAttempt = &now
|
||||||
|
if err != nil {
|
||||||
|
if c.LastError != err.Error() {
|
||||||
|
m.log.Warn("reading certificate failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
c.LastError = err.Error()
|
||||||
|
c.Failures = max(c.Failures, 1)
|
||||||
|
m.store.UpdateCertificate(ctx, c)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.Serial != in.Serial {
|
||||||
|
m.log.Info("certificate read", "certificate", c.Name, "file", c.CertFile, "expires", in.NotAfter.Format(time.DateOnly))
|
||||||
|
}
|
||||||
|
setInfo(c, in)
|
||||||
|
c.Domains = in.Names
|
||||||
|
c.LastSuccess, c.LastError, c.Failures = &now, "", 0
|
||||||
|
if len(in.Warnings) > 0 {
|
||||||
|
c.LastError = in.Warnings[0]
|
||||||
|
}
|
||||||
|
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cert, _ := os.ReadFile(c.CertFile)
|
||||||
|
key, _ := os.ReadFile(c.KeyFile)
|
||||||
|
if err := m.deploy(ctx, c, cert, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.files[c.ID] = stamp
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesDir is where a certificate with the dns use is written for named.
|
||||||
|
func (m *Manager) FilesDir(name string) string { return filepath.Join(m.cfg.Dir, name) }
|
||||||
|
|
||||||
|
// Files names a certificate's files for named.
|
||||||
|
const (
|
||||||
|
ChainFile = "fullchain.pem"
|
||||||
|
KeyFile = "privkey.pem"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deploy puts a certificate where it is used: jarld's HTTPS and/or the
|
||||||
|
// files named reads. When those files change and named's configuration
|
||||||
|
// names them, named rereads it.
|
||||||
|
func (m *Manager) deploy(ctx context.Context, c *model.Certificate, cert, key []byte) error {
|
||||||
|
_, pair, err := Inspect(cert, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.HasUse(model.CertUseWeb) {
|
||||||
|
m.web.Store(&webCert{name: c.Name, pair: pair})
|
||||||
|
} else {
|
||||||
|
m.dropWeb(c.Name)
|
||||||
|
}
|
||||||
|
if !c.HasUse(model.CertUseDNS) {
|
||||||
|
return m.removeFiles(c.Name)
|
||||||
|
}
|
||||||
|
dir := m.FilesDir(c.Name)
|
||||||
|
changed := false
|
||||||
|
for _, f := range []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
mode fs.FileMode
|
||||||
|
}{{ChainFile, cert, 0o644}, {KeyFile, key, 0o640}} {
|
||||||
|
path := filepath.Join(dir, f.name)
|
||||||
|
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, f.content) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.eng.WriteFile(path, f.content, f.mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed && m.namedUses(ctx, dir) {
|
||||||
|
if err := m.eng.Reconfig(ctx); err != nil {
|
||||||
|
return fmt.Errorf("named didn't reread its configuration for the new certificate: %w", err)
|
||||||
|
}
|
||||||
|
m.log.Info("named rereads the new certificate", "certificate", c.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// namedUses reports whether named's configuration names files in dir.
|
||||||
|
func (m *Manager) namedUses(ctx context.Context, dir string) bool {
|
||||||
|
st, err := m.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, o := range st.Options {
|
||||||
|
if strings.Contains(o.Value, dir+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) removeFiles(name string) error {
|
||||||
|
if name == "" || strings.ContainsAny(name, "/.") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir := m.FilesDir(name)
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) dropWeb(name string) {
|
||||||
|
if w := m.web.Load(); w != nil && w.name == name {
|
||||||
|
m.web.CompareAndSwap(w, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebCertificate is the certificate for jarld's HTTPS (nil when no
|
||||||
|
// certificate has the web use, or it has none yet).
|
||||||
|
func (m *Manager) WebCertificate() *tls.Certificate {
|
||||||
|
if w := m.web.Load(); w != nil {
|
||||||
|
return w.pair
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebName is the name of the certificate jarld serves ("" for none).
|
||||||
|
func (m *Manager) WebName() string {
|
||||||
|
if w := m.web.Load(); w != nil {
|
||||||
|
return w.name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasWeb reports whether a certificate has the web use (whether or not
|
||||||
|
// it has been obtained yet).
|
||||||
|
func (m *Manager) HasWeb(ctx context.Context) bool {
|
||||||
|
all, err := m.store.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(all, func(c *model.Certificate) bool { return c.HasUse(model.CertUseWeb) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick makes the loop look at the certificates now.
|
||||||
|
func (m *Manager) Kick() {
|
||||||
|
select {
|
||||||
|
case m.kick <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start loads the certificates and puts them in place (jarld's HTTPS, the
|
||||||
|
// files for named). Call it before serving.
|
||||||
|
func (m *Manager) Start(ctx context.Context) {
|
||||||
|
all, err := m.store.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("loading certificates failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
switch {
|
||||||
|
case c.Source == model.CertSourceFile:
|
||||||
|
m.refreshFile(ctx, c, true)
|
||||||
|
case c.CertPEM != "":
|
||||||
|
if err := m.deploy(ctx, c, []byte(c.CertPEM), []byte(c.KeyPEM)); err != nil {
|
||||||
|
m.log.Warn("putting certificate in place failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run renews certificates when they are due and rereads file-source ones
|
||||||
|
// when their files change, until ctx ends.
|
||||||
|
func (m *Manager) Run(ctx context.Context) {
|
||||||
|
m.base = ctx
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(20 * time.Second): // let jarld finish starting
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
m.Tick(ctx)
|
||||||
|
t := time.NewTimer(10 * time.Minute)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Stop()
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
case <-m.kick:
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick does one round of the loop.
|
||||||
|
func (m *Manager) Tick(ctx context.Context) {
|
||||||
|
all, err := m.store.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("loading certificates failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := m.now()
|
||||||
|
for _, c := range all {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
m.refreshFile(ctx, c, false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if at := m.Due(c); !at.After(now) {
|
||||||
|
if _, busy := m.issuing.LoadOrStore(c.Name, now); !busy {
|
||||||
|
m.issue(ctx, c, auth.System)
|
||||||
|
m.issuing.Delete(c.Name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Ask the CA about the renewal window twice a day (it moves it
|
||||||
|
// earlier when a certificate must be replaced, e.g. revoked).
|
||||||
|
if c.CertPEM != "" && (c.ARICheckedAt == nil || now.Sub(*c.ARICheckedAt) > 12*time.Hour) {
|
||||||
|
in, _, err := Inspect([]byte(c.CertPEM), []byte(c.KeyPEM))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := m.renewalInfo(ctx, c, in.Leaf)
|
||||||
|
checked := now.UTC()
|
||||||
|
c.ARICheckedAt = &checked
|
||||||
|
if err == nil && t != nil && (c.RenewAt == nil || !t.Equal(*c.RenewAt)) {
|
||||||
|
m.log.Info("renewal time from the CA", "certificate", c.Name, "renew_at", t.Format(time.RFC3339))
|
||||||
|
c.RenewAt = t
|
||||||
|
}
|
||||||
|
m.store.UpdateCertificate(ctx, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Due is when c should be obtained (again): at once when it has no
|
||||||
|
// certificate yet or its names changed; else at its renewal time. After
|
||||||
|
// failures it waits 1, 2, 4 ... hours (at most a day) between attempts.
|
||||||
|
func (m *Manager) Due(c *model.Certificate) time.Time {
|
||||||
|
var at time.Time
|
||||||
|
switch {
|
||||||
|
case c.CertPEM == "":
|
||||||
|
at = c.CreatedAt
|
||||||
|
case c.RenewAt != nil:
|
||||||
|
at = *c.RenewAt
|
||||||
|
case c.NotBefore != nil && c.NotAfter != nil:
|
||||||
|
at = twoThirds(*c.NotBefore, *c.NotAfter)
|
||||||
|
}
|
||||||
|
if c.Failures > 0 && c.LastAttempt != nil {
|
||||||
|
wait := time.Hour << min(c.Failures-1, 5)
|
||||||
|
at = maxTime(at, c.LastAttempt.Add(min(wait, 24*time.Hour)))
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxTime(a, b time.Time) time.Time {
|
||||||
|
if a.After(b) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is a certificate's health.
|
||||||
|
type Status struct {
|
||||||
|
State string `json:"state"` // ok, pending, failing, expiring, expired
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusOf says how c is doing.
|
||||||
|
func (m *Manager) StatusOf(c *model.Certificate) Status {
|
||||||
|
now := m.now()
|
||||||
|
if c.NotAfter == nil {
|
||||||
|
if c.Failures > 0 {
|
||||||
|
return Status{"failing", "not obtained yet: " + c.LastError}
|
||||||
|
}
|
||||||
|
return Status{"pending", "not obtained yet"}
|
||||||
|
}
|
||||||
|
left := c.NotAfter.Sub(now)
|
||||||
|
life := c.NotAfter.Sub(*c.NotBefore)
|
||||||
|
switch {
|
||||||
|
case left <= 0:
|
||||||
|
return Status{"expired", "expired on " + c.NotAfter.Format(time.DateOnly)}
|
||||||
|
case c.Failures > 0 && c.Source == model.CertSourceACME:
|
||||||
|
return Status{"failing", fmt.Sprintf("renewing failed %d times (next try %s): %s", c.Failures, m.Due(c).Local().Format("2 Jan 15:04"), c.LastError)}
|
||||||
|
case c.Failures > 0:
|
||||||
|
return Status{"failing", c.LastError}
|
||||||
|
case left < life/6 || (c.Source == model.CertSourceFile && left < 14*24*time.Hour):
|
||||||
|
return Status{"expiring", fmt.Sprintf("expires in %s (%s)", days(left), c.NotAfter.Format(time.DateOnly))}
|
||||||
|
}
|
||||||
|
return Status{"ok", ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func days(d time.Duration) string {
|
||||||
|
n := int(d.Hours() / 24)
|
||||||
|
if n == 1 {
|
||||||
|
return "1 day"
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountInfo is the ACME account as the API shows it.
|
||||||
|
type AccountInfo struct {
|
||||||
|
Directory string `json:"directory"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
AgreeTOS bool `json:"agree_tos"`
|
||||||
|
EAB bool `json:"eab"`
|
||||||
|
Registered bool `json:"registered"`
|
||||||
|
URI string `json:"uri,omitempty"`
|
||||||
|
RegisteredAt *time.Time `json:"registered_at,omitempty"`
|
||||||
|
Porkbun bool `json:"porkbun"`
|
||||||
|
Resolvers []string `json:"resolvers"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
ActiveWebCert string `json:"active_web_certificate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountInfo describes the ACME account and settings.
|
||||||
|
func (m *Manager) AccountInfo(ctx context.Context) AccountInfo {
|
||||||
|
ai := AccountInfo{Directory: m.cfg.Directory, Email: m.cfg.Email, AgreeTOS: m.cfg.AgreeTOS, EAB: m.cfg.EABKeyID != "",
|
||||||
|
Porkbun: m.cfg.Porkbun != nil, Resolvers: m.cfg.Resolvers, Dir: m.cfg.Dir, ActiveWebCert: m.WebName()}
|
||||||
|
if a := m.account(ctx); a != nil && a.Directory == m.cfg.Directory && a.URI != "" {
|
||||||
|
ai.Registered, ai.URI = true, a.URI
|
||||||
|
t := a.RegisteredAt
|
||||||
|
ai.RegisteredAt = &t
|
||||||
|
}
|
||||||
|
return ai
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -30,8 +30,17 @@ type Client struct {
|
|||||||
// Changeset, if set, stages changes into this changeset instead of
|
// Changeset, if set, stages changes into this changeset instead of
|
||||||
// applying them.
|
// applying them.
|
||||||
Changeset int64
|
Changeset int64
|
||||||
DryRun bool
|
// CheckChangeset, if set, runs once before Changeset is first used,
|
||||||
Force bool
|
// 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:
|
// Preflight lets only requests through that can't change anything:
|
||||||
// reads and dry runs. Running a command on several servers uses it to
|
// reads and dry runs. Running a command on several servers uses it to
|
||||||
// check the command everywhere before running it anywhere.
|
// 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)
|
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
|
// Mutate sends a configuration change, honoring Changeset, DryRun and
|
||||||
// Force.
|
// Force.
|
||||||
func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) {
|
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 {
|
for k, v := range query {
|
||||||
q[k] = v
|
q[k] = v
|
||||||
}
|
}
|
||||||
if c.Changeset > 0 {
|
cs, err := c.UseChangeset(ctx)
|
||||||
q.Set("changeset", fmt.Sprint(c.Changeset))
|
if err != nil {
|
||||||
|
return &api.MutationResponse{}, err
|
||||||
|
}
|
||||||
|
if cs > 0 {
|
||||||
|
q.Set("changeset", fmt.Sprint(cs))
|
||||||
}
|
}
|
||||||
if c.DryRun {
|
if c.DryRun {
|
||||||
q.Set("dry_run", "1")
|
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 {
|
if c.Force {
|
||||||
q.Set("force", "1")
|
q.Set("force", "1")
|
||||||
}
|
}
|
||||||
|
if c.AcceptRisk {
|
||||||
|
q.Set("accept_risk", "1")
|
||||||
|
}
|
||||||
var out api.MutationResponse
|
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
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+212
-13
@@ -4,6 +4,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -38,6 +39,67 @@ type Config struct {
|
|||||||
ACME ACME `yaml:"acme"`
|
ACME ACME `yaml:"acme"`
|
||||||
Porkbun Porkbun `yaml:"porkbun"`
|
Porkbun Porkbun `yaml:"porkbun"`
|
||||||
Blocking Blocking `yaml:"blocking"`
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Blocking configures blocklist downloads (docs/blocking.md). The lists
|
||||||
@@ -132,9 +194,85 @@ type Backup struct {
|
|||||||
WarnAfter Duration `yaml:"warn_after"`
|
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 {
|
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
|
// HTTPReq configures the endpoint for lego's httpreq DNS provider
|
||||||
@@ -321,8 +459,10 @@ func Default() *Config {
|
|||||||
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)},
|
||||||
TopN: 100,
|
TopN: 100,
|
||||||
},
|
},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
|
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},
|
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)},
|
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,6 +534,15 @@ func (c *Config) check() error {
|
|||||||
if err := c.Blocking.check(); err != nil {
|
if err := c.Blocking.check(); err != nil {
|
||||||
return fmt.Errorf("blocking: %w", err)
|
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 {
|
switch c.Log.Format {
|
||||||
case "text", "json":
|
case "text", "json":
|
||||||
default:
|
default:
|
||||||
@@ -497,8 +646,10 @@ const Example = `# Jarl daemon configuration (/etc/jarl/jarl.yaml).
|
|||||||
listen:
|
listen:
|
||||||
- "127.0.0.1:8053"
|
- "127.0.0.1:8053"
|
||||||
|
|
||||||
# HTTPS. The certificate is reread when the file changes (or on SIGHUP),
|
# HTTPS from a certificate file you renew yourself. The certificate is
|
||||||
# so renewed Let's Encrypt certificates are picked up automatically.
|
# 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:
|
tls:
|
||||||
cert_file: ""
|
cert_file: ""
|
||||||
key_file: ""
|
key_file: ""
|
||||||
@@ -612,15 +763,44 @@ acme:
|
|||||||
# this server. Leave false if a reverse proxy runs on this server but
|
# this server. Leave false if a reverse proxy runs on this server but
|
||||||
# isn't in trusted_proxies: its requests would look local.
|
# isn't in trusted_proxies: its requests would look local.
|
||||||
allow_loopback_http: false
|
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
|
# Where certificates with the "dns" use are written for named, one
|
||||||
# show and set a domain's name servers, glue records and DNSSEC DS records
|
# directory per certificate (fullchain.pem, privkey.pem), readable by
|
||||||
# there, after checking that the change won't break the domain. Off without
|
# bind.file_group. Default: <bind.config_dir>/tls.
|
||||||
# a key. Create the key at porkbun.com (Account -> API Access), switch on
|
certificates:
|
||||||
# "API Access" for the domain, and limit the key to that domain if you can.
|
dir: ""
|
||||||
# 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
|
# Porkbun, as registrar and as DNS host. With an API key, Jarl can:
|
||||||
# Jarl's database or backups.
|
# - 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:
|
porkbun:
|
||||||
api_key: "" # pk1_...
|
api_key: "" # pk1_...
|
||||||
secret_api_key: "" # sk1_...
|
secret_api_key: "" # sk1_...
|
||||||
@@ -633,4 +813,23 @@ blocking:
|
|||||||
refresh: 24h # check each list for changes this often (at least 1h)
|
refresh: 24h # check each list for changes this often (at least 1h)
|
||||||
max_download_mb: 64 # refuse larger downloads
|
max_download_mb: 64 # refuse larger downloads
|
||||||
max_entries: 1000000 # refuse lists with more domains
|
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: ""
|
||||||
`
|
`
|
||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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" {
|
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)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
// 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"
|
||||||
|
"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 {
|
||||||
|
// Hostname is the name clients connect to (the certificate's).
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
// 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)
|
||||||
|
// Views get the hostname's A/AAAA records (default: none). In a
|
||||||
|
// linked zone they are local to the view.
|
||||||
|
Views []string `json:"views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), "."))
|
||||||
|
if host == "" || !strings.Contains(host, ".") {
|
||||||
|
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
dir := filepath.Join(certDir, s.Certificate)
|
||||||
|
tlsValue := fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
||||||
|
tlsName, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
||||||
|
|
||||||
|
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 name's addresses, in the views asked for.
|
||||||
|
fqdn := host + "."
|
||||||
|
for _, vn := range s.Views {
|
||||||
|
v := st.ViewByName(vn)
|
||||||
|
if v == nil {
|
||||||
|
return nil, nil, fmt.Errorf("view %q does not exist", vn)
|
||||||
|
}
|
||||||
|
z := zoneFor(st, fqdn, v.ID)
|
||||||
|
if z == nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var local *bool
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local = model.Ptr(true)
|
||||||
|
}
|
||||||
|
for _, set := range []struct {
|
||||||
|
typ string
|
||||||
|
addrs []string
|
||||||
|
}{{"A", v4}, {"AAAA", v6}} {
|
||||||
|
if len(set.addrs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/iprange"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,10 +53,16 @@ func NormalizeMember(st *model.State, m string) (string, error) {
|
|||||||
if p, err := netip.ParsePrefix(m); err == nil {
|
if p, err := netip.ParsePrefix(m); err == nil {
|
||||||
return p.Masked().String(), 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 {
|
if a := st.ACLByName(m); a != nil {
|
||||||
return a.Name, nil
|
return a.Name, nil
|
||||||
}
|
}
|
||||||
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24) or an ACL", m)
|
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 {
|
func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
@@ -23,7 +24,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
||||||
s := to.Settings
|
s := to.Settings
|
||||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
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))}, "restore settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, k := range sortedKeys(to.Keys) {
|
for _, k := range sortedKeys(to.Keys) {
|
||||||
@@ -47,7 +49,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
for _, k := range sortedKeys(to.Views) {
|
for _, k := range sortedKeys(to.Views) {
|
||||||
n := to.Views[k]
|
n := to.Views[k]
|
||||||
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
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))}
|
||||||
if n.MatchRecursiveOnly != nil {
|
if n.MatchRecursiveOnly != nil {
|
||||||
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
||||||
} else {
|
} else {
|
||||||
@@ -63,7 +66,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
n := to.Zones[k]
|
n := to.Zones[k]
|
||||||
s := n.SOA
|
s := n.SOA
|
||||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
|
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)}
|
||||||
if n.Type == model.ZonePrimary {
|
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}
|
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
}
|
}
|
||||||
for _, k := range sortedKeys(to.Records) {
|
for _, k := range sortedKeys(to.Records) {
|
||||||
n := to.Records[k]
|
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 {
|
if n.TTL != nil {
|
||||||
in.TTL = model.Some(*n.TTL)
|
in.TTL = model.Some(*n.TTL)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+79
-12
@@ -15,6 +15,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -56,6 +57,10 @@ type Config struct {
|
|||||||
NoReload bool
|
NoReload bool
|
||||||
// SkipCheck disables named-checkconf (only for tests without BIND).
|
// SkipCheck disables named-checkconf (only for tests without BIND).
|
||||||
SkipCheck bool
|
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.
|
// Engine applies changesets.
|
||||||
@@ -110,17 +115,20 @@ type Result struct {
|
|||||||
DryRun bool `json:"dry_run,omitempty"`
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
Problems []validate.Problem `json:"problems"` // problems this change introduces
|
Problems []validate.Problem `json:"problems"` // problems this change introduces
|
||||||
Existing []validate.Problem `json:"existing_problems,omitempty"`
|
// Risks are the introduced problems that open the server up; applying
|
||||||
Check []CheckMessage `json:"check,omitempty"`
|
// needs AcceptRisk (docs/exposure.md).
|
||||||
CheckOK bool `json:"check_ok"`
|
Risks []validate.Problem `json:"risks,omitempty"`
|
||||||
Diff string `json:"diff"`
|
Existing []validate.Problem `json:"existing_problems,omitempty"`
|
||||||
Files []string `json:"files_changed"`
|
Check []CheckMessage `json:"check,omitempty"`
|
||||||
Serials []SerialChange `json:"serials,omitempty"`
|
CheckOK bool `json:"check_ok"`
|
||||||
Reloaded []string `json:"reloaded,omitempty"`
|
Diff string `json:"diff"`
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
Files []string `json:"files_changed"`
|
||||||
Drift []DriftEntry `json:"drift,omitempty"`
|
Serials []SerialChange `json:"serials,omitempty"`
|
||||||
Ops []model.Op `json:"ops,omitempty"`
|
Reloaded []string `json:"reloaded,omitempty"`
|
||||||
Error *Error `json:"error,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.
|
// stored is what is kept in a changeset's result column.
|
||||||
@@ -138,6 +146,9 @@ type ApplyOptions struct {
|
|||||||
Force bool
|
Force bool
|
||||||
// NoReload writes the files without reloading named.
|
// NoReload writes the files without reloading named.
|
||||||
NoReload bool
|
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 lets WipeDNSSEC switch signing off for this zone at once.
|
||||||
wipeZone int64
|
wipeZone int64
|
||||||
}
|
}
|
||||||
@@ -180,7 +191,46 @@ func ValidateOptions(st *model.State) validate.Options {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) validate(st *model.State) []validate.Problem {
|
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
|
// ---- changeset management
|
||||||
@@ -389,6 +439,8 @@ type plan struct {
|
|||||||
base, work *model.State
|
base, work *model.State
|
||||||
introduced []validate.Problem
|
introduced []validate.Problem
|
||||||
existing []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
|
serials []SerialChange
|
||||||
out *render.Output
|
out *render.Output
|
||||||
changed []*render.File
|
changed []*render.File
|
||||||
@@ -419,6 +471,13 @@ func (pl *plan) files() []string {
|
|||||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
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{}}
|
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
||||||
before := e.validate(base)
|
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)
|
after := e.validate(work)
|
||||||
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
||||||
introducedKeys := map[string]bool{}
|
introducedKeys := map[string]bool{}
|
||||||
@@ -652,6 +711,7 @@ func (pl *plan) result() *Result {
|
|||||||
Files: pl.files(),
|
Files: pl.files(),
|
||||||
Serials: pl.serials,
|
Serials: pl.serials,
|
||||||
Drift: pl.drift,
|
Drift: pl.drift,
|
||||||
|
Risks: risks(pl.before, pl.introduced, pl.public),
|
||||||
}
|
}
|
||||||
r.Valid = r.Valid && r.CheckOK
|
r.Valid = r.Valid && r.CheckOK
|
||||||
if r.Files == nil {
|
if r.Files == nil {
|
||||||
@@ -781,6 +841,13 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
|||||||
if opts.DryRun {
|
if opts.DryRun {
|
||||||
return res, nil
|
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 {
|
if len(pl.drift) > 0 && !opts.Force {
|
||||||
var paths []string
|
var paths []string
|
||||||
for _, d := range pl.drift {
|
for _, d := range pl.drift {
|
||||||
|
|||||||
@@ -21,19 +21,26 @@ const (
|
|||||||
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
|
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
|
||||||
KindDrift ErrorKind = "drift" // files were changed outside Jarl
|
KindDrift ErrorKind = "drift" // files were changed outside Jarl
|
||||||
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
|
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.
|
// Error is an engine error with details for the caller.
|
||||||
type Error struct {
|
type Error struct {
|
||||||
Kind ErrorKind `json:"code"`
|
Kind ErrorKind `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
OpSeq int `json:"op,omitempty"` // the operation that failed
|
OpSeq int `json:"op,omitempty"` // the operation that failed
|
||||||
Problems []validate.Problem `json:"problems,omitempty"`
|
// OpSummary describes that operation, so people can find it in the
|
||||||
Check []CheckMessage `json:"check,omitempty"`
|
// changeset (record IDs mean little to them).
|
||||||
Diff string `json:"diff,omitempty"`
|
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 {
|
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 {
|
if e.OpSeq > 0 {
|
||||||
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
|
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
|
||||||
}
|
}
|
||||||
|
|||||||
+288
-21
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -131,7 +132,24 @@ func dateSerial(now time.Time) uint32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) expand(req Request) ([]model.Op, error) {
|
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||||
|
ops, err := x.expandOne(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
switch req.Op {
|
switch req.Op {
|
||||||
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
||||||
|
// Changes to linked zones reach the other zones of the link.
|
||||||
|
return x.linkSync(ops)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
||||||
|
switch req.Op {
|
||||||
|
case OpZoneLink:
|
||||||
|
return x.zoneLink(req)
|
||||||
|
case OpZoneUnlink:
|
||||||
|
return x.zoneUnlink(req)
|
||||||
case OpZoneCreate:
|
case OpZoneCreate:
|
||||||
return x.zoneCreate(req)
|
return x.zoneCreate(req)
|
||||||
case OpZoneUpdate, OpZoneDelete:
|
case OpZoneUpdate, OpZoneDelete:
|
||||||
@@ -152,7 +170,24 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
ops := []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}
|
||||||
|
if req.Op == OpZoneDelete {
|
||||||
|
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||||
|
ops = append(ops, x.unlinkOps(rest[0], "unlink zone "+zoneDesc(x.st, rest[0])+": the zone it was linked with is deleted")...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var in ZoneInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if in.LinkGroup != nil && *in.LinkGroup != z.LinkGroup {
|
||||||
|
return nil, errf(KindBadRequest, "link zones with zone.link and zone.unlink (jarl zone link / unlink)")
|
||||||
|
}
|
||||||
|
if in.LinkWith != "" {
|
||||||
|
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
case OpRecordCreate:
|
case OpRecordCreate:
|
||||||
return x.recordCreate(req)
|
return x.recordCreate(req)
|
||||||
case OpRecordUpdate:
|
case OpRecordUpdate:
|
||||||
@@ -228,6 +263,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
||||||
case OpSettingsUpdate:
|
case OpSettingsUpdate:
|
||||||
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
||||||
|
case OpExposureAck, OpExposureUnack:
|
||||||
|
return x.exposureAck(req, req.Op == OpExposureAck)
|
||||||
case OpBlocklistCreate:
|
case OpBlocklistCreate:
|
||||||
return x.blocklistCreate(req)
|
return x.blocklistCreate(req)
|
||||||
case OpBlocklistUpdate, OpBlocklistDelete:
|
case OpBlocklistUpdate, OpBlocklistDelete:
|
||||||
@@ -353,7 +390,25 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
records := in.Records
|
records := in.Records
|
||||||
cloneFrom := in.CloneFrom
|
cloneFrom := in.CloneFrom
|
||||||
zoneOpts := in.Options
|
zoneOpts := in.Options
|
||||||
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
|
linkWith := in.LinkWith
|
||||||
|
in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil
|
||||||
|
if linkWith != "" {
|
||||||
|
if cloneFrom != 0 {
|
||||||
|
return nil, errf(KindBadRequest, "give clone_from or link_with, not both")
|
||||||
|
}
|
||||||
|
v := x.st.ViewByName(linkWith)
|
||||||
|
if v == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %q does not exist", linkWith)
|
||||||
|
}
|
||||||
|
o := x.st.FindZone(name, v.ID)
|
||||||
|
if o == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name))
|
||||||
|
}
|
||||||
|
if o.Type != model.ZonePrimary || zt != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "only primary zones can be linked")
|
||||||
|
}
|
||||||
|
cloneFrom = o.ID
|
||||||
|
}
|
||||||
|
|
||||||
set := x.st.Settings
|
set := x.st.Settings
|
||||||
var src *model.Zone
|
var src *model.Zone
|
||||||
@@ -478,9 +533,30 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var linkOps []model.Op
|
||||||
|
if src != nil && linkWith != "" {
|
||||||
|
// The new zone joins src's link, or starts one with src: then
|
||||||
|
// all of src's records become shared.
|
||||||
|
group := src.LinkGroup
|
||||||
|
if group == 0 {
|
||||||
|
group = src.ID
|
||||||
|
linkOps = x.shareAllOps(src, group)
|
||||||
|
}
|
||||||
|
in.LinkGroup = &group
|
||||||
|
}
|
||||||
if src != nil {
|
if src != nil {
|
||||||
for _, r := range x.st.ZoneRecords(src.ID) {
|
for _, r := range x.st.ZoneRecords(src.ID) {
|
||||||
|
if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 {
|
||||||
|
continue // local to src's view
|
||||||
|
}
|
||||||
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
||||||
|
if linkWith != "" {
|
||||||
|
id := r.SharedID
|
||||||
|
if id == 0 {
|
||||||
|
id = r.ID
|
||||||
|
}
|
||||||
|
ri.SharedID = &id
|
||||||
|
}
|
||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
ri.Disabled = model.Ptr(true)
|
ri.Disabled = model.Ptr(true)
|
||||||
}
|
}
|
||||||
@@ -510,10 +586,12 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
summary += " in view " + v.Name
|
summary += " in view " + v.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if src != nil {
|
if src != nil && linkWith != "" {
|
||||||
|
summary += " (linked with " + zoneDesc(x.st, src) + ")"
|
||||||
|
} else if src != nil {
|
||||||
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
|
ops := append(linkOps, newOp(OpZoneCreate, ids[0], 0, in, summary))
|
||||||
for i, zo := range zoneOpts {
|
for i, zo := range zoneOpts {
|
||||||
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
||||||
if zo.Comment != "" {
|
if zo.Comment != "" {
|
||||||
@@ -565,11 +643,42 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
found := false
|
||||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil, nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
more, err := x.ptrCreateIn(rz, ptrName, owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrViews are the views whose reverse zones get the PTR records of zone
|
||||||
|
// z's address records: z's own, and, for a linked zone, the other views
|
||||||
|
// of the link (the record is in all of them).
|
||||||
|
func ptrViews(st *model.State, z *model.Zone) []int64 {
|
||||||
|
out := []int64{z.ViewID}
|
||||||
|
for _, o := range st.LinkedWith(z) {
|
||||||
|
if !slices.Contains(out, o.ViewID) {
|
||||||
|
out = append(out, o.ViewID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
||||||
|
// in reverse zone rz (none when it is there; enabling it when disabled).
|
||||||
|
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.Op, error) {
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" {
|
if r.Name == ptrName && r.Type == "PTR" {
|
||||||
if strings.EqualFold(r.Data, owner) {
|
if strings.EqualFold(r.Data, owner) {
|
||||||
@@ -597,10 +706,18 @@ func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, dis
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
|
||||||
}
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
|
||||||
var ops []model.Op
|
var ops []model.Op
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
||||||
@@ -623,12 +740,16 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
||||||
|
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
|
||||||
}
|
}
|
||||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
return ops
|
||||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||||
@@ -664,7 +785,27 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
|
summary := fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data)
|
||||||
|
in.SharedID = nil
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local, err := x.localFor(z, owner, p.Type, in.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if local {
|
||||||
|
in.SharedID = model.Ptr(int64(0))
|
||||||
|
summary += " (local to view " + x.st.ViewName(z) + ")"
|
||||||
|
} else {
|
||||||
|
if x.st.HasLocal(z.ID, owner, p.Type) {
|
||||||
|
return nil, errf(KindConflict, "%s %s is local in view %s; share the set first (jarl record update ID --shared) or add this one as local", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||||
|
}
|
||||||
|
in.SharedID = &ids[0]
|
||||||
|
}
|
||||||
|
} else if in.Local != nil && *in.Local {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
in.Local = nil
|
||||||
|
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||||
if ptr {
|
if ptr {
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
more, err := x.ptrCreate(z, owner, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -688,6 +829,35 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
ptr := in.PTR
|
ptr := in.PTR
|
||||||
in.PTR = false
|
in.PTR = false
|
||||||
in.Zone, in.View = "", ""
|
in.Zone, in.View = "", ""
|
||||||
|
if in.SharedID != nil {
|
||||||
|
return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared")
|
||||||
|
}
|
||||||
|
var pre []model.Op
|
||||||
|
if in.Local != nil {
|
||||||
|
if z.LinkGroup == 0 {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
if want := *in.Local; want != (r.SharedID == 0) {
|
||||||
|
// The whole set of this name and type changes sides.
|
||||||
|
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||||
|
id := int64(0)
|
||||||
|
what := fmt.Sprintf("make %s %s local to view %s", o.Name, o.Type, x.st.ViewName(z))
|
||||||
|
if !want {
|
||||||
|
id = o.ID
|
||||||
|
what = fmt.Sprintf("share %s %s %s with the linked views", o.Name, o.Type, o.Data)
|
||||||
|
}
|
||||||
|
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||||
|
}
|
||||||
|
if !want {
|
||||||
|
// Sharing a version makes it every view's.
|
||||||
|
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.Local = nil
|
||||||
|
if string(mustJSON(in)) == "{}" {
|
||||||
|
return pre, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
name, typ, data := r.Name, r.Type, r.Data
|
name, typ, data := r.Name, r.Type, r.Data
|
||||||
if in.Name != nil {
|
if in.Name != nil {
|
||||||
name = *in.Name
|
name = *in.Name
|
||||||
@@ -749,11 +919,20 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
case len(changes) > 0:
|
case len(changes) > 0:
|
||||||
summary += ": " + strings.Join(changes, ", ")
|
summary += ": " + strings.Join(changes, ", ")
|
||||||
}
|
}
|
||||||
|
if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) {
|
||||||
|
return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||||
|
}
|
||||||
base := req.BaseVersion
|
base := req.BaseVersion
|
||||||
if base == 0 {
|
if base == 0 {
|
||||||
base = r.Version
|
base = r.Version
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
|
if len(pre) > 0 {
|
||||||
|
base = 0 // the record was just updated by pre
|
||||||
|
}
|
||||||
|
ops := pre
|
||||||
|
if string(mustJSON(in)) != "{}" || !ptr {
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||||
|
}
|
||||||
disabled := r.Disabled
|
disabled := r.Disabled
|
||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
disabled = *in.Disabled
|
disabled = *in.Disabled
|
||||||
@@ -777,6 +956,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
||||||
}
|
}
|
||||||
|
if ptr && !moved && !toggled && !disabled {
|
||||||
|
// Nothing else changes: make sure the PTR record is there (the
|
||||||
|
// reverse zone may be newer than the record).
|
||||||
|
more, err := x.ptrCreate(z, owner, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,17 +1139,35 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
|||||||
canon = append(canon, p.Data)
|
canon = append(canon, p.Data)
|
||||||
}
|
}
|
||||||
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
||||||
|
where := ""
|
||||||
|
var pre []model.Op
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local, err := x.localFor(z, owner, typ, in.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in.Shared = model.Ptr(!local)
|
||||||
|
if local {
|
||||||
|
where = " (local to view " + x.st.ViewName(z) + ")"
|
||||||
|
} else if in.Local != nil {
|
||||||
|
// Explicitly shared: this version becomes every view's.
|
||||||
|
pre = x.dropOverrides(z, owner, typ)
|
||||||
|
}
|
||||||
|
} else if in.Local != nil && *in.Local {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
in.Local = nil
|
||||||
if len(canon) > 0 {
|
if len(canon) > 0 {
|
||||||
in.NewIDs, err = x.ids(len(canon))
|
in.NewIDs, err = x.ids(len(canon))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
|
summary := fmt.Sprintf("set %s%s IN %s to [%s]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where)
|
||||||
if len(canon) == 0 {
|
if len(canon) == 0 {
|
||||||
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
|
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
||||||
}
|
}
|
||||||
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
|
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
||||||
@@ -1089,3 +1295,64 @@ func disabledZoneEffect(st *model.State, z *model.Zone) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
|
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exposureAck acknowledges an exposure finding of a view or the server,
|
||||||
|
// or takes the acknowledgement back: the view's (or the settings')
|
||||||
|
// acknowledgements are updated as a whole.
|
||||||
|
func (x *expander) exposureAck(req Request, ack bool) ([]model.Op, error) {
|
||||||
|
var in AckInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in.Code = strings.TrimSpace(in.Code)
|
||||||
|
if !validate.Ackable(in.Code) {
|
||||||
|
return nil, errf(KindBadRequest, "%q is not an exposure finding that can be acknowledged (exposure_... or security_open_...)", in.Code)
|
||||||
|
}
|
||||||
|
if ack && strings.TrimSpace(in.Reason) == "" {
|
||||||
|
return nil, errf(KindBadRequest, "say why %s is accepted (a reason is required)", in.Code)
|
||||||
|
}
|
||||||
|
var v *model.View
|
||||||
|
switch {
|
||||||
|
case in.ViewID != 0:
|
||||||
|
v = x.st.Views[in.ViewID]
|
||||||
|
case in.View != "":
|
||||||
|
v = x.st.ViewByName(in.View)
|
||||||
|
}
|
||||||
|
if (in.ViewID != 0 || in.View != "") && v == nil {
|
||||||
|
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
|
||||||
|
}
|
||||||
|
acks := x.st.Settings.Acknowledged
|
||||||
|
where := "the server"
|
||||||
|
if v != nil {
|
||||||
|
acks, where = v.Acknowledged, "view "+v.Name
|
||||||
|
}
|
||||||
|
var out []model.Acknowledgement
|
||||||
|
for _, a := range acks {
|
||||||
|
if a.Code != in.Code {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary := "acknowledge " + in.Code + " for " + where + ": " + strings.TrimSpace(in.Reason)
|
||||||
|
if ack {
|
||||||
|
out = append(out, model.Acknowledgement{Code: in.Code, Reason: strings.TrimSpace(in.Reason), By: x.p.Name(), At: x.now})
|
||||||
|
} else {
|
||||||
|
if len(out) == len(acks) {
|
||||||
|
return nil, errf(KindConflict, "%s isn't acknowledged for %s", in.Code, where)
|
||||||
|
}
|
||||||
|
summary = "take back the acknowledgement of " + in.Code + " for " + where
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []model.Acknowledgement{}
|
||||||
|
}
|
||||||
|
if v != nil {
|
||||||
|
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{Acknowledged: &out}, summary)}, nil
|
||||||
|
}
|
||||||
|
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{Acknowledged: &out}, summary)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStr(a, b string) string {
|
||||||
|
if a != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -51,8 +51,25 @@ const (
|
|||||||
// operations.
|
// operations.
|
||||||
OpRecordDisable = "record.disable"
|
OpRecordDisable = "record.disable"
|
||||||
OpRecordEnable = "record.enable"
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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.
|
// OpKinds lists all operation kinds.
|
||||||
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
||||||
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
||||||
@@ -118,6 +135,13 @@ type ZoneInput struct {
|
|||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
// Disabled keeps the zone in Jarl but out of named's configuration.
|
// Disabled keeps the zone in Jarl but out of named's configuration.
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
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"`
|
||||||
|
|
||||||
// Create only: name servers for the apex NS records (default: the
|
// Create only: name servers for the apex NS records (default: the
|
||||||
// configured default name servers), additional records, and a zone to
|
// configured default name servers), additional records, and a zone to
|
||||||
@@ -150,10 +174,31 @@ type RecordInput struct {
|
|||||||
// Disabled keeps the record in Jarl but out of the zone.
|
// Disabled keeps the record in Jarl but out of the zone.
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
// PTR also creates (or deletes, disables or enables) the matching PTR
|
// 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"`
|
PTR bool `json:"ptr,omitempty"`
|
||||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||||
MissingOK bool `json:"missing_ok,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RRSetInput replaces all records of one name and type.
|
// RRSetInput replaces all records of one name and type.
|
||||||
@@ -168,6 +213,11 @@ type RRSetInput struct {
|
|||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
// NewIDs are allocated for records that need to be created.
|
// NewIDs are allocated for records that need to be created.
|
||||||
NewIDs []int64 `json:"new_ids,omitempty"`
|
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.
|
// ViewInput creates or updates a view.
|
||||||
@@ -184,6 +234,9 @@ type ViewInput struct {
|
|||||||
AdoptZones bool `json:"adopt_zones,omitempty"`
|
AdoptZones bool `json:"adopt_zones,omitempty"`
|
||||||
// Cascade (delete) also deletes the view's zones.
|
// Cascade (delete) also deletes the view's zones.
|
||||||
Cascade bool `json:"cascade,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReorderInput sets the order of views.
|
// ReorderInput sets the order of views.
|
||||||
@@ -227,6 +280,9 @@ type SettingsInput struct {
|
|||||||
Expire *uint32 `json:"expire,omitempty"`
|
Expire *uint32 `json:"expire,omitempty"`
|
||||||
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
|
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
|
||||||
SerialPolicy *string `json:"serial_policy,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteInput carries options for delete operations.
|
// DeleteInput carries options for delete operations.
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
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 gets the shared records back from
|
||||||
|
// a zone that has them.
|
||||||
|
// - 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) {
|
||||||
|
// The override was removed: back to the shared records.
|
||||||
|
for _, o := range members {
|
||||||
|
if st.HasLocal(o.ID, name, typ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src := st.SharedRecords(o.ID, name, typ)
|
||||||
|
if len(src) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
|
||||||
|
has := x.st.HasLocal(z.ID, name, typ)
|
||||||
|
if want == nil {
|
||||||
|
return has, nil
|
||||||
|
}
|
||||||
|
return *want, 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
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 brings the shared records back.
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
|
||||||
|
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
||||||
|
t.Errorf("override removed: internal %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-5
@@ -11,6 +11,7 @@ import (
|
|||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/catalog"
|
"jarl/internal/catalog"
|
||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/iprange"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/namedconf"
|
"jarl/internal/namedconf"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
@@ -120,7 +121,7 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
|
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
|
||||||
}
|
}
|
||||||
if e, ok := AsError(err); ok && e.OpSeq == 0 {
|
if e, ok := AsError(err); ok && e.OpSeq == 0 {
|
||||||
e.OpSeq = op.Seq
|
e.OpSeq, e.OpSummary = op.Seq, op.Summary
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -264,6 +265,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
z.Comment = *in.Comment
|
z.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.LinkGroup != nil {
|
||||||
|
z.LinkGroup = *in.LinkGroup
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +450,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
r.Disabled = *in.Disabled
|
r.Disabled = *in.Disabled
|
||||||
}
|
}
|
||||||
|
if in.SharedID != nil {
|
||||||
|
r.SharedID = *in.SharedID
|
||||||
|
}
|
||||||
for _, w := range p.Warnings {
|
for _, w := range p.Warnings {
|
||||||
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
||||||
}
|
}
|
||||||
@@ -457,7 +464,7 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
r := st.Records[op.ID]
|
r := st.Records[op.ID]
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return errf(KindNotFound, "record %d does not exist", op.ID)
|
return errf(KindConflict, "the record (ID %d) no longer exists: it was deleted after this change was staged; remove this operation from the changeset", op.ID)
|
||||||
}
|
}
|
||||||
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -525,6 +532,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
r.Disabled = *in.Disabled
|
r.Disabled = *in.Disabled
|
||||||
}
|
}
|
||||||
|
if in.SharedID != nil {
|
||||||
|
r.SharedID = *in.SharedID
|
||||||
|
}
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -533,7 +543,10 @@ func applyRecordDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if op.ID != 0 {
|
if op.ID != 0 {
|
||||||
r := st.Records[op.ID]
|
r := st.Records[op.ID]
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return errf(KindNotFound, "record %d does not exist", op.ID)
|
// Deleted since this was staged (by someone else, or the
|
||||||
|
// CLI): what the operation wanted is done.
|
||||||
|
c.warn("%s: the record was already deleted outside this changeset, so there was nothing to delete", strings.TrimPrefix(op.Summary, "delete "))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -658,16 +671,34 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
k := strings.ToLower(r.Data)
|
k := strings.ToLower(r.Data)
|
||||||
if seen[k] && !keep[k] {
|
if seen[k] && !keep[k] {
|
||||||
keep[k] = true
|
keep[k] = true
|
||||||
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled {
|
shared := r.SharedID
|
||||||
|
if in.Shared != nil {
|
||||||
|
shared = 0
|
||||||
|
if *in.Shared {
|
||||||
|
shared = r.SharedID
|
||||||
|
if shared == 0 {
|
||||||
|
shared = r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled || shared != r.SharedID {
|
||||||
if ttlKnown {
|
if ttlKnown {
|
||||||
r.TTL = copyTTL(ttl)
|
r.TTL = copyTTL(ttl)
|
||||||
}
|
}
|
||||||
r.Disabled = false
|
r.Disabled, r.SharedID = false, shared
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
|
// Kept, but shared or local like the rest of the set.
|
||||||
|
if in.Shared != nil && (*in.Shared) != (r.SharedID != 0) {
|
||||||
|
r.SharedID = 0
|
||||||
|
if *in.Shared {
|
||||||
|
r.SharedID = r.ID
|
||||||
|
}
|
||||||
|
r.Touch(c.user(), c.now)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
delete(st.Records, id)
|
delete(st.Records, id)
|
||||||
@@ -688,6 +719,9 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
r.Comment = *in.Comment
|
r.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.Shared != nil && *in.Shared {
|
||||||
|
r.SharedID = r.ID
|
||||||
|
}
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
st.Records[r.ID] = r
|
st.Records[r.ID] = r
|
||||||
}
|
}
|
||||||
@@ -761,9 +795,41 @@ func setViewFields(v *model.View, in *ViewInput) error {
|
|||||||
if in.Position != nil {
|
if in.Position != nil {
|
||||||
v.Position = *in.Position
|
v.Position = *in.Position
|
||||||
}
|
}
|
||||||
|
if in.Exposure != nil {
|
||||||
|
e, err := normalizeExposure(*in.Exposure)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v.Exposure = e
|
||||||
|
}
|
||||||
|
if in.Acknowledged != nil {
|
||||||
|
v.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeExposure checks an exposure override.
|
||||||
|
func normalizeExposure(s string) (string, error) {
|
||||||
|
switch e := strings.ToLower(strings.TrimSpace(s)); e {
|
||||||
|
case "", "auto":
|
||||||
|
return model.ExposureAuto, nil
|
||||||
|
case model.ExposurePublic, model.ExposurePrivate:
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
return "", errf(KindBadRequest, "exposure must be auto, public or private, not %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanAcks drops empty acknowledgements (nil for none).
|
||||||
|
func cleanAcks(acks []model.Acknowledgement) []model.Acknowledgement {
|
||||||
|
var out []model.Acknowledgement
|
||||||
|
for _, a := range acks {
|
||||||
|
if a.Code != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error {
|
func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
var in ViewInput
|
var in ViewInput
|
||||||
if err := decode(op.Data, &in); err != nil {
|
if err := decode(op.Data, &in); err != nil {
|
||||||
@@ -1141,9 +1207,44 @@ func NormalizeOption(st *model.State, scope string, scopeID int64, name, value s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
|
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
|
||||||
}
|
}
|
||||||
|
namedconf.JoinRanges(parsed)
|
||||||
|
if err := checkOptionRanges(parsed, strings.Contains(stmt.Grammar, "<address_match_element>")); err != nil {
|
||||||
|
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
|
||||||
|
}
|
||||||
return n, namedconf.Compact(parsed.Items[1:]), nil
|
return n, namedconf.Compact(parsed.Items[1:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkOptionRanges checks the address ranges in an option's blocks: they
|
||||||
|
// must be valid, and only options with address match lists take them.
|
||||||
|
func checkOptionRanges(st *namedconf.Stmt, aml bool) error {
|
||||||
|
for _, it := range st.Items {
|
||||||
|
if it.Block == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range it.Block.Stmts {
|
||||||
|
items := s.Items
|
||||||
|
if len(items) == 2 && items[0].Bang {
|
||||||
|
items = items[1:]
|
||||||
|
}
|
||||||
|
if len(items) == 1 && items[0].Value != nil && !items[0].Value.Quoted {
|
||||||
|
if _, ok, err := iprange.Parse(items[0].Value.Text); ok {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !aml {
|
||||||
|
return fmt.Errorf("%s: address ranges only work in address match lists (such as allow-query or an ACL), not here", items[0].Value.Text)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := checkOptionRanges(s, aml); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error {
|
func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
var in OptionInput
|
var in OptionInput
|
||||||
if err := decode(op.Data, &in); err != nil {
|
if err := decode(op.Data, &in); err != nil {
|
||||||
@@ -1290,6 +1391,16 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.SerialPolicy != nil {
|
if in.SerialPolicy != nil {
|
||||||
s.SerialPolicy = strings.ToLower(*in.SerialPolicy)
|
s.SerialPolicy = strings.ToLower(*in.SerialPolicy)
|
||||||
}
|
}
|
||||||
|
if in.Exposure != nil {
|
||||||
|
e, err := normalizeExposure(*in.Exposure)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Exposure = e
|
||||||
|
}
|
||||||
|
if in.Acknowledged != nil {
|
||||||
|
s.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||||
|
}
|
||||||
s.Version++
|
s.Version++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ticking "update PTR" on an unchanged address record adds its PTR when
|
||||||
|
// the reverse zone is there (it was created after the record).
|
||||||
|
func TestUpdatePTRAddsMissing(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}))
|
||||||
|
env.submit(env.admin, rec("example.com", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32")}))
|
||||||
|
var id int64
|
||||||
|
for _, r := range env.state().Records {
|
||||||
|
if r.Name == "ldn.example.com." {
|
||||||
|
id = r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, id, RecordInput{PTR: true}))
|
||||||
|
got := env.records("154.144.144.139.in-addr.arpa.")
|
||||||
|
if !slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "PTR ldn.example.com.") }) {
|
||||||
|
t.Errorf("reverse zone = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In linked zones the PTR goes to the reverse zone of whichever linked
|
||||||
|
// view has one.
|
||||||
|
func TestUpdatePTRLinked(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
env.submit(env.admin, recIn("internal", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32"), View: model.Ptr("external")}))
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true}))
|
||||||
|
st := env.state()
|
||||||
|
z := st.FindZone("154.144.144.139.in-addr.arpa.", st.ViewByName("external").ID)
|
||||||
|
ok := false
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
ok = ok || r.Type == "PTR" && r.Data == "ldn.example.com."
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("no PTR in external's reverse zone (warnings %q)", res.Warnings)
|
||||||
|
}
|
||||||
|
// Again: already there, nothing to do.
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true})); !strings.Contains(e.Message, "nothing to do") {
|
||||||
|
t.Errorf("second time: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hasProblem(ps []validate.Problem, code string) bool {
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Code == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeProblems(e *Error) string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range e.Problems {
|
||||||
|
out = append(out, p.Message)
|
||||||
|
}
|
||||||
|
return strings.Join(out, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionReq(name, value string) Request {
|
||||||
|
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddressRanges(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("dhcp"), Elements: &[]string{"!10.0.10.150 - 10.0.10.160", "10.0.10.101-10.0.10.199", "2001:db8::100-2001:db8::1ff"}}),
|
||||||
|
optionReq("allow-query", "10.0.20.1 - 10.0.20.6; localhost"),
|
||||||
|
)
|
||||||
|
st := env.state()
|
||||||
|
if a := st.ACLByName("dhcp"); strings.Join(a.Elements, " ") != "!10.0.10.150-10.0.10.160 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff" {
|
||||||
|
t.Errorf("stored elements = %q", a.Elements)
|
||||||
|
}
|
||||||
|
if o := st.FindOption(model.ScopeOptions, 0, "allow-query"); o == nil || o.Value != "{ 10.0.20.1-10.0.20.6; localhost; }" {
|
||||||
|
t.Errorf("stored option = %+v", o)
|
||||||
|
}
|
||||||
|
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||||
|
c := string(conf)
|
||||||
|
for _, want := range []string{
|
||||||
|
"acl \"dhcp\" {\n\t// !10.0.10.150-10.0.10.160\n\t!10.0.10.150/31;\n\t!10.0.10.152/29;\n\t!10.0.10.160;\n\t// 10.0.10.101-10.0.10.199\n\t10.0.10.101;\n",
|
||||||
|
"\t10.0.10.192/29;\n\t// 2001:db8::100-2001:db8::1ff\n\t2001:db8::100/120;\n};\n",
|
||||||
|
"\tallow-query {\n\t\t// 10.0.20.1-10.0.20.6\n\t\t10.0.20.1;\n\t\t10.0.20.2/31;\n\t\t10.0.20.4/31;\n\t\t10.0.20.6;\n\t\tlocalhost;\n\t};\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(c, want) {
|
||||||
|
t.Errorf("named.conf lacks %q:\n%s", want, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range strings.Split(c, "\n") {
|
||||||
|
if code, _, _ := strings.Cut(l, "//"); strings.Contains(code, "-10.0.") || strings.Contains(code, "-2001:") {
|
||||||
|
t.Errorf("a range reached named.conf outside a comment: %q", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mistakes are caught when they're entered.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
req Request
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("bad"), Elements: &[]string{"10.0.10.199-10.0.10.101"}}), "start is after the end"},
|
||||||
|
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("bad"), Elements: &[]string{"10.0.10.101-199"}}), "not a full IP address"},
|
||||||
|
{optionReq("forwarders", "{ 192.0.2.1-192.0.2.9; }"), "only work in address match lists"},
|
||||||
|
{optionReq("allow-recursion", "{ 10.0.0.9-10.0.0.1; }"), "start is after the end"},
|
||||||
|
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("10.0.0.1-10.0.0.9"), Elements: &[]string{"10.0.0.1"}}), "looks like an address range"},
|
||||||
|
} {
|
||||||
|
if e := env.submitErr(env.admin, tc.req); !strings.Contains(e.Error(), tc.want) && !strings.Contains(describeProblems(e), tc.want) {
|
||||||
|
t.Errorf("%s: %v %s, want %q", tc.req.Op, e, describeProblems(e), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation sees what a range covers: everything is "any".
|
||||||
|
res := env.submit(env.admin, optionReq("allow-recursion", "0.0.0.0-255.255.255.255"))
|
||||||
|
if !hasProblem(res.Problems, "security_open_resolver") {
|
||||||
|
t.Errorf("no open resolver warning for 0.0.0.0-255.255.255.255: %+v", res.Problems)
|
||||||
|
}
|
||||||
|
// IPv6 ranges in the IPv4-mapped space get a warning.
|
||||||
|
res = env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("mapped"), Elements: &[]string{"::ffff:10.0.0.1-::ffff:10.0.0.9"}}))
|
||||||
|
if !hasProblem(res.Problems, "range_mapped4") {
|
||||||
|
t.Errorf("no range_mapped4 warning: %+v", res.Problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientGroupRanges(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
_, srv := newListServer(t, "||ads.example^\n")
|
||||||
|
env.submit(env.admin,
|
||||||
|
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"), clientGroup("kids", "off", "tablets", "10.0.0.20 - 10.0.0.23"))
|
||||||
|
if g := env.state().SortedClientGroups(); len(g) != 1 || strings.Join(g[0].Members, " ") != "tablets 10.0.0.20-10.0.0.23" {
|
||||||
|
t.Fatalf("groups = %+v", g)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default/off.gate.jarl.invalid.rpz"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"31.50.0.0.10.rpz-client-ip", "30.20.0.0.10.rpz-client-ip"} {
|
||||||
|
if !strings.Contains(string(b), want) {
|
||||||
|
t.Errorf("off gate lacks %s:\n%s", want, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Records deleted or changed after a change to them was staged (in the
|
||||||
|
// CLI while a web UI changeset is open, say).
|
||||||
|
func TestStaleChangeset(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin, rec("example.com", "one", "A", "192.0.2.51"), rec("example.com", "two", "A", "192.0.2.52"), rec("example.com", "three", "A", "192.0.2.53"))
|
||||||
|
st := env.state()
|
||||||
|
one, two, three := findRec(st, "one.example.com.", "A"), findRec(st, "two.example.com.", "A"), findRec(st, "three.example.com.", "A")
|
||||||
|
|
||||||
|
// Two deletes staged; one of the records is deleted elsewhere first.
|
||||||
|
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordDelete, one.ID, nil), NewRequest(OpRecordDelete, two.ID, nil)}, env.admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, one.ID, nil))
|
||||||
|
res, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply after the record was deleted elsewhere: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "one.example.com. A 192.0.2.51: the record was already deleted") {
|
||||||
|
t.Errorf("warnings = %q", res.Warnings)
|
||||||
|
}
|
||||||
|
if st := env.state(); st.Records[two.ID] != nil {
|
||||||
|
t.Error("two was not deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A staged edit of a record deleted elsewhere names the operation.
|
||||||
|
cs, _ = env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||||
|
env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordUpdate, three.ID, RecordInput{Data: model.Ptr("192.0.2.54")})}, env.admin)
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, three.ID, nil))
|
||||||
|
p, err := env.e.Preview(env.ctx, cs.ID, env.admin)
|
||||||
|
if err != nil || p.Error == nil || p.Error.OpSeq != 1 || !strings.HasPrefix(p.Error.OpSummary, "change three.example.com. A 192.0.2.53") {
|
||||||
|
t.Fatalf("preview = %+v, %v", p, err)
|
||||||
|
}
|
||||||
|
_, err = env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||||
|
if ee, ok := AsError(err); !ok || ee.Kind != KindConflict || !strings.Contains(err.Error(), "operation 1 (change three.example.com. A") || !strings.Contains(err.Error(), "remove this operation") {
|
||||||
|
t.Errorf("apply = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package iprange handles address ranges such as 10.0.10.101-10.0.10.199
|
||||||
|
// or 2001:db8::100-2001:db8::1ff. BIND matches only addresses and
|
||||||
|
// prefixes, so Jarl keeps a range as it was written and turns it into the
|
||||||
|
// exact set of prefixes that covers it when it writes named.conf.
|
||||||
|
package iprange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Range is an inclusive range of addresses of one family.
|
||||||
|
type Range struct {
|
||||||
|
Start, End netip.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the range as Jarl writes it: START-END.
|
||||||
|
func (r Range) String() string { return r.Start.String() + "-" + r.End.String() }
|
||||||
|
|
||||||
|
// Parse parses START-END, both full addresses of the same family. ok is
|
||||||
|
// false when s isn't meant as a range (no "-", or text before it that
|
||||||
|
// isn't an address, such as the ACL name "branch-office"); err is set when
|
||||||
|
// it is meant as one but is wrong.
|
||||||
|
func Parse(s string) (r Range, ok bool, err error) {
|
||||||
|
left, right, found := strings.Cut(s, "-")
|
||||||
|
if !found {
|
||||||
|
return Range{}, false, nil
|
||||||
|
}
|
||||||
|
left, right = strings.TrimSpace(left), strings.TrimSpace(right)
|
||||||
|
if !looksLikeAddr(left) {
|
||||||
|
return Range{}, false, nil
|
||||||
|
}
|
||||||
|
start, err := netip.ParseAddr(left)
|
||||||
|
if err != nil {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q: %q is not a full IP address", s, left)
|
||||||
|
}
|
||||||
|
end, err := netip.ParseAddr(right)
|
||||||
|
if err != nil {
|
||||||
|
if right == "" {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q has no end address", s)
|
||||||
|
}
|
||||||
|
return Range{}, true, fmt.Errorf("range %q: %q is not a full IP address (write both ends in full, e.g. 10.0.10.101-10.0.10.199)", s, right)
|
||||||
|
}
|
||||||
|
if start.Zone() != "" || end.Zone() != "" {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q: addresses with a zone (%%interface) can't be matched", s)
|
||||||
|
}
|
||||||
|
if start.Is4() != end.Is4() {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q mixes IPv4 and IPv6", s)
|
||||||
|
}
|
||||||
|
if end.Less(start) {
|
||||||
|
return Range{}, true, fmt.Errorf("range %q: the start is after the end", s)
|
||||||
|
}
|
||||||
|
return Range{Start: start, End: end}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeAddr reports whether s could only be meant as an address: it
|
||||||
|
// has a colon, or it is digits and dots with at least one dot.
|
||||||
|
func looksLikeAddr(s string) bool {
|
||||||
|
if strings.Contains(s, ":") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, ".") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if c := s[i]; !(c >= '0' && c <= '9' || c == '.') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefixes returns the fewest prefixes that together cover exactly the
|
||||||
|
// range, in address order: 10.0.10.101-10.0.10.199 is 10.0.10.101/32,
|
||||||
|
// 10.0.10.102/31, 10.0.10.104/29, 10.0.10.112/28, 10.0.10.128/26 and
|
||||||
|
// 10.0.10.192/29. That is at most 62 prefixes for IPv4 and 254 for IPv6.
|
||||||
|
func (r Range) Prefixes() []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
a := r.Start
|
||||||
|
for {
|
||||||
|
// The largest prefix that starts at a and ends at or before End.
|
||||||
|
p := netip.PrefixFrom(a, a.BitLen())
|
||||||
|
for bits := a.BitLen() - 1; bits >= 0; bits-- {
|
||||||
|
q := netip.PrefixFrom(a, bits)
|
||||||
|
if q.Masked().Addr() != a || r.End.Less(last(q)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p = q
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
end := last(p)
|
||||||
|
if end == r.End {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
a = end.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// last returns the last address of a prefix.
|
||||||
|
func last(p netip.Prefix) netip.Addr {
|
||||||
|
b := p.Addr().AsSlice()
|
||||||
|
for i := p.Bits(); i < len(b)*8; i++ {
|
||||||
|
b[i/8] |= 0x80 >> (i % 8)
|
||||||
|
}
|
||||||
|
a, _ := netip.AddrFromSlice(b)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the number of addresses in the range.
|
||||||
|
func (r Range) Size() *big.Int {
|
||||||
|
s := new(big.Int).SetBytes(r.Start.AsSlice())
|
||||||
|
e := new(big.Int).SetBytes(r.End.AsSlice())
|
||||||
|
return e.Sub(e, s).Add(e, big.NewInt(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SizeText describes the size for people: "99 addresses", or a power of
|
||||||
|
// two for very large IPv6 ranges ("about 2^80 addresses").
|
||||||
|
func (r Range) SizeText() string {
|
||||||
|
n := r.Size()
|
||||||
|
if n.BitLen() <= 40 {
|
||||||
|
if n.Int64() == 1 {
|
||||||
|
return "1 address"
|
||||||
|
}
|
||||||
|
return n.String() + " addresses"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("about 2^%d addresses", n.BitLen()-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapped4 is the IPv4-mapped IPv6 space; BIND can see IPv4 clients as
|
||||||
|
// addresses in it.
|
||||||
|
var Mapped4 = netip.MustParsePrefix("::ffff:0:0/96")
|
||||||
|
|
||||||
|
// TouchesMapped4 reports whether an IPv6 range overlaps ::ffff:0:0/96.
|
||||||
|
func (r Range) TouchesMapped4() bool {
|
||||||
|
if r.Start.Is4() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lo, hi := Mapped4.Addr(), last(Mapped4)
|
||||||
|
return !(r.End.Less(lo) || hi.Less(r.Start))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromPrefix returns the addresses of a prefix as a range.
|
||||||
|
func FromPrefix(p netip.Prefix) Range {
|
||||||
|
p = p.Masked()
|
||||||
|
return Range{Start: p.Addr(), End: last(p)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package iprange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in, want, err string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{in: "10.0.10.101-10.0.10.199", want: "10.0.10.101-10.0.10.199", ok: true},
|
||||||
|
{in: "10.0.10.101 - 10.0.10.199", want: "10.0.10.101-10.0.10.199", ok: true},
|
||||||
|
{in: "2001:DB8::100-2001:db8::1ff", want: "2001:db8::100-2001:db8::1ff", ok: true},
|
||||||
|
{in: "10.0.0.5-10.0.0.5", want: "10.0.0.5-10.0.0.5", ok: true},
|
||||||
|
{in: "branch-office", ok: false},
|
||||||
|
{in: "10-net", ok: false},
|
||||||
|
{in: "10.0.0.0/24", ok: false},
|
||||||
|
{in: "10.0.10.101-199", ok: true, err: "not a full IP address"},
|
||||||
|
{in: "10.0.10.101-", ok: true, err: "no end address"},
|
||||||
|
{in: "2001:db8::100-1ff", ok: true, err: "not a full IP address"},
|
||||||
|
{in: "10.0.0.9-10.0.0.1", ok: true, err: "start is after the end"},
|
||||||
|
{in: "10.0.0.1-2001:db8::1", ok: true, err: "mixes IPv4 and IPv6"},
|
||||||
|
{in: "fe80::1%eth0-fe80::9%eth0", ok: true, err: "zone"},
|
||||||
|
{in: "10.0.300.1-10.0.300.9", ok: true, err: "not a full IP address"},
|
||||||
|
{in: "10.0.0.1-10.0.0.9-10.0.0.20", ok: true, err: "not a full IP address"},
|
||||||
|
} {
|
||||||
|
r, ok, err := Parse(tc.in)
|
||||||
|
if ok != tc.ok || (err == nil) != (tc.err == "") || err != nil && !strings.Contains(err.Error(), tc.err) {
|
||||||
|
t.Errorf("%s: ok=%v err=%v, want ok=%v err~%q", tc.in, ok, err, tc.ok, tc.err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == nil && ok && r.String() != tc.want {
|
||||||
|
t.Errorf("%s = %s, want %s", tc.in, r, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrefixes(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"10.0.10.101-10.0.10.199": "10.0.10.101/32 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",
|
||||||
|
"10.0.0.0-10.0.0.255": "10.0.0.0/24",
|
||||||
|
"10.0.0.5-10.0.0.5": "10.0.0.5/32",
|
||||||
|
"0.0.0.0-255.255.255.255": "0.0.0.0/0",
|
||||||
|
"255.255.255.254-255.255.255.255": "255.255.255.254/31",
|
||||||
|
"2001:db8::100-2001:db8::1ff": "2001:db8::100/120",
|
||||||
|
"::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff": "::/0",
|
||||||
|
} {
|
||||||
|
r, _, err := Parse(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, p := range r.Prefixes() {
|
||||||
|
got = append(got, p.String())
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != want {
|
||||||
|
t.Errorf("%s = %s, want %s", in, strings.Join(got, " "), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The worst cases stay small.
|
||||||
|
if n := len(must(t, "0.0.0.1-255.255.255.254").Prefixes()); n != 62 {
|
||||||
|
t.Errorf("worst IPv4 case = %d prefixes, want 62", n)
|
||||||
|
}
|
||||||
|
if n := len(must(t, "::1-ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe").Prefixes()); n != 254 {
|
||||||
|
t.Errorf("worst IPv6 case = %d prefixes, want 254", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every address of a random range is covered once, and nothing outside it.
|
||||||
|
func TestPrefixesExact(t *testing.T) {
|
||||||
|
rnd := rand.New(rand.NewSource(1))
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
lo, hi := rnd.Intn(4096), rnd.Intn(4096)
|
||||||
|
if hi < lo {
|
||||||
|
lo, hi = hi, lo
|
||||||
|
}
|
||||||
|
base := []byte{10, 20, 0, 0}
|
||||||
|
addr := func(n int) netip.Addr {
|
||||||
|
return netip.AddrFrom4([4]byte{base[0], base[1], byte(n >> 8), byte(n)})
|
||||||
|
}
|
||||||
|
r := Range{Start: addr(lo), End: addr(hi)}
|
||||||
|
ps := r.Prefixes()
|
||||||
|
for n := max(lo-3, 0); n <= min(hi+3, 4095); n++ {
|
||||||
|
c := 0
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Contains(addr(n)) {
|
||||||
|
c++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in := n >= lo && n <= hi; in && c != 1 || !in && c != 0 {
|
||||||
|
t.Fatalf("%s: %s covered %d times (%v)", r, addr(n), c, ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := r.Size().Int64(); got != int64(hi-lo+1) {
|
||||||
|
t.Fatalf("%s: size %d", r, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSizeAndMapped(t *testing.T) {
|
||||||
|
if s := must(t, "10.0.10.101-10.0.10.199").SizeText(); s != "99 addresses" {
|
||||||
|
t.Errorf("size = %s", s)
|
||||||
|
}
|
||||||
|
if s := must(t, "2001:db8::-2001:db8:0:ffff:ffff:ffff:ffff:ffff").SizeText(); s != "about 2^80 addresses" {
|
||||||
|
t.Errorf("size = %s", s)
|
||||||
|
}
|
||||||
|
if !must(t, "::ffff:10.0.0.1-::ffff:10.0.0.9").TouchesMapped4() || must(t, "2001:db8::1-2001:db8::9").TouchesMapped4() || must(t, "10.0.0.1-10.0.0.9").TouchesMapped4() {
|
||||||
|
t.Error("TouchesMapped4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(t *testing.T, s string) Range {
|
||||||
|
t.Helper()
|
||||||
|
r, ok, err := Parse(s)
|
||||||
|
if !ok || err != nil {
|
||||||
|
t.Fatalf("%s: %v %v", s, ok, err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/iprange"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Blocklist is a list of domains to block, downloaded from URL and kept up
|
// Blocklist is a list of domains to block, downloaded from URL and kept up
|
||||||
@@ -268,6 +270,8 @@ func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
|
|||||||
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||||
} else if p, err := netip.ParsePrefix(e); err == nil {
|
} else if p, err := netip.ParsePrefix(e); err == nil {
|
||||||
out = append(out, p.Masked())
|
out = append(out, p.Masked())
|
||||||
|
} else if r, ok, err := iprange.Parse(e); ok && err == nil {
|
||||||
|
out = append(out, r.Prefixes()...)
|
||||||
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
|
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
|
||||||
seen[acl.Name] = true
|
seen[acl.Name] = true
|
||||||
add(acl.Elements)
|
add(acl.Elements)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificate sources.
|
||||||
|
const (
|
||||||
|
CertSourceACME = "acme" // Jarl obtains and renews it (docs/certificates.md)
|
||||||
|
CertSourceFile = "file" // you renew it; Jarl reads it from cert_file and key_file
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificate uses.
|
||||||
|
const (
|
||||||
|
CertUseWeb = "web" // jarld's own HTTPS
|
||||||
|
CertUseDNS = "dns" // written where named can read it, for DNS over TLS/HTTPS
|
||||||
|
)
|
||||||
|
|
||||||
|
// Challenge routes: where Jarl publishes the DNS-01 challenge.
|
||||||
|
const (
|
||||||
|
ChallengeAuto = "auto" // decided at every issue from the domain's public name servers
|
||||||
|
ChallengeJarl = "jarl" // in a zone Jarl serves (following a CNAME on _acme-challenge)
|
||||||
|
ChallengePorkbun = "porkbun" // through Porkbun's DNS API
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificate is a TLS certificate Jarl manages. Certificates are not
|
||||||
|
// named configuration: they are kept outside changesets, like users.
|
||||||
|
type Certificate struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Domains []string `json:"domains"` // acme: the names to ask for; file: the names in the certificate
|
||||||
|
CertFile string `json:"cert_file,omitempty"` // file source
|
||||||
|
KeyFile string `json:"key_file,omitempty"` // file source
|
||||||
|
Uses []string `json:"uses"`
|
||||||
|
Challenge string `json:"challenge,omitempty"` // acme source
|
||||||
|
KeyType string `json:"key_type,omitempty"` // acme source: ec256, ec384, rsa2048, rsa3072, rsa4096
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
|
||||||
|
// The current certificate: acme source only (a file source's is read
|
||||||
|
// from its files). The key never leaves the server through the API.
|
||||||
|
CertPEM string `json:"-"`
|
||||||
|
KeyPEM string `json:"-"`
|
||||||
|
|
||||||
|
// What the current certificate says (both sources).
|
||||||
|
Names []string `json:"names,omitempty"` // the names in it
|
||||||
|
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||||
|
NotAfter *time.Time `json:"not_after,omitempty"`
|
||||||
|
Issuer string `json:"issuer,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
|
||||||
|
// Renewal (acme source). RenewAt is the CA's suggested time (ARI) or,
|
||||||
|
// without one, two thirds into the certificate's lifetime.
|
||||||
|
RenewAt *time.Time `json:"renew_at,omitempty"`
|
||||||
|
ARICheckedAt *time.Time `json:"-"`
|
||||||
|
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 says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||||
|
LastRoutes []string `json:"last_routes,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
UpdatedBy string `json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUse reports whether c is used for use.
|
||||||
|
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Linked zones (docs/views.md#linked-zones): zones of the same name in
|
||||||
|
// different views whose shared records Jarl keeps in step. Each zone
|
||||||
|
// keeps its own copy of every shared record (with the same SharedID), so
|
||||||
|
// named, validation and rendering see ordinary zones. A record with
|
||||||
|
// SharedID 0 in a linked zone is local to its view: an override of the
|
||||||
|
// shared records of the same name and type there.
|
||||||
|
|
||||||
|
// LinkMembers returns the zones of a link group in view order.
|
||||||
|
func (s *State) LinkMembers(group int64) []*Zone {
|
||||||
|
if group == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []*Zone
|
||||||
|
for _, z := range s.Zones {
|
||||||
|
if z.LinkGroup == group {
|
||||||
|
out = append(out, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.SortByView(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedWith returns the other zones z is linked with, in view order.
|
||||||
|
func (s *State) LinkedWith(z *Zone) []*Zone {
|
||||||
|
var out []*Zone
|
||||||
|
for _, o := range s.LinkMembers(z.LinkGroup) {
|
||||||
|
if o.ID != z.ID {
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyLinked reports whether any zone is linked.
|
||||||
|
func (s *State) AnyLinked() bool {
|
||||||
|
for _, z := range s.Zones {
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortByView sorts zones in view order.
|
||||||
|
func (s *State) SortByView(zs []*Zone) {
|
||||||
|
pos := func(z *Zone) int {
|
||||||
|
if v := s.Views[z.ViewID]; v != nil {
|
||||||
|
return v.Position
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
sort.Slice(zs, func(i, j int) bool {
|
||||||
|
if a, b := pos(zs[i]), pos(zs[j]); a != b {
|
||||||
|
return a < b
|
||||||
|
}
|
||||||
|
return zs[i].ID < zs[j].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RRset returns the records of one name and type in a zone, by ID.
|
||||||
|
func (s *State) RRset(zoneID int64, name, typ string) []*Record {
|
||||||
|
var out []*Record
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b *Record) int { return int(a.ID - b.ID) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLocal reports whether a linked zone overrides the shared records of
|
||||||
|
// one name and type with records of its own.
|
||||||
|
func (s *State) HasLocal(zoneID int64, name, typ string) bool {
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.SharedID == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedRecords returns a zone's shared records of one name and type,
|
||||||
|
// by SharedID.
|
||||||
|
func (s *State) SharedRecords(zoneID int64, name, typ string) []*Record {
|
||||||
|
var out []*Record
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.SharedID != 0 {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b *Record) int { return int(a.SharedID - b.SharedID) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
+49
-1
@@ -50,9 +50,43 @@ type View struct {
|
|||||||
MatchDestinations []string `json:"match_destinations"`
|
MatchDestinations []string `json:"match_destinations"`
|
||||||
MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"`
|
MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
// Exposure overrides whether the view counts as reachable from the
|
||||||
|
// internet (docs/exposure.md): "" works it out, ExposurePublic or
|
||||||
|
// ExposurePrivate say so.
|
||||||
|
Exposure string `json:"exposure,omitempty"`
|
||||||
|
// Acknowledged are exposure findings accepted on purpose; they are
|
||||||
|
// shown as info instead of warnings.
|
||||||
|
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exposure overrides.
|
||||||
|
const (
|
||||||
|
ExposureAuto = ""
|
||||||
|
ExposurePublic = "public"
|
||||||
|
ExposurePrivate = "private"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Acknowledgement accepts one exposure finding (by its code) for a view,
|
||||||
|
// or for the server: the finding stays, as info, with who accepted it,
|
||||||
|
// when and why.
|
||||||
|
type Acknowledgement struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
By string `json:"by"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAck returns the acknowledgement of code in acks, if any.
|
||||||
|
func FindAck(acks []Acknowledgement, code string) *Acknowledgement {
|
||||||
|
for i := range acks {
|
||||||
|
if acks[i].Code == code {
|
||||||
|
return &acks[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ACL is a named address match list.
|
// ACL is a named address match list.
|
||||||
type ACL struct {
|
type ACL struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -132,6 +166,10 @@ type Zone struct {
|
|||||||
// Disabled zones stay in Jarl, records and all, but named doesn't
|
// Disabled zones stay in Jarl, records and all, but named doesn't
|
||||||
// load them: their zone statement is left out of named.conf.
|
// load them: their zone statement is left out of named.conf.
|
||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
// LinkGroup links zones of the same name in different views: Jarl
|
||||||
|
// keeps their shared records in step (docs/views.md#linked-zones). 0
|
||||||
|
// for a zone that isn't linked; linked zones share the ID.
|
||||||
|
LinkGroup int64 `json:"link_group,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +199,10 @@ type Record struct {
|
|||||||
// Disabled records stay in Jarl but are left out of the zone (the
|
// Disabled records stay in Jarl but are left out of the zone (the
|
||||||
// zone file lists them as comments).
|
// zone file lists them as comments).
|
||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
// SharedID ties the copies of a shared record in linked zones
|
||||||
|
// together (the same in every copy). 0 for a record of a zone that
|
||||||
|
// isn't linked, and for a record local to its view in one that is.
|
||||||
|
SharedID int64 `json:"shared_id,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +259,11 @@ type Settings struct {
|
|||||||
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
||||||
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
|
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
|
||||||
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
||||||
Version int `json:"version"`
|
// Exposure and Acknowledged are the server's own (without views, and
|
||||||
|
// for server-wide findings such as a visible version): see View.
|
||||||
|
Exposure string `json:"exposure,omitempty"`
|
||||||
|
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockingPaused reports whether blocking is paused, for a time or until
|
// BlockingPaused reports whether blocking is paused, for a time or until
|
||||||
@@ -289,10 +335,12 @@ func (s *State) Clone() *State {
|
|||||||
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
|
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
|
||||||
}
|
}
|
||||||
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
|
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
|
||||||
|
c.Settings.Acknowledged = slices.Clone(s.Settings.Acknowledged)
|
||||||
for id, v := range s.Views {
|
for id, v := range s.Views {
|
||||||
n := *v
|
n := *v
|
||||||
n.MatchClients = slices.Clone(v.MatchClients)
|
n.MatchClients = slices.Clone(v.MatchClients)
|
||||||
n.MatchDestinations = slices.Clone(v.MatchDestinations)
|
n.MatchDestinations = slices.Clone(v.MatchDestinations)
|
||||||
|
n.Acknowledged = slices.Clone(v.Acknowledged)
|
||||||
if v.MatchRecursiveOnly != nil {
|
if v.MatchRecursiveOnly != nil {
|
||||||
b := *v.MatchRecursiveOnly
|
b := *v.MatchRecursiveOnly
|
||||||
n.MatchRecursiveOnly = &b
|
n.MatchRecursiveOnly = &b
|
||||||
|
|||||||
+141
-8
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/iprange"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AMKind is the kind of an address match list element.
|
// AMKind is the kind of an address match list element.
|
||||||
@@ -17,6 +19,9 @@ const (
|
|||||||
AMName // an ACL name (or built-in ACL)
|
AMName // an ACL name (or built-in ACL)
|
||||||
AMNested // { ... }
|
AMNested // { ... }
|
||||||
AMOther // anything else (e.g. geoip), passed through
|
AMOther // anything else (e.g. geoip), passed through
|
||||||
|
// AMRange is an address range, START-END: Jarl's own syntax, written
|
||||||
|
// to named.conf as the prefixes that cover it (ExpandRanges).
|
||||||
|
AMRange
|
||||||
)
|
)
|
||||||
|
|
||||||
// Builtin ACL names.
|
// Builtin ACL names.
|
||||||
@@ -26,10 +31,11 @@ var BuiltinACLs = map[string]bool{"any": true, "none": true, "localhost": true,
|
|||||||
type AMElement struct {
|
type AMElement struct {
|
||||||
Negated bool
|
Negated bool
|
||||||
Kind AMKind
|
Kind AMKind
|
||||||
Prefix netip.Prefix // AMPrefix
|
Prefix netip.Prefix // AMPrefix
|
||||||
Name string // AMKey, AMName
|
Name string // AMKey, AMName
|
||||||
Nested []AMElement // AMNested
|
Nested []AMElement // AMNested
|
||||||
Raw string // AMOther
|
Raw string // AMOther
|
||||||
|
Range iprange.Range // AMRange
|
||||||
}
|
}
|
||||||
|
|
||||||
// String returns the element in canonical named.conf syntax, without the
|
// String returns the element in canonical named.conf syntax, without the
|
||||||
@@ -55,6 +61,8 @@ func (e AMElement) String() string {
|
|||||||
s = "{ " + strings.Join(parts, " ") + " }"
|
s = "{ " + strings.Join(parts, " ") + " }"
|
||||||
case AMOther:
|
case AMOther:
|
||||||
s = e.Raw
|
s = e.Raw
|
||||||
|
case AMRange:
|
||||||
|
s = e.Range.String()
|
||||||
}
|
}
|
||||||
if e.Negated {
|
if e.Negated {
|
||||||
return "!" + s
|
return "!" + s
|
||||||
@@ -137,6 +145,7 @@ func parseAMItems(items []Item) (AMElement, error) {
|
|||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return e, errors.New("empty address match element")
|
return e, errors.New("empty address match element")
|
||||||
}
|
}
|
||||||
|
items = joinRange(items)
|
||||||
first := items[0]
|
first := items[0]
|
||||||
if first.Block != nil {
|
if first.Block != nil {
|
||||||
if len(items) != 1 {
|
if len(items) != 1 {
|
||||||
@@ -172,6 +181,16 @@ func parseAMItems(items []Item) (AMElement, error) {
|
|||||||
}
|
}
|
||||||
return e, fmt.Errorf("unexpected text after %q", word)
|
return e, fmt.Errorf("unexpected text after %q", word)
|
||||||
}
|
}
|
||||||
|
if !first.Value.Quoted {
|
||||||
|
if r, ok, err := iprange.Parse(word); ok {
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
e.Kind = AMRange
|
||||||
|
e.Range = r
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if !first.Value.Quoted || BuiltinACLs[lw] {
|
if !first.Value.Quoted || BuiltinACLs[lw] {
|
||||||
if p, ok, err := parsePrefix(word); ok {
|
if p, ok, err := parsePrefix(word); ok {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,6 +234,9 @@ func CheckACLName(name string) error {
|
|||||||
return fmt.Errorf("ACL name %q looks like an address", name)
|
return fmt.Errorf("ACL name %q looks like an address", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, ok, _ := iprange.Parse(name); ok {
|
||||||
|
return fmt.Errorf("ACL name %q looks like an address range", name)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,18 +340,18 @@ func AMRefs(elems []AMElement) (acls, keys []string) {
|
|||||||
func Shadowed(elems []AMElement) []string {
|
func Shadowed(elems []AMElement) []string {
|
||||||
var warns []string
|
var warns []string
|
||||||
for i, e := range elems {
|
for i, e := range elems {
|
||||||
if e.Kind != AMPrefix && e.Kind != AMName {
|
if e.Kind != AMPrefix && e.Kind != AMRange && e.Kind != AMName {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
er, eok := e.addrRange()
|
||||||
for j := 0; j < i; j++ {
|
for j := 0; j < i; j++ {
|
||||||
prev := elems[j]
|
prev := elems[j]
|
||||||
if prev.Kind == AMName && prev.Name == "any" {
|
if prev.Kind == AMName && prev.Name == "any" {
|
||||||
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already matches every address", e.String(), prev.String()))
|
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already matches every address", e.String(), prev.String()))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if prev.Kind == AMPrefix && e.Kind == AMPrefix &&
|
if pr, ok := prev.addrRange(); ok && eok && pr.Start.Is4() == er.Start.Is4() &&
|
||||||
prev.Prefix.Addr().Is4() == e.Prefix.Addr().Is4() &&
|
!er.Start.Less(pr.Start) && !pr.End.Less(er.End) {
|
||||||
prev.Prefix.Bits() <= e.Prefix.Bits() && prev.Prefix.Contains(e.Prefix.Addr()) {
|
|
||||||
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already covers it (first match wins; put the more specific element first)", e.String(), prev.String()))
|
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already covers it (first match wins; put the more specific element first)", e.String(), prev.String()))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -338,6 +360,117 @@ func Shadowed(elems []AMElement) []string {
|
|||||||
return warns
|
return warns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addrRange returns the addresses a prefix or range element covers.
|
||||||
|
func (e AMElement) addrRange() (iprange.Range, bool) {
|
||||||
|
switch e.Kind {
|
||||||
|
case AMRange:
|
||||||
|
return e.Range, true
|
||||||
|
case AMPrefix:
|
||||||
|
return iprange.FromPrefix(e.Prefix), true
|
||||||
|
}
|
||||||
|
return iprange.Range{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand returns the element as named.conf can take it: a range becomes
|
||||||
|
// the prefixes that cover it (each negated when the range is), nested
|
||||||
|
// lists are expanded too, and anything else is returned as it is.
|
||||||
|
func (e AMElement) Expand() []AMElement {
|
||||||
|
switch e.Kind {
|
||||||
|
case AMRange:
|
||||||
|
var out []AMElement
|
||||||
|
for _, p := range e.Range.Prefixes() {
|
||||||
|
out = append(out, AMElement{Negated: e.Negated, Kind: AMPrefix, Prefix: p})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case AMNested:
|
||||||
|
n := e
|
||||||
|
n.Nested = nil
|
||||||
|
for _, in := range e.Nested {
|
||||||
|
n.Nested = append(n.Nested, in.Expand()...)
|
||||||
|
}
|
||||||
|
return []AMElement{n}
|
||||||
|
}
|
||||||
|
return []AMElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinRange turns a range written with spaces around the dash
|
||||||
|
// ("10.0.0.1 - 10.0.0.9", three words) into a single word.
|
||||||
|
func joinRange(items []Item) []Item {
|
||||||
|
if len(items) != 3 || items[0].Value == nil || items[1].Value == nil || items[2].Value == nil ||
|
||||||
|
items[0].Value.Quoted || items[1].Value.Quoted || items[2].Value.Quoted || items[1].Value.Text != "-" {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
text := items[0].Value.Text + "-" + items[2].Value.Text
|
||||||
|
if _, ok, _ := iprange.Parse(text); !ok {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return []Item{{Value: &Value{Text: text, Pos: items[0].Value.Pos}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinRanges rewrites ranges written with spaces around the dash, in st's
|
||||||
|
// blocks, as single words (the form Jarl stores).
|
||||||
|
func JoinRanges(st *Stmt) {
|
||||||
|
for _, it := range st.Items {
|
||||||
|
if it.Block == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range it.Block.Stmts {
|
||||||
|
if len(s.Items) == 4 && s.Items[0].Bang {
|
||||||
|
s.Items = append(s.Items[:1], joinRange(s.Items[1:])...)
|
||||||
|
} else {
|
||||||
|
s.Items = joinRange(s.Items)
|
||||||
|
}
|
||||||
|
JoinRanges(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandRanges replaces the address ranges in st's blocks (at any depth)
|
||||||
|
// by the prefixes that cover them, with the range as a comment, so named
|
||||||
|
// can read it. A range is a block statement that is a single word START-END,
|
||||||
|
// optionally negated; nothing else in named.conf's grammar looks like one.
|
||||||
|
func ExpandRanges(st *Stmt) {
|
||||||
|
for _, it := range st.Items {
|
||||||
|
if it.Block != nil {
|
||||||
|
expandBlock(it.Block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandBlock(b *Block) {
|
||||||
|
var out []*Stmt
|
||||||
|
for _, s := range b.Stmts {
|
||||||
|
items, neg := s.Items, false
|
||||||
|
if len(items) == 2 && items[0].Bang {
|
||||||
|
items, neg = items[1:], true
|
||||||
|
}
|
||||||
|
if len(items) == 1 && items[0].Value != nil && !items[0].Value.Quoted {
|
||||||
|
if r, ok, err := iprange.Parse(items[0].Value.Text); ok && err == nil {
|
||||||
|
for i, p := range r.Prefixes() {
|
||||||
|
var ns Stmt
|
||||||
|
if neg {
|
||||||
|
ns.Items = append(ns.Items, Item{Bang: true})
|
||||||
|
}
|
||||||
|
text := p.String()
|
||||||
|
if p.IsSingleIP() {
|
||||||
|
text = p.Addr().String()
|
||||||
|
}
|
||||||
|
ns.Items = append(ns.Items, Item{Value: &Value{Text: text}})
|
||||||
|
ns.Pos = s.Pos
|
||||||
|
if i == 0 {
|
||||||
|
ns.Comment = AMElement{Negated: neg, Kind: AMRange, Range: r}.String()
|
||||||
|
}
|
||||||
|
out = append(out, &ns)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ExpandRanges(s)
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
b.Stmts = out
|
||||||
|
}
|
||||||
|
|
||||||
// ContainsAny reports whether elems contain a non-negated "any" before any
|
// ContainsAny reports whether elems contain a non-negated "any" before any
|
||||||
// negated element would restrict it, which makes the list match everyone.
|
// negated element would restrict it, which makes the list match everyone.
|
||||||
// It is used for security lints (open resolvers, open transfers).
|
// It is used for security lints (open resolvers, open transfers).
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ type Item struct {
|
|||||||
type Stmt struct {
|
type Stmt struct {
|
||||||
Items []Item
|
Items []Item
|
||||||
Pos Pos
|
Pos Pos
|
||||||
|
// Comment is written before the statement (only by Jarl: comments in
|
||||||
|
// parsed text are dropped). ExpandRanges notes the range there.
|
||||||
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyword returns the statement's first word, lowercased, or "" if the
|
// Keyword returns the statement's first word, lowercased, or "" if the
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ func valueText(v *Value) string {
|
|||||||
|
|
||||||
func writeStmt(sb *strings.Builder, s *Stmt, indent int) {
|
func writeStmt(sb *strings.Builder, s *Stmt, indent int) {
|
||||||
tabs := strings.Repeat("\t", indent)
|
tabs := strings.Repeat("\t", indent)
|
||||||
|
if s.Comment != "" {
|
||||||
|
sb.WriteString(tabs + "// " + s.Comment + "\n")
|
||||||
|
}
|
||||||
sb.WriteString(tabs)
|
sb.WriteString(tabs)
|
||||||
inline := Compact(s.Items)
|
inline := Compact(s.Items)
|
||||||
if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 {
|
if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 {
|
||||||
@@ -167,6 +170,9 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
|
|||||||
} else {
|
} else {
|
||||||
sb.WriteString("{ ")
|
sb.WriteString("{ ")
|
||||||
for _, s := range it.Block.Stmts {
|
for _, s := range it.Block.Stmts {
|
||||||
|
if s.Comment != "" {
|
||||||
|
sb.WriteString("/* " + s.Comment + " */ ")
|
||||||
|
}
|
||||||
writeItemsInline(sb, s.Items)
|
writeItemsInline(sb, s.Items)
|
||||||
sb.WriteString("; ")
|
sb.WriteString("; ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package namedconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRangeElements(t *testing.T) {
|
||||||
|
e, err := ParseAMElement("!10.0.10.150 - 10.0.10.160;")
|
||||||
|
if err != nil || e.Kind != AMRange || !e.Negated || e.String() != "!10.0.10.150-10.0.10.160" {
|
||||||
|
t.Fatalf("negated range = %+v, %v", e, err)
|
||||||
|
}
|
||||||
|
if e, err := ParseAMElement("2001:db8::100-2001:db8::1ff"); err != nil || e.Kind != AMRange {
|
||||||
|
t.Errorf("IPv6 range = %+v, %v", e, err)
|
||||||
|
}
|
||||||
|
// ACL names with dashes are still names.
|
||||||
|
if e, err := ParseAMElement("branch-office"); err != nil || e.Kind != AMName {
|
||||||
|
t.Errorf("name = %+v, %v", e, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"10.0.10.199-10.0.10.101", "10.0.10.101-199", "10.0.0.1-2001:db8::1"} {
|
||||||
|
if _, err := ParseAMElement(bad); err == nil {
|
||||||
|
t.Errorf("%s accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := CheckACLName("10.0.0.1-10.0.0.9"); err == nil {
|
||||||
|
t.Error("an ACL name that looks like a range was accepted")
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, x := range e.Expand() {
|
||||||
|
got = append(got, x.String())
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != "!10.0.10.150/31 !10.0.10.152/29 !10.0.10.160" {
|
||||||
|
t.Errorf("expand = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandRanges(t *testing.T) {
|
||||||
|
st, err := ParseStatement(`acl "lan" { !10.0.10.150-10.0.10.160; 10.0.10.101-10.0.10.199; key xfer; branch-office; { 10.0.0.1-10.0.0.2; }; }`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ExpandRanges(st)
|
||||||
|
want := `acl "lan" {
|
||||||
|
// !10.0.10.150-10.0.10.160
|
||||||
|
!10.0.10.150/31;
|
||||||
|
!10.0.10.152/29;
|
||||||
|
!10.0.10.160;
|
||||||
|
// 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;
|
||||||
|
key xfer;
|
||||||
|
branch-office;
|
||||||
|
{ /* 10.0.0.1-10.0.0.2 */ 10.0.0.1; 10.0.0.2; };
|
||||||
|
};
|
||||||
|
`
|
||||||
|
if got := FormatStmt(st, 0); got != want {
|
||||||
|
t.Errorf("got:\n%s\nwant:\n%s", got, want)
|
||||||
|
}
|
||||||
|
// Short lists stay on one line, with the range as a /* */ comment;
|
||||||
|
// both forms parse again.
|
||||||
|
st, _ = ParseOptionValue("allow-query", "{ 2001:db8::100-2001:db8::1ff; localhost; }")
|
||||||
|
ExpandRanges(st)
|
||||||
|
got := FormatStmt(st, 0)
|
||||||
|
if got != "allow-query { /* 2001:db8::100-2001:db8::1ff */ 2001:db8::100/120; localhost; };\n" {
|
||||||
|
t.Errorf("inline = %q", got)
|
||||||
|
}
|
||||||
|
if _, err := ParseStatement(strings.TrimSuffix(strings.TrimSpace(got), ";")); err != nil {
|
||||||
|
t.Errorf("output doesn't parse: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShadowedRanges(t *testing.T) {
|
||||||
|
elems, _ := ParseAMListString("10.0.10.0/24; 10.0.10.101-10.0.10.199; 10.0.10.150-10.0.11.5; 10.0.11.1")
|
||||||
|
w := Shadowed(elems)
|
||||||
|
if len(w) != 2 || !strings.Contains(w[0], `"10.0.10.101-10.0.10.199" never matches`) || !strings.Contains(w[1], `"10.0.11.1" never matches: "10.0.10.150-10.0.11.5"`) {
|
||||||
|
t.Errorf("shadowed = %q", w)
|
||||||
|
}
|
||||||
|
elems, _ = ParseAMListString("10.0.10.101-10.0.10.199; 10.0.10.120")
|
||||||
|
if w := Shadowed(elems); len(w) != 1 || !strings.Contains(w[0], `"10.0.10.120" never matches`) {
|
||||||
|
t.Errorf("shadowed by a range = %q", w)
|
||||||
|
}
|
||||||
|
elems, _ = ParseAMListString("0.0.0.0-255.255.255.255")
|
||||||
|
if ContainsAny(elems) {
|
||||||
|
t.Error("ContainsAny should only see expanded elements") // callers expand first
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package porkbun
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,7 +16,8 @@ type Fake struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
Domains map[string]*FakeDomain
|
Domains map[string]*FakeDomain
|
||||||
// Calls lists the paths called, in order.
|
// Calls lists the paths called, in order.
|
||||||
Calls []string
|
Calls []string
|
||||||
|
nextID int
|
||||||
}
|
}
|
||||||
|
|
||||||
// FakeDomain is a domain in a Fake.
|
// FakeDomain is a domain in a Fake.
|
||||||
@@ -24,6 +26,14 @@ type FakeDomain struct {
|
|||||||
Glue map[string][]string // host -> addresses
|
Glue map[string][]string // host -> addresses
|
||||||
DS map[uint16]DS
|
DS map[uint16]DS
|
||||||
APIAccess bool
|
APIAccess bool
|
||||||
|
// Records are the DNS records created through the API, by ID.
|
||||||
|
Records map[string]FakeRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// FakeRecord is a DNS record in a Fake.
|
||||||
|
type FakeRecord struct {
|
||||||
|
Name, Type, Content string // Name is the full name, without a trailing dot
|
||||||
|
TTL int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
||||||
@@ -31,7 +41,7 @@ type FakeDomain struct {
|
|||||||
func NewFake(key, secret string, domains ...string) *Fake {
|
func NewFake(key, secret string, domains ...string) *Fake {
|
||||||
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true}
|
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true, Records: map[string]FakeRecord{}}
|
||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
@@ -158,7 +168,48 @@ func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
tag, _ := strconv.Atoi(parts[3])
|
tag, _ := strconv.Atoi(parts[3])
|
||||||
delete(d.DS, uint16(tag))
|
delete(d.DS, uint16(tag))
|
||||||
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
||||||
|
case "dns/create":
|
||||||
|
name, _ := body["name"].(string)
|
||||||
|
full := domain
|
||||||
|
if name != "" {
|
||||||
|
full = name + "." + domain
|
||||||
|
}
|
||||||
|
ttl, _ := strconv.Atoi(fmt.Sprint(body["ttl"]))
|
||||||
|
if ttl < MinTTL {
|
||||||
|
ttl = MinTTL
|
||||||
|
}
|
||||||
|
f.nextID++
|
||||||
|
id := strconv.Itoa(f.nextID)
|
||||||
|
d.Records[id] = FakeRecord{Name: full, Type: fmt.Sprint(body["type"]), Content: fmt.Sprint(body["content"]), TTL: ttl}
|
||||||
|
reply(200, map[string]any{"status": "SUCCESS", "id": f.nextID})
|
||||||
|
case "dns/delete":
|
||||||
|
if len(parts) < 4 || d.Records == nil {
|
||||||
|
fail(400, "INVALID_RECORD_ID", "Invalid record ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := d.Records[parts[3]]; !ok {
|
||||||
|
fail(400, "INVALID_RECORD_ID", "Invalid record ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(d.Records, parts[3])
|
||||||
|
reply(200, map[string]any{"status": "SUCCESS"})
|
||||||
default:
|
default:
|
||||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TXT returns the values of the TXT records at name (without a trailing
|
||||||
|
// dot) in the fake's domains.
|
||||||
|
func (f *Fake) TXT(name string) []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, d := range f.Domains {
|
||||||
|
for _, r := range d.Records {
|
||||||
|
if r.Type == "TXT" && strings.EqualFold(r.Name, strings.TrimSuffix(name, ".")) {
|
||||||
|
out = append(out, r.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package porkbun is a small client for the parts of Porkbun's API v3
|
// Package porkbun is a small client for the parts of Porkbun's API v3
|
||||||
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
||||||
// a domain's delegation: its name servers, glue records (host objects at
|
// a domain's delegation: its name servers, glue records (host objects at
|
||||||
// the registry) and DNSSEC DS records.
|
// the registry) and DNSSEC DS records, and the TXT records of DNS-01
|
||||||
|
// challenges while Porkbun still serves a domain (docs/certificates.md).
|
||||||
//
|
//
|
||||||
// Every call is a POST with the API key and secret in the JSON body. API
|
// Every call is a POST with the API key and secret in the JSON body. API
|
||||||
// access must be switched on for each domain in Porkbun's web interface,
|
// access must be switched on for each domain in Porkbun's web interface,
|
||||||
@@ -79,6 +80,7 @@ type response struct {
|
|||||||
Valid bool `json:"credentialsValid"`
|
Valid bool `json:"credentialsValid"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
|
ID json.Number `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
||||||
@@ -282,6 +284,37 @@ func (c *Client) DeleteDS(ctx context.Context, domain string, keyTag uint16) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinTTL is the lowest TTL Porkbun accepts for a DNS record.
|
||||||
|
const MinTTL = 600
|
||||||
|
|
||||||
|
// CreateTXT adds a TXT record at name (a name inside domain, or the
|
||||||
|
// domain itself) in Porkbun's DNS and returns its ID.
|
||||||
|
func (c *Client) CreateTXT(ctx context.Context, domain, name, value string) (string, error) {
|
||||||
|
sub := ""
|
||||||
|
if normName(name) != normName(domain) {
|
||||||
|
var err error
|
||||||
|
if sub, err = subdomain(domain, name); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, err := c.call(ctx, domainPath("/dns/create", domain), map[string]any{
|
||||||
|
"name": sub, "type": "TXT", "content": value, "ttl": strconv.Itoa(MinTTL),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if r.ID == "" {
|
||||||
|
return "", &Error{Message: "the record was created but Porkbun didn't say its ID"}
|
||||||
|
}
|
||||||
|
return r.ID.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRecord deletes the DNS record with id.
|
||||||
|
func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error {
|
||||||
|
_, err := c.call(ctx, domainPath("/dns/delete", domain, id), nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// subdomain returns host's label(s) below domain ("ns1" for
|
// subdomain returns host's label(s) below domain ("ns1" for
|
||||||
// ns1.example.com in example.com).
|
// ns1.example.com in example.com).
|
||||||
func subdomain(domain, host string) (string, error) {
|
func subdomain(domain, host string) (string, error) {
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ func optionText(o *model.Option, indent int) string {
|
|||||||
// named-checkconf reports the problem.
|
// named-checkconf reports the problem.
|
||||||
return prefix + strings.Repeat("\t", indent) + o.Text() + "\n"
|
return prefix + strings.Repeat("\t", indent) + o.Text() + "\n"
|
||||||
}
|
}
|
||||||
return prefix + namedconf.FormatStmt(st, indent)
|
return prefix + formatStmt(st, indent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func indentLines(s string, indent int) string {
|
func indentLines(s string, indent int) string {
|
||||||
@@ -272,6 +272,12 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
pzs := PolicyZones(s, p)
|
pzs := PolicyZones(s, p)
|
||||||
out.Policy = pzs
|
out.Policy = pzs
|
||||||
logRewrites := logResponses && len(pzs) > 0
|
logRewrites := logResponses && len(pzs) > 0
|
||||||
|
// Rate limiting logs what it limits (or would, in log-only mode) as
|
||||||
|
// query-errors; the statistics count it (docs/exposure.md).
|
||||||
|
logRRL := false
|
||||||
|
for _, o := range s.Options {
|
||||||
|
logRRL = logRRL || logResponses && o.Name == "rate-limit"
|
||||||
|
}
|
||||||
sawStats, sawLogging := false, false
|
sawStats, sawLogging := false, false
|
||||||
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
@@ -282,7 +288,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write(st.statsStatement(o))
|
w.write(st.statsStatement(o))
|
||||||
sawStats = true
|
sawStats = true
|
||||||
case logResponses && o.Name == "logging" && !sawLogging:
|
case logResponses && o.Name == "logging" && !sawLogging:
|
||||||
w.write(st.loggingStatement(o, logRewrites))
|
w.write(st.loggingStatement(o, logRewrites, logRRL))
|
||||||
sawLogging = true
|
sawLogging = true
|
||||||
default:
|
default:
|
||||||
w.write(optionText(o, 0))
|
w.write(optionText(o, 0))
|
||||||
@@ -297,7 +303,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write(st.statsStatement(nil))
|
w.write(st.statsStatement(nil))
|
||||||
}
|
}
|
||||||
if logResponses && !sawLogging {
|
if logResponses && !sawLogging {
|
||||||
w.write(st.loggingStatement(nil, logRewrites))
|
w.write(st.loggingStatement(nil, logRewrites, logRRL))
|
||||||
}
|
}
|
||||||
w.end()
|
w.end()
|
||||||
}
|
}
|
||||||
@@ -313,7 +319,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n")
|
w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n")
|
||||||
} else {
|
} else {
|
||||||
w.write(namedconf.FormatStmt(st, 0))
|
w.write(formatStmt(st, 0))
|
||||||
}
|
}
|
||||||
w.end()
|
w.end()
|
||||||
}
|
}
|
||||||
@@ -382,7 +388,7 @@ func writeAML(w *confWriter, name string, elems []string) {
|
|||||||
w.write("\t" + name + " " + amlBlock(elems) + ";\n")
|
w.write("\t" + name + " " + amlBlock(elems) + ";\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.write(namedconf.FormatStmt(st, 1))
|
w.write(formatStmt(st, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
func yesNo(b bool) string {
|
func yesNo(b bool) string {
|
||||||
@@ -507,3 +513,10 @@ func ZoneContentHash(s *model.State, z *model.Zone) string {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatStmt lays out a statement for named.conf, with address ranges
|
||||||
|
// (Jarl's own syntax) written as the prefixes that cover them.
|
||||||
|
func formatStmt(st *namedconf.Stmt, indent int) string {
|
||||||
|
namedconf.ExpandRanges(st)
|
||||||
|
return namedconf.FormatStmt(st, indent)
|
||||||
|
}
|
||||||
@@ -62,19 +62,20 @@ func (h *StatsHooks) statsStatement(user *model.Option) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.Stmts = append(b.Stmts, h.statsInet())
|
b.Stmts = append(b.Stmts, h.statsInet())
|
||||||
return commentLines(user.Comment) + commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0)
|
return commentLines(user.Comment) + commentBlock("//", statsComment) + formatStmt(st, 0)
|
||||||
}
|
}
|
||||||
// Unparsable: validation reports it; leave it alone.
|
// Unparsable: validation reports it; leave it alone.
|
||||||
return optionText(user, 0)
|
return optionText(user, 0)
|
||||||
}
|
}
|
||||||
st := namedconf.NewStmt("statistics-channels", namedconf.NewBlock(h.statsInet()))
|
st := namedconf.NewStmt("statistics-channels", namedconf.NewBlock(h.statsInet()))
|
||||||
return commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0)
|
return commentBlock("//", statsComment) + formatStmt(st, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loggingStatement returns the logging statement with Jarl's channel and
|
// loggingStatement returns the logging statement with Jarl's channel and
|
||||||
// the responses category (and rpz, for blocking's rewrites), merged into
|
// the responses category (rpz too, for blocking's rewrites, and
|
||||||
// the user's own if there is one.
|
// query-errors for rate limiting), merged into the user's own if there is
|
||||||
func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
|
// one.
|
||||||
|
func (h *StatsHooks) loggingStatement(user *model.Option, rpz, rrl bool) string {
|
||||||
var st *namedconf.Stmt
|
var st *namedconf.Stmt
|
||||||
if user != nil {
|
if user != nil {
|
||||||
var err error
|
var err error
|
||||||
@@ -92,6 +93,9 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
|
|||||||
if rpz {
|
if rpz {
|
||||||
categories = append(categories, "rpz")
|
categories = append(categories, "rpz")
|
||||||
}
|
}
|
||||||
|
if rrl {
|
||||||
|
categories = append(categories, "query-errors")
|
||||||
|
}
|
||||||
for _, cat := range categories {
|
for _, cat := range categories {
|
||||||
merged := false
|
merged := false
|
||||||
for _, s := range b.Stmts {
|
for _, s := range b.Stmts {
|
||||||
@@ -101,14 +105,20 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !merged {
|
if !merged {
|
||||||
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))))
|
channels := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))
|
||||||
|
if cat == "query-errors" {
|
||||||
|
// Naming the category takes it away from named's
|
||||||
|
// default destination: keep that too.
|
||||||
|
channels.Stmts = append(channels.Stmts, namedconf.NewStmt("default_syslog"))
|
||||||
|
}
|
||||||
|
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, channels))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefix := ""
|
prefix := ""
|
||||||
if user != nil {
|
if user != nil {
|
||||||
prefix = commentLines(user.Comment)
|
prefix = commentLines(user.Comment)
|
||||||
}
|
}
|
||||||
return prefix + commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0)
|
return prefix + commentBlock("//", statsComment) + formatStmt(st, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func commentLines(c string) string {
|
func commentLines(c string) string {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// selfSigned writes a self-signed certificate for names into dir.
|
||||||
|
func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile string) {
|
||||||
|
t.Helper()
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||||
|
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(30 * 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)
|
||||||
|
}
|
||||||
|
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
certFile, keyFile = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||||
|
os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
|
||||||
|
os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func withCerts(env *apiEnv) *certs.Manager {
|
||||||
|
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
|
||||||
|
env.srv.cfg.Certs = m
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertificatesAPI(t *testing.T) {
|
||||||
|
env := newAPIEnv(t)
|
||||||
|
m := withCerts(env)
|
||||||
|
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
||||||
|
|
||||||
|
// A user who isn't an administrator sees nothing.
|
||||||
|
hash, _ := auth.HashPassword("pw-pw-pw-pw")
|
||||||
|
u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}}
|
||||||
|
env.st.CreateUser(context.Background(), u)
|
||||||
|
tok, pid, sh := auth.NewToken()
|
||||||
|
env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||||
|
env.do("GET", "/certificates", tok, nil, 403)
|
||||||
|
|
||||||
|
out := env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "source": "file", "cert_file": certFile, "key_file": keyFile, "uses": []string{"web"}}, 201)
|
||||||
|
c := out["certificate"].(map[string]any)
|
||||||
|
if c["serving"] != true || c["status"].(map[string]any)["state"] != "ok" || c["domains"].([]any)[0] != "jarl.example.com" {
|
||||||
|
t.Errorf("created: %v", c)
|
||||||
|
}
|
||||||
|
if _, has := c["key_pem"]; has {
|
||||||
|
t.Error("the key left the server")
|
||||||
|
}
|
||||||
|
if m.WebCertificate() == nil {
|
||||||
|
t.Error("not serving")
|
||||||
|
}
|
||||||
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "domains": []string{"a.example.com"}}, 409)
|
||||||
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "domains": []string{"a.example.com"}, "uses": []string{"web"}}, 400)
|
||||||
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "source": "file", "cert_file": "/nonexistent.pem", "key_file": keyFile}, 400)
|
||||||
|
list := env.do("GET", "/certificates", env.token, nil, 200)
|
||||||
|
if n := len(list["certificates"].([]any)); n != 1 || list["account"].(map[string]any)["directory"] != "https://acme.invalid/dir" {
|
||||||
|
t.Errorf("list: %v", list)
|
||||||
|
}
|
||||||
|
env.do("PATCH", "/certificates/own", env.token, map[string]any{"uses": []string{"dns"}}, 200)
|
||||||
|
if m.WebCertificate() != nil {
|
||||||
|
t.Error("still serving after the web use was taken off")
|
||||||
|
}
|
||||||
|
got := env.do("GET", "/certificates/own", env.token, nil, 200)
|
||||||
|
if files, ok := got["files"].(map[string]any); !ok || !strings.HasSuffix(files["key"].(string), "/own/privkey.pem") {
|
||||||
|
t.Errorf("files: %v", got["files"])
|
||||||
|
}
|
||||||
|
env.do("POST", "/certificates/own/issue", env.token, map[string]any{}, 200)
|
||||||
|
env.do("DELETE", "/certificates/own", env.token, nil, 200)
|
||||||
|
env.do("GET", "/certificates/own", env.token, nil, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
||||||
|
env := newAPIEnv(t)
|
||||||
|
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
||||||
|
s := env.srv
|
||||||
|
cr, err := newCertReloader(certFile, keyFile, s.log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.certs = cr
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &tls.Config{GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
||||||
|
hs := &http.Server{Handler: s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.TLS != nil {
|
||||||
|
io.WriteString(w, "tls "+r.Proto)
|
||||||
|
} else {
|
||||||
|
io.WriteString(w, "plain")
|
||||||
|
}
|
||||||
|
}))}
|
||||||
|
go hs.Serve(newSniffListener(ln, cfg))
|
||||||
|
t.Cleanup(func() { hs.Close() })
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
|
||||||
|
get := func(c *http.Client, url string) string {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := c.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
tlsClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, ForceAttemptHTTP2: true}}
|
||||||
|
if got := get(tlsClient, "https://"+addr+"/"); got != "tls HTTP/2.0" {
|
||||||
|
t.Errorf("HTTPS: %q", got)
|
||||||
|
}
|
||||||
|
// Loopback may still use plain HTTP (tools on the server itself).
|
||||||
|
if got := get(http.DefaultClient, "http://"+addr+"/"); got != "plain" {
|
||||||
|
t.Errorf("plain from loopback: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From elsewhere: browsers are redirected, API calls refused.
|
||||||
|
h := s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "served") }))
|
||||||
|
req := httptest.NewRequest("GET", "http://dns.example.com:8053/zones?x=1", nil)
|
||||||
|
req.RemoteAddr = "192.0.2.10:5555"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != "https://dns.example.com:8053/zones?x=1" {
|
||||||
|
t.Errorf("GET from elsewhere: %d %s", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
req = httptest.NewRequest("POST", "http://dns.example.com:8053/api/v1/records", strings.NewReader("{}"))
|
||||||
|
req.RemoteAddr = "192.0.2.10:5555"
|
||||||
|
req.Header.Set("Authorization", "Bearer x")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "https_required") || !strings.Contains(rec.Body.String(), "rotate the token") {
|
||||||
|
t.Errorf("POST from elsewhere: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// Without any certificate, plain HTTP is served as before.
|
||||||
|
s.certs = nil
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest("GET", "http://dns.example.com:8053/", nil)
|
||||||
|
req.RemoteAddr = "192.0.2.10:5555"
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Body.String() != "served" {
|
||||||
|
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExposureAPI(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"192.168.1.0/24", "localhost"}, "adopt_zones": true}, 201)
|
||||||
|
ext := e.do("POST", "/views", e.token, map[string]any{"name": "external", "match_clients": []string{"any"}}, 201)["object"].(map[string]any)["id"]
|
||||||
|
|
||||||
|
type report struct {
|
||||||
|
Scopes []struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
RateLimit string `json:"rate_limit"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
Findings []struct {
|
||||||
|
Code, Severity, Fix string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Server []struct{ Code, Fix string }
|
||||||
|
}
|
||||||
|
get := func() report {
|
||||||
|
b, status := e.req("GET", "/exposure", e.token, nil)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("exposure = %d %s", status, b)
|
||||||
|
}
|
||||||
|
var r report
|
||||||
|
json.Unmarshal(b, &r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
r := get()
|
||||||
|
if len(r.Scopes) != 2 || r.Scopes[0].View != "internal" || r.Scopes[0].Public || !r.Scopes[1].Public {
|
||||||
|
t.Fatalf("scopes = %+v", r.Scopes)
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
for _, f := range r.Scopes[1].Findings {
|
||||||
|
codes = append(codes, f.Code)
|
||||||
|
if f.Code == "exposure_rate_limit" && !strings.Contains(f.Fix, "log-only yes") {
|
||||||
|
t.Errorf("rate limit fix = %q", f.Fix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(codes, " "), "exposure_rate_limit") || len(r.Server) < 2 {
|
||||||
|
t.Errorf("findings = %v, server = %+v", codes, r.Server)
|
||||||
|
}
|
||||||
|
e.do("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_rate_limit"}, 201)
|
||||||
|
if r := get(); r.Scopes[1].RateLimit != "log-only" {
|
||||||
|
t.Errorf("rate limit = %q", r.Scopes[1].RateLimit)
|
||||||
|
}
|
||||||
|
if _, status := e.req("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_open_notify"}); status != 400 {
|
||||||
|
t.Errorf("fix without an automatic fix = %d", status)
|
||||||
|
}
|
||||||
|
// Opening a public view up needs confirming.
|
||||||
|
b, status := e.req("POST", "/options", e.token, map[string]any{"scope": "view", "scope_id": ext, "name": "allow-recursion", "value": "any"})
|
||||||
|
if status != 409 || !strings.Contains(string(b), "confirm_risk") {
|
||||||
|
t.Errorf("open resolver = %d %s", status, b)
|
||||||
|
}
|
||||||
|
e.do("POST", "/exposure/ack", e.token, map[string]any{"view": "external", "code": "exposure_minimal_any", "reason": "testing"}, 201)
|
||||||
|
for _, f := range get().Scopes[1].Findings {
|
||||||
|
if f.Code == "exposure_minimal_any" && f.Severity != "info" {
|
||||||
|
t.Errorf("acknowledged finding = %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,7 +158,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
|
|||||||
for _, z := range st.SortedZones() {
|
for _, z := range st.SortedZones() {
|
||||||
vn := st.ViewName(z)
|
vn := st.ViewName(z)
|
||||||
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
|
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
|
||||||
out = append(out, acme.Zone{Name: z.Name, View: vn})
|
out = append(out, acme.Zone{Name: z.Name, View: vn, Link: z.LinkGroup})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLS certificates (docs/certificates.md). Administrators only: a
|
||||||
|
// certificate's key can impersonate the server.
|
||||||
|
|
||||||
|
type certView struct {
|
||||||
|
*model.Certificate
|
||||||
|
Status certs.Status `json:"status"`
|
||||||
|
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
||||||
|
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
||||||
|
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
||||||
|
// IssuingSince is set while Jarl is obtaining it.
|
||||||
|
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type certFiles struct {
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) presentCert(c *model.Certificate) certView {
|
||||||
|
m := s.cfg.Certs
|
||||||
|
v := certView{Certificate: c, Status: m.StatusOf(c), Serving: m.WebName() == c.Name && c.HasUse(model.CertUseWeb)}
|
||||||
|
if c.Source == model.CertSourceACME {
|
||||||
|
t := m.Due(c)
|
||||||
|
v.NextTry = &t
|
||||||
|
}
|
||||||
|
if t := m.Issuing(c.Name); !t.IsZero() {
|
||||||
|
v.IssuingSince = &t
|
||||||
|
}
|
||||||
|
if c.HasUse(model.CertUseDNS) && c.NotAfter != nil {
|
||||||
|
dir := m.FilesDir(c.Name)
|
||||||
|
v.Files = &certFiles{Chain: dir + "/" + certs.ChainFile, Key: dir + "/" + certs.KeyFile}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) certsReady(p *auth.Principal) error {
|
||||||
|
if err := requireAdmin(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func certErr(err error) error {
|
||||||
|
var ie *certs.InputError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &ie):
|
||||||
|
return badRequest("%s", ie.Msg)
|
||||||
|
case errors.Is(err, certs.ErrNotFound):
|
||||||
|
return notFound("no such certificate")
|
||||||
|
case errors.Is(err, certs.ErrConflict):
|
||||||
|
return conflict("a certificate with that name exists")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// certList: GET /certificates.
|
||||||
|
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
all, err := s.cfg.Certs.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []certView{}
|
||||||
|
for _, c := range all {
|
||||||
|
out = append(out, s.presentCert(c))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certGet: GET /certificates/{name}.
|
||||||
|
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.presentCert(c))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certCreate: POST /certificates. With "issue": true (the default for
|
||||||
|
// certificates Jarl obtains) it is obtained before the answer.
|
||||||
|
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
certs.Input
|
||||||
|
Issue *bool `json:"issue"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Create(r.Context(), in.Input, p)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceACME && (in.Issue == nil || *in.Issue) {
|
||||||
|
return s.writeIssue(w, r, c.Name, p, http.StatusCreated)
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceACME {
|
||||||
|
s.cfg.Certs.Kick()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"certificate": s.presentCert(c)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certUpdate: PATCH /certificates/{name}.
|
||||||
|
func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in certs.Input
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Update(r.Context(), r.PathValue("name"), in, p)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"certificate": s.presentCert(c)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certDelete: DELETE /certificates/{name}.
|
||||||
|
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certIssue: POST /certificates/{name}/issue starts obtaining it now
|
||||||
|
// (202), or rereads the files of a file-source certificate (200).
|
||||||
|
func (s *Server) certIssue(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeIssue(w, r, r.PathValue("name"), p, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeIssue starts obtaining the certificate (it takes a while: poll
|
||||||
|
// GET /certificates/{name} until issuing_since is gone).
|
||||||
|
func (s *Server) writeIssue(w http.ResponseWriter, r *http.Request, name string, p *auth.Principal, status int) error {
|
||||||
|
c, err := s.cfg.Certs.IssueAsync(r.Context(), name, p)
|
||||||
|
if c == nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceACME && status == http.StatusOK {
|
||||||
|
status = http.StatusAccepted
|
||||||
|
}
|
||||||
|
out := map[string]any{"certificate": s.presentCert(c)}
|
||||||
|
if err != nil {
|
||||||
|
out["error"] = err.Error()
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certCheck: POST /certificates/{name}/check (or POST /certificates/check
|
||||||
|
// with a certificate's fields) says where each challenge would go, without
|
||||||
|
// asking the CA for anything.
|
||||||
|
func (s *Server) certCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var c *model.Certificate
|
||||||
|
if name := r.PathValue("name"); name != "" {
|
||||||
|
var err error
|
||||||
|
if c, err = s.cfg.Certs.Get(r.Context(), name); err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var in certs.Input
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
in.Name = model.Ptr("check")
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if c, err = s.cfg.Certs.Preview(in); err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routes, err := s.cfg.Certs.Check(r.Context(), c)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
for _, rt := range routes {
|
||||||
|
ok = ok && rt.Error == ""
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "routes": routes, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certSummary sums up the certificates for the dashboard (nil without any).
|
||||||
|
func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
all, err := s.cfg.Certs.List(ctx)
|
||||||
|
if err != nil || len(all) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &api.CertSummary{Total: len(all), Problems: []api.CertProblem{}, Serving: s.cfg.Certs.WebName()}
|
||||||
|
for _, c := range all {
|
||||||
|
if st := s.cfg.Certs.StatusOf(c); st.State != "ok" && st.State != "pending" {
|
||||||
|
out.Problems = append(out.Problems, api.CertProblem{Name: c.Name, State: st.State, Message: st.Message})
|
||||||
|
}
|
||||||
|
if c.NotAfter != nil && (out.NextAfter == nil || c.NotAfter.Before(*out.NextAfter)) {
|
||||||
|
out.Next, out.NextAfter = c.Name, c.NotAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
if in.Force {
|
if in.Force {
|
||||||
q.Set("force", "1")
|
q.Set("force", "1")
|
||||||
}
|
}
|
||||||
|
if in.AcceptRisk {
|
||||||
|
q.Set("accept_risk", "1")
|
||||||
|
}
|
||||||
r.URL.RawQuery = q.Encode()
|
r.URL.RawQuery = q.Encode()
|
||||||
return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil)
|
return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil)
|
||||||
}
|
}
|
||||||
@@ -106,7 +109,7 @@ func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth
|
|||||||
}
|
}
|
||||||
cs = res.Changeset
|
cs = res.Changeset
|
||||||
if in.Apply {
|
if in.Apply {
|
||||||
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force})
|
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force, AcceptRisk: in.AcceptRisk})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeErr(w, r, err, out)
|
s.writeErr(w, r, err, out)
|
||||||
return nil
|
return nil
|
||||||
@@ -214,8 +217,9 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var in struct {
|
var in struct {
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
Force bool `json:"force"`
|
Force bool `json:"force"`
|
||||||
|
AcceptRisk bool `json:"accept_risk"`
|
||||||
}
|
}
|
||||||
if body, err := readBody(r); err != nil {
|
if body, err := readBody(r); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -224,7 +228,8 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return badRequest("invalid JSON: %v", err)
|
return badRequest("invalid JSON: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force")})
|
res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force"),
|
||||||
|
AcceptRisk: in.AcceptRisk || queryBool(r, "accept_risk")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeErr(w, r, err, res)
|
s.writeErr(w, r, err, res)
|
||||||
return nil
|
return nil
|
||||||
@@ -278,6 +283,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
if p.IsAdmin() {
|
if p.IsAdmin() {
|
||||||
resp.Backup = s.backupStatus(r.Context(), now)
|
resp.Backup = s.backupStatus(r.Context(), now)
|
||||||
resp.TOTP = s.totpOverview(r.Context())
|
resp.TOTP = s.totpOverview(r.Context())
|
||||||
|
resp.Certificates = s.certSummary(r.Context())
|
||||||
}
|
}
|
||||||
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"jarl/internal/catalog"
|
"jarl/internal/catalog"
|
||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/iprange"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/render"
|
"jarl/internal/render"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
@@ -554,6 +555,42 @@ func (s *Server) catalogRRTypes(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogRanges: GET /catalog/ranges?r=A-B,C-D shows the prefixes each
|
||||||
|
// address range is written to named.conf as (the web UI shows them while
|
||||||
|
// you type). Text that isn't a range is left out; a range with a mistake
|
||||||
|
// comes back with its error.
|
||||||
|
func (s *Server) catalogRanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
type rangeInfo struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
Range string `json:"range,omitempty"`
|
||||||
|
Prefixes []string `json:"prefixes,omitempty"`
|
||||||
|
Size string `json:"size,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
out := []rangeInfo{}
|
||||||
|
for _, in := range strings.Split(r.URL.Query().Get("r"), ",") {
|
||||||
|
in = strings.TrimSpace(in)
|
||||||
|
if in == "" || len(out) >= 100 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rg, ok, err := iprange.Parse(strings.TrimSpace(strings.TrimPrefix(in, "!")))
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
continue
|
||||||
|
case err != nil:
|
||||||
|
out = append(out, rangeInfo{Input: in, Error: err.Error()})
|
||||||
|
default:
|
||||||
|
ri := rangeInfo{Input: in, Range: rg.String(), Size: rg.SizeText()}
|
||||||
|
for _, pf := range rg.Prefixes() {
|
||||||
|
ri.Prefixes = append(ri.Prefixes, pf.String())
|
||||||
|
}
|
||||||
|
out = append(out, ri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
roles := map[string][]string{}
|
roles := map[string][]string{}
|
||||||
for name, gs := range auth.RoleTemplates {
|
for name, gs := range auth.RoleTemplates {
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/encdns"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/nscheck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNS over TLS and HTTPS (docs/encrypted-dns.md).
|
||||||
|
|
||||||
|
type encAddress struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
HasZone bool `json:"has_zone,omitempty"` // has a zone for the host name
|
||||||
|
}
|
||||||
|
|
||||||
|
type encCert struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
DNS bool `json:"dns"` // has the dns use
|
||||||
|
Status certs.Status `json:"status"`
|
||||||
|
Obtained bool `json:"obtained"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encStatus struct {
|
||||||
|
encdns.Status
|
||||||
|
// Hostname is the name clients connect to (the certificate's first).
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
// DoTHost and DoHURL are what to give clients.
|
||||||
|
DoTHost string `json:"dot_host,omitempty"`
|
||||||
|
DoHURL string `json:"doh_url,omitempty"`
|
||||||
|
// For the setup form.
|
||||||
|
Addresses []encAddress `json:"addresses"`
|
||||||
|
Views []encView `json:"views"`
|
||||||
|
Certificates []encCert `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) encReady(p *auth.Principal, write bool) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions, Write: write}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localAddrs returns this host's addresses (a test may replace it).
|
||||||
|
var localAddrs = nscheck.LocalAddrs
|
||||||
|
|
||||||
|
// encryptedDNS: GET /encrypted-dns.
|
||||||
|
func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := s.cfg.Certs
|
||||||
|
out := encStatus{Status: encdns.GetStatus(st, m.FilesDir("")), Addresses: []encAddress{}, Views: []encView{}, Certificates: []encCert{}}
|
||||||
|
all, err := m.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
out.Certificates = append(out.Certificates, encCert{Name: c.Name, Names: c.Names, Domains: c.Domains, DNS: c.HasUse(model.CertUseDNS),
|
||||||
|
Status: m.StatusOf(c), Obtained: c.NotAfter != nil})
|
||||||
|
if c.Name == out.Certificate {
|
||||||
|
for _, n := range append(c.Names, c.Domains...) {
|
||||||
|
if !strings.HasPrefix(n, "*.") {
|
||||||
|
out.Hostname = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Hostname != "" {
|
||||||
|
if len(out.DoT) > 0 {
|
||||||
|
out.DoTHost = out.Hostname
|
||||||
|
if out.DoT[0].Port != encdns.DoTPort {
|
||||||
|
out.DoTHost += ":" + strconv.Itoa(out.DoT[0].Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out.DoH) > 0 {
|
||||||
|
out.DoHURL = "https://" + out.Hostname
|
||||||
|
if out.DoH[0].Port != 443 {
|
||||||
|
out.DoHURL += ":" + strconv.Itoa(out.DoH[0].Port)
|
||||||
|
}
|
||||||
|
out.DoHURL += "/dns-query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[netip.Addr]bool{}
|
||||||
|
for _, a := range localAddrs() {
|
||||||
|
a = a.Unmap()
|
||||||
|
if seen[a] || a.IsLinkLocalUnicast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[a] = true
|
||||||
|
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
|
||||||
|
}
|
||||||
|
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
|
||||||
|
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".")
|
||||||
|
if host == "" {
|
||||||
|
host = out.Hostname
|
||||||
|
}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
if x.View == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := encView{Name: x.View, Public: x.Public}
|
||||||
|
if host != "" {
|
||||||
|
if vv := st.ViewByName(x.View); vv != nil {
|
||||||
|
for _, z := range st.ZonesInView(vv.ID) {
|
||||||
|
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
|
||||||
|
v.HasZone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Views = append(out.Views, v)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSCheck: POST /encrypted-dns/check {"addresses", "dot", "doh",
|
||||||
|
// "doh_port"} says what already listens where the listeners would go.
|
||||||
|
func (s *Server) encryptedDNSCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in encdns.Setup
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.DoHPort == 0 {
|
||||||
|
in.DoHPort = 443
|
||||||
|
}
|
||||||
|
if in.DoTPort == 0 {
|
||||||
|
in.DoTPort = encdns.DoTPort
|
||||||
|
}
|
||||||
|
var ports []int
|
||||||
|
if in.DoT {
|
||||||
|
ports = append(ports, in.DoTPort)
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
ports = append(ports, in.DoHPort)
|
||||||
|
}
|
||||||
|
conflicts, err := encdns.PortCheck(st, in.Addresses, ports, s.cfg.Listen)
|
||||||
|
out := map[string]any{"conflicts": conflicts}
|
||||||
|
if err != nil {
|
||||||
|
out["error"] = "can't see which ports are in use: " + err.Error()
|
||||||
|
}
|
||||||
|
if conflicts == nil {
|
||||||
|
out["conflicts"] = []encdns.Conflict{}
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
out["free_doh_port"] = encdns.FreeDoHPort(st, in.Addresses, s.cfg.Listen)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSSetup: POST /encrypted-dns stages (or applies) the setup.
|
||||||
|
func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in encdns.Setup
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Get(r.Context(), in.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !c.HasUse(model.CertUseDNS):
|
||||||
|
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
|
||||||
|
case c.NotAfter == nil:
|
||||||
|
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
|
||||||
|
case in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}):
|
||||||
|
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname)
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
|
||||||
|
if err != nil {
|
||||||
|
return badRequest("%v", err)
|
||||||
|
}
|
||||||
|
what := []string{}
|
||||||
|
if in.DoT {
|
||||||
|
what = append(what, "DNS over TLS")
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
what = append(what, "DNS over HTTPS")
|
||||||
|
}
|
||||||
|
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSDisable: DELETE /encrypted-dns.
|
||||||
|
func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs := encdns.Disable(st)
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
return conflict("encrypted DNS isn't set up")
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/stats"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exposureFinding is an exposure problem with the fix Jarl can stage for
|
||||||
|
// it ("" when there is none).
|
||||||
|
type exposureFinding struct {
|
||||||
|
validate.Problem
|
||||||
|
Fix string `json:"fix,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type exposureScope struct {
|
||||||
|
validate.Exposure
|
||||||
|
RateLimit string `json:"rate_limit"` // off, log-only or enforcing
|
||||||
|
Findings []exposureFinding `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type exposureReport struct {
|
||||||
|
Scopes []exposureScope `json:"scopes"`
|
||||||
|
// Server holds server-wide findings (version, host name, management
|
||||||
|
// interfaces).
|
||||||
|
Server []exposureFinding `json:"server"`
|
||||||
|
// RateLimiting is what rate limiting limited, or would have, in the
|
||||||
|
// last 24 hours (with query statistics on).
|
||||||
|
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateLimitActivity struct {
|
||||||
|
Since string `json:"since"`
|
||||||
|
Counts []stats.Entry `json:"counts"` // drop, slip, would-drop, would-slip
|
||||||
|
Networks []stats.Entry `json:"networks"` // client networks, most limited first
|
||||||
|
Names []stats.Entry `json:"names"` // "name TYPE"
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposure: GET /exposure reports which views are reachable from the
|
||||||
|
// internet and what the exposure checks found (docs/exposure.md).
|
||||||
|
func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
problems := s.eng.Validate(st)
|
||||||
|
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
|
||||||
|
finding := func(pr validate.Problem, viewID int64) exposureFinding {
|
||||||
|
_, desc := engine.ExposureFix(st, viewID, pr.Code)
|
||||||
|
return exposureFinding{Problem: pr, Fix: desc}
|
||||||
|
}
|
||||||
|
scopes := map[string]int{}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
sc := exposureScope{Exposure: x, RateLimit: "off", Findings: []exposureFinding{}}
|
||||||
|
v := st.Views[x.ViewID]
|
||||||
|
o := st.FindOption(model.ScopeOptions, 0, "rate-limit")
|
||||||
|
if v != nil {
|
||||||
|
if vo := st.FindOption(model.ScopeView, v.ID, "rate-limit"); vo != nil {
|
||||||
|
o = vo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o != nil {
|
||||||
|
sc.RateLimit = "enforcing"
|
||||||
|
if validate.LogOnly(o) {
|
||||||
|
sc.RateLimit = "log-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scopes[x.View] = len(out.Scopes)
|
||||||
|
out.Scopes = append(out.Scopes, sc)
|
||||||
|
}
|
||||||
|
for _, pr := range problems {
|
||||||
|
if !validate.Ackable(pr.Code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pr.ObjectType == "view" || pr.ObjectType == "zone" {
|
||||||
|
if i, ok := scopes[pr.View]; ok {
|
||||||
|
out.Scopes[i].Findings = append(out.Scopes[i].Findings, finding(pr, out.Scopes[i].ViewID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Server = append(out.Server, finding(pr, 0))
|
||||||
|
}
|
||||||
|
if s.statsQ != nil && p.Can(auth.Action{Resource: auth.ResStats}) {
|
||||||
|
ctx, since := r.Context(), 24*time.Hour
|
||||||
|
a := &rateLimitActivity{Since: "24h"}
|
||||||
|
a.Counts, _ = s.statsQ.Breakdown(ctx, "rrl", since)
|
||||||
|
a.Networks, _ = s.statsQ.Top(ctx, stats.KindRateLimited, since, 10)
|
||||||
|
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
|
||||||
|
out.RateLimiting = a
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposureFix: POST /exposure/fix {"view_id": 3, "code": "..."} stages (or
|
||||||
|
// applies) the fix for a finding, like any other change.
|
||||||
|
func (s *Server) exposureFix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
var in struct {
|
||||||
|
ViewID int64 `json:"view_id"`
|
||||||
|
View string `json:"view"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.View != "" {
|
||||||
|
v := st.ViewByName(in.View)
|
||||||
|
if v == nil {
|
||||||
|
return notFound("no view %s", in.View)
|
||||||
|
}
|
||||||
|
in.ViewID = v.ID
|
||||||
|
}
|
||||||
|
reqs, desc := engine.ExposureFix(st, in.ViewID, in.Code)
|
||||||
|
if reqs == nil {
|
||||||
|
return badRequest("there is no automatic fix for %s: what to allow is yours to decide; change the option yourself, or acknowledge the finding", in.Code)
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, desc, reqs, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposureAck: POST /exposure/ack {"view": "external", "code": "...",
|
||||||
|
// "reason": "..."} accepts a finding (unack takes it back).
|
||||||
|
func (s *Server) exposureAck(ack bool) handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
var in engine.AckInput
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
op := engine.OpExposureUnack
|
||||||
|
if ack {
|
||||||
|
op = engine.OpExposureAck
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,14 +15,24 @@ import (
|
|||||||
"jarl/internal/stats"
|
"jarl/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// statsQuerier checks permission and that statistics are enabled.
|
// statsQuerier checks permission and that statistics are enabled. With
|
||||||
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
|
// ?views=internal,vps the figures are for those views only.
|
||||||
|
func (s *Server) statsQuerier(r *http.Request, p *auth.Principal) (*stats.Querier, error) {
|
||||||
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
|
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if s.statsQ == nil {
|
if s.statsQ == nil {
|
||||||
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
|
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
|
||||||
}
|
}
|
||||||
|
var views []string
|
||||||
|
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
views = append(views, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(views) > 0 {
|
||||||
|
return s.statsQ.ForViews(views), nil
|
||||||
|
}
|
||||||
return s.statsQ, nil
|
return s.statsQ, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +57,7 @@ func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,7 +77,7 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,7 +104,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -124,7 +135,7 @@ func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.P
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -143,7 +154,7 @@ func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -152,6 +163,9 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
|
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
|
||||||
|
if errors.Is(err, stats.ErrNotPerView) {
|
||||||
|
return &httpError{http.StatusBadRequest, "not_per_view", r.URL.Query().Get("group") + ": " + err.Error()}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return badRequest("%v", err)
|
return badRequest("%v", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +261,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
|
|||||||
// limits (rndc status), and the space left on the file systems Jarl and
|
// limits (rndc status), and the space left on the file systems Jarl and
|
||||||
// named write to.
|
// named write to.
|
||||||
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if _, err := s.statsQuerier(p); err != nil {
|
if _, err := s.statsQuerier(r, p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out := map[string]any{"server": s.cfg.Stats.Server()}
|
out := map[string]any{"server": s.cfg.Stats.Server()}
|
||||||
|
|||||||
@@ -157,6 +157,37 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zoneLink: POST /zones/{zone}/link {"with": ["external"]} links the zone
|
||||||
|
// with the zones of the same name in those views (default: every view
|
||||||
|
// that has one). zoneUnlink: POST /zones/{zone}/unlink.
|
||||||
|
func (s *Server) zoneLink(link bool) handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
z, err := s.zoneByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in := engine.LinkInput{ZoneID: z.ID}
|
||||||
|
op := engine.OpZoneUnlink
|
||||||
|
if link {
|
||||||
|
op = engine.OpZoneLink
|
||||||
|
var body struct {
|
||||||
|
With []string `json:"with"`
|
||||||
|
}
|
||||||
|
if r.ContentLength != 0 {
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.With = body.With
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
st, err := s.state(r)
|
st, err := s.state(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -462,11 +493,12 @@ func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
||||||
Data []string `json:"data"`
|
Data []string `json:"data"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
Local *bool `json:"local,omitempty"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &in); err != nil {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment})
|
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment, Local: in.Local})
|
||||||
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
||||||
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
||||||
z := st.Zones[zid]
|
z := st.Zones[zid]
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/dnssec"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/render"
|
||||||
|
"jarl/internal/stats"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||||
|
type Metrics struct {
|
||||||
|
Enabled bool
|
||||||
|
// Listen are extra addresses serving only /metrics (plain HTTP).
|
||||||
|
Listen []string
|
||||||
|
// Allow may scrape without a token; Token, when set, lets anyone who
|
||||||
|
// sends it scrape.
|
||||||
|
Allow []netip.Prefix
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsStateTTL is how long the Jarl state part of /metrics is reused:
|
||||||
|
// it runs rndc status, validation and the drift check, too much for every
|
||||||
|
// scrape when several Prometheus servers scrape often.
|
||||||
|
const metricsStateTTL = 15 * time.Second
|
||||||
|
|
||||||
|
// metricsCache holds the last Jarl state part of /metrics.
|
||||||
|
type metricsCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
at time.Time
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricsContentType = "text/plain; version=0.0.4; charset=utf-8"
|
||||||
|
|
||||||
|
// metrics serves GET /metrics in Prometheus's text format.
|
||||||
|
func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.cfg.Metrics.Enabled {
|
||||||
|
http.Error(w, "metrics are disabled; set metrics.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/metrics.md)", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code, msg := s.metricsAccess(r); code != 0 {
|
||||||
|
if code == http.StatusUnauthorized {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Bearer realm="jarl metrics"`)
|
||||||
|
}
|
||||||
|
http.Error(w, msg, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.Write(s.stateMetrics(r.Context()))
|
||||||
|
if c := s.cfg.Stats; c != nil {
|
||||||
|
pw := &promWriter{}
|
||||||
|
statsMetrics(pw, c.Totals(), c.Server(), c.Status())
|
||||||
|
pw.writeTo(&buf)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", metricsContentType)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsAccess decides whether a scrape is allowed: from an address in
|
||||||
|
// metrics.allow, with metrics.token, or on the local socket from root or
|
||||||
|
// jarld's own user. It returns 0 when allowed, or the status and message
|
||||||
|
// to refuse with.
|
||||||
|
func (s *Server) metricsAccess(r *http.Request) (int, string) {
|
||||||
|
m := s.cfg.Metrics
|
||||||
|
ip := s.clientIP(r)
|
||||||
|
if h := r.Header.Get("Authorization"); h != "" {
|
||||||
|
scheme, tok, _ := strings.Cut(h, " ")
|
||||||
|
if m.Token == "" || !strings.EqualFold(scheme, "Bearer") {
|
||||||
|
return http.StatusUnauthorized, "this server takes no token for /metrics (metrics.token is not set)"
|
||||||
|
}
|
||||||
|
if s.limiter.blocked("ip:"+ip, failsPerIP) {
|
||||||
|
return http.StatusTooManyRequests, "too many failed attempts; try again later"
|
||||||
|
}
|
||||||
|
got, want := sha256.Sum256([]byte(strings.TrimSpace(tok))), sha256.Sum256([]byte(m.Token))
|
||||||
|
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||||
|
s.limiter.fail("ip:" + ip)
|
||||||
|
s.log.Warn("metrics: wrong token", "ip", ip)
|
||||||
|
return http.StatusUnauthorized, "wrong token for /metrics"
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
if pr := peerOf(r.Context()); pr != nil {
|
||||||
|
if trustedUID(pr.UID) {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
return http.StatusForbidden, "on the local socket, only root and jarld's own user may read /metrics without metrics.token"
|
||||||
|
}
|
||||||
|
if a, err := netip.ParseAddr(ip); err == nil {
|
||||||
|
a = a.Unmap().WithZone("")
|
||||||
|
for _, p := range m.Allow {
|
||||||
|
if p.Contains(a) {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Token != "" {
|
||||||
|
return http.StatusUnauthorized, "send metrics.token as 'Authorization: Bearer TOKEN', or add " + ip + " to metrics.allow in jarl.yaml"
|
||||||
|
}
|
||||||
|
return http.StatusForbidden, ip + " may not read /metrics: add it to metrics.allow in jarl.yaml (or set metrics.token)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveMetricsListeners serves /metrics alone on metrics.listen.
|
||||||
|
func (s *Server) serveMetricsListeners(errc chan error) error {
|
||||||
|
if !s.cfg.Metrics.Enabled || len(s.cfg.Metrics.Listen) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /metrics", s.metrics)
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "this address serves only /metrics", http.StatusNotFound)
|
||||||
|
})
|
||||||
|
h := s.recoverer(s.logRequests(mux))
|
||||||
|
for _, addr := range s.cfg.Metrics.Listen {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("metrics.listen %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: time.Minute, IdleTimeout: 2 * time.Minute,
|
||||||
|
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn)}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.srvs = append(s.srvs, srv)
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.log.Info("serving metrics", "url", "http://"+ln.Addr().String()+"/metrics")
|
||||||
|
go func() {
|
||||||
|
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errc <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateMetrics renders Jarl's own state (cached for metricsStateTTL).
|
||||||
|
func (s *Server) stateMetrics(ctx context.Context) []byte {
|
||||||
|
c := &s.metricsCache
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
if c.body != nil && now.Sub(c.at) < metricsStateTTL {
|
||||||
|
return c.body
|
||||||
|
}
|
||||||
|
pw := &promWriter{}
|
||||||
|
s.collectState(ctx, pw, now)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
pw.writeTo(&buf)
|
||||||
|
c.body, c.at = buf.Bytes(), now
|
||||||
|
return c.body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time) {
|
||||||
|
pw.gauge("jarl_build_info", "Jarl's version (always 1).", 1, "version", s.cfg.Version)
|
||||||
|
pw.gauge("jarl_start_time_seconds", "When jarld started (Unix time).", unix(s.started))
|
||||||
|
ok := 1.0
|
||||||
|
defer func() {
|
||||||
|
pw.gauge("jarl_metrics_state_ok", "1 when Jarl's state could be read for this scrape.", ok)
|
||||||
|
}()
|
||||||
|
|
||||||
|
st, err := s.eng.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("metrics: reading the status failed", "err", err)
|
||||||
|
ok = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_adopted", "1 when named loads Jarl's configuration.", b2f(st.Adopted))
|
||||||
|
if st.Named != nil || st.NamedError != "" {
|
||||||
|
pw.gauge("jarl_named_up", "1 when named answers rndc status.", b2f(st.Named != nil && st.Named.Running))
|
||||||
|
}
|
||||||
|
if n := st.Named; n != nil {
|
||||||
|
if v := fractions(n.RecursiveClients); len(v) == 3 {
|
||||||
|
pw.gauge("jarl_named_recursive_clients", "Recursive clients named is serving now (rndc status).", v[0])
|
||||||
|
pw.gauge("jarl_named_recursive_clients_soft_limit", "Recursive clients at which named starts dropping the oldest queries.", v[1])
|
||||||
|
pw.gauge("jarl_named_recursive_clients_limit", "Most recursive clients named serves at once (recursive-clients).", v[2])
|
||||||
|
}
|
||||||
|
if v := fractions(n.TCPClients); len(v) == 2 {
|
||||||
|
pw.gauge("jarl_named_tcp_clients", "TCP clients named is serving now (rndc status).", v[0])
|
||||||
|
pw.gauge("jarl_named_tcp_clients_limit", "Most TCP clients named serves at once (tcp-clients).", v[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_open_changesets", "Changesets with staged changes that are not applied yet.", float64(st.OpenChangesets))
|
||||||
|
if st.LastApplied != nil && st.LastApplied.AppliedAt != nil {
|
||||||
|
pw.gauge("jarl_last_apply_timestamp_seconds", "When the last changeset was applied (Unix time).", unix(*st.LastApplied.AppliedAt))
|
||||||
|
}
|
||||||
|
sev := map[validate.Severity]int{validate.Error: 0, validate.Warning: 0, validate.Info: 0}
|
||||||
|
for _, p := range st.Problems {
|
||||||
|
sev[p.Severity]++
|
||||||
|
}
|
||||||
|
for _, k := range []validate.Severity{validate.Error, validate.Warning, validate.Info} {
|
||||||
|
pw.gauge("jarl_problems", "Validation problems in the current configuration, as on the dashboard.", float64(sev[k]), "severity", string(k))
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_drifted_files", "Files Jarl manages that were changed or removed outside Jarl.", float64(len(st.Drift)))
|
||||||
|
for _, k := range sortedKeys(st.Counts) {
|
||||||
|
pw.gauge("jarl_objects", "Objects in Jarl's configuration.", float64(st.Counts[k]), "kind", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cfg.BackupDir != "" {
|
||||||
|
bs := s.backupStatus(ctx, now)
|
||||||
|
if bs.LastAt != nil {
|
||||||
|
pw.gauge("jarl_backup_last_success_timestamp_seconds", "When the newest backup was made by 'jarld backup' (Unix time).", unix(*bs.LastAt))
|
||||||
|
pw.gauge("jarl_backup_last_size_bytes", "Size of the newest backup.", float64(bs.LastSize))
|
||||||
|
}
|
||||||
|
if bs.LastErrorAt != nil {
|
||||||
|
pw.gauge("jarl_backup_last_failure_timestamp_seconds", "When a backup last failed, if after the newest backup (Unix time).", unix(*bs.LastErrorAt))
|
||||||
|
}
|
||||||
|
if s.cfg.BackupWarnAfter > 0 {
|
||||||
|
pw.gauge("jarl_backup_stale", "1 when the newest backup is older than backup.warn_after.", b2f(bs.Stale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := s.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ok = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(state.Blocklists) > 0 || len(state.BlockRules) > 0 {
|
||||||
|
pw.gauge("jarl_blocking_paused", "1 while blocking is paused.", b2f(state.Settings.BlockingPaused()))
|
||||||
|
if t := state.Settings.BlockingPausedUntil; t != nil {
|
||||||
|
pw.gauge("jarl_blocking_paused_until_timestamp_seconds", "When paused blocking resumes (Unix time).", unix(*t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statuses, _ := s.store.BlocklistStatuses(ctx)
|
||||||
|
for _, b := range state.SortedBlocklists() {
|
||||||
|
pw.gauge("jarl_blocklist_enabled", "1 for blocklists that are switched on.", b2f(!b.Disabled), "list", b.Name)
|
||||||
|
bs := statuses[b.ID]
|
||||||
|
if bs == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_blocklist_entries", "Domains in the blocklist's last good download.", float64(bs.Entries), "list", b.Name)
|
||||||
|
pw.gauge("jarl_blocklist_failing", "1 when the last download or check of an enabled blocklist failed.", b2f(bs.Error != "" && !b.Disabled), "list", b.Name)
|
||||||
|
if bs.CheckedAt != nil {
|
||||||
|
pw.gauge("jarl_blocklist_last_check_timestamp_seconds", "When the blocklist was last checked for changes (Unix time).", unix(*bs.CheckedAt), "list", b.Name)
|
||||||
|
}
|
||||||
|
if bs.UpdatedAt != nil {
|
||||||
|
pw.gauge("jarl_blocklist_last_change_timestamp_seconds", "When the blocklist's content last changed (Unix time).", unix(*bs.UpdatedAt), "list", b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zones := make([]*model.Zone, 0, len(state.Zones))
|
||||||
|
for _, z := range state.Zones {
|
||||||
|
if z.DNSSECPolicy != "" && !z.Disabled {
|
||||||
|
zones = append(zones, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(zones, func(a, b *model.Zone) int {
|
||||||
|
return strings.Compare(a.Name+" "+state.ViewName(a), b.Name+" "+state.ViewName(b))
|
||||||
|
})
|
||||||
|
for _, z := range zones {
|
||||||
|
keys, err := dnssec.Keys(render.KeyDir(state, z, st.Paths), z.Name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ph := dnssec.Assess(z.DNSSECPolicy, keys)
|
||||||
|
pw.gauge("jarl_dnssec_phase", "The DNSSEC phase of each signed zone (always 1; see docs/dnssec.md). ready: add the DS at the parent; removing_ds: remove it.", 1,
|
||||||
|
"zone", engine.Display(z.Name), "view", state.ViewName(z), "phase", ph.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cfg.Certs != nil {
|
||||||
|
if list, err := s.cfg.Certs.List(ctx); err == nil {
|
||||||
|
for _, c := range list {
|
||||||
|
if c.NotAfter != nil {
|
||||||
|
pw.gauge("jarl_certificate_expiry_timestamp_seconds", "When each certificate Jarl manages expires (Unix time; docs/certificates.md).", unix(*c.NotAfter), "certificate", c.Name, "source", c.Source)
|
||||||
|
}
|
||||||
|
st := s.cfg.Certs.StatusOf(c)
|
||||||
|
pw.gauge("jarl_certificate_failing", "1 when obtaining or reading a certificate failed the last time.", b2f(st.State == "failing" || st.State == "expired"), "certificate", c.Name, "source", c.Source)
|
||||||
|
if c.Source == model.CertSourceACME {
|
||||||
|
pw.gauge("jarl_certificate_renewal_failures", "Failed attempts to obtain the certificate since the last success.", float64(c.Failures), "certificate", c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
||||||
|
for _, t := range tokens {
|
||||||
|
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
||||||
|
pw.gauge("jarl_token_expiry_timestamp_seconds", "When each API token with an expiry date expires (Unix time).", unix(*t.ExpiresAt),
|
||||||
|
"id", strconv.FormatInt(t.ID, 10), "user", t.Username, "token", t.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// counterMetrics maps the statistics counters (docs/stats.md) to metrics:
|
||||||
|
// a key "PREFIX:VALUE" becomes metric{label="VALUE"}, a key without a
|
||||||
|
// colon a metric without labels.
|
||||||
|
var counterMetrics = map[string]struct{ name, label, help string }{
|
||||||
|
"queries": {"jarl_queries_total", "", "Queries named received (statistics channel)."},
|
||||||
|
"recursion": {"jarl_recursion_queries_total", "", "Queries that caused recursion."},
|
||||||
|
"dropped": {"jarl_dropped_queries_total", "", "Queries named dropped."},
|
||||||
|
"duplicate": {"jarl_duplicate_queries_total", "", "Duplicate queries named ignored."},
|
||||||
|
"rcode": {"jarl_responses_by_rcode_total", "rcode", "Responses by response code."},
|
||||||
|
"qtype": {"jarl_queries_by_type_total", "type", "Queries by record type."},
|
||||||
|
"answer": {"jarl_answers_total", "source", "Answers that were authoritative or not (recursive)."},
|
||||||
|
"transport": {"jarl_queries_by_transport_total", "transport", "Queries over UDP and TCP."},
|
||||||
|
"ratelimit": {"jarl_ratelimit_responses_total", "action", "Responses rate limiting dropped or slipped (truncated)."},
|
||||||
|
"rrl": {"jarl_ratelimit_logged_total", "action", "Rate limited responses from named's log: drop and slip, or would-drop and would-slip in log-only mode."},
|
||||||
|
"rtt": {"jarl_upstream_responses_by_rtt_total", "rtt", "Responses from other servers during recursion, by round-trip time."},
|
||||||
|
"dnssec": {"jarl_dnssec_validations_total", "result", "DNSSEC validations of recursive answers by result."},
|
||||||
|
"cache": {"jarl_cache_lookups_total", "result", "Cache lookups that hit or missed."},
|
||||||
|
"responses": {"jarl_logged_responses_total", "", "Responses read from named's response log (without stats.exclude_clients)."},
|
||||||
|
"view": {"jarl_responses_by_view_total", "view", "Logged responses by view."},
|
||||||
|
"zone": {"jarl_responses_by_zone_total", "zone", "Logged responses by local zone (\"-\": names in no local zone)."},
|
||||||
|
"blocked": {"jarl_blocked_queries_total", "", "Queries blocking answered."},
|
||||||
|
"blocklist": {"jarl_blocked_by_list_total", "list", "Blocked queries by blocklist (\"rules\": Jarl's own rules)."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsMetrics adds the query statistics: counters since jarld started,
|
||||||
|
// and named's process and server gauges.
|
||||||
|
func statsMetrics(pw *promWriter, totals map[string]int64, srv *stats.Server, st stats.Status) {
|
||||||
|
if _, ok := totals["queries"]; !ok {
|
||||||
|
totals["queries"] = 0
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(totals) {
|
||||||
|
prefix, value, labeled := strings.Cut(k, ":")
|
||||||
|
m, ok := counterMetrics[prefix]
|
||||||
|
if !ok || labeled != (m.label != "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if labeled {
|
||||||
|
pw.counter(m.name, m.help, float64(totals[k]), m.label, value)
|
||||||
|
} else {
|
||||||
|
pw.counter(m.name, m.help, float64(totals[k]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_stats_poll_ok", "1 when the last poll of named's statistics channel worked.", b2f(st.PollError == ""))
|
||||||
|
if st.LastPoll != nil {
|
||||||
|
pw.gauge("jarl_stats_last_poll_timestamp_seconds", "When named's statistics channel was last read (Unix time).", unix(*st.LastPoll))
|
||||||
|
}
|
||||||
|
if st.QueryLog {
|
||||||
|
pw.counter("jarl_stats_log_lines_total", "Lines read from named's response log.", float64(st.LinesRead))
|
||||||
|
if st.LastLine != nil {
|
||||||
|
pw.gauge("jarl_stats_last_log_line_timestamp_seconds", "Time of the last response log line read (Unix time).", unix(*st.LastLine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.gauge("jarl_stats_warnings", "Warnings about statistics collection, as on the Stats page.", float64(len(st.Warnings)))
|
||||||
|
if srv == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, srv.BootTime); err == nil {
|
||||||
|
pw.gauge("jarl_named_start_time_seconds", "When named started (Unix time).", unix(t))
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, srv.ConfigTime); err == nil {
|
||||||
|
pw.gauge("jarl_named_config_time_seconds", "When named last loaded its configuration (Unix time).", unix(t))
|
||||||
|
}
|
||||||
|
if srv.PID != 0 {
|
||||||
|
pw.gauge("jarl_named_resident_memory_bytes", "named's resident memory (/proc).", float64(srv.RSS))
|
||||||
|
pw.gauge("jarl_named_threads", "named's threads (/proc).", float64(srv.Threads))
|
||||||
|
pw.counter("jarl_named_cpu_seconds_total", "CPU time named used since it started (/proc).", srv.CPUSeconds)
|
||||||
|
}
|
||||||
|
if srv.Version != "" {
|
||||||
|
pw.gauge("jarl_named_memory_in_use_bytes", "Memory named counts as in use (statistics channel).", float64(srv.MemInUse))
|
||||||
|
pw.gauge("jarl_named_cache_rrsets", "RRsets in named's caches, all views.", float64(srv.CacheRRsets))
|
||||||
|
pw.gauge("jarl_named_cache_memory_bytes", "Memory named's caches use, all views.", float64(srv.CacheMem))
|
||||||
|
pw.gauge("jarl_named_sockets", "Open sockets.", float64(srv.SocketsUDP), "proto", "udp")
|
||||||
|
pw.gauge("jarl_named_sockets", "Open sockets.", float64(srv.SocketsTCP), "proto", "tcp")
|
||||||
|
pw.gauge("jarl_named_recursive_clients_highwater", "Most recursive clients at once since named started.", float64(srv.RecursionHighwater))
|
||||||
|
pw.gauge("jarl_named_tcp_clients_highwater", "Most TCP clients at once since named started.", float64(srv.TCPHighwater))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// promWriter collects metrics and writes them in Prometheus's text format,
|
||||||
|
// each metric's samples together under one HELP and TYPE.
|
||||||
|
type promWriter struct {
|
||||||
|
order []string
|
||||||
|
fams map[string]*promFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
type promFamily struct {
|
||||||
|
typ, help string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promWriter) gauge(name, help string, v float64, labels ...string) {
|
||||||
|
w.add(name, "gauge", help, v, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promWriter) counter(name, help string, v float64, labels ...string) {
|
||||||
|
w.add(name, "counter", help, v, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promWriter) add(name, typ, help string, v float64, labels []string) {
|
||||||
|
if w.fams == nil {
|
||||||
|
w.fams = map[string]*promFamily{}
|
||||||
|
}
|
||||||
|
f := w.fams[name]
|
||||||
|
if f == nil {
|
||||||
|
f = &promFamily{typ: typ, help: help}
|
||||||
|
w.fams[name] = f
|
||||||
|
w.order = append(w.order, name)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(name)
|
||||||
|
if len(labels) > 0 {
|
||||||
|
b.WriteByte('{')
|
||||||
|
for i := 0; i+1 < len(labels); i += 2 {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
b.WriteString(labels[i])
|
||||||
|
b.WriteString(`="`)
|
||||||
|
b.WriteString(labelEscaper.Replace(labels[i+1]))
|
||||||
|
b.WriteByte('"')
|
||||||
|
}
|
||||||
|
b.WriteByte('}')
|
||||||
|
}
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(formatFloat(v))
|
||||||
|
f.lines = append(f.lines, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promWriter) writeTo(buf *bytes.Buffer) {
|
||||||
|
for _, name := range w.order {
|
||||||
|
f := w.fams[name]
|
||||||
|
fmt.Fprintf(buf, "# HELP %s %s\n# TYPE %s %s\n", name, helpEscaper.Replace(f.help), name, f.typ)
|
||||||
|
for _, l := range f.lines {
|
||||||
|
buf.WriteString(l)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
labelEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`)
|
||||||
|
helpEscaper = strings.NewReplacer(`\`, `\\`, "\n", `\n`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func formatFloat(v float64) string {
|
||||||
|
switch {
|
||||||
|
case math.IsInf(v, 1):
|
||||||
|
return "+Inf"
|
||||||
|
case math.IsInf(v, -1):
|
||||||
|
return "-Inf"
|
||||||
|
case math.IsNaN(v):
|
||||||
|
return "NaN"
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unix(t time.Time) float64 { return float64(t.UnixMilli()) / 1000 }
|
||||||
|
|
||||||
|
func b2f(b bool) float64 {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// fractions parses rndc status values such as "0/900/1000".
|
||||||
|
func fractions(s string) []float64 {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []float64
|
||||||
|
for _, p := range strings.Split(s, "/") {
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys[V any](m map[string]V) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scrape GETs /metrics with an optional bearer token.
|
||||||
|
func (e *apiEnv) scrape(token string) (string, int) {
|
||||||
|
e.t.Helper()
|
||||||
|
r, _ := http.NewRequest("GET", e.ts.URL+"/metrics", nil)
|
||||||
|
if token != "" {
|
||||||
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return string(b), resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
promSampleRe = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*(\{[a-zA-Z_][a-zA-Z0-9_]*="(?:[^"\\]|\\.)*"(,[a-zA-Z_][a-zA-Z0-9_]*="(?:[^"\\]|\\.)*")*\})? -?[0-9.eE+-]+$`)
|
||||||
|
promTypeRe = regexp.MustCompile(`^# TYPE ([a-zA-Z_:][a-zA-Z0-9_:]*) (gauge|counter)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkExposition checks Prometheus's text format: valid lines, and each
|
||||||
|
// metric's samples together under a single TYPE.
|
||||||
|
func checkExposition(t *testing.T, body string) {
|
||||||
|
t.Helper()
|
||||||
|
typed := map[string]bool{}
|
||||||
|
cur := ""
|
||||||
|
for _, l := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(l, "# HELP "):
|
||||||
|
case strings.HasPrefix(l, "# TYPE "):
|
||||||
|
m := promTypeRe.FindStringSubmatch(l)
|
||||||
|
if m == nil {
|
||||||
|
t.Errorf("bad TYPE line %q", l)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if typed[m[1]] {
|
||||||
|
t.Errorf("metric %s appears twice", m[1])
|
||||||
|
}
|
||||||
|
typed[m[1]], cur = true, m[1]
|
||||||
|
default:
|
||||||
|
if !promSampleRe.MatchString(l) {
|
||||||
|
t.Errorf("bad sample line %q", l)
|
||||||
|
}
|
||||||
|
if name, _, _ := strings.Cut(strings.SplitN(l, " ", 2)[0], "{"); name != cur {
|
||||||
|
t.Errorf("sample %q is not under its TYPE (%s)", l, cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsAccess(t *testing.T) {
|
||||||
|
loopback := []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
if _, status := e.scrape(""); status != 404 {
|
||||||
|
t.Errorf("disabled = %d, want 404", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: loopback}})
|
||||||
|
body, status := e.scrape("")
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("allowed scrape = %d: %s", status, body)
|
||||||
|
}
|
||||||
|
checkExposition(t, body)
|
||||||
|
for _, want := range []string{`jarl_build_info{version="test"} 1`, `jarl_problems{severity="error"} 0`, `jarl_objects{kind="zones"} 0`, "jarl_open_changesets 0", "jarl_metrics_state_ok 1"} {
|
||||||
|
if !strings.Contains(body, want+"\n") {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "jarl_queries_total") {
|
||||||
|
t.Error("query counters without statistics")
|
||||||
|
}
|
||||||
|
// No token is configured: one that is sent is refused.
|
||||||
|
if _, status := e.scrape("whatever-token-123"); status != 401 {
|
||||||
|
t.Errorf("token without metrics.token = %d, want 401", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}}})
|
||||||
|
if body, status := e.scrape(""); status != 403 || !strings.Contains(body, "metrics.allow") {
|
||||||
|
t.Errorf("not allowed = %d %s", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = "0123456789abcdef-metrics"
|
||||||
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Token: token}})
|
||||||
|
if _, status := e.scrape(""); status != 401 {
|
||||||
|
t.Errorf("no token = %d, want 401", status)
|
||||||
|
}
|
||||||
|
if _, status := e.scrape("wrong-token-0123456789"); status != 401 {
|
||||||
|
t.Errorf("wrong token = %d, want 401", status)
|
||||||
|
}
|
||||||
|
if _, status := e.scrape(token); status != 200 {
|
||||||
|
t.Errorf("right token = %d, want 200", status)
|
||||||
|
}
|
||||||
|
// The API's tokens don't count for /metrics.
|
||||||
|
if _, status := e.scrape(e.token); status != 401 {
|
||||||
|
t.Errorf("API token = %d, want 401", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsState(t *testing.T) {
|
||||||
|
e := newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}})
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||||
|
e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "ads", "url": "https://lists.example/ads.txt"}, 201)
|
||||||
|
e.srv.metricsCache.body = nil // skip the cache
|
||||||
|
body, _ := e.scrape("")
|
||||||
|
checkExposition(t, body)
|
||||||
|
for _, want := range []string{`jarl_objects{kind="zones"} 1`, `jarl_blocklist_enabled{list="ads"} 1`, "jarl_blocking_paused 0"} {
|
||||||
|
if !strings.Contains(body, want+"\n") {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cached: a change shows after metricsStateTTL.
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.org", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||||
|
if body, _ := e.scrape(""); !strings.Contains(body, `jarl_objects{kind="zones"} 1`+"\n") {
|
||||||
|
t.Error("state was not cached")
|
||||||
|
}
|
||||||
|
e.srv.metricsCache.at = time.Now().Add(-metricsStateTTL)
|
||||||
|
if body, _ := e.scrape(""); !strings.Contains(body, `jarl_objects{kind="zones"} 2`+"\n") {
|
||||||
|
t.Errorf("state not refreshed:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsStats(t *testing.T) {
|
||||||
|
ss, err := stats.OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ss.Close()
|
||||||
|
opts := stats.Options{QueryLog: true, LogFile: "/nonexistent", StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour}
|
||||||
|
c := stats.NewCollector(ss, opts, func() map[string][]string { return map[string][]string{"internal": {"example.com"}} }, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
ts := time.Now().Format("02-Jan-2006 15:04:05.000")
|
||||||
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5353 (www.example.com): view internal: response: www.example.com IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)")
|
||||||
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5354 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid")
|
||||||
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5354 (ads.example): view internal: response: ads.example IN A NXDOMAIN 1 0 1 +E(0)K (10.10.0.2)")
|
||||||
|
|
||||||
|
e := newAPIEnvConfig(t, Config{Version: "test", Stats: c, Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}})
|
||||||
|
body, status := e.scrape("")
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("scrape = %d", status)
|
||||||
|
}
|
||||||
|
checkExposition(t, body)
|
||||||
|
for _, want := range []string{
|
||||||
|
"jarl_queries_total 0", "jarl_logged_responses_total 2", `jarl_responses_by_view_total{view="internal"} 2`,
|
||||||
|
`jarl_responses_by_zone_total{zone="example.com"} 1`, `jarl_responses_by_zone_total{zone="-"} 1`,
|
||||||
|
"jarl_blocked_queries_total 1", `jarl_blocked_by_list_total{list="hagezi"} 1`, "jarl_stats_log_lines_total 2",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want+"\n") {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsMetricsMapping(t *testing.T) {
|
||||||
|
pw := &promWriter{}
|
||||||
|
totals := map[string]int64{"queries": 10, "rcode:NOERROR": 8, "rcode:NXDOMAIN": 2, "qtype:A": 10, "ratelimit:slipped": 3,
|
||||||
|
"cache:hits": 4, "rtt:<10ms": 5, "unknown:x": 1, "rcode": 7}
|
||||||
|
srv := &stats.Server{BootTime: "2026-10-07T20:00:00.000Z", Version: "9.20.4", PID: 42, RSS: 1 << 20, CPUSeconds: 1.5, SocketsUDP: 4}
|
||||||
|
statsMetrics(pw, totals, srv, stats.Status{})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
pw.writeTo(&buf)
|
||||||
|
out := buf.String()
|
||||||
|
checkExposition(t, out)
|
||||||
|
for _, want := range []string{
|
||||||
|
"jarl_queries_total 10", `jarl_responses_by_rcode_total{rcode="NXDOMAIN"} 2`, `jarl_ratelimit_responses_total{action="slipped"} 3`,
|
||||||
|
`jarl_upstream_responses_by_rtt_total{rtt="<10ms"} 5`, `jarl_cache_lookups_total{result="hits"} 4`,
|
||||||
|
"jarl_named_start_time_seconds 1791403200", "jarl_named_cpu_seconds_total 1.5", `jarl_named_sockets{proto="udp"} 4`,
|
||||||
|
"jarl_named_resident_memory_bytes 1048576",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want+"\n") {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "unknown") || strings.Contains(out, "jarl_responses_by_rcode_total 7") {
|
||||||
|
t.Errorf("unknown keys exported:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromWriterEscaping(t *testing.T) {
|
||||||
|
pw := &promWriter{}
|
||||||
|
pw.gauge("m", "a \\ help\nline", 1, "l", "a\"b\\c\nd")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
pw.writeTo(&buf)
|
||||||
|
want := "# HELP m a \\\\ help\\nline\n# TYPE m gauge\nm{l=\"a\\\"b\\\\c\\nd\"} 1\n"
|
||||||
|
if buf.String() != want {
|
||||||
|
t.Errorf("got %q, want %q", buf.String(), want)
|
||||||
|
}
|
||||||
|
if got := formatFloat(1759867200.123); got != "1759867200.123" {
|
||||||
|
t.Errorf("formatFloat = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
|||||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||||
}
|
}
|
||||||
out.Registrar = s.registrarFor(z)
|
out.Registrar = s.registrarFor(z)
|
||||||
|
for _, o := range st.LinkedWith(z) {
|
||||||
|
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
||||||
|
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +34,12 @@ func presentRecord(st *model.State, r *model.Record) api.Record {
|
|||||||
out := api.Record{Record: *r}
|
out := api.Record{Record: *r}
|
||||||
if z != nil {
|
if z != nil {
|
||||||
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
|
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
out.Linked = "local"
|
||||||
|
if r.SharedID != 0 {
|
||||||
|
out.Linked = "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
if rr.IsTextType(r.Type) {
|
if rr.IsTextType(r.Type) {
|
||||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||||
if t, ok := rr.TextValue(p.RR); ok {
|
if t, ok := rr.TextValue(p.RR); ok {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogRanges(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
b, status := e.req("GET", "/catalog/ranges?r=10.0.10.101-10.0.10.199,!2001:db8::100-2001:db8::1ff,branch-office,10.0.0.9-10.0.0.1", e.token, nil)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("status %d: %s", status, b)
|
||||||
|
}
|
||||||
|
var out []struct {
|
||||||
|
Input, Range, Size, Error string
|
||||||
|
Prefixes []string
|
||||||
|
}
|
||||||
|
json.Unmarshal(b, &out)
|
||||||
|
if len(out) != 3 {
|
||||||
|
t.Fatalf("got %s", b)
|
||||||
|
}
|
||||||
|
if out[0].Range != "10.0.10.101-10.0.10.199" || len(out[0].Prefixes) != 6 || out[0].Size != "99 addresses" {
|
||||||
|
t.Errorf("IPv4 = %+v", out[0])
|
||||||
|
}
|
||||||
|
if out[1].Input != "!2001:db8::100-2001:db8::1ff" || len(out[1].Prefixes) != 1 || out[1].Prefixes[0] != "2001:db8::100/120" {
|
||||||
|
t.Errorf("IPv6 = %+v", out[1])
|
||||||
|
}
|
||||||
|
if out[2].Error == "" {
|
||||||
|
t.Errorf("reversed range = %+v", out[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ func engineStatus(k engine.ErrorKind) int {
|
|||||||
return http.StatusForbidden
|
return http.StatusForbidden
|
||||||
case engine.KindNotFound:
|
case engine.KindNotFound:
|
||||||
return http.StatusNotFound
|
return http.StatusNotFound
|
||||||
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted:
|
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted, engine.KindRisk:
|
||||||
return http.StatusConflict
|
return http.StatusConflict
|
||||||
case engine.KindValidation:
|
case engine.KindValidation:
|
||||||
return http.StatusUnprocessableEntity
|
return http.StatusUnprocessableEntity
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.cfg.Version})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.cfg.Version})
|
||||||
})
|
})
|
||||||
|
m.HandleFunc("GET /metrics", s.metrics)
|
||||||
// Authentication
|
// Authentication
|
||||||
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
||||||
m.HandleFunc("POST "+p+"/auth/login/totp", s.public(s.loginTOTP))
|
m.HandleFunc("POST "+p+"/auth/login/totp", s.public(s.loginTOTP))
|
||||||
@@ -110,6 +111,8 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
|
||||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||||
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
||||||
@@ -135,6 +138,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions))
|
m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions))
|
||||||
m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes))
|
m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes))
|
||||||
m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants))
|
m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants))
|
||||||
|
m.HandleFunc("GET "+p+"/catalog/ranges", s.authed(s.catalogRanges))
|
||||||
m.HandleFunc("GET "+p+"/search", s.authed(s.search))
|
m.HandleFunc("GET "+p+"/search", s.authed(s.search))
|
||||||
// Changesets
|
// Changesets
|
||||||
m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch))
|
m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch))
|
||||||
@@ -160,6 +164,22 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
|
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
|
||||||
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
|
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
|
||||||
// Query statistics
|
// Query statistics
|
||||||
|
m.HandleFunc("GET "+p+"/certificates", s.authed(s.certList))
|
||||||
|
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
|
||||||
|
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
|
||||||
|
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
|
||||||
|
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
|
||||||
|
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||||
|
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
||||||
|
m.HandleFunc("POST "+p+"/certificates/{name}/check", s.authed(s.certCheck))
|
||||||
|
m.HandleFunc("GET "+p+"/encrypted-dns", s.authed(s.encryptedDNS))
|
||||||
|
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
|
||||||
|
m.HandleFunc("DELETE "+p+"/encrypted-dns", s.authed(s.encryptedDNSDisable))
|
||||||
|
m.HandleFunc("POST "+p+"/encrypted-dns/check", s.authed(s.encryptedDNSCheck))
|
||||||
|
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
|
||||||
|
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
|
||||||
|
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
|
||||||
|
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
|
||||||
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
|
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
|
||||||
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
|
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
|
||||||
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
|
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
|
||||||
@@ -237,6 +257,11 @@ func changesetTarget(r *http.Request) (int64, error) {
|
|||||||
// mutate applies (or stages, or previews) requests and writes the
|
// mutate applies (or stages, or previews) requests and writes the
|
||||||
// response. object, if set, returns the resulting object to include.
|
// response. object, if set, returns the resulting object to include.
|
||||||
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
|
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
|
||||||
|
return s.mutateNotes(w, r, p, description, reqs, object, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutateNotes is mutate with notes for the user, shown as warnings.
|
||||||
|
func (s *Server) mutateNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any, notes []string) error {
|
||||||
csID, err := changesetTarget(r)
|
csID, err := changesetTarget(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -247,16 +272,16 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
|
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
|
||||||
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
|
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: append(notes, res.Warnings...), Problems: raw(res.Problems)})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")}
|
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")}
|
||||||
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
|
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeErr(w, r, err, res)
|
s.writeErr(w, r, err, res)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings}
|
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: append(notes, res.Warnings...)}
|
||||||
if object != nil && res.Applied {
|
if object != nil && res.Applied {
|
||||||
st, err := s.store.LoadState(r.Context())
|
st, err := s.store.LoadState(r.Context())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
+34
-14
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/nscheck"
|
"jarl/internal/nscheck"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
@@ -58,6 +59,11 @@ type Config struct {
|
|||||||
// name authenticator apps show (h_totp.go).
|
// name authenticator apps show (h_totp.go).
|
||||||
TOTPRequire string
|
TOTPRequire string
|
||||||
TOTPIssuer string
|
TOTPIssuer string
|
||||||
|
// Metrics configures /metrics (metrics.go).
|
||||||
|
Metrics Metrics
|
||||||
|
// Certs manages TLS certificates (nil in tests that don't need it),
|
||||||
|
// including the one jarld serves (h_certs.go, sniff.go).
|
||||||
|
Certs *certs.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the HTTP server.
|
// Server is the HTTP server.
|
||||||
@@ -80,6 +86,10 @@ type Server struct {
|
|||||||
// nsChecker, when set, replaces the default name server checker
|
// nsChecker, when set, replaces the default name server checker
|
||||||
// (tests point it at local DNS servers).
|
// (tests point it at local DNS servers).
|
||||||
nsChecker *nscheck.Checker
|
nsChecker *nscheck.Checker
|
||||||
|
// started is when the server was created; metricsCache holds the Jarl
|
||||||
|
// state part of /metrics.
|
||||||
|
started time.Time
|
||||||
|
metricsCache metricsCache
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
srvs []*http.Server
|
srvs []*http.Server
|
||||||
@@ -108,7 +118,7 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
|||||||
if log == nil {
|
if log == nil {
|
||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web}
|
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web, started: time.Now()}
|
||||||
s.expiryWarn.every = 24 * time.Hour
|
s.expiryWarn.every = 24 * time.Hour
|
||||||
if cfg.Stats != nil {
|
if cfg.Stats != nil {
|
||||||
s.statsQ = stats.NewQuerier(cfg.Stats)
|
s.statsQ = stats.NewQuerier(cfg.Stats)
|
||||||
@@ -137,14 +147,19 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
if len(s.cfg.Listen) == 0 {
|
if len(s.cfg.Listen) == 0 {
|
||||||
return errors.New("no listen addresses configured")
|
return errors.New("no listen addresses configured")
|
||||||
}
|
}
|
||||||
var tlsCfg *tls.Config
|
|
||||||
if s.cfg.TLSCertFile != "" {
|
if s.cfg.TLSCertFile != "" {
|
||||||
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.certs = cr
|
s.certs = cr
|
||||||
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get}
|
}
|
||||||
|
// With a certificate (tls.cert_file, or one Jarl manages, which may
|
||||||
|
// turn up later), the listeners serve HTTPS and plain HTTP side by
|
||||||
|
// side (sniff.go).
|
||||||
|
var tlsCfg *tls.Config
|
||||||
|
if s.certs != nil || s.cfg.Certs != nil {
|
||||||
|
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
||||||
}
|
}
|
||||||
errc := make(chan error, len(s.cfg.Listen)+1)
|
errc := make(chan error, len(s.cfg.Listen)+1)
|
||||||
var listeners []net.Listener
|
var listeners []net.Listener
|
||||||
@@ -162,8 +177,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
listeners = append(listeners, ln)
|
listeners = append(listeners, ln)
|
||||||
}
|
}
|
||||||
for i, ln := range listeners {
|
for i, ln := range listeners {
|
||||||
|
handler := s.Handler()
|
||||||
|
if tlsCfg != nil {
|
||||||
|
handler = s.requireHTTPS(handler)
|
||||||
|
}
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Handler: s.Handler(),
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 2 * time.Minute,
|
ReadTimeout: 2 * time.Minute,
|
||||||
WriteTimeout: 5 * time.Minute,
|
WriteTimeout: 5 * time.Minute,
|
||||||
@@ -175,23 +194,20 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
s.srvs = append(s.srvs, srv)
|
s.srvs = append(s.srvs, srv)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if tlsCfg != nil {
|
if s.httpsAvailable() {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
||||||
|
if tlsCfg != nil {
|
||||||
|
ln = newSniffListener(ln, tlsCfg)
|
||||||
|
}
|
||||||
go func(srv *http.Server, ln net.Listener) {
|
go func(srv *http.Server, ln net.Listener) {
|
||||||
var err error
|
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||||
if tlsCfg != nil {
|
|
||||||
err = srv.ServeTLS(ln, "", "")
|
|
||||||
} else {
|
|
||||||
err = srv.Serve(ln)
|
|
||||||
}
|
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
errc <- err
|
errc <- err
|
||||||
}
|
}
|
||||||
}(srv, ln)
|
}(srv, ln)
|
||||||
}
|
}
|
||||||
if tlsCfg == nil {
|
if !s.httpsAvailable() && (s.cfg.Certs == nil || !s.cfg.Certs.HasWeb(ctx)) {
|
||||||
for _, a := range s.cfg.Listen {
|
for _, a := range s.cfg.Listen {
|
||||||
if host, _, err := net.SplitHostPort(a); err == nil {
|
if host, _, err := net.SplitHostPort(a); err == nil {
|
||||||
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
||||||
@@ -200,8 +216,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := s.serveMetricsListeners(errc); err != nil {
|
||||||
|
s.shutdown()
|
||||||
|
return err
|
||||||
|
}
|
||||||
s.serveSocket(errc)
|
s.serveSocket(errc)
|
||||||
s.logHTTPReq(tlsCfg != nil)
|
s.logHTTPReq(s.httpsAvailable())
|
||||||
if ready != nil {
|
if ready != nil {
|
||||||
ready()
|
ready()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sniffListener serves TLS and plain HTTP on the same port: it looks at a
|
||||||
|
// connection's first byte and hands the server a *tls.Conn when it is a
|
||||||
|
// TLS handshake (0x16), the plain connection otherwise. That way jarld
|
||||||
|
// can start serving HTTPS when a certificate turns up (one Jarl obtains,
|
||||||
|
// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets
|
||||||
|
// a redirect instead of an error.
|
||||||
|
type sniffListener struct {
|
||||||
|
net.Listener
|
||||||
|
cfg *tls.Config
|
||||||
|
ch chan net.Conn
|
||||||
|
errc chan error
|
||||||
|
done chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener {
|
||||||
|
l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})}
|
||||||
|
go l.loop()
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sniffListener) loop() {
|
||||||
|
for {
|
||||||
|
c, err := l.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) && ne.Timeout() {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case l.errc <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go l.classify(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sniffListener) classify(c net.Conn) {
|
||||||
|
c.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
br := bufio.NewReader(c)
|
||||||
|
b, err := br.Peek(1)
|
||||||
|
c.SetReadDeadline(time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
c.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out net.Conn = &peekedConn{Conn: c, r: br}
|
||||||
|
if b[0] == 0x16 {
|
||||||
|
out = tls.Server(out, l.cfg)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case l.ch <- out:
|
||||||
|
case <-l.done:
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sniffListener) Accept() (net.Conn, error) {
|
||||||
|
select {
|
||||||
|
case c := <-l.ch:
|
||||||
|
return c, nil
|
||||||
|
case err := <-l.errc:
|
||||||
|
return nil, err
|
||||||
|
case <-l.done:
|
||||||
|
return nil, net.ErrClosed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sniffListener) Close() error {
|
||||||
|
l.once.Do(func() { close(l.done) })
|
||||||
|
return l.Listener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// peekedConn reads through the buffer that holds the peeked byte.
|
||||||
|
type peekedConn struct {
|
||||||
|
net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||||
|
|
||||||
|
var errNoCertificate = errors.New("no certificate for HTTPS yet")
|
||||||
|
|
||||||
|
// webCertificate is the certificate jarld serves: one Jarl manages with
|
||||||
|
// the web use, else tls.cert_file.
|
||||||
|
func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
if s.cfg.Certs != nil {
|
||||||
|
if c := s.cfg.Certs.WebCertificate(); c != nil {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.certs != nil {
|
||||||
|
return s.certs.get(hello)
|
||||||
|
}
|
||||||
|
return nil, errNoCertificate
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpsAvailable reports whether jarld has a certificate to serve.
|
||||||
|
func (s *Server) httpsAvailable() bool {
|
||||||
|
return s.certs != nil || s.cfg.Certs != nil && s.cfg.Certs.WebCertificate() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireHTTPS sends plain-HTTP requests to HTTPS once jarld has a
|
||||||
|
// certificate: browsers (GET and HEAD) get a redirect, anything else an
|
||||||
|
// error, so tokens and passwords aren't sent in the clear again. Requests
|
||||||
|
// from loopback (tools on this server) and from trusted proxies (which
|
||||||
|
// terminate TLS themselves) are served as they are.
|
||||||
|
func (s *Server) requireHTTPS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.TLS != nil || !s.httpsAvailable() {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := "https://" + r.Host + r.URL.RequestURI()
|
||||||
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
|
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "jarld serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI())
|
||||||
|
if r.Header.Get("Authorization") != "" {
|
||||||
|
msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token"
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusBadRequest, "https_required", msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,7 +35,10 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
minute := now.Unix() - now.Unix()%60 - 120
|
minute := now.Unix() - now.Unix()%60 - 120
|
||||||
hour := minute - minute%3600
|
hour := minute - minute%3600
|
||||||
b := &stats.Batch{}
|
b := &stats.Batch{}
|
||||||
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10}
|
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10,
|
||||||
|
"view:internal": 30, "view:external": 20, stats.ViewKey("internal", "responses"): 30, stats.ViewKey("external", "responses"): 20,
|
||||||
|
stats.ViewKey("internal", "rcode:NOERROR"): 30, stats.ViewKey("external", "rcode:NOERROR"): 15, stats.ViewKey("external", "rcode:NXDOMAIN"): 5,
|
||||||
|
"answer:authoritative": 40}
|
||||||
b.AddCounters(stats.ResMinute, minute, vals)
|
b.AddCounters(stats.ResMinute, minute, vals)
|
||||||
b.AddCounters(stats.ResHour, hour, vals)
|
b.AddCounters(stats.ResHour, hour, vals)
|
||||||
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
|
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
|
||||||
@@ -47,6 +50,16 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
e := newAPIEnv(t, c)
|
e := newAPIEnv(t, c)
|
||||||
|
|
||||||
|
// Some views only.
|
||||||
|
if sum := e.do("GET", "/stats/summary?since=1h&views=external", e.token, nil, 200); sum["queries"].(float64) != 20 || len(sum["rcodes"].([]any)) != 2 || sum["filter_views"] == nil {
|
||||||
|
t.Errorf("external summary = %v", sum)
|
||||||
|
}
|
||||||
|
if b := e.list("/stats/breakdown?group=rcode&since=1h&views=internal,external", e.token); len(b) != 2 || b[0]["count"].(float64) != 45 {
|
||||||
|
t.Errorf("rcodes of both views = %v", b)
|
||||||
|
}
|
||||||
|
if out, status := e.req("GET", "/stats/breakdown?group=answer&since=1h&views=internal", e.token, nil); status != 400 || !strings.Contains(string(out), "not_per_view") {
|
||||||
|
t.Errorf("answers per view = %d %s", status, out)
|
||||||
|
}
|
||||||
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
|
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
|
||||||
t.Errorf("status = %v", st)
|
t.Errorf("status = %v", st)
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-20
@@ -26,10 +26,22 @@ const (
|
|||||||
KindBlocked = "blocked" // names blocked (docs/blocking.md)
|
KindBlocked = "blocked" // names blocked (docs/blocking.md)
|
||||||
// KindBlockedClient: clients with the most blocked queries.
|
// KindBlockedClient: clients with the most blocked queries.
|
||||||
KindBlockedClient = "blocked_client"
|
KindBlockedClient = "blocked_client"
|
||||||
|
// KindRateLimited: client networks whose responses rate limiting
|
||||||
|
// limited (or would have, in log-only mode); KindRateLimitedName: the
|
||||||
|
// names and types they asked for (docs/exposure.md).
|
||||||
|
KindRateLimited = "ratelimited"
|
||||||
|
KindRateLimitedName = "ratelimited_name"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ViewKey is the key of a counter for one view: per-view copies of the
|
||||||
|
// counters that come from named's response log (docs/stats.md).
|
||||||
|
func ViewKey(view, key string) string { return "@" + view + "/" + key }
|
||||||
|
|
||||||
|
// ViewKind is the kind of a top list for one view.
|
||||||
|
func ViewKind(kind, view string) string { return kind + "@" + view }
|
||||||
|
|
||||||
// Kinds lists the top-list kinds.
|
// Kinds lists the top-list kinds.
|
||||||
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient}
|
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName}
|
||||||
|
|
||||||
// OtherZone is the zone key for names outside every local zone (answered
|
// OtherZone is the zone key for names outside every local zone (answered
|
||||||
// by recursion, or refused).
|
// by recursion, or refused).
|
||||||
@@ -110,10 +122,16 @@ type Collector struct {
|
|||||||
// rewritten holds queries named blocked (client#port|name) until their
|
// rewritten holds queries named blocked (client#port|name) until their
|
||||||
// response line arrives.
|
// response line arrives.
|
||||||
rewritten map[string]bool
|
rewritten map[string]bool
|
||||||
|
// viewsSince is set once per-view counters exist (meta views_since).
|
||||||
|
viewsSince bool
|
||||||
|
viewsSinceAt int64
|
||||||
// proc is the last sample of named's process, server the latest server
|
// proc is the last sample of named's process, server the latest server
|
||||||
// statistics (server.go).
|
// statistics (server.go).
|
||||||
proc *namedProc
|
proc *namedProc
|
||||||
server *Server
|
server *Server
|
||||||
|
// totals are the counters added up since jarld started (gauges left
|
||||||
|
// out), for Prometheus (Totals).
|
||||||
|
totals map[string]int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
||||||
@@ -125,7 +143,7 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
|
|||||||
opts.Location = time.Local
|
opts.Location = time.Local
|
||||||
}
|
}
|
||||||
c := &Collector{opts: opts, store: store, zones: zones, log: log, poller: NewPoller(opts.StatisticsChannel), now: time.Now,
|
c := &Collector{opts: opts, store: store, zones: zones, log: log, poller: NewPoller(opts.StatisticsChannel), now: time.Now,
|
||||||
minutes: map[int64]map[string]int64{}, hours: map[int64]map[string]map[string]int64{}, late: map[int64]map[string]map[string]int64{}}
|
minutes: map[int64]map[string]int64{}, totals: map[string]int64{}, hours: map[int64]map[string]map[string]int64{}, late: map[int64]map[string]map[string]int64{}}
|
||||||
if opts.QueryLog {
|
if opts.QueryLog {
|
||||||
c.tail = &Tailer{Path: opts.LogFile}
|
c.tail = &Tailer{Path: opts.LogFile}
|
||||||
}
|
}
|
||||||
@@ -204,6 +222,9 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v, _ := c.store.Meta(ctx, "views_since"); v != "" {
|
||||||
|
c.viewsSince = true
|
||||||
|
}
|
||||||
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
|
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
|
||||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
t := time.Unix(n, 0)
|
t := time.Unix(n, 0)
|
||||||
@@ -219,7 +240,8 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
|
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
|
||||||
from := max(last+3600, cur-48*3600)
|
from := max(last+3600, cur-48*3600)
|
||||||
for h := from; h < cur; h += 3600 {
|
for h := from; h < cur; h += 3600 {
|
||||||
for _, k := range Kinds {
|
kinds, _ := c.store.KindsAt(ctx, ResHour, h)
|
||||||
|
for _, k := range kinds {
|
||||||
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
|
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
|
||||||
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
|
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
|
||||||
}
|
}
|
||||||
@@ -227,7 +249,8 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
|
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
|
||||||
for _, k := range Kinds {
|
curKinds, _ := c.store.KindsAt(ctx, ResHour, cur)
|
||||||
|
for _, k := range curKinds {
|
||||||
es, err := c.store.TopsAt(ctx, ResHour, cur, k)
|
es, err := c.store.TopsAt(ctx, ResHour, cur, k)
|
||||||
if err != nil || len(es) == 0 {
|
if err != nil || len(es) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -286,6 +309,23 @@ func (c *Collector) count(minute int64, key string, n int64) {
|
|||||||
c.minutes[minute] = m
|
c.minutes[minute] = m
|
||||||
}
|
}
|
||||||
m[key] += n
|
m[key] += n
|
||||||
|
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") && !strings.HasPrefix(key, "@") {
|
||||||
|
c.totals[key] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals returns every counter added up since the collector started (the
|
||||||
|
// keys of docs/stats.md, without gauges). They only grow, so they suit
|
||||||
|
// Prometheus counters; named's counters move once a minute, when they are
|
||||||
|
// polled.
|
||||||
|
func (c *Collector) Totals() map[string]int64 {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
out := make(map[string]int64, len(c.totals))
|
||||||
|
for k, v := range c.totals {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLog reads new response log lines.
|
// readLog reads new response log lines.
|
||||||
@@ -317,6 +357,8 @@ func (c *Collector) handleLine(line string) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
if rw, ok := ParseRewrite(line, c.opts.Location); ok {
|
if rw, ok := ParseRewrite(line, c.opts.Location); ok {
|
||||||
c.handleRewrite(rw)
|
c.handleRewrite(rw)
|
||||||
|
} else if rl, ok := ParseRateLimit(line, c.opts.Location); ok {
|
||||||
|
c.handleRateLimit(rl)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -338,33 +380,55 @@ func (c *Collector) handleLine(line string) {
|
|||||||
if view == "" {
|
if view == "" {
|
||||||
view = "_default"
|
view = "_default"
|
||||||
}
|
}
|
||||||
|
zone := c.zoneOf(ev.Name, ev.View)
|
||||||
c.count(minute, "responses", 1)
|
c.count(minute, "responses", 1)
|
||||||
c.count(minute, "view:"+view, 1)
|
c.count(minute, "view:"+view, 1)
|
||||||
c.count(minute, "zone:"+c.zoneOf(ev.Name, ev.View), 1)
|
c.count(minute, "zone:"+zone, 1)
|
||||||
|
// The same, per view, for the statistics of chosen views.
|
||||||
|
c.countView(minute, view, "responses", 1)
|
||||||
|
c.countView(minute, view, "zone:"+zone, 1)
|
||||||
|
c.countView(minute, view, "rcode:"+ev.RCode, 1)
|
||||||
|
c.countView(minute, view, "qtype:"+ev.Type, 1)
|
||||||
client := clientKey(ev.Client, c.opts.Anonymize)
|
client := clientKey(ev.Client, c.opts.Anonymize)
|
||||||
hours := c.hours
|
hours := c.hours
|
||||||
if hour < c.liveHour {
|
if hour < c.liveHour {
|
||||||
hours = c.late
|
hours = c.late
|
||||||
}
|
}
|
||||||
hours[hour] = c.hourMaps(hours, hour)
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
tops := hours[hour]
|
add := func(kind, key string) {
|
||||||
tops[KindName][ev.Name]++
|
hours[hour][kind][key]++
|
||||||
tops[KindClient][client]++
|
m := c.topsMap(hours, hour, ViewKind(kind, view))
|
||||||
switch {
|
m[key]++
|
||||||
case blocked:
|
|
||||||
// Counted by handleRewrite; not a name that doesn't exist.
|
|
||||||
case ev.RCode == "NXDOMAIN":
|
|
||||||
tops[KindNXDomain][ev.Name]++
|
|
||||||
case ev.RCode == "SERVFAIL":
|
|
||||||
tops[KindServFail][ev.Name]++
|
|
||||||
case ev.RCode == "REFUSED":
|
|
||||||
tops[KindRefused][client]++
|
|
||||||
}
|
|
||||||
for _, m := range tops {
|
|
||||||
if len(m) > maxKeys {
|
if len(m) > maxKeys {
|
||||||
shrink(m)
|
shrink(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
add(KindName, ev.Name)
|
||||||
|
add(KindClient, client)
|
||||||
|
switch {
|
||||||
|
case blocked:
|
||||||
|
// Counted by handleRewrite; not a name that doesn't exist.
|
||||||
|
case ev.RCode == "NXDOMAIN":
|
||||||
|
add(KindNXDomain, ev.Name)
|
||||||
|
case ev.RCode == "SERVFAIL":
|
||||||
|
add(KindServFail, ev.Name)
|
||||||
|
case ev.RCode == "REFUSED":
|
||||||
|
add(KindRefused, client)
|
||||||
|
}
|
||||||
|
for _, k := range Kinds {
|
||||||
|
if m := hours[hour][k]; len(m) > maxKeys {
|
||||||
|
shrink(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countView counts a per-view copy of a counter.
|
||||||
|
func (c *Collector) countView(minute int64, view, key string, n int64) {
|
||||||
|
if !c.viewsSince {
|
||||||
|
c.viewsSince = true
|
||||||
|
c.viewsSinceAt = minute
|
||||||
|
}
|
||||||
|
c.count(minute, ViewKey(view, key), n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRewrite counts a query that blocking answered: "blocked", and
|
// handleRewrite counts a query that blocking answered: "blocked", and
|
||||||
@@ -385,15 +449,55 @@ func (c *Collector) handleRewrite(rw Rewrite) {
|
|||||||
}
|
}
|
||||||
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
||||||
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
||||||
|
view := rw.View
|
||||||
|
if view == "" {
|
||||||
|
view = "_default"
|
||||||
|
}
|
||||||
c.count(minute, "blocked", 1)
|
c.count(minute, "blocked", 1)
|
||||||
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
|
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
|
||||||
|
c.countView(minute, view, "blocked", 1)
|
||||||
|
c.countView(minute, view, "blocklist:"+policySource(rw.Via), 1)
|
||||||
hours := c.hours
|
hours := c.hours
|
||||||
if hour < c.liveHour {
|
if hour < c.liveHour {
|
||||||
hours = c.late
|
hours = c.late
|
||||||
}
|
}
|
||||||
hours[hour] = c.hourMaps(hours, hour)
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
|
client := clientKey(rw.Client, c.opts.Anonymize)
|
||||||
hours[hour][KindBlocked][rw.Name]++
|
hours[hour][KindBlocked][rw.Name]++
|
||||||
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
|
hours[hour][KindBlockedClient][client]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindBlocked, view))[rw.Name]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindBlockedClient, view))[client]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRateLimit counts a response rate limiting limited, or would have:
|
||||||
|
// "rrl:drop", "rrl:slip", "rrl:would-drop" or "rrl:would-slip", and the
|
||||||
|
// client network and name in the hour's top lists.
|
||||||
|
func (c *Collector) handleRateLimit(rl RateLimit) {
|
||||||
|
for _, p := range c.opts.Exclude {
|
||||||
|
if p.Contains(rl.Client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := "rrl:" + rl.Action
|
||||||
|
if rl.Would {
|
||||||
|
key = "rrl:would-" + rl.Action
|
||||||
|
}
|
||||||
|
minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time)
|
||||||
|
view := rl.View
|
||||||
|
if view == "" {
|
||||||
|
view = "_default"
|
||||||
|
}
|
||||||
|
c.count(minute, key, 1)
|
||||||
|
c.countView(minute, view, key, 1)
|
||||||
|
hours := c.hours
|
||||||
|
if hour < c.liveHour {
|
||||||
|
hours = c.late
|
||||||
|
}
|
||||||
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
|
hours[hour][KindRateLimited][rl.Prefix]++
|
||||||
|
hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindRateLimited, view))[rl.Prefix]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindRateLimitedName, view))[rl.Name+" "+rl.Type]++
|
||||||
}
|
}
|
||||||
|
|
||||||
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
||||||
@@ -496,6 +600,10 @@ func (c *Collector) flush(ctx context.Context, final bool) {
|
|||||||
b.AddCounters(ResHour, c.hourOf(t), vals)
|
b.AddCounters(ResHour, c.hourOf(t), vals)
|
||||||
b.AddCounters(ResDay, c.dayOf(t), vals)
|
b.AddCounters(ResDay, c.dayOf(t), vals)
|
||||||
delete(c.minutes, m)
|
delete(c.minutes, m)
|
||||||
|
if c.viewsSinceAt != 0 {
|
||||||
|
b.SetMeta("views_since", strconv.FormatInt(c.viewsSinceAt, 10))
|
||||||
|
c.viewsSinceAt = 0
|
||||||
|
}
|
||||||
if first && len(vals) > 0 {
|
if first && len(vals) > 0 {
|
||||||
ft := t
|
ft := t
|
||||||
c.status.FirstData = &ft
|
c.status.FirstData = &ft
|
||||||
|
|||||||
+41
-3
@@ -33,17 +33,55 @@ type Rewrite struct {
|
|||||||
//
|
//
|
||||||
// 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)
|
// 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)
|
||||||
//
|
//
|
||||||
// The "view NAME: " part is missing when no views are configured.
|
// The "view NAME: " part is missing when no views are configured, and a
|
||||||
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `)
|
// query signed with a TSIG key has "/key NAME" after the port.
|
||||||
|
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `)
|
||||||
|
|
||||||
// rewriteRe matches named's RPZ rewrite lines (category rpz), logged just
|
// rewriteRe matches named's RPZ rewrite lines (category rpz), logged just
|
||||||
// before the response line of the same query:
|
// before the response line of the same query:
|
||||||
//
|
//
|
||||||
// 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid
|
// 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid
|
||||||
var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`)
|
var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`)
|
||||||
|
|
||||||
|
// rateLimitRe matches named's response rate limiting lines (category
|
||||||
|
// query-errors), when it limits a response or, in log-only mode, would:
|
||||||
|
//
|
||||||
|
// 12-Oct-2026 09:56:35.996 client @0x70a2... 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)
|
||||||
|
var rateLimitRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?(would )?rate limit (drop|slip) (?:\S+ )?response to (\S+) for (\S+) \S+ (\S+)`)
|
||||||
|
|
||||||
|
// RateLimit is a response that rate limiting dropped or slipped (sent
|
||||||
|
// truncated, so a real client retries over TCP), or would have in
|
||||||
|
// log-only mode.
|
||||||
|
type RateLimit struct {
|
||||||
|
Time time.Time
|
||||||
|
Client netip.Addr
|
||||||
|
View string
|
||||||
|
Would bool // log-only: nothing was limited
|
||||||
|
Action string // drop or slip
|
||||||
|
Prefix string // the client network named counts, e.g. 192.0.2.0/24
|
||||||
|
Name string // the query name, normalized like Event.Name
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
const logTimeLayout = "02-Jan-2006 15:04:05.000"
|
const logTimeLayout = "02-Jan-2006 15:04:05.000"
|
||||||
|
|
||||||
|
// ParseRateLimit parses a rate limiting log line.
|
||||||
|
func ParseRateLimit(line string, loc *time.Location) (rl RateLimit, ok bool) {
|
||||||
|
m := rateLimitRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return rl, false
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation(logTimeLayout, m[1], loc)
|
||||||
|
if err != nil {
|
||||||
|
return rl, false
|
||||||
|
}
|
||||||
|
client, err := netip.ParseAddr(m[2])
|
||||||
|
if err != nil {
|
||||||
|
return rl, false
|
||||||
|
}
|
||||||
|
return RateLimit{Time: t, Client: client.Unmap(), View: m[4], Would: m[5] != "", Action: m[6], Prefix: m[7], Name: normalizeName(m[8]), Type: m[9]}, true
|
||||||
|
}
|
||||||
|
|
||||||
// ParseResponse parses a response log line. ok is false for other lines
|
// ParseResponse parses a response log line. ok is false for other lines
|
||||||
// (named also logs to the channel when it starts and rotates files).
|
// (named also logs to the channel when it starts and rotates files).
|
||||||
func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) {
|
func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package stats
|
package stats
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -42,3 +43,44 @@ func TestParseResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRateLimit(t *testing.T) {
|
||||||
|
loc := time.UTC
|
||||||
|
for _, tc := range []struct {
|
||||||
|
line string
|
||||||
|
want RateLimit
|
||||||
|
views bool
|
||||||
|
}{
|
||||||
|
{line: "12-Oct-2026 09:56:35.996 client @0x70a247adf000 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)",
|
||||||
|
want: RateLimit{Would: true, Action: "slip", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}},
|
||||||
|
{line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 127.0.0.2#35687 (www.rrl.test): rate limit drop response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)",
|
||||||
|
want: RateLimit{Action: "drop", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}},
|
||||||
|
{line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 2001:db8::7#35687 (Adbraun.lol): view external: rate limit drop response to 2001:db8::/56 for adbraun.lol IN ANY (0ce833a4)",
|
||||||
|
want: RateLimit{View: "external", Action: "drop", Prefix: "2001:db8::/56", Name: "adbraun.lol", Type: "ANY"}},
|
||||||
|
} {
|
||||||
|
rl, ok := ParseRateLimit(tc.line, loc)
|
||||||
|
rl.Time, rl.Client = time.Time{}, netip.Addr{}
|
||||||
|
if !ok || rl != tc.want {
|
||||||
|
t.Errorf("%s:\n got %+v %v\nwant %+v", tc.line, rl, ok, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := ParseRateLimit("12-Oct-2026 09:56:35.996 rate-limit: info: would limit responses to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)", loc); ok {
|
||||||
|
t.Error("the start-of-limiting line counted as a response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed queries have "/key NAME" after the client's port.
|
||||||
|
func TestParseSigned(t *testing.T) {
|
||||||
|
loc := time.UTC
|
||||||
|
ev, ok := ParseResponse("12-Oct-2026 10:11:06.490 client @0x7a22f754b000 127.0.0.1#34745/key jarl-view-external (ns1.adbraun.test): view external: response: ns1.adbraun.test IN A NOERROR 1 0 2 +E(0)SK (127.0.0.1)", loc)
|
||||||
|
if !ok || ev.View != "external" || ev.Name != "ns1.adbraun.test" || ev.Port != "34745" {
|
||||||
|
t.Errorf("signed response = %+v %v", ev, ok)
|
||||||
|
}
|
||||||
|
rl, ok := ParseRateLimit("12-Oct-2026 10:11:06.599 client @0x7a22f754b000 127.0.0.1#34856/key jarl-view-external (ns1.adbraun.test): view external: would rate limit slip response to 127.0.0.0/24 for ns1.adbraun.test IN A (e777e288)", loc)
|
||||||
|
if !ok || rl.View != "external" || !rl.Would || rl.Action != "slip" || rl.Prefix != "127.0.0.0/24" {
|
||||||
|
t.Errorf("signed rate limit = %+v %v", rl, ok)
|
||||||
|
}
|
||||||
|
if _, ok := ParseRewrite("09-Oct-2026 23:49:13.089 client @0x7bd607320000 127.0.0.1#56905/key k1 (x.ads.example): view internal: rpz QNAME NXDOMAIN rewrite x.ads.example/A/IN via x.ads.example.rules.jarl.invalid", loc); !ok {
|
||||||
|
t.Error("signed rewrite not parsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user