Compare commits

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:33:41 +01:00
53 changed files with 2426 additions and 97 deletions

No files matched your search

+64
View File
@@ -6,6 +6,70 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Upgrading from 0.4
- 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
- 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
- `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 ## [0.4.0] - 2026-10-11
### Upgrading from 0.3 ### Upgrading from 0.3
+9 -1
View File
@@ -71,6 +71,11 @@ 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.
* **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 +125,7 @@ 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/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,7 +177,9 @@ review step.
## Status ## Status
Version 0.4.0 is the current release. It adds blocking (blocklists and Version 0.4.0 is the current release, and 0.4.1 is in testing
(0.4.1-rc.1): it adds Prometheus metrics, address ranges in ACLs and
other address match lists, and safer changeset handling. 0.4.0 added blocking (blocklists and
your own rules, like Pi-hole, done by named, with different blocking for your own rules, like Pi-hole, done by named, with different blocking for
different devices), disabling zones, server statistics and view keys, on different devices), disabling zones, server statistics and view keys, on
top of 0.3.0's disabled records, script-friendly CLI output and client top of 0.3.0's disabled records, script-friendly CLI output and client
+1 -1
View File
@@ -1 +1 @@
0.4.0 0.4.1-rc.1
+67 -28
View File
@@ -120,8 +120,12 @@ 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)
@@ -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}, &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{
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"errors"
"fmt"
"os"
"strconv"
"jarl/internal/api"
"jarl/internal/client"
"jarl/internal/model"
)
// A changeset given with --changeset is used as given (jarld still refuses
// what isn't allowed). One taken from JARL_CHANGESET is checked first: an
// exported ID outlives the changeset (it gets applied), and changeset IDs
// keep growing, so a stale or mistyped one can name someone else's
// changeset later. It is used only when it is open and the caller's own,
// and the CLI says on stderr which changeset that is.
// envChangeset returns the changeset in JARL_CHANGESET (0 if unset).
func envChangeset() (int64, error) {
v := os.Getenv("JARL_CHANGESET")
if v == "" {
return 0, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid JARL_CHANGESET %q", v)
}
return n, nil
}
// checkEnvChangeset checks the changeset id from JARL_CHANGESET before it
// is used. With mustOwn, it must also be open and the caller's own (for
// staging into it, applying or discarding it).
func checkEnvChangeset(c *client.Client, id int64, mustOwn bool) (*api.Changeset, error) {
var cs api.Changeset
if err := c.Get(ctx(), fmt.Sprintf("/changesets/%d", id), nil, &cs); err != nil {
var ce *client.Error
if errors.As(err, &ce) && ce.Status == 404 {
return nil, fmt.Errorf("JARL_CHANGESET is %d, but there is no changeset %d: start one with 'export JARL_CHANGESET=$(jarl changeset create -q)', or 'unset JARL_CHANGESET' to make changes live", id, id)
}
return nil, err
}
if mustOwn {
if cs.Status != model.ChangesetOpen {
return nil, fmt.Errorf("JARL_CHANGESET is %d, but changeset %d is %s: 'unset JARL_CHANGESET' to make changes live, or start a new one with 'export JARL_CHANGESET=$(jarl changeset create -q)'", id, id, cs.Status)
}
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return nil, err
}
mine := cs.UserID == me.User.ID && (me.User.ID != 0 || cs.Username == me.User.Username)
if !mine {
return nil, fmt.Errorf("JARL_CHANGESET is %d, but changeset %d is %s's (made via %s), not yours, so it isn't used from JARL_CHANGESET; give it as -C %d to use it anyway, or 'unset JARL_CHANGESET'", id, id, cs.Username, cs.Via, id)
}
}
if !flagQuiet {
desc := cs.Description
if desc == "" {
desc = "no description"
}
fmt.Fprintf(os.Stderr, "changeset %d (%s, %d operations) from JARL_CHANGESET\n", id, desc, len(cs.Ops))
}
return &cs, nil
}
// changesetArg returns the changeset a changeset subcommand works on: the
// ID argument, else --changeset, else JARL_CHANGESET (checked; mustOwn as
// for checkEnvChangeset). fromEnv says it came from JARL_CHANGESET.
func changesetArg(c *client.Client, args []string, mustOwn bool) (id string, fromEnv bool, err error) {
if len(args) > 0 {
return args[0], false, nil
}
if flagChangeset > 0 {
return strconv.FormatInt(flagChangeset, 10), false, nil
}
n, err := envChangeset()
if err != nil {
return "", false, err
}
if n == 0 {
return "", false, errors.New("which changeset? Give its ID, or set JARL_CHANGESET (export JARL_CHANGESET=$(jarl changeset create -q))")
}
if _, err := checkEnvChangeset(c, n, mustOwn); err != nil {
return "", false, err
}
return strconv.FormatInt(n, 10), true, nil
}
// envChangesetDone reminds that JARL_CHANGESET still names a changeset
// that was just applied or discarded: later changes would fail to stage.
func envChangesetDone(id, what string) {
if !flagQuiet {
fmt.Fprintf(os.Stderr, "JARL_CHANGESET still names changeset %s, which is now %s: run 'unset JARL_CHANGESET' (or export a new one) before making more changes\n", id, what)
}
}
+11 -6
View File
@@ -13,7 +13,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv"
"strings" "strings"
"sync" "sync"
"text/tabwriter" "text/tabwriter"
@@ -149,12 +148,18 @@ 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 = flagDryRun || c.Preflight, flagForce
+7
View File
@@ -303,6 +303,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 +316,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,
+12 -1
View File
@@ -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) |
@@ -461,6 +471,7 @@ 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 | `/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
+2 -2
View File
@@ -177,8 +177,8 @@ jarl block group list
In the web UI, the Blocking page's **Devices** panel holds the steps and In the web UI, the Blocking page's **Devices** panel holds the steps and
the groups, and each list has a step. the groups, and each list has a step.
- **Members** are addresses, networks (`10.10.0.0/24`), or ACLs made only - **Members** are addresses, networks (`10.10.0.0/24`), ranges
of those (`key`, `localnets` and `!` can't be used: blocking matches (`10.10.0.20-10.10.0.49`), or ACLs made only of those (`key`, `localnets` and `!` can't be used: blocking matches
clients by address). A device is in one group: two groups with the same clients by address). A device is in one group: two groups with the same
address are refused, but a more specific address in another group wins address are refused, but a more specific address in another group wins
(`10.10.0.0/24` in one group, `10.10.0.20` in another). (`10.10.0.0/24` in one group, `10.10.0.20` in another).
+29 -4
View File
@@ -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
@@ -345,9 +345,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,6 +357,29 @@ 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.
## Views, ACLs, TSIG keys, options, settings ## Views, ACLs, TSIG keys, options, settings
@@ -371,6 +394,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 +410,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
+26
View File
@@ -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.
+12
View File
@@ -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
+1
View File
@@ -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
+231
View File
@@ -0,0 +1,231 @@
# 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`. |
| `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
```
+3
View File
@@ -363,6 +363,9 @@ rndc reconfig
## Monitoring ## Monitoring
* `GET /healthz` (no authentication) reports that jarld is up. * `GET /healthz` (no authentication) reports that jarld is up.
* Prometheus: `/metrics` covers named up, configuration problems, drift,
blocklist failures, backup age, DNSSEC steps waiting at the parent and
expiring tokens, with example alerts ([metrics.md](metrics.md)).
* On the server, a check can use the local socket and needs no token: * On the server, a check can use the local socket and needs no token:
`sudo jarl status -o json`. `sudo jarl status -o json`.
* `GET /api/v1/status` with a read-only token reports named reachability, * `GET /api/v1/status` with a read-only token reports named reachability,
+3
View File
@@ -175,6 +175,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.
+3 -2
View File
@@ -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 |
+7
View File
@@ -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]
+28 -5
View File
@@ -30,8 +30,14 @@ 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
// 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 +249,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 +269,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")
@@ -260,7 +283,7 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
q.Set("force", "1") q.Set("force", "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
} }
+74
View File
@@ -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: ""
` `
+28
View File
@@ -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)
}
}
}
+8 -1
View File
@@ -5,6 +5,7 @@ import (
"net/netip" "net/netip"
"strings" "strings"
"jarl/internal/iprange"
"jarl/internal/model" "jarl/internal/model"
) )
@@ -52,10 +53,16 @@ func NormalizeMember(st *model.State, m string) (string, error) {
if p, err := netip.ParsePrefix(m); err == nil { if p, err := netip.ParsePrefix(m); err == nil {
return p.Masked().String(), nil return p.Masked().String(), nil
} }
if r, ok, err := iprange.Parse(m); ok {
if err != nil {
return "", errf(KindBadRequest, "%v", err)
}
return r.String(), nil
}
if a := st.ACLByName(m); a != nil { if a := st.ACLByName(m); a != nil {
return a.Name, nil return a.Name, nil
} }
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24) or an ACL", m) return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24), a range (10.10.0.20-10.10.0.49) or an ACL", m)
} }
func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error { func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error {
+12 -6
View File
@@ -25,15 +25,21 @@ const (
// 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)
} }
+42 -3
View File
@@ -11,6 +11,7 @@ import (
"jarl/internal/auth" "jarl/internal/auth"
"jarl/internal/catalog" "jarl/internal/catalog"
"jarl/internal/dnsname" "jarl/internal/dnsname"
"jarl/internal/iprange"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/namedconf" "jarl/internal/namedconf"
"jarl/internal/rr" "jarl/internal/rr"
@@ -120,7 +121,7 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
err = errf(KindBadRequest, "unknown operation %q", op.Kind) err = errf(KindBadRequest, "unknown operation %q", op.Kind)
} }
if e, ok := AsError(err); ok && e.OpSeq == 0 { if e, ok := AsError(err); ok && e.OpSeq == 0 {
e.OpSeq = op.Seq e.OpSeq, e.OpSummary = op.Seq, op.Summary
} }
return err return err
} }
@@ -457,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
@@ -533,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
@@ -1141,9 +1145,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 {
+113
View File
@@ -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)
}
}
}
+51
View File
@@ -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)
}
}
+148
View File
@@ -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)}
}
+123
View File
@@ -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
}
+4
View File
@@ -7,6 +7,8 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"jarl/internal/iprange"
) )
// Blocklist is a list of domains to block, downloaded from URL and kept up // Blocklist is a list of domains to block, downloaded from URL and kept up
@@ -268,6 +270,8 @@ func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
out = append(out, netip.PrefixFrom(a, a.BitLen())) out = append(out, netip.PrefixFrom(a, a.BitLen()))
} else if p, err := netip.ParsePrefix(e); err == nil { } else if p, err := netip.ParsePrefix(e); err == nil {
out = append(out, p.Masked()) out = append(out, p.Masked())
} else if r, ok, err := iprange.Parse(e); ok && err == nil {
out = append(out, r.Prefixes()...)
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] { } else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
seen[acl.Name] = true seen[acl.Name] = true
add(acl.Elements) add(acl.Elements)
+141 -8
View File
@@ -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).
+3
View File
@@ -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
+6
View File
@@ -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("; ")
} }
+90
View File
@@ -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
}
}
+10 -3
View File
@@ -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 {
@@ -313,7 +313,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 +382,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 +507,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)
}
+3 -3
View File
@@ -62,13 +62,13 @@ 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
@@ -108,7 +108,7 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
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 {
+37
View File
@@ -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 {
+474
View File
@@ -0,0 +1,474 @@
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)."},
"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
}
+206
View File
@@ -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)
}
}
+31
View File
@@ -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])
}
}
+2
View File
@@ -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))
@@ -135,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))
+11 -1
View File
@@ -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 {
+21 -1
View File
@@ -114,6 +114,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 +128,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 +289,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.
+3 -1
View File
@@ -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)
+4
View File
@@ -4,6 +4,7 @@ import (
"net/netip" "net/netip"
"strings" "strings"
"jarl/internal/iprange"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/namedconf" "jarl/internal/namedconf"
) )
@@ -162,6 +163,9 @@ func (c *checker) checkMember(g *model.ClientGroup, m string, seen map[string]bo
if _, err := netip.ParsePrefix(m); err == nil { if _, err := netip.ParsePrefix(m); err == nil {
return return
} }
if _, ok, err := iprange.Parse(m); ok && err == nil {
return
}
acl := c.s.ACLByName(m) acl := c.s.ACLByName(m)
if acl == nil { 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) 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)
+16 -1
View File
@@ -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 {
+32 -2
View File
@@ -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));
} }
+1 -1
View File
@@ -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 () => {
+7 -5
View File
@@ -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.
@@ -355,7 +356,7 @@ async function del(path, what) {
function devicesPanel(groups, steps, views, autoViews, writable) { function devicesPanel(groups, steps, views, autoViews, writable) {
const live = steps.filter((s) => s.pending !== "delete"); const live = steps.filter((s) => s.pending !== "delete");
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } }); const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
const members = input({ placeholder: "10.10.0.50 10.10.0.51 or an ACL", class: "mono" }); 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 step = stepSelect(steps, live.length ? live[0].name : "off", true);
const vp = viewPicker(views, autoViews); const vp = viewPicker(views, autoViews);
const err = h("div"); const err = h("div");
@@ -368,7 +369,7 @@ function devicesPanel(groups, steps, views, autoViews, writable) {
} catch (e) { mount(err, errorBox(e)); } } catch (e) { mount(err, errorBox(e)); }
} }, } },
h("div", { class: "f" }, h("label", null, "Group"), name), h("div", { class: "f" }, h("label", null, "Group"), name),
h("div", { class: "f grow" }, h("label", null, "Devices"), members), h("div", { class: "f grow" }, h("label", null, "Devices"), members, rangeHints(members)),
h("div", { class: "f" }, h("label", null, "Step"), step), h("div", { class: "f" }, h("label", null, "Step"), step),
vp.el, vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group"))); h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
@@ -389,9 +390,10 @@ function devicesPanel(groups, steps, views, autoViews, writable) {
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" })); ].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. // splitMembers turns "a, b c" into a list; a range may have spaces
// around its dash.
function splitMembers(s) { function splitMembers(s) {
return s.split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean); 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 // stepsLine shows the steps in order, with controls for the ones above
@@ -463,7 +465,7 @@ function editGroup(g, steps, views, autoViews) {
const vp = viewPicker(views, autoViews, ownViews(g)); const vp = viewPicker(views, autoViews, ownViews(g));
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [ editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
["Name", name], ["Name", name],
["Devices", members, "Addresses, networks (10.10.0.0/24) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."], ["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], ["Step", step],
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null, vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
["Comment", comment], ["Comment", comment],
+23 -8
View File
@@ -44,18 +44,33 @@ async function draftPanel(d) {
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");
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");
@@ -64,7 +79,7 @@ async function draftPanel(d) {
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),
+1 -1
View File
@@ -25,7 +25,7 @@ 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 || "");
+1 -1
View File
@@ -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),
+7
View File
@@ -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. */
@@ -424,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); }
+79 -1
View File
@@ -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
@@ -561,6 +623,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"