Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88965096bb | ||
|
|
bad360107e | ||
|
|
bdd7acfd3b | ||
|
|
cf550bd512 | ||
|
|
cee11e7c86 | ||
|
|
e2e5007f94 | ||
|
|
49b1c872c9 | ||
|
|
c37de31f4c | ||
|
|
7e6e274a89 | ||
|
|
11ed877c3c | ||
|
|
76aaaf3ef5 | ||
|
|
5b8f681e2a | ||
|
|
8f0a0cc233 |
No files matched your search
+135
-1
@@ -6,8 +6,135 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Upgrading from 0.4
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 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
|
### Added
|
||||||
|
|
||||||
|
- Internet exposure ([docs/exposure.md](docs/exposure.md)): Jarl works out
|
||||||
|
which views are reachable from the internet (match-clients ending in a
|
||||||
|
catch-all; for a server without views, open allow-query and a
|
||||||
|
non-private listen address), with a per-view override, and checks them:
|
||||||
|
response rate limiting, open resolvers, ANY answers (minimal-any), cache
|
||||||
|
snooping, open NOTIFY and zone transfers, BIND version and host name,
|
||||||
|
management interfaces. Fixes are staged like any change, including
|
||||||
|
response rate limiting from ISC's example in log-only mode, with your
|
||||||
|
secondaries exempt, and a later "start enforcing". With statistics on,
|
||||||
|
Jarl reads named's rate limiting log lines and shows what was (or would
|
||||||
|
have been) limited: counts, client networks and names (Exposure page,
|
||||||
|
`jarl stats breakdown rrl`, top lists `ratelimited`, Prometheus
|
||||||
|
`jarl_ratelimit_logged_total`). A change that newly opens a public view
|
||||||
|
up must be confirmed (checkbox on the Changes page, `--accept-risk`, API
|
||||||
|
`accept_risk`; 409 `confirm_risk` otherwise). Findings can be
|
||||||
|
acknowledged with a reason: they stay visible as info, with who, when
|
||||||
|
and why. Exposure page and dashboard card in the web UI, `jarl
|
||||||
|
exposure` (fix, ack, unack), `GET /exposure`.
|
||||||
|
- `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the
|
||||||
|
changeset from `-C` or `JARL_CHANGESET` when no ID is given
|
||||||
|
(`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now
|
||||||
|
checked before it is used, for staging too: it must exist, be open and
|
||||||
|
be yours, so a stale export (an applied changeset, or an ID that later
|
||||||
|
turned into someone else's web UI draft) can't silently take your
|
||||||
|
changes; the error says what to do. Commands say on stderr which
|
||||||
|
changeset from `JARL_CHANGESET` they use, and `apply`/`discard` remind
|
||||||
|
you to `unset JARL_CHANGESET`. `-C` and ID arguments are used as given.
|
||||||
|
- Address ranges such as `10.0.10.101-10.0.10.199` or
|
||||||
|
`2001:db8::100-2001:db8::1ff` (also `!` negated, and with spaces around
|
||||||
|
the dash) wherever an address match list is used: ACLs, views'
|
||||||
|
match-clients and match-destinations, options such as allow-query, and
|
||||||
|
blocking's client groups ([docs/concepts.md](docs/concepts.md)). Jarl
|
||||||
|
keeps the range as written and writes the fewest prefixes that cover
|
||||||
|
exactly that range to named.conf, with the range as a comment. The web
|
||||||
|
UI shows what a range becomes while you type it (`GET
|
||||||
|
/catalog/ranges`). Mistakes are refused when entered: an end that isn't
|
||||||
|
a full address, the start after the end, IPv4 mixed with IPv6, a range
|
||||||
|
in an option that takes no address match list (forwarders). A range
|
||||||
|
covered by an earlier element is reported as never matching, a range of
|
||||||
|
every address counts as "any" for the open resolver and open transfer
|
||||||
|
checks, and an IPv6 range in the IPv4-mapped space gets a warning. ACL
|
||||||
|
names can no longer look like a range.
|
||||||
|
- Prometheus metrics at `/metrics` ([docs/metrics.md](docs/metrics.md)),
|
||||||
|
set up in the new `metrics:` section of jarl.yaml (off by default).
|
||||||
|
`metrics.allow` lists the addresses that may scrape (loopback by
|
||||||
|
default), `metrics.token` lets anyone who sends it as a bearer token
|
||||||
|
scrape, and `metrics.listen` adds addresses that serve only `/metrics`
|
||||||
|
(for a Prometheus server elsewhere). Root and jarld's own user can also
|
||||||
|
scrape on the local socket.
|
||||||
|
- Health, always: named up, recursive and TCP clients against their
|
||||||
|
limits, adopted, open changesets, last apply, validation problems by
|
||||||
|
severity, drifted files, object counts, the last backup (and failure,
|
||||||
|
and whether it is stale), blocking paused, each blocklist's entries,
|
||||||
|
last check and change and whether it fails, the DNSSEC phase of every
|
||||||
|
signed zone, and when API tokens expire.
|
||||||
|
- With query statistics on: named's counters (queries, result codes,
|
||||||
|
record types, UDP/TCP, authoritative/recursive, rate limiting, upstream
|
||||||
|
round-trip times, DNSSEC validation, cache hits) and Jarl's (logged
|
||||||
|
responses per view and zone, blocked queries per blocklist), as
|
||||||
|
counters since jarld started, plus named's memory, CPU, threads, cache
|
||||||
|
and sockets, and the collector's health.
|
||||||
|
- Example queries and alert rules, checked with promtool.
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-10-11
|
||||||
|
|
||||||
|
### Upgrading from 0.3
|
||||||
|
|
||||||
|
- Back up first (`sudo jarld backup`).
|
||||||
|
- When jarld first starts, it rewrites named's configuration: options
|
||||||
|
move into their new fixed order, and every view gets its view key in
|
||||||
|
`match-clients` and `keys.conf`. It is checked with named-checkconf
|
||||||
|
before named reloads, like any apply. Files you changed by hand are left
|
||||||
|
alone; the log then says to run `jarl resync`.
|
||||||
|
- The systemd unit changed (it allows `AF_NETLINK`). The package replaces
|
||||||
|
it; an override that adds `AF_NETLINK` (`systemctl edit jarl`) does no
|
||||||
|
harm and can be removed.
|
||||||
|
- The database moves to schema 8 (disabled zones, client groups) as
|
||||||
|
jarld starts.
|
||||||
|
- If statistics showed a spike after views were created or changed, see
|
||||||
|
"Statistics" under Fixed: the bad minutes can be removed from
|
||||||
|
`/var/lib/jarl/stats.db` (minute rows have `res = 'm'`; the hours and
|
||||||
|
days that include them are `'h'` and `'d'`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Different blocking for different devices (docs/blocking.md), like
|
||||||
|
Pi-hole's groups, in one view and without loading any list twice.
|
||||||
|
Client groups put devices (addresses, networks or ACLs of them) at a
|
||||||
|
blocking step: `off` (only your rules), `normal` (the default), or steps
|
||||||
|
you add above it, each including the lists of the steps below. Lists
|
||||||
|
start at a step. Jarl puts a small "gate" policy zone in front of each
|
||||||
|
step's lists that lets the devices below that step through. Web UI: a
|
||||||
|
Devices panel on the Blocking page and a step on each list. CLI: `jarl
|
||||||
|
block step`, `jarl block group`, `jarl blocklist update --step`. API:
|
||||||
|
`/blocking/steps`, `/blocking/groups`. Database schema 8.
|
||||||
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
|
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
|
||||||
its match-clients, so a query signed with it lands in that view from any
|
its match-clients, so a query signed with it lands in that view from any
|
||||||
address. Jarl signs its delegation check's queries to this server's own
|
address. Jarl signs its delegation check's queries to this server's own
|
||||||
@@ -75,7 +202,11 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
reorder them; view links everywhere open the view's page.
|
reorder them; view links everywhere open the view's page.
|
||||||
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the
|
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the
|
||||||
add form with a CNAME pointing to the record's name, leaving only the
|
add form with a CNAME pointing to the record's name, leaving only the
|
||||||
alias's name to type.
|
alias's name to type. It shows when the row is hovered or focused
|
||||||
|
(always on touch screens).
|
||||||
|
- Blocking page: how many queries each blocklist blocked in the last 24
|
||||||
|
hours, and your rules together, with statistics on (linking to the
|
||||||
|
Statistics page).
|
||||||
- Options are kept in one fixed order, in named.conf, the web UI and the
|
- Options are kept in one fixed order, in named.conf, the web UI and the
|
||||||
CLI: grouped by topic (files, listening, access control, recursion and
|
CLI: grouped by topic (files, listening, access control, recursion and
|
||||||
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
|
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
|
||||||
@@ -119,6 +250,9 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Reverting a changeset added needless "restore" operations for
|
||||||
|
blocklists, rules and views that hadn't changed: copying the state
|
||||||
|
turned their empty view lists into missing ones.
|
||||||
- Under the shipped systemd unit, jarld couldn't list the host's
|
- Under the shipped systemd unit, jarld couldn't list the host's
|
||||||
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
|
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
|
||||||
didn't allow), so the name server checks didn't recognize this server's
|
didn't allow), so the name server checks didn't recognize this server's
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -120,6 +133,8 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
|
|||||||
| [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/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,11 +186,14 @@ 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.4.1 is in testing
|
||||||
(0.4.0-rc.3): it adds blocking (blocklists and your own rules, like
|
(0.4.1-rc.1): it adds Prometheus metrics, address ranges in ACLs and
|
||||||
Pi-hole, done by named), disabling zones, server statistics and view keys. 0.3.0 added disabled records, script-friendly
|
other address match lists, and safer changeset handling. 0.4.0 added blocking (blocklists and
|
||||||
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC
|
your own rules, like Pi-hole, done by named, with different blocking for
|
||||||
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
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)).
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -43,7 +43,7 @@ named answers "doesn't exist" for the domains on them (docs/blocking.md).
|
|||||||
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
|
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
|
||||||
allow rules ('jarl block') take precedence over every list.`}
|
allow rules ('jarl block') take precedence over every list.`}
|
||||||
var views []string
|
var views []string
|
||||||
var comment string
|
var comment, step string
|
||||||
|
|
||||||
list := &cobra.Command{
|
list := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
@@ -59,7 +59,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(lists, func(w io.Writer) {
|
return output(lists, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "NAME\tSTATE\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
|
fmt.Fprintln(w, "NAME\tSTATE\tSTEP\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
|
||||||
for _, b := range lists {
|
for _, b := range lists {
|
||||||
domains, updated, mem := "-", "-", "-"
|
domains, updated, mem := "-", "-", "-"
|
||||||
if s := b.Status; s != nil && s.UpdatedAt != nil {
|
if s := b.Status; s != nil && s.UpdatedAt != nil {
|
||||||
@@ -68,7 +68,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
|||||||
if b.MemoryMB > 0 {
|
if b.MemoryMB > 0 {
|
||||||
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
|
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, b.Step, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
|
||||||
}
|
}
|
||||||
for _, b := range lists {
|
for _, b := range lists {
|
||||||
if b.Status != nil && b.Status.Error != "" {
|
if b.Status != nil && b.Status.Error != "" {
|
||||||
@@ -95,6 +95,9 @@ allow rules ('jarl block') take precedence over every list.`}
|
|||||||
}
|
}
|
||||||
body := map[string]any{"name": args[0], "url": args[1]}
|
body := map[string]any{"name": args[0], "url": args[1]}
|
||||||
viewsBody(body, views)
|
viewsBody(body, views)
|
||||||
|
if step != "" {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
if comment != "" {
|
if comment != "" {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
@@ -111,6 +114,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
|||||||
}
|
}
|
||||||
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
|
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
|
||||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
add.Flags().StringVar(&step, "step", "", "the blocking step it starts at (default: normal; see 'jarl block step')")
|
||||||
|
|
||||||
var rename, newURL string
|
var rename, newURL string
|
||||||
update := &cobra.Command{
|
update := &cobra.Command{
|
||||||
@@ -136,6 +140,9 @@ allow rules ('jarl block') take precedence over every list.`}
|
|||||||
if f.Changed("comment") {
|
if f.Changed("comment") {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
if f.Changed("step") {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
viewsBody(body, views)
|
viewsBody(body, views)
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return fmt.Errorf("nothing to change (see --help)")
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
@@ -151,6 +158,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
|||||||
update.Flags().StringVar(&newURL, "url", "", "new URL")
|
update.Flags().StringVar(&newURL, "url", "", "new URL")
|
||||||
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies to (repeat for several; "auto": every view that offers recursion)`)
|
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies to (repeat for several; "auto": every view that offers recursion)`)
|
||||||
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||||
|
update.Flags().StringVar(&step, "step", "", "the blocking step it starts at (normal, or a step above it)")
|
||||||
|
|
||||||
toggle := func(disable bool) *cobra.Command {
|
toggle := func(disable bool) *cobra.Command {
|
||||||
verb := map[bool]string{true: "disable", false: "enable"}[disable]
|
verb := map[bool]string{true: "disable", false: "enable"}[disable]
|
||||||
@@ -394,6 +402,8 @@ names below it (docs/blocking.md).`}
|
|||||||
switch {
|
switch {
|
||||||
case m.Result == "none":
|
case m.Result == "none":
|
||||||
fmt.Fprintf(w, "%snot blocked\n", where)
|
fmt.Fprintf(w, "%snot blocked\n", where)
|
||||||
|
case m.List != "" && m.Step != "":
|
||||||
|
fmt.Fprintf(w, "%sblocked for clients at step %s and above, by blocklist %s (entry %s); not for others\n", where, m.Step, m.List, m.Entry)
|
||||||
case m.List != "":
|
case m.List != "":
|
||||||
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
|
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
|
||||||
default:
|
default:
|
||||||
@@ -473,7 +483,7 @@ at once. "indefinitely" pauses until 'jarl block resume'.
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume)
|
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume, blockStepCmd(), blockGroupCmd())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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{
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blocking steps and client groups (docs/blocking.md).
|
||||||
|
|
||||||
|
func blockStepCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "step", Aliases: []string{"steps"}, Short: "Blocking steps: which lists apply to which client groups",
|
||||||
|
Long: `Steps decide which blocklists a client group gets. Each step includes the
|
||||||
|
lists of the steps below it, plus its own:
|
||||||
|
|
||||||
|
off only your rules (built in)
|
||||||
|
normal your rules + the normal lists; clients not in a group (built in)
|
||||||
|
strict normal + the lists at step strict (a step you add)
|
||||||
|
...
|
||||||
|
|
||||||
|
Lists start at a step ('jarl blocklist update LIST --step strict'), client
|
||||||
|
groups are at one ('jarl block group add kids 10.10.0.50 --step strict').`}
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List the steps, lowest first",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var steps []api.BlockStep
|
||||||
|
if err := c.Get(ctx(), "/blocking/steps", nil, &steps); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(steps, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "STEP\tLISTS\tGROUPS\tCOMMENT")
|
||||||
|
fmt.Fprintln(w, "off\t-\t(built in)\tonly your rules")
|
||||||
|
fmt.Fprintln(w, "normal\t-\t(built in)\tthe default")
|
||||||
|
for _, s := range steps {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, orDash(strings.Join(s.Lists, ", ")), orDash(strings.Join(s.Groups, ", ")), s.Comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var comment string
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add NAME",
|
||||||
|
Short: "Add a step above the existing ones",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps", nil, map[string]any{"name": args[0], "comment": comment})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
rename := &cobra.Command{
|
||||||
|
Use: "rename STEP NEW-NAME",
|
||||||
|
Short: "Rename a step",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/steps/"+url.PathEscape(args[0]), nil, map[string]any{"name": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
order := &cobra.Command{
|
||||||
|
Use: "order STEP...",
|
||||||
|
Short: "Set the order of the steps above normal, lowest first",
|
||||||
|
Example: " jarl block step order strict very-strict",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps/order", nil, map[string]any{"steps": args})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
del := &cobra.Command{
|
||||||
|
Use: "delete STEP",
|
||||||
|
Short: "Delete a step (refused while lists or groups use it)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/steps/"+url.PathEscape(args[0]), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, add, rename, order, del)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockGroupCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "group", Aliases: []string{"groups"}, Short: "Client groups: blocking steps for some devices",
|
||||||
|
Long: `A client group puts devices at a blocking step: off (only your rules),
|
||||||
|
normal, or a step you added ('jarl block step'). Members are addresses,
|
||||||
|
prefixes, or ACLs made of them. Devices not in a group are at normal.
|
||||||
|
named must see the devices' own addresses (not a router forwarding for
|
||||||
|
them), and they need fixed addresses (DHCP reservations).`}
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List client groups",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var groups []api.ClientGroup
|
||||||
|
if err := c.Get(ctx(), "/blocking/groups", nil, &groups); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(groups, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "GROUP\tSTEP\tMEMBERS\tVIEWS\tCOMMENT")
|
||||||
|
for _, g := range groups {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", g.Name, g.Step, orDash(strings.Join(g.Members, ", ")), viewsText(g.Views, g.AutoViews), g.Comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var step, comment string
|
||||||
|
var views []string
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add NAME MEMBER...",
|
||||||
|
Short: "Add a client group",
|
||||||
|
Example: ` jarl block group add work 10.10.0.20 --step off
|
||||||
|
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
|
||||||
|
jarl block group add guests guest-net --step normal # an ACL`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{"name": args[0], "members": args[1:], "comment": comment}
|
||||||
|
if step != "" {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
|
viewsBody(body, views)
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/groups", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
add.Flags().StringVar(&step, "step", "", "the group's step: off, normal (default) or one you added")
|
||||||
|
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies in (repeat for several; default: every view with blocking)")
|
||||||
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
var addM, removeM []string
|
||||||
|
var rename string
|
||||||
|
update := &cobra.Command{
|
||||||
|
Use: "update GROUP",
|
||||||
|
Short: "Change a group's members, step, name, views or comment",
|
||||||
|
Example: ` jarl block group update kids --add 10.10.0.52 --remove 10.10.0.50
|
||||||
|
jarl block group update kids --step very-strict`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{}
|
||||||
|
f := cmd.Flags()
|
||||||
|
if len(addM) > 0 || len(removeM) > 0 {
|
||||||
|
var g api.ClientGroup
|
||||||
|
var all []api.ClientGroup
|
||||||
|
if err := c.Get(ctx(), "/blocking/groups", nil, &all); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, x := range all {
|
||||||
|
if x.Name == strings.ToLower(args[0]) {
|
||||||
|
g = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.ID == 0 {
|
||||||
|
return fmt.Errorf("client group %s not found", args[0])
|
||||||
|
}
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, m := range removeM {
|
||||||
|
drop[m] = true
|
||||||
|
}
|
||||||
|
members := []string{}
|
||||||
|
for _, m := range g.Members {
|
||||||
|
if !drop[m] {
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["members"] = append(members, addM...)
|
||||||
|
}
|
||||||
|
if f.Changed("step") {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
|
if f.Changed("name") {
|
||||||
|
body["name"] = rename
|
||||||
|
}
|
||||||
|
if f.Changed("comment") {
|
||||||
|
body["comment"] = comment
|
||||||
|
}
|
||||||
|
viewsBody(body, views)
|
||||||
|
if len(body) == 0 {
|
||||||
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/groups/"+url.PathEscape(args[0]), nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
update.Flags().StringArrayVar(&addM, "add", nil, "add a member (repeat for several)")
|
||||||
|
update.Flags().StringArrayVar(&removeM, "remove", nil, "remove a member (repeat for several)")
|
||||||
|
update.Flags().StringVar(&step, "step", "", "new step")
|
||||||
|
update.Flags().StringVar(&rename, "name", "", "new name")
|
||||||
|
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies in (repeat; "auto": every view with blocking)`)
|
||||||
|
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||||
|
del := &cobra.Command{
|
||||||
|
Use: "delete GROUP",
|
||||||
|
Short: "Delete a client group (its devices go back to normal)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/groups/"+url.PathEscape(args[0]), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, add, update, del)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDash(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
+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,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(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||||
setupCompletion(root)
|
setupCompletion(root)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,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()
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -128,14 +128,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,6 +148,9 @@ 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 {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"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 +123,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 +305,11 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 +318,8 @@ 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},
|
||||||
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,
|
||||||
|
|||||||
+20
-3
@@ -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`
|
||||||
@@ -405,7 +415,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) |
|
||||||
@@ -420,14 +430,19 @@ Needs `blocking:read` (`blocking:write` to change). See
|
|||||||
| Method | Path | Body / query |
|
| Method | Path | Body / query |
|
||||||
|--------|------|--------------|
|
|--------|------|--------------|
|
||||||
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
|
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
|
||||||
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": ""}` (`views` empty or absent: automatic); jarld downloads it right after |
|
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": "", "step": "normal"}` (`views` empty or absent: automatic; `step` the blocking step it starts at); jarld downloads it right after |
|
||||||
| GET / PATCH / DELETE | `/blocking/lists/{id or name}` | PATCH any of `name`, `url`, `views`, `disabled`, `comment` |
|
| GET / PATCH / DELETE | `/blocking/lists/{id or name}` | PATCH any of `name`, `url`, `views`, `disabled`, `comment` |
|
||||||
| POST | `/blocking/lists/{id or name}/refresh` | download now and wait; `?force=1` even when the server says nothing changed. Returns the list; a failed update is in `state` and `status.error` |
|
| POST | `/blocking/lists/{id or name}/refresh` | download now and wait; `?force=1` even when the server says nothing changed. Returns the list; a failed update is in `state` and `status.error` |
|
||||||
| GET | `/blocking/rules` | each with `views` and `auto_views` |
|
| GET | `/blocking/rules` | each with `views` and `auto_views` |
|
||||||
| POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
|
| POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
|
||||||
| PATCH / DELETE | `/blocking/rules/{id}` | |
|
| PATCH / DELETE | `/blocking/rules/{id}` | |
|
||||||
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
|
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
|
||||||
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list}` (what applies when blocking isn't paused) |
|
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list, step}` (what applies to normal devices when blocking isn't paused; `step` when only devices at that step or above are blocked) |
|
||||||
|
| GET / POST | `/blocking/steps` | the steps above normal, lowest first, with the `lists` and `groups` at each; POST `{"name": "strict", "comment": ""}` adds one on top |
|
||||||
|
| PATCH / DELETE | `/blocking/steps/{id or name}` | `{"name", "position", "comment"}`; delete is refused while lists or groups use it |
|
||||||
|
| POST | `/blocking/steps/order` | `{"steps": ["strict", "very-strict"]}`: every step above normal, lowest first |
|
||||||
|
| GET / POST | `/blocking/groups` | client groups; POST `{"name": "kids", "members": ["10.10.0.50", "tablets"], "step": "strict", "views": []}` |
|
||||||
|
| PATCH / DELETE | `/blocking/groups/{id or name}` | the same fields |
|
||||||
| GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
|
| GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
|
||||||
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
|
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
|
||||||
| POST | `/blocking/resume` | end a pause now |
|
| POST | `/blocking/resume` | end a pause now |
|
||||||
@@ -456,6 +471,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
|
||||||
|
|||||||
@@ -147,6 +147,56 @@ A list's state is **ok** (in use), **waiting** (not downloaded yet),
|
|||||||
**error** (the last update failed; the previous version stays in use),
|
**error** (the last update failed; the previous version stays in use),
|
||||||
**disabled**, or **idle** (it applies to no view, so it does nothing).
|
**disabled**, or **idle** (it applies to no view, so it does nothing).
|
||||||
|
|
||||||
|
## Different blocking for different devices
|
||||||
|
|
||||||
|
Client groups give some devices less or more blocking than the rest, like
|
||||||
|
Pi-hole's groups: no blocking for a work laptop, an extra list for the
|
||||||
|
kids' tablets.
|
||||||
|
|
||||||
|
**Steps** decide which lists a device gets. Each step includes the lists
|
||||||
|
of the steps below it, plus its own:
|
||||||
|
|
||||||
|
| Step | Gets |
|
||||||
|
|------|------|
|
||||||
|
| `off` (built in) | only your rules |
|
||||||
|
| `normal` (built in) | your rules and the normal lists; every device not in a group |
|
||||||
|
| `strict` (one you add) | normal, plus the lists at step `strict` |
|
||||||
|
| `very-strict` (one you add) | strict, plus the lists at step `very-strict` |
|
||||||
|
|
||||||
|
Every list starts at a step (normal unless you choose one), and every
|
||||||
|
group is at a step. Your rules apply at every step, `off` included.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl block step add strict
|
||||||
|
jarl blocklist update social-media --step strict
|
||||||
|
jarl block group add work 10.10.0.20 --step off
|
||||||
|
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
|
||||||
|
jarl block group list
|
||||||
|
```
|
||||||
|
|
||||||
|
In the web UI, the Blocking page's **Devices** panel holds the steps and
|
||||||
|
the groups, and each list has a step.
|
||||||
|
|
||||||
|
- **Members** are addresses, networks (`10.10.0.0/24`), ranges
|
||||||
|
(`10.10.0.20-10.10.0.49`), or ACLs made only of those (`key`, `localnets` and `!` can't be used: blocking matches
|
||||||
|
clients by address). A device is in one group: two groups with the same
|
||||||
|
address are refused, but a more specific address in another group wins
|
||||||
|
(`10.10.0.0/24` in one group, `10.10.0.20` in another).
|
||||||
|
- **Steps only add.** A group gets everything up to its step, so "kids get
|
||||||
|
list A, guests get list B, and neither gets the other's" can't be done
|
||||||
|
with steps. That needs a view per group, each with its own lists (and
|
||||||
|
their memory).
|
||||||
|
- Lists aren't copied per step: each is loaded once, and the steps cost a
|
||||||
|
small zone each. Lists at a step no group reaches block nothing (Jarl
|
||||||
|
warns, and shows them as "no effect").
|
||||||
|
- named must see the device's own address. When a router hands out itself
|
||||||
|
as DNS server and forwards to named, every query comes from the router,
|
||||||
|
and groups can't tell devices apart. Devices also need fixed addresses
|
||||||
|
(DHCP reservations); IPv6 privacy addresses change, so add a device's
|
||||||
|
network, or rely on its IPv4 address.
|
||||||
|
- `jarl block check NAME` (and the Blocking page) answers for normal
|
||||||
|
devices, and says when a list of a higher step blocks the name.
|
||||||
|
|
||||||
## Checking a name
|
## Checking a name
|
||||||
|
|
||||||
`jarl block check NAME`, or the box at the top of the Blocking page, says
|
`jarl block check NAME`, or the box at the top of the Blocking page, says
|
||||||
@@ -194,6 +244,10 @@ names, and the clients with the most blocked queries (`jarl stats summary`,
|
|||||||
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
|
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
|
||||||
are about names that really don't exist.
|
are about names that really don't exist.
|
||||||
|
|
||||||
|
The Blocking page shows the last 24 hours too: a **Blocked (24h)** column
|
||||||
|
for each list, and one count for your rules together (named logs which
|
||||||
|
policy zone matched, not which rule in it).
|
||||||
|
|
||||||
Without statistics named doesn't log what it blocks at all.
|
Without statistics named doesn't log what it blocks at all.
|
||||||
|
|
||||||
## Memory and performance
|
## Memory and performance
|
||||||
@@ -250,6 +304,16 @@ response-policy {
|
|||||||
blocking in the same view: Jarl refuses the change and says so.
|
blocking in the same view: Jarl refuses the change and says so.
|
||||||
- Rewrites are logged to Jarl's response log when statistics read it, and
|
- Rewrites are logged to Jarl's response log when statistics read it, and
|
||||||
not at all otherwise.
|
not at all otherwise.
|
||||||
|
- With client groups, a **gate** zone stands in front of the lists of each
|
||||||
|
step: `off.gate.jarl.invalid` before the normal lists,
|
||||||
|
`STEP.gate.jarl.invalid` before a step's. named checks the zones in
|
||||||
|
order and the first match decides; a gate matches the devices below its
|
||||||
|
step by address (`rpz-client-ip` triggers) with `rpz-passthru`, so they
|
||||||
|
never reach the lists behind it. A gate is never logged, and its file is
|
||||||
|
next to the rules zone (`/var/lib/bind/jarl/VIEW/strict.gate.jarl.invalid.rpz`).
|
||||||
|
For "everyone except these devices", a gate lists the address ranges
|
||||||
|
around them, at most a few hundred entries. named matches IPv4 clients as
|
||||||
|
IPv4-mapped IPv6 addresses, so the IPv6 part leaves `::ffff:0:0/96` out.
|
||||||
|
|
||||||
Lists and rules need the `blocking` permission ([permissions.md](permissions.md)).
|
Lists and rules need the `blocking` permission ([permissions.md](permissions.md)).
|
||||||
|
|
||||||
|
|||||||
+50
-4
@@ -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
|
||||||
@@ -345,9 +346,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 +358,45 @@ 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).
|
||||||
|
|
||||||
## Views, ACLs, TSIG keys, options, settings
|
## Views, ACLs, TSIG keys, options, settings
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -371,6 +409,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 +425,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
|
||||||
@@ -476,6 +516,12 @@ jarl block delete DOMAIN [--view V] | --id ID
|
|||||||
jarl block check DOMAIN # blocked? by which rule or list?
|
jarl block check DOMAIN # blocked? by which rule or list?
|
||||||
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
|
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
|
||||||
jarl block pause indefinitely # ... until 'jarl block resume'
|
jarl block pause indefinitely # ... until 'jarl block resume'
|
||||||
|
jarl block step list | add NAME | rename STEP NEW | order STEP... | delete STEP
|
||||||
|
jarl block group list
|
||||||
|
jarl block group add NAME MEMBER... [--step S] [--view V]... # addresses, networks or ACLs
|
||||||
|
jarl block group update GROUP [--add M] [--remove M] [--step S] [--name N]
|
||||||
|
jarl block group delete GROUP
|
||||||
|
jarl blocklist update LIST --step STEP # devices at that step or above get it
|
||||||
jarl block resume
|
jarl block resume
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ 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.
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`).
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
# 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. |
|
||||||
|
|
||||||
|
### 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: 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,
|
||||||
|
|||||||
+6
-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,6 +177,9 @@ 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.
|
||||||
|
|||||||
+23
-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 |
|
||||||
@@ -115,14 +116,34 @@ From [blocking.md](blocking.md).
|
|||||||
| `blocking_view_missing` | error | A list or rule names a view that doesn't exist |
|
| `blocking_view_missing` | error | A list or rule names a view that doesn't exist |
|
||||||
| `blocking_no_view` | warning | A list or rule applies to no view: none offers recursion, and blocking only changes recursive answers |
|
| `blocking_no_view` | warning | A list or rule applies to no view: none offers recursion, and blocking only changes recursive answers |
|
||||||
| `blocking_no_recursion` | warning | A list or rule names a view that doesn't offer recursion, where it has no effect |
|
| `blocking_no_recursion` | warning | A list or rule names a view that doesn't offer recursion, where it has no effect |
|
||||||
|
| `blocking_step_missing` | error | A list or client group is at a step that doesn't exist (a list can't be at `off`) |
|
||||||
|
| `client_group_member_invalid` | error | Client group members are addresses, networks, or ACLs made only of those |
|
||||||
|
| `client_group_overlap` | error | The same address or network is in two client groups of a view |
|
||||||
|
| `client_group_empty` | warning | A client group has no members |
|
||||||
|
| `blocklist_step_unused` | warning | A list is at a step that no client group reaches, so it blocks nothing |
|
||||||
|
|
||||||
## Security lints
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ 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
|
**Different data per view (split DNS).** Copy the zone, then change what
|
||||||
|
|||||||
@@ -208,6 +208,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"`
|
||||||
}
|
}
|
||||||
@@ -506,6 +509,27 @@ type Blocklist struct {
|
|||||||
// MemoryMB estimates what named needs for the list, in all its views.
|
// MemoryMB estimates what named needs for the list, in all its views.
|
||||||
MemoryMB int `json:"memory_mb,omitempty"`
|
MemoryMB int `json:"memory_mb,omitempty"`
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
|
// Step is the name of the step the list starts at ("normal" or one
|
||||||
|
// above it).
|
||||||
|
Step string `json:"step"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockStep is a blocking step above normal as returned by the API, with
|
||||||
|
// what uses it.
|
||||||
|
type BlockStep struct {
|
||||||
|
model.BlockStep
|
||||||
|
Lists []string `json:"lists"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
Pending string `json:"pending,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientGroup is a client group as returned by the API.
|
||||||
|
type ClientGroup struct {
|
||||||
|
model.ClientGroup
|
||||||
|
Step string `json:"step"`
|
||||||
|
Views []string `json:"views"`
|
||||||
|
AutoViews bool `json:"auto_views,omitempty"`
|
||||||
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockRule is a block or allow rule as returned by the API.
|
// BlockRule is a block or allow rule as returned by the API.
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package blocking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/netip"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client steps (docs/blocking.md): named checks policy zones in order and
|
||||||
|
// the first match decides, so a "gate" zone that lets some clients through
|
||||||
|
// (rpz-passthru, matched on the client's address) keeps them from every
|
||||||
|
// zone after it. Jarl puts a gate in front of the lists of each step,
|
||||||
|
// letting through the clients whose step is below it.
|
||||||
|
|
||||||
|
// GateSuffix follows a gate's name: off.gate.jarl.invalid,
|
||||||
|
// strict.gate.jarl.invalid.
|
||||||
|
const GateSuffix = ".gate.jarl.invalid"
|
||||||
|
|
||||||
|
// v4Mapped holds the IPv4-mapped IPv6 addresses.
|
||||||
|
var v4Mapped = netip.MustParsePrefix("::ffff:0:0/96")
|
||||||
|
|
||||||
|
// GateZone returns the policy zone name of the gate in front of a step's
|
||||||
|
// lists ("off" for the gate in front of the normal lists).
|
||||||
|
func GateZone(step string) string { return step + GateSuffix }
|
||||||
|
|
||||||
|
// Assignment gives the addresses in Prefix a step rank (-1 off, 0 normal,
|
||||||
|
// 1, 2, ... the steps above it).
|
||||||
|
type Assignment struct {
|
||||||
|
Prefix netip.Prefix
|
||||||
|
Rank int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassBelow returns the fewest prefixes that cover exactly the addresses whose
|
||||||
|
// rank is below k. An address has the rank of the most specific
|
||||||
|
// assignment containing it, and rank 0 (normal) when none does. When two
|
||||||
|
// assignments have the same prefix, the higher rank counts (validation
|
||||||
|
// refuses that anyway).
|
||||||
|
func PassBelow(assigns []Assignment, k int) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, root := range []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")} {
|
||||||
|
var mine []Assignment
|
||||||
|
if !root.Addr().Is4() {
|
||||||
|
// named matches IPv4 clients as IPv4-mapped IPv6 addresses:
|
||||||
|
// keep IPv6 prefixes clear of them, so only the IPv4 ones
|
||||||
|
// decide for IPv4 clients.
|
||||||
|
mine = append(mine, Assignment{v4Mapped, math.MaxInt})
|
||||||
|
}
|
||||||
|
rank := 0
|
||||||
|
for _, a := range assigns {
|
||||||
|
p := a.Prefix.Masked()
|
||||||
|
if p.Addr().Is4() != root.Addr().Is4() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Bits() == 0 {
|
||||||
|
rank = max(rank, a.Rank)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mine = append(mine, Assignment{p, a.Rank})
|
||||||
|
}
|
||||||
|
for _, p := range cover(root, rank, mine, k) {
|
||||||
|
if p.Bits() == 0 {
|
||||||
|
// A trigger needs at least one bit: use both halves.
|
||||||
|
lo, hi := halves(p)
|
||||||
|
out = append(out, lo, hi)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cover returns the prefixes inside p whose addresses have a rank below k.
|
||||||
|
// rank is p's own; inside are the assignments strictly inside p.
|
||||||
|
func cover(p netip.Prefix, rank int, inside []Assignment, k int) []netip.Prefix {
|
||||||
|
if len(inside) == 0 {
|
||||||
|
if rank < k {
|
||||||
|
return []netip.Prefix{p}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []netip.Prefix
|
||||||
|
lo, hi := halves(p)
|
||||||
|
full := 0
|
||||||
|
for _, h := range []netip.Prefix{lo, hi} {
|
||||||
|
var sub []Assignment
|
||||||
|
for _, a := range inside {
|
||||||
|
if a.Prefix.Bits() > h.Bits() && h.Contains(a.Prefix.Addr()) {
|
||||||
|
sub = append(sub, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := cover(h, ownRank(inside, h, rank), sub, k)
|
||||||
|
if len(c) == 1 && c[0] == h {
|
||||||
|
full++
|
||||||
|
}
|
||||||
|
out = append(out, c...)
|
||||||
|
}
|
||||||
|
if full == 2 {
|
||||||
|
return []netip.Prefix{p}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownRank is the rank of h: its own assignments' (the highest), or the
|
||||||
|
// inherited one without any.
|
||||||
|
func ownRank(inside []Assignment, h netip.Prefix, inherited int) int {
|
||||||
|
r, found := 0, false
|
||||||
|
for _, a := range inside {
|
||||||
|
if a.Prefix == h {
|
||||||
|
if !found || a.Rank > r {
|
||||||
|
r = a.Rank
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return inherited
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// halves splits a prefix into its two halves.
|
||||||
|
func halves(p netip.Prefix) (netip.Prefix, netip.Prefix) {
|
||||||
|
bits := p.Bits() + 1
|
||||||
|
lo := netip.PrefixFrom(p.Addr(), bits).Masked()
|
||||||
|
b := lo.Addr().AsSlice()
|
||||||
|
i := (bits - 1) / 8
|
||||||
|
b[i] |= 0x80 >> ((bits - 1) % 8)
|
||||||
|
a, _ := netip.AddrFromSlice(b)
|
||||||
|
return lo, netip.PrefixFrom(a, bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIPOwner returns the policy zone owner name that matches clients
|
||||||
|
// in p (an rpz-client-ip trigger): 32.37.0.10.10.rpz-client-ip for
|
||||||
|
// 10.10.0.37/32, 64.zz.3d7f.8013.2a02.rpz-client-ip for 2a02:8013:3d7f::/64.
|
||||||
|
func ClientIPOwner(p netip.Prefix) string {
|
||||||
|
p = p.Masked()
|
||||||
|
a := p.Addr()
|
||||||
|
var labels []string
|
||||||
|
if a.Is4() {
|
||||||
|
b := a.As4()
|
||||||
|
labels = []string{fmt.Sprint(b[3]), fmt.Sprint(b[2]), fmt.Sprint(b[1]), fmt.Sprint(b[0])}
|
||||||
|
} else {
|
||||||
|
// RFC 5952 text, with "zz" for the "::" and the words reversed.
|
||||||
|
s := a.String()
|
||||||
|
var words []string
|
||||||
|
if left, right, ok := strings.Cut(s, "::"); ok {
|
||||||
|
if left != "" {
|
||||||
|
words = append(words, strings.Split(left, ":")...)
|
||||||
|
}
|
||||||
|
words = append(words, "zz")
|
||||||
|
if right != "" {
|
||||||
|
words = append(words, strings.Split(right, ":")...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
words = strings.Split(s, ":")
|
||||||
|
}
|
||||||
|
for i := len(words) - 1; i >= 0; i-- {
|
||||||
|
labels = append(labels, words[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprint(p.Bits()) + "." + strings.Join(labels, ".") + ".rpz-client-ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GateZoneFile renders a gate: every client in prefixes passes.
|
||||||
|
func GateZoneFile(title string, prefixes []netip.Prefix) string {
|
||||||
|
owners := make([]string, 0, len(prefixes))
|
||||||
|
for _, p := range prefixes {
|
||||||
|
owners = append(owners, ClientIPOwner(p))
|
||||||
|
}
|
||||||
|
sort.Strings(owners)
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(header(title, 1))
|
||||||
|
for _, o := range owners {
|
||||||
|
sb.WriteString(o + "\tCNAME\trpz-passthru.\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package blocking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
|
||||||
|
|
||||||
|
// rankOf is the rank PassBelow's model gives an address.
|
||||||
|
func rankOf(assigns []Assignment, a netip.Addr) int {
|
||||||
|
best, bits := 0, -1
|
||||||
|
for _, x := range assigns {
|
||||||
|
if x.Prefix.Contains(a) && x.Prefix.Bits() >= bits {
|
||||||
|
if x.Prefix.Bits() > bits || x.Rank > best {
|
||||||
|
best = x.Rank
|
||||||
|
}
|
||||||
|
bits = x.Prefix.Bits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func covered(ps []netip.Prefix, a netip.Addr) bool {
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Contains(a) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassBelow(t *testing.T) {
|
||||||
|
assigns := []Assignment{
|
||||||
|
{pfx("10.10.0.20/32"), -1}, // work laptop: off
|
||||||
|
{pfx("10.10.0.0/24"), 2}, // the kids' network: very strict
|
||||||
|
{pfx("10.10.0.50/32"), 1}, // except one tablet: strict
|
||||||
|
{pfx("2a02:db8::/64"), 1}, // an IPv6 network: strict
|
||||||
|
{pfx("2a02:db8::99/128"), 0}, // one device in it: normal
|
||||||
|
}
|
||||||
|
probe := []string{"10.10.0.20", "10.10.0.21", "10.10.0.50", "10.10.0.51", "10.10.1.1", "192.168.1.1", "0.0.0.1", "255.255.255.255",
|
||||||
|
"2a02:db8::1", "2a02:db8::99", "2a02:db8:0:1::1", "::1", "ffff::1"}
|
||||||
|
for k := 0; k <= 3; k++ {
|
||||||
|
gate := PassBelow(assigns, k)
|
||||||
|
for _, s := range probe {
|
||||||
|
a := netip.MustParseAddr(s)
|
||||||
|
if want := rankOf(assigns, a) < k; covered(gate, a) != want {
|
||||||
|
t.Errorf("k=%d %s: passes=%v, want %v (rank %d)", k, s, !want, want, rankOf(assigns, a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range gate {
|
||||||
|
if p.Bits() == 0 {
|
||||||
|
t.Errorf("k=%d: a /0 trigger", k)
|
||||||
|
}
|
||||||
|
// named matches IPv4 clients as ::ffff:a.b.c.d: no IPv6
|
||||||
|
// prefix may reach them.
|
||||||
|
if p.Addr().Is6() && p.Overlaps(v4Mapped) {
|
||||||
|
t.Errorf("k=%d: %s covers IPv4-mapped addresses", k, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Off clients only, before the normal lists.
|
||||||
|
if got := PassBelow(assigns, 0); !slices.Equal(got, []netip.Prefix{pfx("10.10.0.20/32")}) {
|
||||||
|
t.Errorf("off gate = %v", got)
|
||||||
|
}
|
||||||
|
// Nobody above normal: everyone passes the gate of step 1: two IPv4
|
||||||
|
// halves, and IPv6 around the IPv4-mapped /96 (96 prefixes).
|
||||||
|
if got := PassBelow(nil, 1); len(got) != 2+96 {
|
||||||
|
t.Errorf("empty step-1 gate = %v", got)
|
||||||
|
}
|
||||||
|
// The complement of one IPv4 address is 32 prefixes (plus IPv6's 96).
|
||||||
|
if got := PassBelow([]Assignment{{pfx("10.10.0.50/32"), 1}}, 1); len(got) != 32+96 {
|
||||||
|
t.Errorf("complement of one address: %d prefixes", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPOwner(t *testing.T) {
|
||||||
|
for p, want := range map[string]string{
|
||||||
|
"10.10.0.37/32": "32.37.0.10.10.rpz-client-ip",
|
||||||
|
"10.10.0.0/24": "24.0.0.10.10.rpz-client-ip",
|
||||||
|
"2a02:db8::/64": "64.zz.db8.2a02.rpz-client-ip",
|
||||||
|
"2a02:db8::1/128": "128.1.zz.db8.2a02.rpz-client-ip",
|
||||||
|
"::/1": "1.zz.rpz-client-ip",
|
||||||
|
"2001:db8:1:2:3:4:5:6/128": "128.6.5.4.3.2.1.db8.2001.rpz-client-ip",
|
||||||
|
} {
|
||||||
|
if got := ClientIPOwner(pfx(p)); got != want {
|
||||||
|
t.Errorf("%s: %s, want %s", p, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
z := GateZoneFile("t", []netip.Prefix{pfx("10.10.0.20/32")})
|
||||||
|
if !strings.Contains(z, "32.20.0.10.10.rpz-client-ip\tCNAME\trpz-passthru.\n") {
|
||||||
|
t.Errorf("gate zone:\n%s", z)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ type Match struct {
|
|||||||
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
|
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
|
||||||
RuleID int64 `json:"rule_id,omitempty"`
|
RuleID int64 `json:"rule_id,omitempty"`
|
||||||
List string `json:"list,omitempty"` // the blocklist, when a list matched
|
List string `json:"list,omitempty"` // the blocklist, when a list matched
|
||||||
|
// Step is the list's step when it is above normal: only clients at
|
||||||
|
// that step or above get this answer (others: none).
|
||||||
|
Step string `json:"step,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions of rules.
|
// Actions of rules.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,56 @@ 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -323,6 +374,7 @@ func Default() *Config {
|
|||||||
},
|
},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
|
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: 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 +446,9 @@ 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)
|
||||||
|
}
|
||||||
switch c.Log.Format {
|
switch c.Log.Format {
|
||||||
case "text", "json":
|
case "text", "json":
|
||||||
default:
|
default:
|
||||||
@@ -633,4 +688,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: ""
|
||||||
`
|
`
|
||||||
@@ -87,3 +87,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -335,7 +335,9 @@ func (l *limitedReader) Read(p []byte) (int, error) {
|
|||||||
|
|
||||||
// CheckBlocking reports, for every view with blocking, whether name is
|
// CheckBlocking reports, for every view with blocking, whether name is
|
||||||
// blocked or allowed there, and by which rule or list. It looks names up
|
// blocked or allowed there, and by which rule or list. It looks names up
|
||||||
// the way named does: rules first, then the lists in order.
|
// the way named does: rules first, then the lists in order. The answer is
|
||||||
|
// for clients at the normal step; a list of a higher step that blocks the
|
||||||
|
// name says so (Match.Step), and clients at step off skip every list.
|
||||||
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
|
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
|
||||||
n, err := blocking.NormalizeName(name)
|
n, err := blocking.NormalizeName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,8 +351,8 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
|
|||||||
out = append(out, blocking.Match{View: z.View, Result: "none"})
|
out = append(out, blocking.Match{View: z.View, Result: "none"})
|
||||||
cur = &out[len(out)-1]
|
cur = &out[len(out)-1]
|
||||||
}
|
}
|
||||||
if cur.Result != "none" {
|
if cur.Result != "none" || z.IsGate() {
|
||||||
continue // an earlier zone decided
|
continue // an earlier zone decided, or a gate (it never blocks)
|
||||||
}
|
}
|
||||||
if z.ListID == 0 {
|
if z.ListID == 0 {
|
||||||
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
|
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
|
||||||
@@ -372,7 +374,11 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
|
|||||||
feeds[z.File] = entry
|
feeds[z.File] = entry
|
||||||
}
|
}
|
||||||
if entry != "" {
|
if entry != "" {
|
||||||
cur.Result, cur.Entry, cur.List = "blocked", entry, st.Blocklists[z.ListID].Name
|
b := st.Blocklists[z.ListID]
|
||||||
|
cur.Result, cur.Entry, cur.List = "blocked", entry, b.Name
|
||||||
|
if b.StepID != model.StepNormal {
|
||||||
|
cur.Step = st.StepName(b.StepID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -117,6 +117,16 @@ func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
b.Disabled = *in.Disabled
|
b.Disabled = *in.Disabled
|
||||||
}
|
}
|
||||||
|
step, given, err := stepRef(st, in.Step, in.StepID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if given {
|
||||||
|
if step == model.StepOff {
|
||||||
|
return errf(KindBadRequest, "a blocklist starts at normal or a step above it, not off")
|
||||||
|
}
|
||||||
|
b.StepID = step
|
||||||
|
}
|
||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
b.Comment = *in.Comment
|
b.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
@@ -226,6 +236,11 @@ func blockingUsers(st *model.State, viewID int64) []string {
|
|||||||
out = append(out, ruleLabel(r))
|
out = append(out, ruleLabel(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
if slices.Contains(g.ViewIDs, viewID) {
|
||||||
|
out = append(out, "client group "+g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/iprange"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- blocking steps and client groups (docs/blocking.md)
|
||||||
|
|
||||||
|
// stepRef resolves a step given by name or ID. ok is false when neither
|
||||||
|
// is given.
|
||||||
|
func stepRef(st *model.State, name *string, id *int64) (int64, bool, error) {
|
||||||
|
switch {
|
||||||
|
case name != nil:
|
||||||
|
s, found := st.StepByName(*name)
|
||||||
|
if !found {
|
||||||
|
return 0, true, errf(KindBadRequest, "blocking step %q does not exist (steps: %s)", *name, stepNames(st))
|
||||||
|
}
|
||||||
|
return s, true, nil
|
||||||
|
case id != nil:
|
||||||
|
if _, found := st.StepRank(*id); !found {
|
||||||
|
return 0, true, errf(KindBadRequest, "blocking step %d does not exist", *id)
|
||||||
|
}
|
||||||
|
return *id, true, nil
|
||||||
|
}
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepNames lists the steps for messages: "off, normal, strict".
|
||||||
|
func stepNames(st *model.State) string {
|
||||||
|
names := []string{"off", "normal"}
|
||||||
|
for _, s := range st.OrderedBlockSteps() {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeMember checks a client group member: an address, a prefix or
|
||||||
|
// an ACL name (an ACL's own elements are checked by validation). It
|
||||||
|
// returns the canonical form.
|
||||||
|
func NormalizeMember(st *model.State, m string) (string, error) {
|
||||||
|
m = strings.TrimSpace(m)
|
||||||
|
if a, err := netip.ParseAddr(m); err == nil {
|
||||||
|
if a.Zone() != "" {
|
||||||
|
return "", errf(KindBadRequest, "%q: an address with a zone can't be matched", m)
|
||||||
|
}
|
||||||
|
return a.Unmap().String(), nil
|
||||||
|
}
|
||||||
|
if p, err := netip.ParsePrefix(m); err == nil {
|
||||||
|
return p.Masked().String(), nil
|
||||||
|
}
|
||||||
|
if r, ok, err := iprange.Parse(m); ok {
|
||||||
|
if err != nil {
|
||||||
|
return "", errf(KindBadRequest, "%v", err)
|
||||||
|
}
|
||||||
|
return r.String(), nil
|
||||||
|
}
|
||||||
|
if a := st.ACLByName(m); a != nil {
|
||||||
|
return a.Name, nil
|
||||||
|
}
|
||||||
|
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24), a range (10.10.0.20-10.10.0.49) or an ACL", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in BlockStepInput
|
||||||
|
if err := decode(op.Data, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var s *model.BlockStep
|
||||||
|
if op.Kind == OpBlockStepCreate {
|
||||||
|
if op.ID <= 0 || st.BlockSteps[op.ID] != nil {
|
||||||
|
return errf(KindConflict, "step ID %d is already in use", op.ID)
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return errf(KindBadRequest, "a blocking step needs a name")
|
||||||
|
}
|
||||||
|
pos := 0
|
||||||
|
for _, o := range st.BlockSteps {
|
||||||
|
pos = max(pos, o.Position+1)
|
||||||
|
}
|
||||||
|
s = &model.BlockStep{ID: op.ID, Position: pos}
|
||||||
|
} else {
|
||||||
|
s = st.BlockSteps[op.ID]
|
||||||
|
if s == nil {
|
||||||
|
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(*in.Name))
|
||||||
|
if !blocklistNameRe.MatchString(name) || name == "off" || name == "normal" {
|
||||||
|
return errf(KindBadRequest, "step name %q: use 1 to 40 lowercase letters, digits and hyphens, like strict (off and normal are built in)", *in.Name)
|
||||||
|
}
|
||||||
|
if id, found := st.StepByName(name); found && id != s.ID {
|
||||||
|
return errf(KindConflict, "step %s already exists", name)
|
||||||
|
}
|
||||||
|
s.Name = name
|
||||||
|
}
|
||||||
|
if in.Position != nil {
|
||||||
|
s.Position = *in.Position
|
||||||
|
}
|
||||||
|
if in.Comment != nil {
|
||||||
|
s.Comment = *in.Comment
|
||||||
|
}
|
||||||
|
s.Touch(c.user(), c.now)
|
||||||
|
st.BlockSteps[s.ID] = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBlockStepDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := st.BlockSteps[op.ID]
|
||||||
|
if s == nil {
|
||||||
|
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if users := stepUsers(st, s.ID); len(users) > 0 {
|
||||||
|
return errf(KindConflict, "step %s is used by %s; move them to another step first", s.Name, strings.Join(users, ", "))
|
||||||
|
}
|
||||||
|
delete(st.BlockSteps, s.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepUsers lists the blocklists and client groups at a step.
|
||||||
|
func stepUsers(st *model.State, id int64) []string {
|
||||||
|
var out []string
|
||||||
|
for _, b := range st.SortedBlocklists() {
|
||||||
|
if b.StepID == id {
|
||||||
|
out = append(out, "blocklist "+b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
if g.StepID == id {
|
||||||
|
out = append(out, "client group "+g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyClientGroupPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in ClientGroupInput
|
||||||
|
if err := decode(op.Data, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var g *model.ClientGroup
|
||||||
|
if op.Kind == OpClientGroupCreate {
|
||||||
|
if op.ID <= 0 || st.ClientGroups[op.ID] != nil {
|
||||||
|
return errf(KindConflict, "client group ID %d is already in use", op.ID)
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return errf(KindBadRequest, "a client group needs a name")
|
||||||
|
}
|
||||||
|
g = &model.ClientGroup{ID: op.ID, Members: []string{}, StepID: model.StepNormal, ViewIDs: []int64{}}
|
||||||
|
} else {
|
||||||
|
g = st.ClientGroups[op.ID]
|
||||||
|
if g == nil {
|
||||||
|
return errf(KindNotFound, "client group %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Name != nil {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(*in.Name))
|
||||||
|
if !blocklistNameRe.MatchString(name) {
|
||||||
|
return errf(KindBadRequest, "client group name %q: use 1 to 40 lowercase letters, digits and hyphens, like kids", *in.Name)
|
||||||
|
}
|
||||||
|
if other := st.ClientGroupByName(name); other != nil && other.ID != g.ID {
|
||||||
|
return errf(KindConflict, "client group %s already exists", name)
|
||||||
|
}
|
||||||
|
g.Name = name
|
||||||
|
}
|
||||||
|
if in.Members != nil {
|
||||||
|
members := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range *in.Members {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := NormalizeMember(st, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !seen[n] {
|
||||||
|
seen[n] = true
|
||||||
|
members = append(members, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.Members = members
|
||||||
|
}
|
||||||
|
step, given, err := stepRef(st, in.Step, in.StepID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if given {
|
||||||
|
g.StepID = step
|
||||||
|
}
|
||||||
|
ids, err := viewIDs(st, in.Views, in.ViewIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ids != nil {
|
||||||
|
g.ViewIDs = ids
|
||||||
|
}
|
||||||
|
if in.Comment != nil {
|
||||||
|
g.Comment = *in.Comment
|
||||||
|
}
|
||||||
|
g.Touch(c.user(), c.now)
|
||||||
|
st.ClientGroups[g.ID] = g
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyClientGroupDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
|
if err := c.require(blockingAction()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g := st.ClientGroups[op.ID]
|
||||||
|
if g == nil {
|
||||||
|
return errf(KindNotFound, "client group %d does not exist", op.ID)
|
||||||
|
}
|
||||||
|
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(st.ClientGroups, g.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- expansion
|
||||||
|
|
||||||
|
func (x *expander) blockStepCreate(req Request) ([]model.Op, error) {
|
||||||
|
return x.simpleCreate(req, OpBlockStepCreate, func(data json.RawMessage) (string, error) {
|
||||||
|
var in BlockStepInput
|
||||||
|
if err := decode(data, &in); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return "", errf(KindBadRequest, "a blocking step needs a name")
|
||||||
|
}
|
||||||
|
return "add blocking step " + strings.ToLower(*in.Name), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) clientGroupCreate(req Request) ([]model.Op, error) {
|
||||||
|
return x.simpleCreate(req, OpClientGroupCreate, func(data json.RawMessage) (string, error) {
|
||||||
|
var in ClientGroupInput
|
||||||
|
if err := decode(data, &in); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
return "", errf(KindBadRequest, "a client group needs a name")
|
||||||
|
}
|
||||||
|
return "add client group " + strings.ToLower(*in.Name), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func clientGroup(name, step string, members ...string) Request {
|
||||||
|
return NewRequest(OpClientGroupCreate, 0, ClientGroupInput{Name: &name, Step: &step, Members: &members})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientGroups(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
_, srv := newListServer(t, "||ads.example^\n")
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpBlockStepCreate, 0, BlockStepInput{Name: model.Ptr("strict")}),
|
||||||
|
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("tablets"), Elements: &[]string{"10.0.0.50", "10.0.0.51"}}),
|
||||||
|
)
|
||||||
|
env.submit(env.admin,
|
||||||
|
blocklist("ads", srv.URL+"/a.txt"),
|
||||||
|
NewRequest(OpBlocklistCreate, 0, BlocklistInput{Name: model.Ptr("social"), URL: model.Ptr(srv.URL + "/s.txt"), Step: model.Ptr("strict")}),
|
||||||
|
clientGroup("work", "off", "10.0.0.20"),
|
||||||
|
clientGroup("kids", "strict", "tablets", "2001:db8:1::/64"),
|
||||||
|
)
|
||||||
|
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||||
|
c := string(conf)
|
||||||
|
// Off clients pass before the normal lists, everyone below strict
|
||||||
|
// before the strict ones; gates aren't logged.
|
||||||
|
want := "response-policy {\n" +
|
||||||
|
"\t\tzone \"off.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||||
|
"\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||||
|
"\t\tzone \"strict.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
|
||||||
|
"\t\tzone \"social.blocklist.jarl.invalid\" min-update-interval 0 log no;\n"
|
||||||
|
if !strings.Contains(c, want) {
|
||||||
|
t.Errorf("response-policy:\n%s", c)
|
||||||
|
}
|
||||||
|
gate := func(name string) string {
|
||||||
|
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".gate.jarl.invalid.rpz"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
if g := gate("off"); !strings.Contains(g, "32.20.0.0.10.rpz-client-ip\tCNAME\trpz-passthru.\n") || strings.Count(g, "rpz-client-ip") != 1 {
|
||||||
|
t.Errorf("off gate:\n%s", g)
|
||||||
|
}
|
||||||
|
// Everyone but the kids' tablets (via the ACL) and network passes.
|
||||||
|
sg := gate("strict")
|
||||||
|
for _, notIn := range []string{"32.50.0.0.10.rpz-client-ip", "32.51.0.0.10.rpz-client-ip", "64.zz.1.db8.2001.rpz-client-ip"} {
|
||||||
|
if strings.Contains(sg, notIn) {
|
||||||
|
t.Errorf("strict gate lets %s through:\n%s", notIn, sg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 10.0.0.0/27 holds the work laptop, 10.0.0.52/30 the next addresses
|
||||||
|
// after the tablets, 128.0.0.0/1 the upper half of IPv4.
|
||||||
|
for _, in := range []string{"27.0.0.0.10.rpz-client-ip", "30.52.0.0.10.rpz-client-ip", "1.0.0.0.128.rpz-client-ip"} {
|
||||||
|
if !strings.Contains(sg, in) {
|
||||||
|
t.Errorf("strict gate lacks %s:\n%s", in, sg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st := env.state()
|
||||||
|
strict, _ := st.StepByName("strict")
|
||||||
|
// A step that lists or groups use can't be deleted.
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpBlockStepDelete, strict, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist social") {
|
||||||
|
t.Errorf("deleting a used step: %+v", e)
|
||||||
|
}
|
||||||
|
// One client, one group; ACLs must hold addresses only.
|
||||||
|
if e := env.submitErr(env.admin, clientGroup("dup", "normal", "10.0.0.20")); e.Kind != KindValidation || !strings.Contains(e.Message, "10.0.0.20/32 is in client groups") {
|
||||||
|
t.Errorf("an address in two groups: %+v", e)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("keyed"), Elements: &[]string{"localnets"}}))
|
||||||
|
if e := env.submitErr(env.admin, clientGroup("bad", "normal", "keyed")); e.Kind != KindValidation || !strings.Contains(e.Message, "isn't an address") {
|
||||||
|
t.Errorf("an ACL with a key: %+v", e)
|
||||||
|
}
|
||||||
|
for _, req := range []Request{clientGroup("x", "nope", "10.1.1.1"), clientGroup("y", "normal", "not-an-acl"), clientGroup("Bad Name", "normal")} {
|
||||||
|
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
|
||||||
|
t.Errorf("%s: %+v", req.Data, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A more specific address in another group is fine: it wins.
|
||||||
|
env.submit(env.admin, clientGroup("guest", "normal", "2001:db8:1::9"))
|
||||||
|
if sg := gate("strict"); !strings.Contains(sg, "128.9.zz.1.db8.2001.rpz-client-ip") {
|
||||||
|
t.Errorf("the guest inside the kids' network passes the strict gate:\n%s", sg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kids' group gone: the strict list blocks nobody (a warning).
|
||||||
|
kids := env.state().ClientGroupByName("kids")
|
||||||
|
res := env.submit(env.admin, NewRequest(OpClientGroupDelete, kids.ID, nil))
|
||||||
|
found := false
|
||||||
|
for _, p := range res.Problems {
|
||||||
|
found = found || p.Code == "blocklist_step_unused"
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("no warning for a step no group reaches: %+v", res.Problems)
|
||||||
|
}
|
||||||
|
// Reverting brings it back.
|
||||||
|
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rev.Ops) != 1 {
|
||||||
|
t.Errorf("revert ops: %+v (only the deleted group should come back)", rev.Ops)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().ClientGroupByName("kids") == nil {
|
||||||
|
t.Fatalf("revert: %v", err)
|
||||||
|
}
|
||||||
|
// Renaming the ACL renames the member; deleting it is refused.
|
||||||
|
tablets := env.state().ACLByName("tablets")
|
||||||
|
env.submit(env.admin, NewRequest(OpACLUpdate, tablets.ID, ACLInput{Name: model.Ptr("pads")}))
|
||||||
|
if g := env.state().ClientGroupByName("kids"); g.Members[0] != "pads" {
|
||||||
|
t.Errorf("ACL rename: members %v", g.Members)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpACLDelete, tablets.ID, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "client group kids") {
|
||||||
|
t.Errorf("deleting an ACL a group uses: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -104,9 +107,19 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
|
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Steps before the lists and groups that use them.
|
||||||
|
for _, k := range sortedKeys(to.BlockSteps) {
|
||||||
|
n := to.BlockSteps[k]
|
||||||
|
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
|
||||||
|
if o, ok := from.BlockSteps[k]; !ok {
|
||||||
|
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
|
||||||
|
} else if !sameObject(o, n) {
|
||||||
|
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, k := range sortedKeys(to.Blocklists) {
|
for _, k := range sortedKeys(to.Blocklists) {
|
||||||
n := to.Blocklists[k]
|
n := to.Blocklists[k]
|
||||||
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment}
|
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment, StepID: model.Ptr(n.StepID)}
|
||||||
if o, ok := from.Blocklists[k]; !ok {
|
if o, ok := from.Blocklists[k]; !ok {
|
||||||
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
|
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
|
||||||
} else if !sameObject(o, n) {
|
} else if !sameObject(o, n) {
|
||||||
@@ -122,7 +135,21 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
|
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, k := range sortedKeys(to.ClientGroups) {
|
||||||
|
n := to.ClientGroups[k]
|
||||||
|
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
|
||||||
|
if o, ok := from.ClientGroups[k]; !ok {
|
||||||
|
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
|
||||||
|
} else if !sameObject(o, n) {
|
||||||
|
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Deletions, most dependent objects first.
|
// Deletions, most dependent objects first.
|
||||||
|
for _, k := range sortedKeys(from.ClientGroups) {
|
||||||
|
if _, ok := to.ClientGroups[k]; !ok {
|
||||||
|
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, k := range sortedKeys(from.BlockRules) {
|
for _, k := range sortedKeys(from.BlockRules) {
|
||||||
if _, ok := to.BlockRules[k]; !ok {
|
if _, ok := to.BlockRules[k]; !ok {
|
||||||
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
|
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
|
||||||
@@ -133,6 +160,11 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
|
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, k := range sortedKeys(from.BlockSteps) {
|
||||||
|
if _, ok := to.BlockSteps[k]; !ok {
|
||||||
|
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[k].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, k := range sortedKeys(from.Records) {
|
for _, k := range sortedKeys(from.Records) {
|
||||||
if _, ok := to.Records[k]; !ok {
|
if _, ok := to.Records[k]; !ok {
|
||||||
o := from.Records[k]
|
o := from.Records[k]
|
||||||
|
|||||||
+84
-15
@@ -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{}
|
||||||
@@ -501,13 +560,15 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
|||||||
if len(work.Zones) != len(base.Zones) {
|
if len(work.Zones) != len(base.Zones) {
|
||||||
pl.configChanged = true
|
pl.configChanged = true
|
||||||
}
|
}
|
||||||
// Rules zones whose content changed. New ones come with a change to
|
// Rules zones and gates whose content changed. New ones come with a
|
||||||
// named.conf, and reconfig loads them.
|
// change to named.conf, and reconfig loads them.
|
||||||
for _, z := range pl.out.Policy {
|
for _, z := range pl.out.Policy {
|
||||||
if z.ListID != 0 || !changedPaths[z.File] {
|
if z.ListID != 0 || !changedPaths[z.File] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slices.Contains(baseOut.Policy, z) {
|
if slices.ContainsFunc(baseOut.Policy, func(o render.PolicyZone) bool {
|
||||||
|
return o.Name == z.Name && o.ViewID == z.ViewID && o.File == z.File
|
||||||
|
}) {
|
||||||
pl.reloadPolicy = append(pl.reloadPolicy, z)
|
pl.reloadPolicy = append(pl.reloadPolicy, z)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,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 {
|
||||||
@@ -779,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,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:
|
||||||
@@ -264,6 +266,30 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
verb = "delete rule: "
|
verb = "delete rule: "
|
||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
|
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
|
||||||
|
case OpBlockStepCreate:
|
||||||
|
return x.blockStepCreate(req)
|
||||||
|
case OpBlockStepUpdate, OpBlockStepDelete:
|
||||||
|
s := x.st.BlockSteps[req.ID]
|
||||||
|
if s == nil {
|
||||||
|
return nil, errf(KindNotFound, "blocking step %d does not exist", req.ID)
|
||||||
|
}
|
||||||
|
verb := "change blocking step "
|
||||||
|
if req.Op == OpBlockStepDelete {
|
||||||
|
verb = "delete blocking step "
|
||||||
|
}
|
||||||
|
return []model.Op{x.versioned(req, s.Version, verb+s.Name)}, nil
|
||||||
|
case OpClientGroupCreate:
|
||||||
|
return x.clientGroupCreate(req)
|
||||||
|
case OpClientGroupUpdate, OpClientGroupDelete:
|
||||||
|
g := x.st.ClientGroups[req.ID]
|
||||||
|
if g == nil {
|
||||||
|
return nil, errf(KindNotFound, "client group %d does not exist", req.ID)
|
||||||
|
}
|
||||||
|
verb := "change client group "
|
||||||
|
if req.Op == OpClientGroupDelete {
|
||||||
|
verb = "delete client group "
|
||||||
|
}
|
||||||
|
return []model.Op{x.versioned(req, g.Version, verb+g.Name)}, nil
|
||||||
}
|
}
|
||||||
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
||||||
}
|
}
|
||||||
@@ -1065,3 +1091,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})
|
||||||
|
}
|
||||||
@@ -39,18 +39,39 @@ const (
|
|||||||
OpBlockRuleDelete = "blockrule.delete"
|
OpBlockRuleDelete = "blockrule.delete"
|
||||||
OpBlockingPause = "blocking.pause"
|
OpBlockingPause = "blocking.pause"
|
||||||
|
|
||||||
|
OpBlockStepCreate = "blockstep.create"
|
||||||
|
OpBlockStepUpdate = "blockstep.update"
|
||||||
|
OpBlockStepDelete = "blockstep.delete"
|
||||||
|
OpClientGroupCreate = "clientgroup.create"
|
||||||
|
OpClientGroupUpdate = "clientgroup.update"
|
||||||
|
OpClientGroupDelete = "clientgroup.delete"
|
||||||
|
|
||||||
// Requests only: disabling and enabling records, by ID or by match
|
// Requests only: disabling and enabling records, by ID or by match
|
||||||
// (zone, name, type and optionally data), become record.update
|
// (zone, name, type and optionally data), become record.update
|
||||||
// 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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,
|
||||||
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
|
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
|
||||||
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause}
|
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause,
|
||||||
|
OpBlockStepCreate, OpBlockStepUpdate, OpBlockStepDelete, OpClientGroupCreate, OpClientGroupUpdate, OpClientGroupDelete}
|
||||||
|
|
||||||
// Request is an operation as submitted by a client. Requests are expanded
|
// Request is an operation as submitted by a client. Requests are expanded
|
||||||
// into one or more stored operations (for example an A record with "ptr"
|
// into one or more stored operations (for example an A record with "ptr"
|
||||||
@@ -176,6 +197,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.
|
||||||
@@ -219,6 +243,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.
|
||||||
@@ -236,6 +263,31 @@ type BlocklistInput struct {
|
|||||||
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
// Step is the step the list starts at, by name ("normal" or a step
|
||||||
|
// above it) or ID (StepID; 0 is normal).
|
||||||
|
Step *string `json:"step,omitempty"`
|
||||||
|
StepID *int64 `json:"step_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockStepInput creates or updates a blocking step above Normal.
|
||||||
|
// Position orders the steps (new steps go on top).
|
||||||
|
type BlockStepInput struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Position *int `json:"position,omitempty"`
|
||||||
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientGroupInput creates or updates a client group. Members are
|
||||||
|
// addresses, prefixes or ACL names; the step is given by name ("off",
|
||||||
|
// "normal" or a step) or ID; views as for BlocklistInput.
|
||||||
|
type ClientGroupInput struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Members *[]string `json:"members,omitempty"`
|
||||||
|
Step *string `json:"step,omitempty"`
|
||||||
|
StepID *int64 `json:"step_id,omitempty"`
|
||||||
|
Views *[]string `json:"views,omitempty"`
|
||||||
|
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||||
|
Comment *string `json:"comment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockRuleInput creates or updates a block or allow rule. Views as for
|
// BlockRuleInput creates or updates a block or allow rule. Views as for
|
||||||
|
|||||||
+92
-3
@@ -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"
|
||||||
@@ -106,13 +107,21 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
err = applyBlockRulePut(st, op, c)
|
err = applyBlockRulePut(st, op, c)
|
||||||
case OpBlockRuleDelete:
|
case OpBlockRuleDelete:
|
||||||
err = applyBlockRuleDelete(st, op, c)
|
err = applyBlockRuleDelete(st, op, c)
|
||||||
|
case OpBlockStepCreate, OpBlockStepUpdate:
|
||||||
|
err = applyBlockStepPut(st, op, c)
|
||||||
|
case OpBlockStepDelete:
|
||||||
|
err = applyBlockStepDelete(st, op, c)
|
||||||
|
case OpClientGroupCreate, OpClientGroupUpdate:
|
||||||
|
err = applyClientGroupPut(st, op, c)
|
||||||
|
case OpClientGroupDelete:
|
||||||
|
err = applyClientGroupDelete(st, op, c)
|
||||||
case OpBlockingPause:
|
case OpBlockingPause:
|
||||||
err = applyBlockingPause(st, op, c)
|
err = applyBlockingPause(st, op, c)
|
||||||
default:
|
default:
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -449,7 +458,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,7 +534,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
|
||||||
@@ -753,9 +765,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 {
|
||||||
@@ -1133,9 +1177,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 {
|
||||||
@@ -1282,6 +1361,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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,11 @@ func FindRefs(st *model.State, kind refKind, name string) []string {
|
|||||||
out = append(out, "view "+v.Name)
|
out = append(out, "view "+v.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
if _, hit := rewriteElements(g.Members, kind, name, name); hit {
|
||||||
|
out = append(out, "client group "+g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
var opts []string
|
var opts []string
|
||||||
for _, o := range st.Options {
|
for _, o := range st.Options {
|
||||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
@@ -188,6 +193,12 @@ func renameRefs(st *model.State, kind refKind, old, new string, c *applyCtx) {
|
|||||||
v.Touch(c.user(), c.now)
|
v.Touch(c.user(), c.now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, g := range st.ClientGroups {
|
||||||
|
if l, hit := rewriteElements(g.Members, kind, old, new); hit {
|
||||||
|
g.Members = l
|
||||||
|
g.Touch(c.user(), c.now)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, o := range st.Options {
|
for _, o := range st.Options {
|
||||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+148
-1
@@ -1,10 +1,14 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"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
|
||||||
@@ -17,10 +21,126 @@ type Blocklist struct {
|
|||||||
// that offers recursion (see BlockingViews).
|
// that offers recursion (see BlockingViews).
|
||||||
ViewIDs []int64 `json:"view_ids"`
|
ViewIDs []int64 `json:"view_ids"`
|
||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
Comment string `json:"comment"`
|
// StepID is the blocking step the list starts at: StepNormal for
|
||||||
|
// every client, or a BlockStep for the clients of groups at that step
|
||||||
|
// or above.
|
||||||
|
StepID int64 `json:"step_id"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built-in blocking steps (BlockStep IDs are positive).
|
||||||
|
const (
|
||||||
|
StepOff int64 = -1 // no blocklists (rules still apply)
|
||||||
|
StepNormal int64 = 0 // the default: every client not in a group
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlockStep is a blocking step above Normal. Steps are ordered: each
|
||||||
|
// includes the lists of the steps below it, plus its own (docs/blocking.md).
|
||||||
|
type BlockStep struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientGroup gives the clients with these addresses a blocking step.
|
||||||
|
type ClientGroup struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Members are addresses, prefixes, or names of ACLs made of them.
|
||||||
|
Members []string `json:"members"`
|
||||||
|
StepID int64 `json:"step_id"` // StepOff, StepNormal or a BlockStep
|
||||||
|
ViewIDs []int64 `json:"view_ids"` // as for Blocklist
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderedBlockSteps returns the steps above Normal, lowest first.
|
||||||
|
func (s *State) OrderedBlockSteps() []*BlockStep {
|
||||||
|
out := make([]*BlockStep, 0, len(s.BlockSteps))
|
||||||
|
for _, st := range s.BlockSteps {
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Position != out[j].Position {
|
||||||
|
return out[i].Position < out[j].Position
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepRank orders steps: -1 for Off, 0 for Normal, 1, 2, ... for the
|
||||||
|
// steps above it. ok is false for a step that doesn't exist.
|
||||||
|
func (s *State) StepRank(id int64) (rank int, ok bool) {
|
||||||
|
switch id {
|
||||||
|
case StepOff:
|
||||||
|
return -1, true
|
||||||
|
case StepNormal:
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
for i, st := range s.OrderedBlockSteps() {
|
||||||
|
if st.ID == id {
|
||||||
|
return i + 1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepName returns a step's name ("off" and "normal" for the built-in
|
||||||
|
// ones).
|
||||||
|
func (s *State) StepName(id int64) string {
|
||||||
|
switch id {
|
||||||
|
case StepOff:
|
||||||
|
return "off"
|
||||||
|
case StepNormal:
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
if st := s.BlockSteps[id]; st != nil {
|
||||||
|
return st.Name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("step %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepByName finds a step by name, the built-in ones included.
|
||||||
|
func (s *State) StepByName(name string) (int64, bool) {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
switch name {
|
||||||
|
case "off":
|
||||||
|
return StepOff, true
|
||||||
|
case "normal":
|
||||||
|
return StepNormal, true
|
||||||
|
}
|
||||||
|
for _, st := range s.BlockSteps {
|
||||||
|
if strings.EqualFold(st.Name, name) {
|
||||||
|
return st.ID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientGroupByName finds a client group by name.
|
||||||
|
func (s *State) ClientGroupByName(name string) *ClientGroup {
|
||||||
|
for _, g := range s.ClientGroups {
|
||||||
|
if strings.EqualFold(g.Name, name) {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedClientGroups returns the client groups ordered by name.
|
||||||
|
func (s *State) SortedClientGroups() []*ClientGroup {
|
||||||
|
out := make([]*ClientGroup, 0, len(s.ClientGroups))
|
||||||
|
for _, g := range s.ClientGroups {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Block rule actions.
|
// Block rule actions.
|
||||||
const (
|
const (
|
||||||
BlockActionBlock = "block"
|
BlockActionBlock = "block"
|
||||||
@@ -134,3 +254,30 @@ func (s *State) BlockingViews(viewIDs []int64) []int64 {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupPrefixes returns the addresses of a client group as prefixes: its
|
||||||
|
// own, and those of the ACLs it names (and the ACLs they name). Elements
|
||||||
|
// that aren't addresses are left out (validation reports them).
|
||||||
|
func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var add func(elems []string)
|
||||||
|
add = func(elems []string) {
|
||||||
|
for _, e := range elems {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if a, err := netip.ParseAddr(e); err == nil {
|
||||||
|
a = a.Unmap()
|
||||||
|
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||||
|
} else if p, err := netip.ParsePrefix(e); err == nil {
|
||||||
|
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] {
|
||||||
|
seen[acl.Name] = true
|
||||||
|
add(acl.Elements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(g.Members)
|
||||||
|
return out
|
||||||
|
}
|
||||||
+70
-13
@@ -9,6 +9,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -49,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"`
|
||||||
@@ -216,7 +251,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
|
||||||
@@ -247,8 +286,10 @@ type State struct {
|
|||||||
Records map[int64]*Record
|
Records map[int64]*Record
|
||||||
Options map[int64]*Option
|
Options map[int64]*Option
|
||||||
// Blocking (docs/blocking.md).
|
// Blocking (docs/blocking.md).
|
||||||
Blocklists map[int64]*Blocklist
|
Blocklists map[int64]*Blocklist
|
||||||
BlockRules map[int64]*BlockRule
|
BlockRules map[int64]*BlockRule
|
||||||
|
BlockSteps map[int64]*BlockStep
|
||||||
|
ClientGroups map[int64]*ClientGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewState returns an empty state with default settings.
|
// NewState returns an empty state with default settings.
|
||||||
@@ -262,8 +303,10 @@ func NewState() *State {
|
|||||||
Records: map[int64]*Record{},
|
Records: map[int64]*Record{},
|
||||||
Options: map[int64]*Option{},
|
Options: map[int64]*Option{},
|
||||||
|
|
||||||
Blocklists: map[int64]*Blocklist{},
|
Blocklists: map[int64]*Blocklist{},
|
||||||
BlockRules: map[int64]*BlockRule{},
|
BlockRules: map[int64]*BlockRule{},
|
||||||
|
BlockSteps: map[int64]*BlockStep{},
|
||||||
|
ClientGroups: map[int64]*ClientGroup{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,14 +321,18 @@ func (s *State) Clone() *State {
|
|||||||
Records: make(map[int64]*Record, len(s.Records)),
|
Records: make(map[int64]*Record, len(s.Records)),
|
||||||
Options: make(map[int64]*Option, len(s.Options)),
|
Options: make(map[int64]*Option, len(s.Options)),
|
||||||
|
|
||||||
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
|
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
|
||||||
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
|
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
|
||||||
|
BlockSteps: make(map[int64]*BlockStep, len(s.BlockSteps)),
|
||||||
|
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
|
||||||
}
|
}
|
||||||
c.Settings.Nameservers = append([]string(nil), 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 = append([]string(nil), v.MatchClients...)
|
n.MatchClients = slices.Clone(v.MatchClients)
|
||||||
n.MatchDestinations = append([]string(nil), 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
|
||||||
@@ -294,7 +341,7 @@ func (s *State) Clone() *State {
|
|||||||
}
|
}
|
||||||
for id, a := range s.ACLs {
|
for id, a := range s.ACLs {
|
||||||
n := *a
|
n := *a
|
||||||
n.Elements = append([]string(nil), a.Elements...)
|
n.Elements = slices.Clone(a.Elements)
|
||||||
c.ACLs[id] = &n
|
c.ACLs[id] = &n
|
||||||
}
|
}
|
||||||
for id, k := range s.Keys {
|
for id, k := range s.Keys {
|
||||||
@@ -319,14 +366,24 @@ func (s *State) Clone() *State {
|
|||||||
}
|
}
|
||||||
for id, b := range s.Blocklists {
|
for id, b := range s.Blocklists {
|
||||||
n := *b
|
n := *b
|
||||||
n.ViewIDs = append([]int64(nil), b.ViewIDs...)
|
n.ViewIDs = slices.Clone(b.ViewIDs)
|
||||||
c.Blocklists[id] = &n
|
c.Blocklists[id] = &n
|
||||||
}
|
}
|
||||||
for id, r := range s.BlockRules {
|
for id, r := range s.BlockRules {
|
||||||
n := *r
|
n := *r
|
||||||
n.ViewIDs = append([]int64(nil), r.ViewIDs...)
|
n.ViewIDs = slices.Clone(r.ViewIDs)
|
||||||
c.BlockRules[id] = &n
|
c.BlockRules[id] = &n
|
||||||
}
|
}
|
||||||
|
for id, st := range s.BlockSteps {
|
||||||
|
n := *st
|
||||||
|
c.BlockSteps[id] = &n
|
||||||
|
}
|
||||||
|
for id, g := range s.ClientGroups {
|
||||||
|
n := *g
|
||||||
|
n.Members = slices.Clone(g.Members)
|
||||||
|
n.ViewIDs = slices.Clone(g.ViewIDs)
|
||||||
|
c.ClientGroups[id] = &n
|
||||||
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 render
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,9 +22,16 @@ type PolicyZone struct {
|
|||||||
ViewID int64 // 0 without views
|
ViewID int64 // 0 without views
|
||||||
View string // "" without views
|
View string // "" without views
|
||||||
File string
|
File string
|
||||||
ListID int64 // the blocklist; 0 for the rules zone
|
ListID int64 // the blocklist; 0 for the rules zone and gates
|
||||||
|
// Gate, for a gate zone: the clients it lets through (past every zone
|
||||||
|
// after it), and the step whose lists it is in front of.
|
||||||
|
Gate []netip.Prefix
|
||||||
|
GateStep string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsGate reports whether z is a gate zone.
|
||||||
|
func (z PolicyZone) IsGate() bool { return z.GateStep != "" }
|
||||||
|
|
||||||
// BlocklistsDir holds the zone files of downloaded blocklists, one per
|
// BlocklistsDir holds the zone files of downloaded blocklists, one per
|
||||||
// list, shared by the views that use it.
|
// list, shared by the views that use it.
|
||||||
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
|
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
|
||||||
@@ -41,7 +49,9 @@ func (p Paths) rulesFile(view string) string {
|
|||||||
|
|
||||||
// PolicyZones returns the policy zones named loads, per view in view
|
// PolicyZones returns the policy zones named loads, per view in view
|
||||||
// order, each view's in the order named checks them: the rules zone first
|
// order, each view's in the order named checks them: the rules zone first
|
||||||
// (rules win over lists), then the enabled lists by name.
|
// (rules win over lists), then for each step (normal, then the steps above
|
||||||
|
// it) a gate letting through the clients below that step, when there are
|
||||||
|
// any, and the step's enabled lists by name.
|
||||||
func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
||||||
var out []PolicyZone
|
var out []PolicyZone
|
||||||
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
|
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
|
||||||
@@ -65,10 +75,26 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, b := range lists {
|
assigns := ClientAssignments(s, vid)
|
||||||
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
|
for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) {
|
||||||
zones = append(zones, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
|
var tier []PolicyZone
|
||||||
|
for _, b := range lists {
|
||||||
|
if !b.Disabled && b.StepID == step && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
|
||||||
|
tier = append(tier, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if len(tier) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The clients below this step pass, and never see its lists.
|
||||||
|
if pass := blocking.PassBelow(assigns, k); len(pass) > 0 {
|
||||||
|
name := "off"
|
||||||
|
if k > 0 {
|
||||||
|
name = s.StepName(step)
|
||||||
|
}
|
||||||
|
zones = append(zones, PolicyZone{Name: blocking.GateZone(name), ViewID: vid, View: view, File: p.gateFile(view, name), Gate: pass, GateStep: s.StepName(step)})
|
||||||
|
}
|
||||||
|
zones = append(zones, tier...)
|
||||||
}
|
}
|
||||||
// While paused, every view with blocking gets a rules zone: it
|
// While paused, every view with blocking gets a rules zone: it
|
||||||
// lets everything through.
|
// lets everything through.
|
||||||
@@ -80,6 +106,39 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gateFile returns a view's gate zone file for a step.
|
||||||
|
func (p Paths) gateFile(view, name string) string {
|
||||||
|
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.GateZone(name)+".rpz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepIDs returns the IDs of the steps above normal, lowest first.
|
||||||
|
func stepIDs(s *model.State) []int64 {
|
||||||
|
var out []int64
|
||||||
|
for _, st := range s.OrderedBlockSteps() {
|
||||||
|
out = append(out, st.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientAssignments returns the step ranks the client groups of a view
|
||||||
|
// give addresses (blocking.PassBelow).
|
||||||
|
func ClientAssignments(s *model.State, viewID int64) []blocking.Assignment {
|
||||||
|
var out []blocking.Assignment
|
||||||
|
for _, g := range s.SortedClientGroups() {
|
||||||
|
if !slices.Contains(s.BlockingViews(g.ViewIDs), viewID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rank, ok := s.StepRank(g.StepID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range s.GroupPrefixes(g) {
|
||||||
|
out = append(out, blocking.Assignment{Prefix: p, Rank: rank})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ViewRules returns the rules that apply in a view (0 without views).
|
// ViewRules returns the rules that apply in a view (0 without views).
|
||||||
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
|
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
|
||||||
var out []blocking.Rule
|
var out []blocking.Rule
|
||||||
@@ -106,7 +165,9 @@ func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
|
|||||||
// changed (at most every blocking.refresh), so named's default
|
// changed (at most every blocking.refresh), so named's default
|
||||||
// delay for batching frequent updates only adds a wait.
|
// delay for batching frequent updates only adds a wait.
|
||||||
opts := []string{"min-update-interval 0"}
|
opts := []string{"min-update-interval 0"}
|
||||||
if !logRewrites {
|
// A gate lets most queries through: logging that would double
|
||||||
|
// the response log, and it blocks nothing.
|
||||||
|
if !logRewrites || z.IsGate() {
|
||||||
opts = append(opts, "log no")
|
opts = append(opts, "log no")
|
||||||
}
|
}
|
||||||
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
|
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
|
||||||
@@ -132,8 +193,11 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
|
|||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
label := "blocking: your rules"
|
label := "blocking: your rules"
|
||||||
if z.ListID != 0 {
|
switch {
|
||||||
|
case z.ListID != 0:
|
||||||
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
|
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
|
||||||
|
case z.IsGate():
|
||||||
|
label = "blocking: clients below step " + z.GateStep
|
||||||
}
|
}
|
||||||
w.begin("blocking", z.ListID, label)
|
w.begin("blocking", z.ListID, label)
|
||||||
w.write(fmt.Sprintf("%szone %s {\n%s\ttype primary;\n%s\tfile %s;\n%s\tallow-query { none; };\n%s\tallow-transfer { none; };\n%s\tnotify no;\n%s};\n",
|
w.write(fmt.Sprintf("%szone %s {\n%s\ttype primary;\n%s\tfile %s;\n%s\tallow-query { none; };\n%s\tallow-transfer { none; };\n%s\tnotify no;\n%s};\n",
|
||||||
@@ -143,6 +207,14 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
|
|||||||
out.Blocklists = appendUnique(out.Blocklists, z.File)
|
out.Blocklists = appendUnique(out.Blocklists, z.File)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if z.IsGate() {
|
||||||
|
title := "Clients that skip the lists from step " + z.GateStep + " on"
|
||||||
|
if z.View != "" {
|
||||||
|
title += " in view " + z.View
|
||||||
|
}
|
||||||
|
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.GateZoneFile(title, z.Gate)), Mode: 0o640, Kind: KindPolicy})
|
||||||
|
continue
|
||||||
|
}
|
||||||
title := "Blocking rules"
|
title := "Blocking rules"
|
||||||
if z.View != "" {
|
if z.View != "" {
|
||||||
title += " for view " + z.View
|
title += " for view " + z.View
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,11 +33,11 @@ func viewNames(st *model.State, ids []int64) []string {
|
|||||||
|
|
||||||
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
|
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
|
||||||
views := st.BlockingViews(b.ViewIDs)
|
views := st.BlockingViews(b.ViewIDs)
|
||||||
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status}
|
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status, Step: st.StepName(b.StepID)}
|
||||||
switch {
|
switch {
|
||||||
case b.Disabled:
|
case b.Disabled:
|
||||||
out.State = "disabled"
|
out.State = "disabled"
|
||||||
case len(views) == 0:
|
case len(views) == 0 || !stepReached(st, b, views):
|
||||||
out.State = "idle"
|
out.State = "idle"
|
||||||
case status != nil && status.Error != "":
|
case status != nil && status.Error != "":
|
||||||
out.State = "error"
|
out.State = "error"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blocking steps and client groups (docs/blocking.md).
|
||||||
|
|
||||||
|
func presentBlockStep(st *model.State, s *model.BlockStep) api.BlockStep {
|
||||||
|
out := api.BlockStep{BlockStep: *s, Lists: []string{}, Groups: []string{}}
|
||||||
|
for _, b := range st.SortedBlocklists() {
|
||||||
|
if b.StepID == s.ID {
|
||||||
|
out.Lists = append(out.Lists, b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
if g.StepID == s.ID {
|
||||||
|
out.Groups = append(out.Groups, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentClientGroup(st *model.State, g *model.ClientGroup) api.ClientGroup {
|
||||||
|
return api.ClientGroup{ClientGroup: *g, Step: st.StepName(g.StepID), Views: viewNames(st, st.BlockingViews(g.ViewIDs)), AutoViews: len(g.ViewIDs) == 0 && len(st.Views) > 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stepObject(st *model.State, id int64) any {
|
||||||
|
if s := st.BlockSteps[id]; s != nil {
|
||||||
|
return presentBlockStep(st, s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupObject(st *model.State, id int64) any {
|
||||||
|
if g := st.ClientGroups[id]; g != nil {
|
||||||
|
return presentClientGroup(st, g)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listBlockSteps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := requireBlocking(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, base, err := s.states(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []api.BlockStep{}
|
||||||
|
for _, x := range st.OrderedBlockSteps() {
|
||||||
|
o := presentBlockStep(st, x)
|
||||||
|
o.Pending = pending(base, base != nil && base.BlockSteps[x.ID] == nil, base != nil && base.BlockSteps[x.ID] != nil && base.BlockSteps[x.ID].Version != x.Version)
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
if base != nil {
|
||||||
|
for _, x := range base.OrderedBlockSteps() {
|
||||||
|
if st.BlockSteps[x.ID] == nil {
|
||||||
|
o := presentBlockStep(base, x)
|
||||||
|
o.Pending = "delete"
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepByPath finds a step above normal by ID or name.
|
||||||
|
func stepByPath(r *http.Request, st *model.State) (*model.BlockStep, error) {
|
||||||
|
ref := r.PathValue("id")
|
||||||
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||||
|
if x := st.BlockSteps[id]; x != nil {
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
} else if id, ok := st.StepByName(ref); ok && id > 0 {
|
||||||
|
return st.BlockSteps[id], nil
|
||||||
|
}
|
||||||
|
return nil, notFound("blocking step %s not found (off and normal are built in)", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepCreate, 0, body)}, stepObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
x, err := stepByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := x.ID
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepUpdate, id, body)}, func(st *model.State, _ int64) any { return stepObject(st, id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
x, err := stepByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepDelete, x.ID, nil)}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderBlockSteps: POST /blocking/steps/order {"steps": ["strict",
|
||||||
|
// "very-strict"]} (names or IDs as strings) sets the order of the steps
|
||||||
|
// above normal, lowest first.
|
||||||
|
func (s *Server) orderBlockSteps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Steps []string `json:"steps"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(in.Steps) != len(st.BlockSteps) {
|
||||||
|
return badRequest("give every step above normal once (%d)", len(st.BlockSteps))
|
||||||
|
}
|
||||||
|
var reqs []engine.Request
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for i, ref := range in.Steps {
|
||||||
|
var x *model.BlockStep
|
||||||
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||||
|
x = st.BlockSteps[id]
|
||||||
|
} else if id, ok := st.StepByName(ref); ok && id > 0 {
|
||||||
|
x = st.BlockSteps[id]
|
||||||
|
}
|
||||||
|
if x == nil || seen[x.ID] {
|
||||||
|
return badRequest("step %q doesn't exist or is given twice", ref)
|
||||||
|
}
|
||||||
|
seen[x.ID] = true
|
||||||
|
if x.Position != i {
|
||||||
|
data, _ := json.Marshal(engine.BlockStepInput{Position: model.Ptr(i)})
|
||||||
|
reqs = append(reqs, request(engine.OpBlockStepUpdate, x.ID, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "the steps are already in that order"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", reqs, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listClientGroups(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := requireBlocking(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, base, err := s.states(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []api.ClientGroup{}
|
||||||
|
for _, g := range st.SortedClientGroups() {
|
||||||
|
o := presentClientGroup(st, g)
|
||||||
|
o.Pending = pending(base, base != nil && base.ClientGroups[g.ID] == nil, base != nil && base.ClientGroups[g.ID] != nil && base.ClientGroups[g.ID].Version != g.Version)
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
if base != nil {
|
||||||
|
for _, g := range base.SortedClientGroups() {
|
||||||
|
if st.ClientGroups[g.ID] == nil {
|
||||||
|
o := presentClientGroup(base, g)
|
||||||
|
o.Pending = "delete"
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupByPath(r *http.Request, st *model.State) (*model.ClientGroup, error) {
|
||||||
|
ref := r.PathValue("id")
|
||||||
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||||
|
if g := st.ClientGroups[id]; g != nil {
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
} else if g := st.ClientGroupByName(ref); g != nil {
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
return nil, notFound("client group %s not found", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupCreate, 0, body)}, groupObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g, err := groupByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := g.ID
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupUpdate, id, body)}, func(st *model.State, _ int64) any { return groupObject(st, id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g, err := groupByPath(r, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupDelete, g.ID, nil)}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepReached reports whether any client gets a list: always for a list at
|
||||||
|
// normal; for one above it, when a client group with members in one of its
|
||||||
|
// views is at its step or above.
|
||||||
|
func stepReached(st *model.State, b *model.Blocklist, views []int64) bool {
|
||||||
|
rank, _ := st.StepRank(b.StepID)
|
||||||
|
if rank <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, g := range st.ClientGroups {
|
||||||
|
r, ok := st.StepRank(g.StepID)
|
||||||
|
if !ok || r < rank || len(st.GroupPrefixes(g)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, v := range st.BlockingViews(g.ViewIDs) {
|
||||||
|
if slices.Contains(views, v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -69,6 +70,15 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName))
|
m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName))
|
||||||
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
|
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
|
||||||
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
|
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
|
||||||
|
m.HandleFunc("GET "+p+"/blocking/steps", s.authed(s.listBlockSteps))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/steps", s.authed(s.createBlockStep))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/steps/order", s.authed(s.orderBlockSteps))
|
||||||
|
m.HandleFunc("PATCH "+p+"/blocking/steps/{id}", s.authed(s.updateBlockStep))
|
||||||
|
m.HandleFunc("DELETE "+p+"/blocking/steps/{id}", s.authed(s.deleteBlockStep))
|
||||||
|
m.HandleFunc("GET "+p+"/blocking/groups", s.authed(s.listClientGroups))
|
||||||
|
m.HandleFunc("POST "+p+"/blocking/groups", s.authed(s.createClientGroup))
|
||||||
|
m.HandleFunc("PATCH "+p+"/blocking/groups/{id}", s.authed(s.updateClientGroup))
|
||||||
|
m.HandleFunc("DELETE "+p+"/blocking/groups/{id}", s.authed(s.deleteClientGroup))
|
||||||
m.HandleFunc("GET "+p+"/blocking/check", s.authed(s.checkBlocking))
|
m.HandleFunc("GET "+p+"/blocking/check", s.authed(s.checkBlocking))
|
||||||
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus))
|
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus))
|
||||||
m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false)))
|
m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false)))
|
||||||
@@ -126,6 +136,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))
|
||||||
@@ -151,6 +162,10 @@ 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+"/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))
|
||||||
@@ -241,7 +256,7 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
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: 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)
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the HTTP server.
|
// Server is the HTTP server.
|
||||||
@@ -80,6 +82,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 +114,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)
|
||||||
@@ -200,6 +206,10 @@ 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(tlsCfg != nil)
|
||||||
if ready != nil {
|
if ready != nil {
|
||||||
|
|||||||
@@ -26,10 +26,15 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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).
|
||||||
@@ -114,6 +119,9 @@ type Collector struct {
|
|||||||
// 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 +133,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}
|
||||||
}
|
}
|
||||||
@@ -286,6 +294,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:") {
|
||||||
|
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 +342,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
|
||||||
}
|
}
|
||||||
@@ -396,6 +423,30 @@ func (c *Collector) handleRewrite(rw Rewrite) {
|
|||||||
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
|
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
c.count(minute, 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]++
|
||||||
|
}
|
||||||
|
|
||||||
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
||||||
// for the rules entered in Jarl, or the policy zone for others.
|
// for the rules entered in Jarl, or the policy zone for others.
|
||||||
func policySource(via string) string {
|
func policySource(via string) string {
|
||||||
|
|||||||
+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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,7 +256,7 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Groups are the counter groups Breakdown accepts.
|
// Groups are the counter groups Breakdown accepts.
|
||||||
var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "blocklist"}
|
var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "rrl", "blocklist"}
|
||||||
|
|
||||||
// Timeline is a set of series over time.
|
// Timeline is a set of series over time.
|
||||||
type Timeline struct {
|
type Timeline struct {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ type Server struct {
|
|||||||
PID int `json:"pid,omitempty"`
|
PID int `json:"pid,omitempty"`
|
||||||
RSS int64 `json:"rss,omitempty"` // resident memory, bytes
|
RSS int64 `json:"rss,omitempty"` // resident memory, bytes
|
||||||
Threads int64 `json:"threads,omitempty"`
|
Threads int64 `json:"threads,omitempty"`
|
||||||
CPUPercent float64 `json:"cpu_percent"` // of one CPU, over the last minute
|
CPUPercent float64 `json:"cpu_percent"` // of one CPU, over the last minute
|
||||||
|
CPUSeconds float64 `json:"cpu_seconds,omitempty"` // user + system time since named started
|
||||||
// From the statistics channel.
|
// From the statistics channel.
|
||||||
MemInUse int64 `json:"mem_in_use"` // named's own count of the memory it uses
|
MemInUse int64 `json:"mem_in_use"` // named's own count of the memory it uses
|
||||||
CacheRRsets int64 `json:"cache_rrsets"`
|
CacheRRsets int64 `json:"cache_rrsets"`
|
||||||
@@ -63,6 +64,7 @@ func (c *Collector) sampleServer(minute int64, now time.Time, snap *Snapshot) {
|
|||||||
c.proc = nil
|
c.proc = nil
|
||||||
} else {
|
} else {
|
||||||
srv.PID, srv.RSS, srv.Threads = p.PID, p.RSS, p.Threads
|
srv.PID, srv.RSS, srv.Threads = p.PID, p.RSS, p.Threads
|
||||||
|
srv.CPUSeconds = float64(p.CPU) / clockTicks
|
||||||
if prev := c.proc; prev.At.Before(now) && !prev.At.IsZero() && p.CPU >= prev.CPU {
|
if prev := c.proc; prev.At.Before(now) && !prev.At.IsZero() && p.CPU >= prev.CPU {
|
||||||
// Tenths of a percent of one CPU: ticks / clockTicks is CPU seconds.
|
// Tenths of a percent of one CPU: ticks / clockTicks is CPU seconds.
|
||||||
permille := (p.CPU - prev.CPU) * 1000 / clockTicks / max(int64(now.Sub(prev.At)/time.Second), 1)
|
permille := (p.CPU - prev.CPU) * 1000 / clockTicks / max(int64(now.Sub(prev.At)/time.Second), 1)
|
||||||
|
|||||||
+74
-12
@@ -31,20 +31,23 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by FROM views`)
|
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
v := &model.View{}
|
v := &model.View{}
|
||||||
var mc, md, ca, ua string
|
var mc, md, acks, ca, ua string
|
||||||
var mro sql.NullInt64
|
var mro sql.NullInt64
|
||||||
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
|
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
json.Unmarshal([]byte(mc), &v.MatchClients)
|
json.Unmarshal([]byte(mc), &v.MatchClients)
|
||||||
json.Unmarshal([]byte(md), &v.MatchDestinations)
|
json.Unmarshal([]byte(md), &v.MatchDestinations)
|
||||||
|
if acks != "[]" && acks != "" {
|
||||||
|
json.Unmarshal([]byte(acks), &v.Acknowledged)
|
||||||
|
}
|
||||||
if mro.Valid {
|
if mro.Valid {
|
||||||
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
|
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
|
||||||
}
|
}
|
||||||
@@ -150,14 +153,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
|
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
b := &model.Blocklist{}
|
b := &model.Blocklist{}
|
||||||
var views, ca, ua string
|
var views, ca, ua string
|
||||||
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
|
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.StepID, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -187,6 +190,44 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
if err := closeRows(rows); err != nil {
|
if err := closeRows(rows); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rows, err = e.QueryContext(ctx, `SELECT id, name, position, comment, version, created_at, updated_at, created_by, updated_by FROM block_steps`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
s := &model.BlockStep{}
|
||||||
|
var ca, ua string
|
||||||
|
if err := rows.Scan(&s.ID, &s.Name, &s.Position, &s.Comment, &s.Version, &ca, &ua, &s.CreatedBy, &s.UpdatedBy); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.CreatedAt, s.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||||
|
st.BlockSteps[s.ID] = s
|
||||||
|
}
|
||||||
|
if err := closeRows(rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = e.QueryContext(ctx, `SELECT id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by FROM client_groups`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
g := &model.ClientGroup{}
|
||||||
|
var members, views, ca, ua string
|
||||||
|
if err := rows.Scan(&g.ID, &g.Name, &members, &g.StepID, &views, &g.Comment, &g.Version, &ca, &ua, &g.CreatedBy, &g.UpdatedBy); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(members), &g.Members)
|
||||||
|
json.Unmarshal([]byte(views), &g.ViewIDs)
|
||||||
|
g.CreatedAt, g.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||||
|
st.ClientGroups[g.ID] = g
|
||||||
|
}
|
||||||
|
if err := closeRows(rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +245,7 @@ func (t *Tx) SaveState(old, new *model.State) error {
|
|||||||
// ReplaceState replaces the stored configuration with st (used by import
|
// ReplaceState replaces the stored configuration with st (used by import
|
||||||
// and tests).
|
// and tests).
|
||||||
func (t *Tx) ReplaceState(st *model.State) error {
|
func (t *Tx) ReplaceState(st *model.State) error {
|
||||||
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules"} {
|
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules", "block_steps", "client_groups"} {
|
||||||
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
|
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -226,8 +267,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
if v.MatchRecursiveOnly != nil {
|
if v.MatchRecursiveOnly != nil {
|
||||||
mro = boolInt(*v.MatchRecursiveOnly)
|
mro = boolInt(*v.MatchRecursiveOnly)
|
||||||
}
|
}
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
|
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -272,17 +313,31 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
|
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
|
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.StepID, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
|
if err := diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "block_rules", id) })
|
}, func(id int64) error { return del(ctx, e, "block_rules", id) }); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := diffMap(old.BlockSteps, new.BlockSteps, func(s *model.BlockStep) error {
|
||||||
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_steps (id, name, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
|
s.ID, s.Name, s.Position, s.Comment, s.Version, ts(s.CreatedAt), ts(s.UpdatedAt), s.CreatedBy, s.UpdatedBy)
|
||||||
|
return err
|
||||||
|
}, func(id int64) error { return del(ctx, e, "block_steps", id) }); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return diffMap(old.ClientGroups, new.ClientGroups, func(g *model.ClientGroup) error {
|
||||||
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO client_groups (id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
g.ID, g.Name, jsonText(nonNil(g.Members)), g.StepID, jsonText(nonNilIDs(g.ViewIDs)), g.Comment, g.Version, ts(g.CreatedAt), ts(g.UpdatedAt), g.CreatedBy, g.UpdatedBy)
|
||||||
|
return err
|
||||||
|
}, func(id int64) error { return del(ctx, e, "client_groups", id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func nonNilIDs(ids []int64) []int64 {
|
func nonNilIDs(ids []int64) []int64 {
|
||||||
@@ -292,6 +347,13 @@ func nonNilIDs(ids []int64) []int64 {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nonNilAcks(a []model.Acknowledgement) []model.Acknowledgement {
|
||||||
|
if a == nil {
|
||||||
|
return []model.Acknowledgement{}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
func nonNil(s []string) []string {
|
func nonNil(s []string) []string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return []string{}
|
return []string{}
|
||||||
|
|||||||
@@ -304,6 +304,34 @@ CREATE TABLE blocklist_status (
|
|||||||
// 7: disabled zones, kept but not loaded by named.
|
// 7: disabled zones, kept but not loaded by named.
|
||||||
`
|
`
|
||||||
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
||||||
|
`,
|
||||||
|
// 8: blocking steps and client groups (docs/blocking.md).
|
||||||
|
`
|
||||||
|
ALTER TABLE blocklists ADD COLUMN step_id INTEGER NOT NULL DEFAULT 0;
|
||||||
|
CREATE TABLE block_steps (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE client_groups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
members TEXT NOT NULL DEFAULT '[]',
|
||||||
|
step_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
view_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
// 9: internet exposure (docs/exposure.md): a view's override and its
|
||||||
|
// acknowledged findings.
|
||||||
|
`
|
||||||
|
ALTER TABLE views ADD COLUMN exposure TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE views ADD COLUMN acknowledged TEXT NOT NULL DEFAULT '[]';
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package validate
|
package validate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/iprange"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
|
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
|
||||||
@@ -67,6 +70,7 @@ func (c *checker) checkBlocking() {
|
|||||||
seen[key] = r
|
seen[key] = r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
c.checkClientGroups(effective)
|
||||||
// Jarl writes the response-policy statement where blocking is used;
|
// Jarl writes the response-policy statement where blocking is used;
|
||||||
// named allows one per view (or for the server).
|
// named allows one per view (or for the server).
|
||||||
for vid := range used {
|
for vid := range used {
|
||||||
@@ -91,3 +95,97 @@ func (c *checker) blockingScope(viewID int64) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkClientGroups checks blocking steps and client groups: steps exist,
|
||||||
|
// members are addresses (directly or through ACLs), and no address is in
|
||||||
|
// two groups of the same view.
|
||||||
|
func (c *checker) checkClientGroups(effective func(objType string, id int64, label string, ids []int64) []int64) {
|
||||||
|
for _, b := range c.s.SortedBlocklists() {
|
||||||
|
if _, ok := c.s.StepRank(b.StepID); !ok || b.StepID == model.StepOff {
|
||||||
|
c.errf("blocking_step_missing", "blocklist", b.ID, "blocklist %s is at step %d, which doesn't exist", b.Name, b.StepID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The highest step any group reaches, per view: lists above it block
|
||||||
|
// nothing.
|
||||||
|
top := map[int64]int{}
|
||||||
|
seen := map[string]*model.ClientGroup{} // view|prefix -> first group
|
||||||
|
for _, g := range c.s.SortedClientGroups() {
|
||||||
|
label := "client group " + g.Name
|
||||||
|
rank, ok := c.s.StepRank(g.StepID)
|
||||||
|
if !ok {
|
||||||
|
c.errf("blocking_step_missing", "clientgroup", g.ID, "%s is at step %d, which doesn't exist", label, g.StepID)
|
||||||
|
}
|
||||||
|
if len(g.Members) == 0 {
|
||||||
|
c.warnf("client_group_empty", "clientgroup", g.ID, "%s has no members", label)
|
||||||
|
}
|
||||||
|
for _, m := range g.Members {
|
||||||
|
c.checkMember(g, m, map[string]bool{})
|
||||||
|
}
|
||||||
|
views := effective("clientgroup", g.ID, label, g.ViewIDs)
|
||||||
|
prefixes := c.s.GroupPrefixes(g)
|
||||||
|
for _, vid := range views {
|
||||||
|
if len(prefixes) > 0 {
|
||||||
|
top[vid] = max(top[vid], rank)
|
||||||
|
}
|
||||||
|
for _, p := range prefixes {
|
||||||
|
key := c.blockingScope(vid) + "|" + p.String()
|
||||||
|
if first := seen[key]; first != nil && first.ID != g.ID {
|
||||||
|
c.errf("client_group_overlap", "clientgroup", g.ID, "%s is in client groups %s and %s; a client can only be in one (a more specific prefix in another group is fine)", p, first.Name, g.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range c.s.SortedBlocklists() {
|
||||||
|
rank, ok := c.s.StepRank(b.StepID)
|
||||||
|
if !ok || rank == 0 || b.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reached := false
|
||||||
|
for _, vid := range c.s.BlockingViews(b.ViewIDs) {
|
||||||
|
if top[vid] >= rank {
|
||||||
|
reached = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reached {
|
||||||
|
c.warnf("blocklist_step_unused", "blocklist", b.ID, "blocklist %s blocks nothing: it is at step %s, and no client group is at that step or above", b.Name, c.s.StepName(b.StepID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMember checks one member of a client group: an address, a prefix,
|
||||||
|
// or an ACL whose elements are (recursively) addresses and prefixes.
|
||||||
|
func (c *checker) checkMember(g *model.ClientGroup, m string, seen map[string]bool) {
|
||||||
|
if _, err := netip.ParseAddr(m); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := netip.ParsePrefix(m); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok, err := iprange.Parse(m); ok && err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acl := c.s.ACLByName(m)
|
||||||
|
if acl == nil {
|
||||||
|
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if seen[acl.Name] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[acl.Name] = true
|
||||||
|
for _, e := range acl.Elements {
|
||||||
|
el, err := namedconf.ParseAMElement(e)
|
||||||
|
if err != nil {
|
||||||
|
continue // reported for the ACL
|
||||||
|
}
|
||||||
|
if el.Negated || el.Kind == namedconf.AMKey || el.Kind == namedconf.AMNested || el.Kind == namedconf.AMName && el.IsBuiltin() {
|
||||||
|
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s uses ACL %s, whose element %q isn't an address or a prefix: blocking matches clients by address only", g.Name, acl.Name, e)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if el.Kind == namedconf.AMName {
|
||||||
|
c.checkMember(g, el.Name, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ func (c *checker) checkACLs() {
|
|||||||
c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label)
|
c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label)
|
||||||
}
|
}
|
||||||
c.checkRefs(elems, label, "acl", a.ID)
|
c.checkRefs(elems, label, "acl", a.ID)
|
||||||
|
c.checkRanges(elems, label, "acl", a.ID)
|
||||||
for _, w := range namedconf.Shadowed(elems) {
|
for _, w := range namedconf.Shadowed(elems) {
|
||||||
c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w)
|
c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w)
|
||||||
}
|
}
|
||||||
@@ -179,6 +180,7 @@ func (c *checker) checkViews() {
|
|||||||
c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err)
|
c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err)
|
||||||
})
|
})
|
||||||
c.checkRefs(elems, label+" "+field, "view", v.ID)
|
c.checkRefs(elems, label+" "+field, "view", v.ID)
|
||||||
|
c.checkRanges(elems, label+" "+field, "view", v.ID)
|
||||||
for _, w := range namedconf.Shadowed(elems) {
|
for _, w := range namedconf.Shadowed(elems) {
|
||||||
c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w)
|
c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w)
|
||||||
}
|
}
|
||||||
@@ -237,11 +239,24 @@ func (c *checker) flatten(list []string, depth int) []namedconf.AMElement {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, e)
|
out = append(out, e.Expand()...)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkRanges warns about IPv6 ranges in the IPv4-mapped space, which may
|
||||||
|
// match IPv4 clients too.
|
||||||
|
func (c *checker) checkRanges(elems []namedconf.AMElement, where, objType string, id int64) {
|
||||||
|
for _, e := range elems {
|
||||||
|
if e.Kind == namedconf.AMRange && e.Range.TouchesMapped4() {
|
||||||
|
c.warnf("range_mapped4", objType, id, "%s: range %s reaches into ::ffff:0:0/96, where named can see IPv4 clients as IPv6 addresses, so it may match IPv4 clients too; for IPv4 clients, write an IPv4 range", where, e.Range)
|
||||||
|
}
|
||||||
|
if e.Kind == namedconf.AMNested {
|
||||||
|
c.checkRanges(e.Nested, where, objType, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// optionScope maps an option to its catalog scope.
|
// optionScope maps an option to its catalog scope.
|
||||||
func (c *checker) optionScope(o *model.Option) (string, string, bool) {
|
func (c *checker) optionScope(o *model.Option) (string, string, bool) {
|
||||||
switch o.Scope {
|
switch o.Scope {
|
||||||
@@ -548,10 +563,15 @@ func (c *checker) checkSecurity() {
|
|||||||
for _, v := range views {
|
for _, v := range views {
|
||||||
ctxs = append(ctxs, ctx{v, "view " + v.Name})
|
ctxs = append(ctxs, ctx{v, "view " + v.Name})
|
||||||
}
|
}
|
||||||
|
exposure := map[int64]Exposure{}
|
||||||
|
for _, x := range c.exposures() {
|
||||||
|
exposure[x.ViewID] = x
|
||||||
|
}
|
||||||
for _, x := range ctxs {
|
for _, x := range ctxs {
|
||||||
// A view only reachable from some clients (e.g. "internal") can
|
// A view only reachable from some clients (e.g. "internal") can
|
||||||
// recurse for everyone inside it without being an open resolver.
|
// recurse for everyone inside it without being an open resolver.
|
||||||
if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) {
|
// Without views, only a server marked private is left out.
|
||||||
|
if x := exposure[viewID(x.v)]; !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if c.effectiveBool(x.v, "recursion", true) {
|
if c.effectiveBool(x.v, "recursion", true) {
|
||||||
@@ -593,6 +613,9 @@ func (c *checker) checkSecurity() {
|
|||||||
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
|
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
|
||||||
p.Zone, p.View = z.Name, c.s.ViewName(z)
|
p.Zone, p.View = z.Name, c.s.ViewName(z)
|
||||||
}
|
}
|
||||||
|
if x, ok := exposure[z.ViewID]; ok && !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
|
||||||
|
continue // only reachable by your own clients
|
||||||
|
}
|
||||||
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
|
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
|
||||||
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
|
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
|
||||||
p.Zone, p.View = z.Name, c.s.ViewName(z)
|
p.Zone, p.View = z.Name, c.s.ViewName(z)
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exposure says whether a view, or the server without views, can be
|
||||||
|
// reached from the internet (docs/exposure.md). The exposure checks only
|
||||||
|
// look at public ones.
|
||||||
|
type Exposure struct {
|
||||||
|
ViewID int64 `json:"view_id"` // 0: the server (no views)
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
Override string `json:"override,omitempty"` // public or private, set by hand
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label names the scope in messages: "view external" or "the server".
|
||||||
|
func (e Exposure) Label() string {
|
||||||
|
if e.ViewID == 0 {
|
||||||
|
return "the server"
|
||||||
|
}
|
||||||
|
return "view " + e.View
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskCodes are the findings that open the server up rather than leave it
|
||||||
|
// less hardened: a change that introduces one must be confirmed
|
||||||
|
// (ApplyOptions.AcceptRisk).
|
||||||
|
var RiskCodes = map[string]bool{
|
||||||
|
"security_open_resolver": true,
|
||||||
|
"security_open_transfer": true,
|
||||||
|
"exposure_cache_snooping": true,
|
||||||
|
"exposure_open_notify": true,
|
||||||
|
"exposure_management": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ackable reports whether a finding can be acknowledged.
|
||||||
|
func Ackable(code string) bool {
|
||||||
|
return strings.HasPrefix(code, "exposure_") || strings.HasPrefix(code, "security_open_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposures classifies every view (or the server, without views).
|
||||||
|
func Exposures(s *model.State, opts Options) []Exposure {
|
||||||
|
c := &checker{s: s, opts: opts}
|
||||||
|
return c.exposures()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *checker) exposures() []Exposure {
|
||||||
|
views := c.s.OrderedViews()
|
||||||
|
if len(views) == 0 {
|
||||||
|
return []Exposure{c.serverExposure()}
|
||||||
|
}
|
||||||
|
var out []Exposure
|
||||||
|
for _, v := range views {
|
||||||
|
out = append(out, c.viewExposure(v))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *checker) viewExposure(v *model.View) Exposure {
|
||||||
|
e := Exposure{ViewID: v.ID, View: v.Name}
|
||||||
|
switch v.Exposure {
|
||||||
|
case model.ExposurePublic:
|
||||||
|
e.Public, e.Override, e.Reason = true, v.Exposure, "marked public"
|
||||||
|
return e
|
||||||
|
case model.ExposurePrivate:
|
||||||
|
e.Override, e.Reason = v.Exposure, "marked private"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
clients := c.flatten(v.MatchClients, 0)
|
||||||
|
what, open := catchAll(clients)
|
||||||
|
if len(v.MatchClients) == 0 {
|
||||||
|
what, open = "nothing (so any client)", true
|
||||||
|
}
|
||||||
|
if !open {
|
||||||
|
e.Reason = "match-clients lists only " + describeList(v.MatchClients) + ", no catch-all"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if len(v.MatchDestinations) > 0 {
|
||||||
|
if dst := c.flatten(v.MatchDestinations, 0); onlyPrivate(dst) {
|
||||||
|
e.Reason = "match-destinations lists only private addresses (" + describeList(v.MatchDestinations) + ")"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Public, e.Reason = true, "match-clients ends in "+what
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverExposure classifies a server without views: public when anyone
|
||||||
|
// may query it and it listens on an address that isn't private.
|
||||||
|
func (c *checker) serverExposure() Exposure {
|
||||||
|
e := Exposure{}
|
||||||
|
switch c.s.Settings.Exposure {
|
||||||
|
case model.ExposurePublic:
|
||||||
|
e.Public, e.Override, e.Reason = true, model.ExposurePublic, "marked public"
|
||||||
|
return e
|
||||||
|
case model.ExposurePrivate:
|
||||||
|
e.Override, e.Reason = model.ExposurePrivate, "marked private"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if aml, _, set := c.effectiveAML(nil, "allow-query"); set {
|
||||||
|
if _, open := catchAll(aml); !open {
|
||||||
|
e.Reason = "allow-query has no catch-all"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addr, ok := c.publicListenAddr()
|
||||||
|
if !ok {
|
||||||
|
e.Reason = "named listens on private addresses only"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
e.Public, e.Reason = true, "anyone may query it, and named listens on "+addr
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicListenAddr returns an address named listens on that isn't
|
||||||
|
// private, from listen-on/listen-on-v6 and this host's addresses.
|
||||||
|
func (c *checker) publicListenAddr() (string, bool) {
|
||||||
|
for _, name := range []string{"listen-on", "listen-on-v6"} {
|
||||||
|
opts := []*model.Option{}
|
||||||
|
for _, o := range c.s.ScopedOptions(model.ScopeOptions, 0) {
|
||||||
|
if o.Name == name {
|
||||||
|
opts = append(opts, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(opts) == 0 {
|
||||||
|
// named listens on every interface by default.
|
||||||
|
if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok {
|
||||||
|
return a, true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
elems := c.amlOf(o)
|
||||||
|
if _, open := catchAll(elems); open {
|
||||||
|
if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok {
|
||||||
|
return a, true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range elems {
|
||||||
|
if e.Kind == namedconf.AMPrefix && !e.Negated && !privatePrefix(e.Prefix) {
|
||||||
|
return e.Prefix.Addr().String(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicLocalAddr returns one of this host's addresses that isn't
|
||||||
|
// private (none known in tests).
|
||||||
|
func (c *checker) publicLocalAddr(v6 bool) (string, bool) {
|
||||||
|
for _, a := range c.opts.LocalAddrs {
|
||||||
|
a = a.Unmap()
|
||||||
|
if a.Is6() == v6 && !privatePrefix(netip.PrefixFrom(a, a.BitLen())) {
|
||||||
|
return a.String(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// catchAll reports whether a list lets in clients from anywhere: a
|
||||||
|
// non-negated "any", or a prefix so wide (shorter than /8 for IPv4, /16
|
||||||
|
// for IPv6) that it holds most of the internet. Negated elements before
|
||||||
|
// it only take some clients out.
|
||||||
|
func catchAll(elems []namedconf.AMElement) (string, bool) {
|
||||||
|
for _, e := range elems {
|
||||||
|
if e.Negated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case e.Kind == namedconf.AMName && e.Name == "any":
|
||||||
|
return "any", true
|
||||||
|
case e.Kind == namedconf.AMName && e.Name == "none":
|
||||||
|
return "", false
|
||||||
|
case e.Kind == namedconf.AMPrefix && e.Prefix.Addr().Is4() && e.Prefix.Bits() < 8,
|
||||||
|
e.Kind == namedconf.AMPrefix && !e.Prefix.Addr().Is4() && e.Prefix.Bits() < 16:
|
||||||
|
return e.Prefix.String(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateBlocks = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("169.254.0.0/16"),
|
||||||
|
netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"),
|
||||||
|
}
|
||||||
|
|
||||||
|
func privatePrefix(p netip.Prefix) bool {
|
||||||
|
a := p.Addr().Unmap()
|
||||||
|
for _, b := range privateBlocks {
|
||||||
|
if b.Addr().Is4() == a.Is4() && b.Contains(a) && p.Bits() >= b.Bits() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyPrivate reports whether every address element is private (names
|
||||||
|
// such as localhost and localnets count as private).
|
||||||
|
func onlyPrivate(elems []namedconf.AMElement) bool {
|
||||||
|
for _, e := range elems {
|
||||||
|
if e.Negated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch e.Kind {
|
||||||
|
case namedconf.AMPrefix:
|
||||||
|
if !privatePrefix(e.Prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case namedconf.AMName:
|
||||||
|
if e.Name == "any" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case namedconf.AMOther, namedconf.AMNested:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeList(list []string) string {
|
||||||
|
if len(list) > 4 {
|
||||||
|
return strings.Join(list[:4], ", ") + ", …"
|
||||||
|
}
|
||||||
|
return strings.Join(list, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkExposure runs the exposure checks on public views (or the server).
|
||||||
|
func (c *checker) checkExposure() {
|
||||||
|
exps := c.exposures()
|
||||||
|
anyPublic := false
|
||||||
|
for _, x := range exps {
|
||||||
|
if !x.Public {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
anyPublic = true
|
||||||
|
v := c.s.Views[x.ViewID]
|
||||||
|
label := x.Label()
|
||||||
|
warn := func(code, format string, args ...any) {
|
||||||
|
p := c.warnf(code, "view", x.ViewID, "%s is reachable from the internet (%s): %s", label, x.Reason, fmt.Sprintf(format, args...))
|
||||||
|
p.View = x.View
|
||||||
|
}
|
||||||
|
if o := c.option(v, "rate-limit"); o == nil {
|
||||||
|
warn("exposure_rate_limit", "it has no rate-limit, so it can be used to flood others with its answers (DNS amplification); start response rate limiting in log-only mode")
|
||||||
|
} else if LogOnly(o) {
|
||||||
|
p := c.infof("exposure_rate_limit_log_only", "view", x.ViewID, "%s: rate limiting is in log-only mode: named only logs what it would limit; check that nobody legitimate would be limited, then start enforcing", label)
|
||||||
|
p.View = x.View
|
||||||
|
}
|
||||||
|
if !c.effectiveBool(v, "minimal-any", false) {
|
||||||
|
warn("exposure_minimal_any", "ANY queries get full answers, a favourite for amplification; set minimal-any yes")
|
||||||
|
}
|
||||||
|
if !c.effectiveBool(v, "recursion", true) {
|
||||||
|
if aml, _, set := c.effectiveAML(v, "allow-query-cache"); set {
|
||||||
|
if _, open := catchAll(aml); open {
|
||||||
|
warn("exposure_cache_snooping", "anyone may query its cache (allow-query-cache), which shows what your clients look up; set allow-query-cache { none; }")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if aml, _, set := c.effectiveAML(v, "allow-notify"); set {
|
||||||
|
if _, open := catchAll(aml); open {
|
||||||
|
warn("exposure_open_notify", "anyone may send it NOTIFY messages (allow-notify); allow only your primaries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anyPublic {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server := func(code, format string, args ...any) {
|
||||||
|
c.warnf(code, "server", 0, format, args...)
|
||||||
|
}
|
||||||
|
if c.s.FindOption(model.ScopeOptions, 0, "version") == nil {
|
||||||
|
server("exposure_version", "the server is reachable from the internet and tells anyone its exact BIND version (dig CH TXT version.bind), which helps attackers pick exploits; set version none")
|
||||||
|
}
|
||||||
|
if c.s.FindOption(model.ScopeOptions, 0, "hostname") == nil {
|
||||||
|
server("exposure_hostname", "the server is reachable from the internet and tells anyone its host name (dig CH TXT hostname.bind); set hostname none")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"controls", "statistics-channels"} {
|
||||||
|
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if addr, ok := openManagement(o); ok {
|
||||||
|
c.warnf("exposure_management", "option", o.ID, "%s listens on %s and accepts anyone there; management interfaces must not be reachable from the internet: use 127.0.0.1, or an allow list of your own hosts", name, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// option returns the option name for a view, or the server's.
|
||||||
|
func (c *checker) option(v *model.View, name string) *model.Option {
|
||||||
|
if v != nil {
|
||||||
|
if o := c.s.FindOption(model.ScopeView, v.ID, name); o != nil {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.s.FindOption(model.ScopeOptions, 0, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogOnly reports whether a rate-limit option is in log-only mode.
|
||||||
|
func LogOnly(o *model.Option) bool {
|
||||||
|
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
|
if err != nil || st.FirstBlock() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, s := range st.FirstBlock().Stmts {
|
||||||
|
if s.Keyword() == "log-only" && len(s.Items) == 2 && s.Items[1].Value != nil {
|
||||||
|
switch strings.ToLower(s.Items[1].Value.Text) {
|
||||||
|
case "yes", "true", "1":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// openManagement reports an inet clause of controls or
|
||||||
|
// statistics-channels on an address that isn't loopback, open to anyone.
|
||||||
|
func openManagement(o *model.Option) (string, bool) {
|
||||||
|
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
|
if err != nil || st.FirstBlock() == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, s := range st.FirstBlock().Stmts {
|
||||||
|
if s.Keyword() != "inet" || len(s.Items) < 2 || s.Items[1].Value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr := s.Items[1].Value.Text
|
||||||
|
if a, err := netip.ParseAddr(addr); err == nil && a.IsLoopback() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, it := range s.Items {
|
||||||
|
if it.Value != nil && strings.EqualFold(it.Value.Text, "allow") && i+1 < len(s.Items) && s.Items[i+1].Block != nil {
|
||||||
|
var elems []namedconf.AMElement
|
||||||
|
for _, as := range s.Items[i+1].Block.Stmts {
|
||||||
|
if e, err := namedconf.ParseAMElement(namedconf.Compact(as.Items)); err == nil {
|
||||||
|
elems = append(elems, e.Expand()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, open := catchAll(elems); open {
|
||||||
|
return addr, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAcks turns acknowledged findings into info, saying who accepted
|
||||||
|
// them, when and why. A finding of a view (or of a zone in it) uses the
|
||||||
|
// view's acknowledgements, anything else the server's.
|
||||||
|
func (c *checker) applyAcks() {
|
||||||
|
for i := range c.problems {
|
||||||
|
p := &c.problems[i]
|
||||||
|
if p.Severity != Warning || !Ackable(p.Code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acks := c.s.Settings.Acknowledged
|
||||||
|
if v := c.problemView(p); v != nil {
|
||||||
|
acks = v.Acknowledged
|
||||||
|
}
|
||||||
|
if a := model.FindAck(acks, p.Code); a != nil {
|
||||||
|
p.Severity = Info
|
||||||
|
p.Acknowledged = a
|
||||||
|
p.Message += fmt.Sprintf(" (acknowledged by %s on %s: %s)", a.By, a.At.Format("2 Jan 2006"), a.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *checker) problemView(p *Problem) *model.View {
|
||||||
|
switch p.ObjectType {
|
||||||
|
case "view":
|
||||||
|
return c.s.Views[p.ObjectID]
|
||||||
|
case "zone":
|
||||||
|
if z := c.s.Zones[p.ObjectID]; z != nil {
|
||||||
|
return c.s.Views[z.ViewID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ package validate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -37,6 +38,9 @@ type Problem struct {
|
|||||||
Zone string `json:"zone,omitempty"`
|
Zone string `json:"zone,omitempty"`
|
||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
// Acknowledged is set when an exposure finding was accepted on
|
||||||
|
// purpose (it is then info, not a warning).
|
||||||
|
Acknowledged *model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key identifies a problem independently of object IDs, so problems can be
|
// Key identifies a problem independently of object IDs, so problems can be
|
||||||
@@ -67,6 +71,10 @@ type Options struct {
|
|||||||
// ExternalKeysUnknown is set when an included file could not be read,
|
// ExternalKeysUnknown is set when an included file could not be read,
|
||||||
// so references to unknown keys are not reported.
|
// so references to unknown keys are not reported.
|
||||||
ExternalKeysUnknown bool
|
ExternalKeysUnknown bool
|
||||||
|
// LocalAddrs are this host's addresses: a server without views is
|
||||||
|
// public when named listens on one that isn't private
|
||||||
|
// (docs/exposure.md).
|
||||||
|
LocalAddrs []netip.Addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result holds the problems found.
|
// Result holds the problems found.
|
||||||
@@ -140,8 +148,10 @@ func State(s *model.State, opts Options) []Problem {
|
|||||||
c.checkZones()
|
c.checkZones()
|
||||||
c.checkOptions()
|
c.checkOptions()
|
||||||
c.checkSecurity()
|
c.checkSecurity()
|
||||||
|
c.checkExposure()
|
||||||
c.checkDNSSEC()
|
c.checkDNSSEC()
|
||||||
c.checkBlocking()
|
c.checkBlocking()
|
||||||
|
c.applyAcks()
|
||||||
sortProblems(c.problems)
|
sortProblems(c.problems)
|
||||||
return c.problems
|
return c.problems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export async function amlContext() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i;
|
const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i;
|
||||||
|
// rangeRe finds address ranges (START-END); the server checks them.
|
||||||
|
const rangeRe = /!?\s*[0-9a-f]*[.:][0-9a-f:.]*\s*-\s*[0-9a-f]*[.:][0-9a-f:.]*/gi;
|
||||||
|
|
||||||
// checkElement returns a problem with one element, or "".
|
// checkElement returns a problem with one element, or "".
|
||||||
function checkElement(line, ctx, exclude) {
|
function checkElement(line, ctx, exclude) {
|
||||||
@@ -24,6 +26,7 @@ function checkElement(line, ctx, exclude) {
|
|||||||
if (e.startsWith("!")) e = e.slice(1).trim();
|
if (e.startsWith("!")) e = e.slice(1).trim();
|
||||||
const lower = e.toLowerCase();
|
const lower = e.toLowerCase();
|
||||||
if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return "";
|
if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return "";
|
||||||
|
if (e.replace(rangeRe, "").trim() === "") return ""; // a range: rangeHints explains it
|
||||||
const key = e.match(/^key\s+"?([^"\s]+)"?$/i);
|
const key = e.match(/^key\s+"?([^"\s]+)"?$/i);
|
||||||
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
|
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
|
||||||
if (/\s/.test(e)) return "";
|
if (/\s/.test(e)) return "";
|
||||||
@@ -32,6 +35,33 @@ function checkElement(line, ctx, exclude) {
|
|||||||
return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)");
|
return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rangeHints shows, below a field, what each address range in it
|
||||||
|
// (10.0.10.101-10.0.10.199) becomes in named.conf, which only knows
|
||||||
|
// prefixes, and what is wrong with a range that can't be used.
|
||||||
|
export function rangeHints(field) {
|
||||||
|
const box = h("div", { class: "range-hints" });
|
||||||
|
let timer, last = "";
|
||||||
|
const update = async () => {
|
||||||
|
const found = (field.value.match(rangeRe) || []).map((r) => r.replace(/\s+/g, ""));
|
||||||
|
const key = found.join(",");
|
||||||
|
if (key === last) return;
|
||||||
|
last = key;
|
||||||
|
if (!found.length) { mount(box); return; }
|
||||||
|
try {
|
||||||
|
const info = await get("/catalog/ranges", { r: key });
|
||||||
|
if (key !== last) return;
|
||||||
|
mount(box, info.map((x) => x.error
|
||||||
|
? h("div", { class: "msg warn small" }, x.error)
|
||||||
|
: h("details", { class: "range-hint small" },
|
||||||
|
h("summary", null, h("span", { class: "mono" }, x.input), " = " + x.size + ", written to named.conf as " + (x.prefixes.length === 1 ? "1 prefix" : x.prefixes.length + " prefixes")),
|
||||||
|
h("div", { class: "prefixes mono" }, x.prefixes.map((p) => h("span", null, p))))));
|
||||||
|
} catch { mount(box); }
|
||||||
|
};
|
||||||
|
field.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(update, 300); });
|
||||||
|
update();
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
// suggestions adds clickable names below a text field. mode "lines" adds
|
// suggestions adds clickable names below a text field. mode "lines" adds
|
||||||
// one element per line (and checks the names); "inline" inserts
|
// one element per line (and checks the names); "inline" inserts
|
||||||
// "name; " at the cursor in named.conf syntax.
|
// "name; " at the cursor in named.conf syntax.
|
||||||
@@ -58,7 +88,7 @@ export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
|
|||||||
acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null,
|
acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null,
|
||||||
ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null,
|
ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null,
|
||||||
h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])),
|
h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])),
|
||||||
mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it.") : null);
|
mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it. Ranges: 10.0.10.101-10.0.10.199.") : null);
|
||||||
if (mode === "lines") {
|
if (mode === "lines") {
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m);
|
const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m);
|
||||||
@@ -67,5 +97,5 @@ export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
|
|||||||
field.addEventListener("input", check);
|
field.addEventListener("input", check);
|
||||||
check();
|
check();
|
||||||
}
|
}
|
||||||
return h("div", { class: "aml" }, row, problems);
|
return h("div", { class: "aml" }, row, problems, rangeHints(field));
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ import * as account from "./pages/account.js";
|
|||||||
import * as search from "./pages/search.js";
|
import * as search from "./pages/search.js";
|
||||||
import * as stats from "./pages/stats.js";
|
import * as stats from "./pages/stats.js";
|
||||||
import * as blocking from "./pages/blocking.js";
|
import * as blocking from "./pages/blocking.js";
|
||||||
|
import * as exposure from "./pages/exposure.js";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||||
@@ -37,6 +38,7 @@ const routes = [
|
|||||||
{ pattern: /^search$/, page: search, nav: "" },
|
{ pattern: /^search$/, page: search, nav: "" },
|
||||||
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
||||||
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
||||||
|
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -45,6 +47,7 @@ const navItems = [
|
|||||||
["zones", "#/zones", "Zones"],
|
["zones", "#/zones", "Zones"],
|
||||||
["blocking", "#/blocking", "Blocking"],
|
["blocking", "#/blocking", "Blocking"],
|
||||||
["views", "#/views", "Views"],
|
["views", "#/views", "Views"],
|
||||||
|
["exposure", "#/exposure", "Exposure"],
|
||||||
["acls", "#/acls", "ACLs"],
|
["acls", "#/acls", "ACLs"],
|
||||||
["keys", "#/keys", "TSIG keys"],
|
["keys", "#/keys", "TSIG keys"],
|
||||||
["options", "#/options", "Server options"],
|
["options", "#/options", "Server options"],
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function aclDialog(a) {
|
|||||||
const comment = input({ value: a ? a.comment : "" });
|
const comment = input({ value: a ? a.comment : "" });
|
||||||
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
|
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Name", name, a ? "Renaming updates every reference automatically." : ""),
|
field("Name", name, a ? "Renaming updates every reference automatically." : ""),
|
||||||
field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
|
field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), range (10.0.10.101-10.0.10.199), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
|
||||||
field("Comment", comment)), err), [
|
field("Comment", comment)), err), [
|
||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
|
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge
|
|||||||
import { get, change, describeResult, draftQuery, can, api } from "../api.js";
|
import { get, change, describeResult, draftQuery, can, api } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { pendingBadge } from "./zones.js";
|
import { pendingBadge } from "./zones.js";
|
||||||
|
import { rangeHints } from "../aml.js";
|
||||||
|
|
||||||
// Well-known lists in Adblock format (they say which entries cover
|
// Well-known lists in Adblock format (they say which entries cover
|
||||||
// subdomains). Sizes change; these are rough guides.
|
// subdomains). Sizes change; these are rough guides.
|
||||||
@@ -22,7 +23,12 @@ let refreshTimer = null;
|
|||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
clearTimeout(refreshTimer);
|
clearTimeout(refreshTimer);
|
||||||
const [lists, rules, views, status] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status")]);
|
// Queries blocked in the last day, per list and for the rules together
|
||||||
|
// (null without query statistics, or without permission to see them).
|
||||||
|
const [lists, rules, views, status, hitList, steps, groups] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
|
||||||
|
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null,
|
||||||
|
get("/blocking/steps", draftQuery()), get("/blocking/groups", draftQuery())]);
|
||||||
|
const hits = hitList ? Object.fromEntries(hitList.map((e) => [e.key, e.count])) : null;
|
||||||
const writable = can("blocking", true);
|
const writable = can("blocking", true);
|
||||||
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
||||||
const autoViews = recursive ? recursive.views : null;
|
const autoViews = recursive ? recursive.views : null;
|
||||||
@@ -53,8 +59,9 @@ export async function render(el, params, query) {
|
|||||||
h("form", { class: "actions", onsubmit: runCheck },
|
h("form", { class: "actions", onsubmit: runCheck },
|
||||||
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
|
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
|
||||||
checkOut),
|
checkOut),
|
||||||
rulesPanel(rules, views, autoViews, writable),
|
rulesPanel(rules, views, autoViews, writable, hits),
|
||||||
listsPanel(lists, views, autoViews, writable));
|
listsPanel(lists, views, autoViews, writable, hits, steps),
|
||||||
|
devicesPanel(groups, steps, views, autoViews, writable));
|
||||||
if (checkName.value) runCheck();
|
if (checkName.value) runCheck();
|
||||||
|
|
||||||
// Lists are downloaded in the background, and a pause ends by itself:
|
// Lists are downloaded in the background, and a pause ends by itself:
|
||||||
@@ -110,7 +117,8 @@ function checkResult(name, res, rules, writable) {
|
|||||||
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
|
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
|
||||||
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
|
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
|
||||||
" ",
|
" ",
|
||||||
m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
|
m.list && m.step ? ["for devices at step ", h("strong", null, m.step), " and above, by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), "); other devices aren't blocked"]
|
||||||
|
: m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
|
||||||
: m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null);
|
: m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null);
|
||||||
})),
|
})),
|
||||||
action);
|
action);
|
||||||
@@ -172,7 +180,8 @@ async function editDialog(title, path, rows, data) {
|
|||||||
], { sticky: true });
|
], { sticky: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function editList(l, views, autoViews) {
|
function editList(l, views, autoViews, steps) {
|
||||||
|
const step = stepSelect(steps, l.step, false);
|
||||||
const name = input({ value: l.name, class: "mono" });
|
const name = input({ value: l.name, class: "mono" });
|
||||||
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
|
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
|
||||||
const comment = input({ value: l.comment || "" });
|
const comment = input({ value: l.comment || "" });
|
||||||
@@ -180,8 +189,9 @@ function editList(l, views, autoViews) {
|
|||||||
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
|
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
|
||||||
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
|
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
|
||||||
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
|
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
|
||||||
|
steps.length ? ["Step", step, "Devices at this step or above get the list. normal: every device except those at off."] : null,
|
||||||
["Comment", comment],
|
["Comment", comment],
|
||||||
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value }));
|
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value, step: step.value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function editRule(r, views, autoViews) {
|
function editRule(r, views, autoViews) {
|
||||||
@@ -207,7 +217,12 @@ function viewsCell(x) {
|
|||||||
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
|
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function rulesPanel(rules, views, autoViews, writable) {
|
// hitsLink shows a count of blocked queries, linking to the statistics.
|
||||||
|
function hitsLink(n) {
|
||||||
|
return h("a", { href: "#/stats?since=24h", title: "queries blocked in the last 24 hours; more on the Statistics page" }, (n || 0).toLocaleString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function rulesPanel(rules, views, autoViews, writable, hits) {
|
||||||
const name = input({ placeholder: "ads.example.com", class: "mono" });
|
const name = input({ placeholder: "ads.example.com", class: "mono" });
|
||||||
const action = select([["block", "Block"], ["allow", "Allow"]], "block");
|
const action = select([["block", "Block"], ["allow", "Allow"]], "block");
|
||||||
const exact = checkbox("only this exact name", false);
|
const exact = checkbox("only this exact name", false);
|
||||||
@@ -230,6 +245,7 @@ function rulesPanel(rules, views, autoViews, writable) {
|
|||||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule")));
|
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule")));
|
||||||
return h("div", { class: "panel" },
|
return h("div", { class: "panel" },
|
||||||
h("h2", null, "Your rules"),
|
h("h2", null, "Your rules"),
|
||||||
|
hits && rules.some((r) => r.action === "block") ? h("p", { class: "small" }, "Blocked ", hitsLink(hits.rules), " queries in the last 24 hours (all rules together: named doesn't say which rule matched).") : null,
|
||||||
h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."),
|
h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."),
|
||||||
writable ? [form, err] : null,
|
writable ? [form, err] : null,
|
||||||
table([
|
table([
|
||||||
@@ -243,21 +259,33 @@ function rulesPanel(rules, views, autoViews, writable) {
|
|||||||
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function listsPanel(lists, views, autoViews, writable) {
|
// stepSelect picks a step: off (only for groups), normal and the steps
|
||||||
|
// above it.
|
||||||
|
function stepSelect(steps, value, withOff) {
|
||||||
|
const opts = [];
|
||||||
|
if (withOff) opts.push(["off", "off (only your rules)"]);
|
||||||
|
opts.push(["normal", "normal" + (withOff ? " (like devices in no group)" : " (every device)")]);
|
||||||
|
steps.filter((s) => s.pending !== "delete").forEach((s) => opts.push([s.name, s.name]));
|
||||||
|
return select(opts, value || "normal");
|
||||||
|
}
|
||||||
|
|
||||||
|
function listsPanel(lists, views, autoViews, writable, hits, steps) {
|
||||||
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
|
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
|
||||||
const url = input({ placeholder: "https://…", class: "mono" });
|
const url = input({ placeholder: "https://…", class: "mono" });
|
||||||
const vp = viewPicker(views, autoViews);
|
const vp = viewPicker(views, autoViews);
|
||||||
|
const listStep = stepSelect(steps, "normal", false);
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
try {
|
try {
|
||||||
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value() });
|
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value(), step: listStep.value });
|
||||||
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
|
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
|
||||||
rerender();
|
rerender();
|
||||||
} catch (e) { mount(err, errorBox(e)); }
|
} catch (e) { mount(err, errorBox(e)); }
|
||||||
} },
|
} },
|
||||||
h("div", { class: "f" }, h("label", null, "Name"), name),
|
h("div", { class: "f" }, h("label", null, "Name"), name),
|
||||||
h("div", { class: "f grow" }, h("label", null, "URL"), url),
|
h("div", { class: "f grow" }, h("label", null, "URL"), url),
|
||||||
|
steps.length ? h("div", { class: "f" }, h("label", null, "Step"), listStep) : null,
|
||||||
vp.el,
|
vp.el,
|
||||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
|
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
|
||||||
const used = new Set(lists.map((l) => l.url));
|
const used = new Set(lists.map((l) => l.url));
|
||||||
@@ -273,12 +301,14 @@ function listsPanel(lists, views, autoViews, writable) {
|
|||||||
{ label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] },
|
{ label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] },
|
||||||
{ label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } },
|
{ label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } },
|
||||||
{ label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" },
|
{ label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" },
|
||||||
|
hits ? { label: "Blocked (24h)", render: (l) => hitsLink(hits[l.name]), class: "right", tdClass: "right" } : null,
|
||||||
|
steps.length ? { label: "Step", render: (l) => l.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(l.step, "accent") } : null,
|
||||||
views.length ? { label: "Views", render: viewsCell } : null,
|
views.length ? { label: "Views", render: viewsCell } : null,
|
||||||
{ label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" },
|
{ label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" },
|
||||||
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
|
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
|
||||||
{ label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
|
{ label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
|
||||||
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
|
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
|
||||||
h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ",
|
h("button", { class: "small", onclick: () => editList(l, views, autoViews, steps) }, "Edit"), " ",
|
||||||
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
|
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
|
||||||
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
|
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
|
||||||
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||||||
@@ -318,3 +348,126 @@ async function del(path, what) {
|
|||||||
} catch (e) { toast(e.message, "err", 8000); }
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- devices: blocking steps and client groups (docs/blocking.md)
|
||||||
|
|
||||||
|
// devicesPanel shows the steps (each includes the lists of the steps
|
||||||
|
// below it) and the client groups that put devices at a step.
|
||||||
|
function devicesPanel(groups, steps, views, autoViews, writable) {
|
||||||
|
const live = steps.filter((s) => s.pending !== "delete");
|
||||||
|
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
|
||||||
|
const members = input({ placeholder: "10.10.0.50 10.10.0.20-10.10.0.29 or an ACL", class: "mono" });
|
||||||
|
const step = stepSelect(steps, live.length ? live[0].name : "off", true);
|
||||||
|
const vp = viewPicker(views, autoViews);
|
||||||
|
const err = h("div");
|
||||||
|
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/blocking/groups", { name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value() });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { mount(err, errorBox(e)); }
|
||||||
|
} },
|
||||||
|
h("div", { class: "f" }, h("label", null, "Group"), name),
|
||||||
|
h("div", { class: "f grow" }, h("label", null, "Devices"), members, rangeHints(members)),
|
||||||
|
h("div", { class: "f" }, h("label", null, "Step"), step),
|
||||||
|
vp.el,
|
||||||
|
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
|
||||||
|
return h("div", { class: "panel" },
|
||||||
|
h("h2", null, "Devices"),
|
||||||
|
h("p", { class: "muted small" }, "Give some devices less or more blocking. Each step gets the lists of the steps below it plus its own; devices in no group are at normal. Your rules apply at every step. named must see the devices' own addresses (not a router's), so give them fixed ones."),
|
||||||
|
stepsLine(steps, writable),
|
||||||
|
writable ? [form, err] : null,
|
||||||
|
table([
|
||||||
|
{ label: "Group", render: (g) => [h("strong", { class: "mono" }, g.name), g.pending ? [" ", pendingBadge(g.pending)] : null] },
|
||||||
|
{ label: "Step", render: (g) => g.step === "off" ? badge("off", "") : g.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(g.step, "accent") },
|
||||||
|
{ label: "Devices", render: (g) => g.members.length ? h("span", { class: "mono small" }, g.members.join(", ")) : h("span", { class: "muted" }, "none") },
|
||||||
|
views.length ? { label: "Views", render: viewsCell } : null,
|
||||||
|
{ label: "Comment", render: (g) => h("span", { class: "muted" }, g.comment || "") },
|
||||||
|
writable ? { label: "", render: (g) => g.pending === "delete" ? "" : h("span", null,
|
||||||
|
h("button", { class: "small", onclick: () => editGroup(g, steps, views, autoViews) }, "Edit"), " ",
|
||||||
|
h("button", { class: "small danger", onclick: () => del("/blocking/groups/" + g.id, "client group " + g.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||||||
|
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitMembers turns "a, b c" into a list; a range may have spaces
|
||||||
|
// around its dash.
|
||||||
|
function splitMembers(s) {
|
||||||
|
return s.replace(/\s*-\s*/g, "-").split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepsLine shows the steps in order, with controls for the ones above
|
||||||
|
// normal.
|
||||||
|
function stepsLine(steps, writable) {
|
||||||
|
const live = steps.filter((s) => s.pending !== "delete");
|
||||||
|
const order = async (ids) => {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/blocking/steps/order", { steps: ids.map(String) });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const move = (i, d) => {
|
||||||
|
const ids = live.map((s) => s.id);
|
||||||
|
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
|
||||||
|
order(ids);
|
||||||
|
};
|
||||||
|
const add = async () => {
|
||||||
|
const n = await promptName("Add a step", "Name of the new step (above the others), e.g. strict", "");
|
||||||
|
if (!n) return;
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/blocking/steps", { name: n });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const rename = async (s) => {
|
||||||
|
const n = await promptName("Rename step " + s.name, "New name", s.name);
|
||||||
|
if (!n || n === s.name) return;
|
||||||
|
try {
|
||||||
|
const r = await change("PATCH", "/blocking/steps/" + s.id, { name: n });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const chip = (label, title) => h("span", { class: "step-chip", title }, label);
|
||||||
|
const items = [chip("off", "only your rules"), chip("normal", "your rules and the normal lists; devices in no group")];
|
||||||
|
live.forEach((s, i) => {
|
||||||
|
const used = s.lists.length || s.groups.length;
|
||||||
|
items.push(h("span", { class: "step-chip accent", title: "lists: " + (s.lists.join(", ") || "none") + "; groups: " + (s.groups.join(", ") || "none") },
|
||||||
|
s.name, s.pending ? [" ", pendingBadge(s.pending)] : null,
|
||||||
|
writable ? h("span", { class: "step-tools" },
|
||||||
|
h("button", { class: "small", disabled: i === 0, title: "Move down: fewer lists", onclick: () => move(i, -1) }, "←"),
|
||||||
|
h("button", { class: "small", disabled: i === live.length - 1, title: "Move up: more lists", onclick: () => move(i, 1) }, "→"),
|
||||||
|
h("button", { class: "small", title: "Rename", onclick: () => rename(s) }, "✎"),
|
||||||
|
h("button", { class: "small danger", disabled: !!used, title: used ? "In use by lists or groups" : "Delete", onclick: () => del("/blocking/steps/" + s.id, "step " + s.name) }, "×")) : null));
|
||||||
|
});
|
||||||
|
return h("div", { class: "steps-line" }, h("span", { class: "muted small" }, "Steps: "),
|
||||||
|
items.map((x, i) => [i ? h("span", { class: "muted" }, " → ") : null, x]),
|
||||||
|
writable ? [" ", h("button", { class: "small", onclick: add }, "Add step")] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptName asks for a short name in a dialog.
|
||||||
|
async function promptName(title, label, value) {
|
||||||
|
const i = input({ value, class: "mono" });
|
||||||
|
const v = await modal(title, h("div", { class: "form-grid" }, field(label, i)), [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "OK", kind: "primary", value: true, onclick: () => !!i.value.trim() },
|
||||||
|
]);
|
||||||
|
return v === true ? i.value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editGroup(g, steps, views, autoViews) {
|
||||||
|
const name = input({ value: g.name, class: "mono" });
|
||||||
|
const members = input({ value: g.members.join(" "), class: "mono", style: { width: "100%" } });
|
||||||
|
const step = stepSelect(steps, g.step, true);
|
||||||
|
const comment = input({ value: g.comment || "" });
|
||||||
|
const vp = viewPicker(views, autoViews, ownViews(g));
|
||||||
|
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
|
||||||
|
["Name", name],
|
||||||
|
["Devices", h("div", null, members, rangeHints(members)), "Addresses, networks (10.10.0.0/24), ranges (10.10.0.20-10.10.0.49) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
|
||||||
|
["Step", step],
|
||||||
|
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
|
||||||
|
["Comment", comment],
|
||||||
|
], () => ({ name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value(), comment: comment.value }));
|
||||||
|
}
|
||||||
@@ -43,28 +43,50 @@ async function draftPanel(d) {
|
|||||||
let valid = false;
|
let valid = false;
|
||||||
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
||||||
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
|
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
|
||||||
|
// Changes that open a public view up must be confirmed (docs/exposure.md).
|
||||||
|
const riskBox = h("label", { class: "check risk-confirm hidden" }, h("input", { type: "checkbox" }), "");
|
||||||
|
let risky = false;
|
||||||
|
riskBox.querySelector("input").addEventListener("change", () => { applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked); });
|
||||||
const ops = d.ops || [];
|
const ops = d.ops || [];
|
||||||
|
const removeOp = async (seq) => {
|
||||||
|
try {
|
||||||
|
const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + seq);
|
||||||
|
useDraft(cs);
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
const opsTable = table([
|
const opsTable = table([
|
||||||
{ label: "#", render: (o) => String(o.seq) },
|
{ label: "#", render: (o) => String(o.seq) },
|
||||||
{ label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" },
|
{ label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" },
|
||||||
{ label: "Added", render: (o) => time(o.added_at) },
|
{ label: "Added", render: (o) => time(o.added_at) },
|
||||||
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: async () => {
|
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: () => removeOp(o.seq) }, "Remove"), tdClass: "actions-cell" },
|
||||||
try {
|
|
||||||
const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + o.seq);
|
|
||||||
useDraft(cs);
|
|
||||||
rerender();
|
|
||||||
} catch (e) { toast(e.message, "err", 8000); }
|
|
||||||
} }, "Remove"), tdClass: "actions-cell" },
|
|
||||||
], ops);
|
], ops);
|
||||||
|
// failedOp marks the operation that no longer applies and says how to
|
||||||
|
// get past it.
|
||||||
|
const failedOp = (err) => {
|
||||||
|
const i = ops.findIndex((o) => o.seq === err.op);
|
||||||
|
const rows = opsTable.querySelectorAll("tbody tr");
|
||||||
|
if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err });
|
||||||
|
rows[i].classList.add("op-failed");
|
||||||
|
return h("div", { class: "msg err" },
|
||||||
|
h("div", null, h("strong", null, "Operation " + err.op + " can't be applied: "), err.message),
|
||||||
|
h("div", { class: "mono small" }, ops[i].summary || ops[i].op),
|
||||||
|
h("div", { style: { marginTop: "8px" } },
|
||||||
|
h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op),
|
||||||
|
" ", h("span", { class: "muted small" }, "It is marked in the list above. Then check the rest again.")));
|
||||||
|
};
|
||||||
const runPreview = async () => {
|
const runPreview = async () => {
|
||||||
try {
|
try {
|
||||||
const p = await get("/changesets/" + d.id + "/preview");
|
const p = await get("/changesets/" + d.id + "/preview");
|
||||||
valid = p.valid && !p.error;
|
valid = p.valid && !p.error;
|
||||||
applyBtn.disabled = !valid;
|
risky = (p.risks || []).length > 0;
|
||||||
|
riskBox.classList.toggle("hidden", !risky);
|
||||||
|
if (risky) riskBox.lastChild.textContent = " I understand: " + p.risks.map((x) => x.message).join("; ");
|
||||||
|
applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked);
|
||||||
const drift = (p.drift || []);
|
const drift = (p.drift || []);
|
||||||
forceBox.classList.toggle("hidden", drift.length === 0);
|
forceBox.classList.toggle("hidden", drift.length === 0);
|
||||||
mount(preview,
|
mount(preview,
|
||||||
p.error ? errorBox({ message: p.error.message, details: p.error }) : null,
|
p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null,
|
||||||
valid ? h("div", { class: "msg ok" }, "Valid. named-checkconf accepts the new configuration.", (p.serials || []).length ? " Serials: " + p.serials.map((s) => s.zone.replace(/\.$/, "") + " " + s.old + "→" + s.new).join(", ") : "") : null,
|
valid ? h("div", { class: "msg ok" }, "Valid. named-checkconf accepts the new configuration.", (p.serials || []).length ? " Serials: " + p.serials.map((s) => s.zone.replace(/\.$/, "") + " " + s.old + "→" + s.new).join(", ") : "") : null,
|
||||||
!p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null,
|
!p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null,
|
||||||
problemsList(p.problems),
|
problemsList(p.problems),
|
||||||
@@ -82,7 +104,7 @@ async function draftPanel(d) {
|
|||||||
try {
|
try {
|
||||||
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
|
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
|
||||||
const force = forceBox.querySelector("input").checked;
|
const force = forceBox.querySelector("input").checked;
|
||||||
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force } });
|
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force, accept_risk: riskBox.querySelector("input").checked } });
|
||||||
const res = r.result || {};
|
const res = r.result || {};
|
||||||
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
|
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
|
||||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
@@ -104,6 +126,7 @@ async function draftPanel(d) {
|
|||||||
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
||||||
desc,
|
desc,
|
||||||
h("div", { style: { marginTop: "10px" } }, opsTable),
|
h("div", { style: { marginTop: "10px" } }, opsTable),
|
||||||
|
riskBox,
|
||||||
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
|
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
|
||||||
result),
|
result),
|
||||||
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from ".
|
|||||||
import { get, state } from "../api.js";
|
import { get, state } from "../api.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const [st, mine] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]);
|
const [st, mine, exposure] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => []), get("/exposure").catch(() => null)]);
|
||||||
const s = st.engine || {};
|
const s = st.engine || {};
|
||||||
// The user's own changesets with something in them: what the Changes page shows.
|
// The user's own changesets with something in them: what the Changes page shows.
|
||||||
const pending = mine.filter((c) => (c.ops || []).length).length;
|
const pending = mine.filter((c) => (c.ops || []).length).length;
|
||||||
@@ -16,6 +16,7 @@ export async function render(el) {
|
|||||||
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
|
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
|
||||||
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
|
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
|
||||||
st.backup ? backupCard(st.backup) : null,
|
st.backup ? backupCard(st.backup) : null,
|
||||||
|
exposure ? exposureCard(exposure) : null,
|
||||||
);
|
);
|
||||||
const last = s.last_applied;
|
const last = s.last_applied;
|
||||||
const parts = [h("div", { class: "page-head" },
|
const parts = [h("div", { class: "page-head" },
|
||||||
@@ -106,6 +107,16 @@ function backupCard(b) {
|
|||||||
return card("Backup", value, sub);
|
return card("Backup", value, sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exposureCard sums up the internet exposure page.
|
||||||
|
function exposureCard(r) {
|
||||||
|
const pub = r.scopes.filter((s) => s.public);
|
||||||
|
let warn = r.server.filter((f) => f.severity === "warning").length;
|
||||||
|
for (const s of r.scopes) warn += s.findings.filter((f) => f.severity === "warning").length;
|
||||||
|
const value = !pub.length ? badge("nothing public", "ok") : warn ? badge(plural(warn, "warning"), "warn") : badge("hardened", "ok");
|
||||||
|
const sub = pub.length ? "public: " + pub.map((s) => s.view || "the server").join(", ") : "no view is reachable from the internet";
|
||||||
|
return h("a", { class: "card link", href: "#/exposure" }, h("div", { class: "label" }, "Exposure"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
function card(label, value, sub) {
|
function card(label, value, sub) {
|
||||||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Internet exposure (docs/exposure.md): which views are reachable from
|
||||||
|
// the internet, why, and what to harden, with fixes staged like any edit
|
||||||
|
// and findings that can be accepted on purpose.
|
||||||
|
import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural } from "../ui.js";
|
||||||
|
import { get, change, describeResult, can } from "../api.js";
|
||||||
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
|
const sevKind = { warning: "warn", error: "err", info: "" };
|
||||||
|
|
||||||
|
export async function render(el) {
|
||||||
|
const r = await get("/exposure");
|
||||||
|
const writable = can("options", true);
|
||||||
|
const parts = [h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" }, h("h1", null, "Internet exposure"),
|
||||||
|
h("div", { class: "muted" }, "Which views can be reached from the internet, and what to harden on them. A view is public when its match-clients let in anyone (they end in any), whatever kind of addresses your own networks use. Fixes are staged as pending changes for you to review.")))];
|
||||||
|
|
||||||
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Views"), table([
|
||||||
|
{ label: "View", render: (s) => s.view ? h("a", { href: "#/views/" + s.view_id }, s.view) : h("span", { class: "muted" }, "the server (no views)") },
|
||||||
|
{ label: "Exposure", render: (s) => [badge(s.public ? "public" : "private", s.public ? "warn" : "ok"), s.override ? h("span", { class: "muted small" }, " set by hand") : null] },
|
||||||
|
{ label: "Why", render: (s) => h("span", { class: "small" }, s.reason) },
|
||||||
|
{ label: "Rate limiting", render: (s) => s.public ? badge(s.rate_limit, { off: "err", "log-only": "warn", enforcing: "ok" }[s.rate_limit]) : h("span", { class: "muted" }, "–") },
|
||||||
|
{ label: "", render: (s) => writable ? overrideSelect(s) : null, tdClass: "actions-cell" },
|
||||||
|
], r.scopes)));
|
||||||
|
|
||||||
|
const all = [];
|
||||||
|
for (const s of r.scopes) for (const f of s.findings) all.push({ ...f, scope: s.view || "server", viewID: s.view_id });
|
||||||
|
for (const f of r.server) all.push({ ...f, scope: "server", viewID: 0 });
|
||||||
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")),
|
||||||
|
all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check.")));
|
||||||
|
|
||||||
|
if (r.rate_limiting) parts.push(activity(r.rate_limiting, r.scopes));
|
||||||
|
mount(el, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideSelect(s) {
|
||||||
|
const sel = select([["", "Work it out"], ["public", "Public"], ["private", "Private"]], s.override || "");
|
||||||
|
sel.title = "Override the classification when Jarl gets it wrong (e.g. a port forward, where named only sees private addresses)";
|
||||||
|
sel.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
const r = s.view_id
|
||||||
|
? await change("PATCH", "/views/" + s.view_id, { exposure: sel.value || "auto" })
|
||||||
|
: await change("PATCH", "/settings", { exposure: sel.value || "auto" });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
});
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingRow(f, writable) {
|
||||||
|
const acked = f.acknowledged;
|
||||||
|
const actions = writable ? h("div", { class: "actions" },
|
||||||
|
f.fix && !acked ? h("button", { class: "small primary", title: f.fix, onclick: () => fix(f) }, "Fix") : null,
|
||||||
|
acked
|
||||||
|
? h("button", { class: "small", onclick: () => unack(f) }, "Take back")
|
||||||
|
: h("button", { class: "small", onclick: () => ack(f) }, "Acknowledge")) : null;
|
||||||
|
return h("div", { class: "finding " + (acked ? "acked" : f.severity) },
|
||||||
|
h("div", { class: "grow" },
|
||||||
|
h("div", null, badge(acked ? "acknowledged" : f.severity, acked ? "" : sevKind[f.severity]), " ", h("strong", null, f.scope), " ", h("span", { class: "mono small muted" }, f.code)),
|
||||||
|
h("div", null, f.message),
|
||||||
|
f.fix && !acked ? h("div", { class: "small muted" }, "Fix: " + f.fix) : null),
|
||||||
|
actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fix(f) {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/exposure/fix", { view_id: f.viewID, code: f.code });
|
||||||
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 10000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ack(f) {
|
||||||
|
const reason = input({ placeholder: "e.g. public resolver for the club's network, monitored", style: { width: "100%" } });
|
||||||
|
const err = h("div");
|
||||||
|
await modal("Acknowledge " + f.code, h("div", null,
|
||||||
|
h("p", null, f.message),
|
||||||
|
h("p", { class: "muted small" }, "The finding stays visible, as info, with your name, the date and your reason. Jarl warns again if it comes up somewhere else."),
|
||||||
|
field("Why is this accepted?", reason), err), [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "Acknowledge", kind: "primary", onclick: async () => {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/exposure/ack", { view_id: f.viewID, code: f.code, reason: reason.value.trim() });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
return true;
|
||||||
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unack(f) {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/exposure/unack", { view_id: f.viewID, code: f.code });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// activity shows what rate limiting limited, or would have in log-only
|
||||||
|
// mode, so you can check nobody legitimate is hit before enforcing.
|
||||||
|
function activity(a, scopes) {
|
||||||
|
const counts = Object.fromEntries((a.counts || []).map((e) => [e.key, e.count]));
|
||||||
|
const would = (counts["would-drop"] || 0) + (counts["would-slip"] || 0);
|
||||||
|
const did = (counts["drop"] || 0) + (counts["slip"] || 0);
|
||||||
|
const logOnly = scopes.some((s) => s.public && s.rate_limit === "log-only");
|
||||||
|
const any = scopes.some((s) => s.public && s.rate_limit !== "off");
|
||||||
|
if (!any) return null;
|
||||||
|
return h("div", { class: "panel" }, h("h2", null, "Rate limiting, last " + a.since),
|
||||||
|
!would && !did ? h("div", { class: "empty" }, "Nothing was limited" + (logOnly ? " (or would have been)" : "") + ". If you just turned it on, give it a day.") : [
|
||||||
|
h("p", null,
|
||||||
|
did ? plural(did, "response") + " limited (" + (counts["drop"] || 0) + " dropped, " + (counts["slip"] || 0) + " slipped). " : "",
|
||||||
|
would ? plural(would, "response") + " would have been limited in log-only mode (" + (counts["would-drop"] || 0) + " dropped, " + (counts["would-slip"] || 0) + " slipped)." : ""),
|
||||||
|
h("p", { class: "muted small" }, "Dropped responses get no answer; slipped ones get a short reply asking the client to retry over TCP (or with a DNS cookie), which a real client does and a spoofed victim of an attack never sees."),
|
||||||
|
logOnly ? h("p", { class: "muted small" }, "Check the networks below: your secondaries, monitoring, or a big shared resolver shouldn't be among them. If they are, add them to exempt-clients; otherwise start enforcing.") : null,
|
||||||
|
h("div", { class: "chart-grid" },
|
||||||
|
table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []),
|
||||||
|
table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]);
|
||||||
|
}
|
||||||
@@ -25,15 +25,19 @@ function valueWidget(stmt, value, ctx) {
|
|||||||
if (kind === "address_match_list") {
|
if (kind === "address_match_list") {
|
||||||
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
|
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
|
||||||
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
|
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
|
||||||
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
|
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address, prefix or range (10.0.10.101-10.0.10.199), !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
|
||||||
}
|
}
|
||||||
if (kind === "raw") {
|
if (kind === "raw") {
|
||||||
const t = codeArea(value || "");
|
const t = codeArea(value || "");
|
||||||
// Statements such as allow-transfer and listen-on take an address
|
// Statements such as allow-transfer and listen-on take an address
|
||||||
// match list after optional parameters: suggest names at the cursor.
|
// match list after optional parameters: suggest names at the cursor.
|
||||||
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
|
// Names are suggested wherever the grammar has an address match list
|
||||||
|
// (rate-limit's exempt-clients too); the braces are only optional for
|
||||||
|
// statements that end in the list (catalog WrapList).
|
||||||
|
const hasAML = stmt && /address_match_element/.test(stmt.grammar || "");
|
||||||
|
const takesList = stmt && /\{ <address_match_element>; \.\.\. \};$/.test((stmt.grammar || "").split("\n")[0]);
|
||||||
if (takesList && !value) t.placeholder = "homelab; key xfer-key";
|
if (takesList && !value) t.placeholder = "homelab; key xfer-key";
|
||||||
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
|
return { el: h("div", null, t, hasAML && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
|
||||||
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
|
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
|
||||||
}
|
}
|
||||||
const i = input({ value: value || "", class: "mono" });
|
const i = input({ value: value || "", class: "mono" });
|
||||||
@@ -83,7 +87,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
mount(listEl, table([
|
mount(listEl, table([
|
||||||
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
|
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
|
||||||
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code", style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.formatted || o.value || "(no value)") },
|
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code optval" }, o.formatted || o.value || "(no value)") },
|
||||||
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
||||||
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
||||||
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ async function viewForm(v, viewless) {
|
|||||||
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
||||||
const body = h("div", null, h("div", { class: "form-grid" },
|
const body = h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Name", name),
|
field("Name", name),
|
||||||
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
|
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ranges (10.0.10.101-10.0.10.199), ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
|
||||||
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
|
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
|
||||||
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
||||||
field("Comment", comment),
|
field("Comment", comment),
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
||||||
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||||
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
||||||
? [h("button", { class: "small", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
||||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||||
r.disabled
|
r.disabled
|
||||||
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
|
|||||||
tr.editing td { background: var(--accent-soft); }
|
tr.editing td { background: var(--accent-soft); }
|
||||||
tr.pending-add td { background: var(--add); }
|
tr.pending-add td { background: var(--add); }
|
||||||
tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); }
|
tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); }
|
||||||
|
tr.op-failed td { background: var(--err-soft); }
|
||||||
|
tr.op-failed td:first-child { box-shadow: inset 3px 0 0 var(--err); }
|
||||||
tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); }
|
tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); }
|
||||||
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
|
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
|
||||||
/* Disabled records: hatched, so they stand out from live and pending rows. */
|
/* Disabled records: hatched, so they stand out from live and pending rows. */
|
||||||
@@ -320,6 +322,18 @@ td.break { word-break: break-all; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
||||||
|
/* Secondary row actions show when the row is hovered or focused; touch
|
||||||
|
screens, which can't hover, always show them. Opacity keeps them in the
|
||||||
|
layout (no shifting) and reachable with Tab. */
|
||||||
|
tr .reveal { opacity: 0; transition: opacity 0.1s; }
|
||||||
|
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
|
||||||
|
@media (hover: none) { tr .reveal { opacity: 1; } }
|
||||||
|
/* Blocking steps, in order, on the Blocking page. */
|
||||||
|
.steps-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 4px 0 10px; }
|
||||||
|
.step-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 13px; background: var(--panel); }
|
||||||
|
.step-chip.accent { border-color: var(--accent); }
|
||||||
|
.step-tools { display: inline-flex; gap: 2px; margin-left: 2px; }
|
||||||
|
.step-tools button { padding: 0 5px; min-width: 0; }
|
||||||
h2.section { margin: 24px 0 10px; font-size: 18px; }
|
h2.section { margin: 24px 0 10px; font-size: 18px; }
|
||||||
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
||||||
@@ -412,6 +426,11 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|||||||
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
|
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
|
||||||
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
|
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||||
.aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; }
|
.aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; }
|
||||||
|
.range-hints .msg { margin: 4px 0 0; padding: 3px 8px; }
|
||||||
|
.range-hint { margin-top: 4px; color: var(--muted); }
|
||||||
|
.range-hint summary { cursor: pointer; }
|
||||||
|
.range-hint .prefixes { display: flex; flex-wrap: wrap; gap: 2px 12px; margin: 2px 0 0 14px; }
|
||||||
|
.range-hint .prefixes span { white-space: nowrap; }
|
||||||
|
|
||||||
/* Zone name and record previews. */
|
/* Zone name and record previews. */
|
||||||
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
|
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
|
||||||
@@ -470,3 +489,23 @@ details.view-picker summary { cursor: pointer; padding: 5px 8px; border: 1px sol
|
|||||||
details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||||
ul.plain li { padding: 3px 0; }
|
ul.plain li { padding: 3px 0; }
|
||||||
.query-details { margin: 8px 0; }
|
.query-details { margin: 8px 0; }
|
||||||
|
|
||||||
|
/* Internet exposure findings */
|
||||||
|
.findings { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.finding { display: flex; gap: 12px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line); border-left-width: 3px; border-radius: 6px; }
|
||||||
|
.finding.warning { border-left-color: var(--warn); }
|
||||||
|
.finding.error { border-left-color: var(--err); }
|
||||||
|
.finding.acked, .finding.info { border-left-color: var(--line); color: var(--muted); }
|
||||||
|
.finding .actions { flex-shrink: 0; }
|
||||||
|
.risk-confirm { margin-top: 8px; }
|
||||||
|
|
||||||
|
a.card.link { color: inherit; text-decoration: none; }
|
||||||
|
a.card.link:hover { border-color: var(--accent, var(--link)); }
|
||||||
|
|
||||||
|
/* An option's value as shown: laid out like the editor (no wrapping, a
|
||||||
|
long line scrolls; same padding and an invisible border), so the text
|
||||||
|
stays put when Edit opens it. */
|
||||||
|
pre.optval { padding: 5px 8px; border: 1px solid transparent; background: none; white-space: pre; overflow-x: auto; max-width: min(72ch, 50vw); }
|
||||||
|
/* ...and the editor in a table cell gets the same width, so the column
|
||||||
|
doesn't jump when Edit opens. */
|
||||||
|
td textarea.code { min-width: min(72ch, 50vw); }
|
||||||
+155
-2
@@ -13,7 +13,7 @@ set -u
|
|||||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
WORK=${WORK:-$(mktemp -d /tmp/jarl-e2e.XXXXXX)}
|
WORK=${WORK:-$(mktemp -d /tmp/jarl-e2e.XXXXXX)}
|
||||||
E=$WORK
|
E=$WORK
|
||||||
DNS=${DNS_PORT:-25353}; CTL=${CTL_PORT:-25953}; HTTP=${HTTP_PORT:-28053}; LISTS=${LISTS_PORT:-28054}
|
DNS=${DNS_PORT:-25353}; CTL=${CTL_PORT:-25953}; HTTP=${HTTP_PORT:-28053}; LISTS=${LISTS_PORT:-28054}; METRICS=${METRICS_PORT:-28055}
|
||||||
if [ -n "${JARL_BIND_ROOT:-}" ]; then
|
if [ -n "${JARL_BIND_ROOT:-}" ]; then
|
||||||
B=$JARL_BIND_ROOT
|
B=$JARL_BIND_ROOT
|
||||||
export LD_LIBRARY_PATH=$(echo $B/usr/lib/*-linux-gnu | cut -d' ' -f1)
|
export LD_LIBRARY_PATH=$(echo $B/usr/lib/*-linux-gnu | cut -d' ' -f1)
|
||||||
@@ -108,6 +108,10 @@ bind:
|
|||||||
rndc_args: ["-k", "$E/legacy/rndc.key", "-s", "127.0.0.1", "-p", "$CTL"]
|
rndc_args: ["-k", "$E/legacy/rndc.key", "-s", "127.0.0.1", "-p", "$CTL"]
|
||||||
backup:
|
backup:
|
||||||
dir: $E/backups
|
dir: $E/backups
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
listen: ["127.0.0.1:$METRICS"]
|
||||||
|
token: e2e-metrics-token-0123456789
|
||||||
EOF
|
EOF
|
||||||
cd $E
|
cd $E
|
||||||
echo "secret-pass-123" | $JARLD -c jarl.yaml init --password-stdin --token --nameservers ns1.adbraun.test. --contact admin@adbraun.test > init.out 2>&1 || { cat init.out; exit 1; }
|
echo "secret-pass-123" | $JARLD -c jarl.yaml init --password-stdin --token --nameservers ns1.adbraun.test. --contact admin@adbraun.test > init.out 2>&1 || { cat init.out; exit 1; }
|
||||||
@@ -223,6 +227,37 @@ run "changeset apply" 0 $J changeset apply $CS
|
|||||||
expect_dig "staged now live" "192.168.1.80" staged.adbraun.test A
|
expect_dig "staged now live" "192.168.1.80" staged.adbraun.test A
|
||||||
REV=$($J changeset revert $CS -q)
|
REV=$($J changeset revert $CS -q)
|
||||||
run "apply revert changeset" 0 $J changeset apply $REV
|
run "apply revert changeset" 0 $J changeset apply $REV
|
||||||
|
# JARL_CHANGESET: staging and the changeset commands default to it, and
|
||||||
|
# it is checked before it is used.
|
||||||
|
export JARL_CHANGESET=$($J changeset create "e2e env" -q)
|
||||||
|
ENVCS=$JARL_CHANGESET
|
||||||
|
run "stage via JARL_CHANGESET" 0 $J record add adbraun.test envstaged A 192.168.1.81
|
||||||
|
contains "changeset $ENVCS (e2e env, 0 operations) from JARL_CHANGESET"
|
||||||
|
expect_dig "env-staged not live yet" "" envstaged.adbraun.test A
|
||||||
|
run "changeset show defaults to it" 0 $J changeset show
|
||||||
|
contains "envstaged"
|
||||||
|
run "changeset diff defaults to it" 0 $J changeset diff
|
||||||
|
contains "apply with 'jarl changeset apply'"
|
||||||
|
OTHER=$($J changeset create "e2e other" -q)
|
||||||
|
run "-C wins over JARL_CHANGESET" 0 $J changeset show -C $OTHER
|
||||||
|
contains "e2e other"
|
||||||
|
run "an ID argument wins too" 0 $J changeset discard $OTHER
|
||||||
|
run "changeset apply defaults to it" 0 $J changeset apply
|
||||||
|
contains "unset JARL_CHANGESET"
|
||||||
|
expect_dig "env-staged live" "192.168.1.81" envstaged.adbraun.test A
|
||||||
|
run "staging into the applied changeset is refused" 1 $J record add adbraun.test envlate A 192.168.1.82
|
||||||
|
contains "changeset $ENVCS is applied"
|
||||||
|
run "applying it again is refused" 1 $J changeset apply
|
||||||
|
contains "is applied"
|
||||||
|
export JARL_CHANGESET=999999
|
||||||
|
run "a changeset that doesn't exist is explained" 1 $J record add adbraun.test envlate A 192.168.1.82
|
||||||
|
contains "there is no changeset 999999"
|
||||||
|
run "reads don't care about a stale JARL_CHANGESET" 0 $J zone list
|
||||||
|
unset JARL_CHANGESET
|
||||||
|
expect_dig "nothing was staged or applied" "" envlate.adbraun.test A
|
||||||
|
run "changeset apply without an ID or JARL_CHANGESET" 1 $J changeset apply
|
||||||
|
contains "which changeset?"
|
||||||
|
run "clean up envstaged" 0 $J record delete adbraun.test envstaged A --yes
|
||||||
expect_dig "staged reverted" "" staged.adbraun.test A
|
expect_dig "staged reverted" "" staged.adbraun.test A
|
||||||
run "history" 0 $J history
|
run "history" 0 $J history
|
||||||
contains "e2e batch"
|
contains "e2e batch"
|
||||||
@@ -235,6 +270,26 @@ contains "172.16.0.0/12"
|
|||||||
run "acl delete in-use refused" 5 sh -c "$J option set allow-transfer '{ trusted; }' --zone adbraun.test && $J acl delete trusted"
|
run "acl delete in-use refused" 5 sh -c "$J option set allow-transfer '{ trusted; }' --zone adbraun.test && $J acl delete trusted"
|
||||||
run "option unset" 0 $J option unset allow-transfer --zone adbraun.test
|
run "option unset" 0 $J option unset allow-transfer --zone adbraun.test
|
||||||
run "acl delete" 0 $J acl delete trusted
|
run "acl delete" 0 $J acl delete trusted
|
||||||
|
# Address ranges: kept as written, written to named.conf as prefixes.
|
||||||
|
run "acl with ranges" 0 $J acl create ranged "127.0.0.2 - 127.0.0.3" 2001:db8::100-2001:db8::1ff
|
||||||
|
run "acl list keeps the ranges" 0 $J acl list
|
||||||
|
contains "127.0.0.2-127.0.0.3"
|
||||||
|
run "named.conf has the prefixes" 0 grep -A4 'acl "ranged"' $E/etc-jarl/named.conf
|
||||||
|
contains "127.0.0.2/31;"
|
||||||
|
contains "2001:db8::100/120;"
|
||||||
|
contains "// 127.0.0.2-127.0.0.3"
|
||||||
|
run "a reversed range is refused (exit 6)" 6 $J acl create badrange 127.0.0.9-127.0.0.1
|
||||||
|
contains "start is after the end"
|
||||||
|
run "zone allow-query by the range" 0 $J option set allow-query '{ ranged; }' --zone adbraun.test
|
||||||
|
range_dig() { $DIG +tries=1 +time=3 -b "$1" -p $DNS @127.0.0.1 ns1.adbraun.test A | awk '/status:/ {sub(",", "", $6); print $6}'; }
|
||||||
|
for src in 127.0.0.2 127.0.0.3; do
|
||||||
|
if [ "$(range_dig $src)" = NOERROR ]; then ok "range: $src may query"; else bad "range: $src got $(range_dig $src)"; fi
|
||||||
|
done
|
||||||
|
for src in 127.0.0.1 127.0.0.4; do
|
||||||
|
if [ "$(range_dig $src)" = REFUSED ]; then ok "range: $src is refused"; else bad "range: $src got $(range_dig $src)"; fi
|
||||||
|
done
|
||||||
|
run "unset the zone's allow-query" 0 $J option unset allow-query --zone adbraun.test
|
||||||
|
run "acl delete ranged" 0 $J acl delete ranged
|
||||||
run "tsig create" 0 $J tsig create xfer-test
|
run "tsig create" 0 $J tsig create xfer-test
|
||||||
contains "secret:"
|
contains "secret:"
|
||||||
run "tsig rotate" 0 $J tsig rotate xfer-test
|
run "tsig rotate" 0 $J tsig rotate xfer-test
|
||||||
@@ -261,6 +316,13 @@ run "acme set again (idempotent)" 0 $J $ACME acme set adbraun.test value-one
|
|||||||
run "acme set wildcard second value" 0 $J $ACME acme set '*.adbraun.test' value-two
|
run "acme set wildcard second value" 0 $J $ACME acme set '*.adbraun.test' value-two
|
||||||
expect_dig "acme TXT both" '"value-one" "value-two"' _acme-challenge.adbraun.test TXT
|
expect_dig "acme TXT both" '"value-one" "value-two"' _acme-challenge.adbraun.test TXT
|
||||||
run "acme token cannot touch A records (exit 3)" 3 $J $ACME record add adbraun.test evil A 192.168.1.66
|
run "acme token cannot touch A records (exit 3)" 3 $J $ACME record add adbraun.test evil A 192.168.1.66
|
||||||
|
# Someone else's changeset in JARL_CHANGESET isn't used, even by an admin.
|
||||||
|
THEIRS=$($J $ACME changeset create "certbot's" -q)
|
||||||
|
run "another user's changeset from JARL_CHANGESET is refused" 1 env JARL_CHANGESET=$THEIRS $J record add adbraun.test notmine A 192.168.1.83
|
||||||
|
contains "is certbot's"
|
||||||
|
run "applying it from JARL_CHANGESET is refused" 1 env JARL_CHANGESET=$THEIRS $J changeset apply
|
||||||
|
run "-C uses it anyway (admin)" 0 $J changeset show -C $THEIRS
|
||||||
|
run "discard certbot's changeset" 0 $J changeset discard $THEIRS
|
||||||
run "certbot hooks" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-auth
|
run "certbot hooks" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-auth
|
||||||
expect_dig "certbot TXT" '"cb-value"' _acme-challenge.nas.adbraun.test TXT
|
expect_dig "certbot TXT" '"cb-value"' _acme-challenge.nas.adbraun.test TXT
|
||||||
run "certbot cleanup" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-cleanup
|
run "certbot cleanup" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-cleanup
|
||||||
@@ -304,6 +366,45 @@ expect_dig "unsigned from here: the internal view" "127.0.0.1" ns1.adbraun.test
|
|||||||
expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
|
expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
|
||||||
run "view key shows a dig command" 0 $J view key external
|
run "view key shows a dig command" 0 $J view key external
|
||||||
contains "dig -y hmac-sha256:jarl-view-external:"
|
contains "dig -y hmac-sha256:jarl-view-external:"
|
||||||
|
# Internet exposure (docs/exposure.md): external lets in anyone, internal
|
||||||
|
# only homelab.
|
||||||
|
run "exposure" 0 $J exposure
|
||||||
|
contains "^internal *private"
|
||||||
|
contains "^external *public *off"
|
||||||
|
contains "exposure_rate_limit "
|
||||||
|
contains "fix: jarl exposure fix external exposure_rate_limit"
|
||||||
|
run "fix: rate limiting, log-only first" 0 $J exposure fix external exposure_rate_limit
|
||||||
|
run "named.conf has it" 0 grep -A2 'view "external"' $E/etc-jarl/named.conf
|
||||||
|
contains "rate-limit { responses-per-second 10; log-only yes; };"
|
||||||
|
run "exposure: log-only now" 0 $J exposure
|
||||||
|
contains "^external *public *log-only"
|
||||||
|
contains "exposure_rate_limit_log_only"
|
||||||
|
run "fix: start enforcing" 0 $J exposure fix external exposure_rate_limit_log_only
|
||||||
|
contains "responses-per-second 10; }"
|
||||||
|
# A burst of identical queries in the external view (signed with its key):
|
||||||
|
# some are dropped. The internal view has no rate limit.
|
||||||
|
DROPS=0
|
||||||
|
for i in $(seq 60); do
|
||||||
|
$DIG +tries=1 +time=1 +ignore -k $E/external.key -p $DNS @127.0.0.1 ns1.adbraun.test A | grep -q "status:" || DROPS=$((DROPS+1))
|
||||||
|
done
|
||||||
|
if [ $DROPS -gt 0 ]; then ok "rate limiting dropped $DROPS of 60"; else bad "rate limiting dropped nothing"; fi
|
||||||
|
INTERNAL=$(for i in $(seq 30); do $DIG +tries=1 +time=1 +short -p $DNS @127.0.0.1 ns1.adbraun.test A; done | grep -c 127.0.0.1)
|
||||||
|
if [ "$INTERNAL" = 30 ]; then ok "the internal view isn't limited"; else bad "the internal view answered $INTERNAL of 30"; fi
|
||||||
|
run "remove the rate limit again" 0 $J option unset rate-limit --view external
|
||||||
|
run "an open resolver on a public view needs confirming (exit 5)" 5 $J option set allow-recursion any --view external
|
||||||
|
contains "accept-risk"
|
||||||
|
run "with --accept-risk" 0 $J option set allow-recursion any --view external --accept-risk
|
||||||
|
run "acknowledge it" 0 $J exposure ack external security_open_resolver --reason "e2e: on purpose"
|
||||||
|
run "acknowledged: info, with the reason" 0 $J exposure
|
||||||
|
contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose"
|
||||||
|
run "take it back" 0 $J exposure unack external security_open_resolver
|
||||||
|
run "close the resolver again" 0 $J option unset allow-recursion --view external
|
||||||
|
run "fix: hide the version" 0 $J exposure fix server exposure_version
|
||||||
|
expect_dig "version.bind is hidden" "" -c CH version.bind TXT
|
||||||
|
run "mark external private" 0 $J view update external --exposure private
|
||||||
|
run "exposure: private by hand" 0 $J exposure
|
||||||
|
contains "^external *private (set)"
|
||||||
|
run "back to auto" 0 $J view update external --exposure auto
|
||||||
# named-checkconf's own warnings stay on the dashboard after the apply.
|
# named-checkconf's own warnings stay on the dashboard after the apply.
|
||||||
run "option set recursion no (external)" 0 $J option set recursion no --view external
|
run "option set recursion no (external)" 0 $J option set recursion no --view external
|
||||||
run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external
|
run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external
|
||||||
@@ -486,7 +587,7 @@ expect_rpz "an Adblock entry blocks the names below it" x.tracker.adbraun.test t
|
|||||||
expect_rpz "a hosts entry blocks the name" ads.adbraun.test test.blocklist.jarl.invalid.
|
expect_rpz "a hosts entry blocks the name" ads.adbraun.test test.blocklist.jarl.invalid.
|
||||||
expect_rpz "but not the names below it" sub.ads.adbraun.test none
|
expect_rpz "but not the names below it" sub.ads.adbraun.test none
|
||||||
run "blocklist list" 0 $J blocklist list
|
run "blocklist list" 0 $J blocklist list
|
||||||
contains "^test *ok *2 "
|
contains "^test *ok *normal *2 "
|
||||||
run "block allow" 0 $J block allow ok.tracker.adbraun.test
|
run "block allow" 0 $J block allow ok.tracker.adbraun.test
|
||||||
expect_rpz "an allow rule beats the list" deep.ok.tracker.adbraun.test none
|
expect_rpz "an allow rule beats the list" deep.ok.tracker.adbraun.test none
|
||||||
expect_rpz "the rest of the list still blocks" x.tracker.adbraun.test test.blocklist.jarl.invalid.
|
expect_rpz "the rest of the list still blocks" x.tracker.adbraun.test test.blocklist.jarl.invalid.
|
||||||
@@ -516,6 +617,42 @@ printf '<html>gone</html>\n' > $E/lists/list.txt
|
|||||||
run "a broken download fails (exit 2)" 2 $J blocklist refresh test --force
|
run "a broken download fails (exit 2)" 2 $J blocklist refresh test --force
|
||||||
contains "no domains found"
|
contains "no domains found"
|
||||||
expect_rpz "and the previous version stays" x.new.adbraun.test test.blocklist.jarl.invalid.
|
expect_rpz "and the previous version stays" x.new.adbraun.test test.blocklist.jarl.invalid.
|
||||||
|
# Client groups and steps: per-device blocking in one view, by client
|
||||||
|
# address. 127.0.0.2 and .3 are in homelab too, so they get view internal.
|
||||||
|
rpz_from() { # SOURCE NAME
|
||||||
|
$DIG +tries=1 +time=3 -b "$1" -p $DNS @127.0.0.1 "$2" A | awk '$4 == "SOA" && $1 ~ /jarl\.invalid\.$/ {print $1; f = 1} END {if (!f) print "none"}'
|
||||||
|
}
|
||||||
|
expect_rpz_from() { # DESC SOURCE NAME WANT
|
||||||
|
local got
|
||||||
|
for i in $(seq 30); do got=$(rpz_from "$2" "$3"); [ "$got" = "$4" ] && break; sleep 0.2; done
|
||||||
|
if [ "$got" = "$4" ]; then ok "$1: $3 from $2 -> $4"; else bad "$1: $3 from $2 -> $got, want $4"; fi
|
||||||
|
}
|
||||||
|
printf '||social.adbraun.test^\n' > $E/lists/strict.txt
|
||||||
|
run "block step add" 0 $J block step add strict
|
||||||
|
run "blocklist add at a step" 0 $J blocklist add social http://127.0.0.1:$LISTS/strict.txt --step strict
|
||||||
|
contains "applied"
|
||||||
|
run "a step no group reaches: warned" 0 $J status
|
||||||
|
contains "blocks nothing"
|
||||||
|
run "block group add (off)" 0 $J block group add work 127.0.0.2 --step off
|
||||||
|
run "block group add (strict, with an IPv6 network)" 0 $J block group add kids 127.0.0.3 2001:db8:77::/64 --step strict
|
||||||
|
run "block group list" 0 $J block group list
|
||||||
|
contains "kids *strict *127.0.0.3, 2001:db8:77::/64"
|
||||||
|
expect_rpz_from "normal: the normal list" 127.0.0.1 x.new.adbraun.test test.blocklist.jarl.invalid.
|
||||||
|
expect_rpz_from "normal: not the strict list" 127.0.0.1 x.social.adbraun.test none
|
||||||
|
expect_rpz_from "off: no lists" 127.0.0.2 x.new.adbraun.test none
|
||||||
|
expect_rpz_from "off: but your rules" 127.0.0.2 blocked.adbraun.test rules.jarl.invalid.
|
||||||
|
expect_rpz_from "strict: the strict list" 127.0.0.3 x.social.adbraun.test social.blocklist.jarl.invalid.
|
||||||
|
expect_rpz_from "strict: and the normal list" 127.0.0.3 x.new.adbraun.test test.blocklist.jarl.invalid.
|
||||||
|
run "block check names the step" 0 $J block check x.social.adbraun.test
|
||||||
|
contains "for clients at step strict and above"
|
||||||
|
run "a step in use can't be deleted (exit 5)" 5 $J block step delete strict
|
||||||
|
run "move the work laptop to normal" 0 $J block group update work --step normal
|
||||||
|
expect_rpz_from "now normal" 127.0.0.2 x.new.adbraun.test test.blocklist.jarl.invalid.
|
||||||
|
run "block group delete" 0 $J block group delete work
|
||||||
|
run "block group delete kids" 0 $J block group delete kids
|
||||||
|
run "blocklist delete social" 0 $J blocklist delete social --yes
|
||||||
|
run "block step delete" 0 $J block step delete strict
|
||||||
|
run "no gates left" 1 grep -q 'gate.jarl.invalid' $E/etc-jarl/named.conf
|
||||||
run "blocklist disable" 0 $J blocklist disable test
|
run "blocklist disable" 0 $J blocklist disable test
|
||||||
expect_rpz "a disabled list doesn't block" x.new.adbraun.test none
|
expect_rpz "a disabled list doesn't block" x.new.adbraun.test none
|
||||||
run "blocklist delete" 0 $J blocklist delete test --yes
|
run "blocklist delete" 0 $J blocklist delete test --yes
|
||||||
@@ -525,6 +662,22 @@ run "block delete (allow rule)" 0 $J block delete ok.tracker.adbraun.test
|
|||||||
run "no blocking left in named.conf" 1 grep -q 'response-policy' $E/etc-jarl/named.conf
|
run "no blocking left in named.conf" 1 grep -q 'response-policy' $E/etc-jarl/named.conf
|
||||||
kill $LISTS_PID 2>/dev/null; LISTS_PID=
|
kill $LISTS_PID 2>/dev/null; LISTS_PID=
|
||||||
|
|
||||||
|
# Prometheus metrics (docs/metrics.md)
|
||||||
|
run "metrics on the main listener" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
||||||
|
contains "^jarl_named_up 1$"
|
||||||
|
contains "^jarl_adopted 1$"
|
||||||
|
contains '^jarl_problems{severity="error"} 0$'
|
||||||
|
contains '^jarl_objects{kind="zones"} '
|
||||||
|
run "metrics on metrics.listen" 0 curl -sf http://127.0.0.1:$METRICS/metrics
|
||||||
|
contains "^jarl_build_info{version="
|
||||||
|
run "metrics.listen serves nothing else" 0 curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$METRICS/api/v1/zones
|
||||||
|
contains "^404$"
|
||||||
|
run "metrics with the token" 0 curl -sf -H "Authorization: Bearer e2e-metrics-token-0123456789" http://127.0.0.1:$METRICS/metrics
|
||||||
|
run "metrics with a wrong token" 0 curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer wrong-token-0123456789" http://127.0.0.1:$METRICS/metrics
|
||||||
|
contains "^401$"
|
||||||
|
run "metrics on the socket" 0 curl -sf --unix-socket $SOCK http://jarl/metrics
|
||||||
|
contains "^jarl_open_changesets "
|
||||||
|
|
||||||
run "status at the end" 0 $J status
|
run "status at the end" 0 $J status
|
||||||
contains "Drift:.*none"
|
contains "Drift:.*none"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user