Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf550bd512 | ||
|
|
cee11e7c86 | ||
|
|
e2e5007f94 | ||
|
|
49b1c872c9 | ||
|
|
c37de31f4c |
No files matched your search
@@ -6,6 +6,70 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
## [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
|
||||
|
||||
### Upgrading from 0.3
|
||||
|
||||
@@ -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,
|
||||
and exportable as JSON. Stored as aggregates in a separate database with
|
||||
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,
|
||||
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
|
||||
@@ -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/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/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
|
||||
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
||||
| [docs/validation.md](docs/validation.md) | Every check Jarl makes |
|
||||
| [docs/operations.md](docs/operations.md) | Backups, drift, failures, recovery, monitoring |
|
||||
@@ -171,7 +177,9 @@ review step.
|
||||
|
||||
## 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
|
||||
different devices), disabling zones, server statistics and view keys, on
|
||||
top of 0.3.0's disabled records, script-friendly CLI output and client
|
||||
|
||||
+67
-28
@@ -120,8 +120,12 @@ func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
|
||||
// changeset given with --changeset.
|
||||
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
|
||||
var resp api.MutationResponse
|
||||
if c.Changeset > 0 {
|
||||
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), nil, map[string]any{"ops": reqs}, &resp); err != nil {
|
||||
cs, err := c.UseChangeset(ctx())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cs > 0 {
|
||||
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", cs), nil, map[string]any{"ops": reqs}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
@@ -230,8 +234,9 @@ func changesetCmd() *cobra.Command {
|
||||
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
|
||||
jarl record add example.com www2 A 192.0.2.20
|
||||
jarl record delete example.com www A
|
||||
jarl changeset diff $JARL_CHANGESET
|
||||
jarl changeset apply $JARL_CHANGESET`,
|
||||
jarl changeset diff # JARL_CHANGESET's
|
||||
jarl changeset apply
|
||||
unset JARL_CHANGESET`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
@@ -245,21 +250,25 @@ func changesetCmd() *cobra.Command {
|
||||
fmt.Println(cs.ID)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d)\n", cs.ID, cs.ID, cs.ID)
|
||||
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d), then 'jarl changeset apply'\n", cs.ID, cs.ID, cs.ID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
show := &cobra.Command{
|
||||
Use: "show ID",
|
||||
Short: "Show a changeset's operations",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "show [ID]",
|
||||
Short: "Show a changeset's operations (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _, err := changesetArg(c, args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cs api.Changeset
|
||||
if err := c.Get(ctx(), "/changesets/"+args[0], nil, &cs); err != nil {
|
||||
if err := c.Get(ctx(), "/changesets/"+id, nil, &cs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(cs, func(w io.Writer) {
|
||||
@@ -280,16 +289,20 @@ func changesetCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
diff := &cobra.Command{
|
||||
Use: "diff ID",
|
||||
Short: "Validate an open changeset and show what applying it would change",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "diff [ID]",
|
||||
Short: "Validate an open changeset and show what applying it would change (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, fromEnv, err := changesetArg(c, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := c.Get(ctx(), "/changesets/"+args[0]+"/preview", nil, &raw); err != nil {
|
||||
if err := c.Get(ctx(), "/changesets/"+id+"/preview", nil, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOutput != "table" {
|
||||
@@ -312,56 +325,82 @@ func changesetCmd() *cobra.Command {
|
||||
fmt.Println()
|
||||
fmt.Print(r.Diff)
|
||||
if r.Valid {
|
||||
fmt.Println("\nvalid: apply with 'jarl changeset apply " + args[0] + "'")
|
||||
how := "jarl changeset apply " + id
|
||||
if fromEnv {
|
||||
how = "jarl changeset apply"
|
||||
}
|
||||
fmt.Println("\nvalid: apply with '" + how + "'")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("the changeset can't be applied as it is")
|
||||
},
|
||||
}
|
||||
apply := &cobra.Command{
|
||||
Use: "apply ID",
|
||||
Short: "Apply an open changeset",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "apply [ID]",
|
||||
Short: "Apply an open changeset (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, fromEnv, err := changesetArg(c, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
if err := report(&resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if fromEnv && resp.Applied {
|
||||
envChangesetDone(id, "applied")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
discard := &cobra.Command{
|
||||
Use: "discard ID",
|
||||
Short: "Throw away an open changeset",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "discard [ID]",
|
||||
Short: "Throw away an open changeset (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/discard", nil, nil, nil); err != nil {
|
||||
id, fromEnv, err := changesetArg(c, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("discarded changeset", args[0])
|
||||
fmt.Println("discarded changeset", id)
|
||||
}
|
||||
if fromEnv {
|
||||
envChangesetDone(id, "discarded")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
remove := &cobra.Command{
|
||||
Use: "remove ID SEQ",
|
||||
Short: "Remove one operation from an open changeset",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Use: "remove [ID] SEQ",
|
||||
Short: "Remove one operation from an open changeset (default: -C or JARL_CHANGESET)",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Do(ctx(), "DELETE", "/changesets/"+args[0]+"/ops/"+args[1], nil, nil, nil)
|
||||
seq := args[len(args)-1]
|
||||
id, _, err := changesetArg(c, args[:len(args)-1], true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+seq, nil, nil, nil)
|
||||
},
|
||||
}
|
||||
revert := &cobra.Command{
|
||||
|
||||
@@ -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
@@ -13,7 +13,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
@@ -149,12 +148,18 @@ func newClient() (*client.Client, error) {
|
||||
}
|
||||
c.Changeset = flagChangeset
|
||||
if c.Changeset == 0 {
|
||||
if v := os.Getenv("JARL_CHANGESET"); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v)
|
||||
}
|
||||
n, err := envChangeset()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
// Checked when a change is first staged into it
|
||||
// (envchangeset.go).
|
||||
c.Changeset = n
|
||||
c.CheckChangeset = func(context.Context) error {
|
||||
_, err := checkEnvChangeset(c, n, true)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
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{
|
||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
||||
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
||||
@@ -311,6 +316,8 @@ func serveCmd() *cobra.Command {
|
||||
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
||||
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
||||
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
||||
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
||||
HTTPReq: server.HTTPReq{
|
||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||
|
||||
+12
-1
@@ -10,6 +10,8 @@ what is documented here.
|
||||
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
|
||||
`relative_name`).
|
||||
* `GET /healthz` (no authentication) returns `{"ok": true, "version": ...}`.
|
||||
* `GET /metrics` serves Prometheus metrics when `metrics.enabled` is set.
|
||||
Access is set in jarl.yaml, not with API tokens ([metrics.md](metrics.md)).
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -147,6 +149,14 @@ honours `If-Match: <version>`, and batch operations take `base_version`. If
|
||||
the object changed in the meantime the request fails with 409. Staged
|
||||
operations record the version they were staged against automatically.
|
||||
|
||||
**Stale changesets.** When an object a staged operation needs was changed
|
||||
or deleted after it was staged (from the CLI, say), preview and apply fail
|
||||
on that operation: the error has `op` (its number in the changeset),
|
||||
`op_summary` and a message saying what happened; remove the operation
|
||||
(`DELETE /changesets/{id}/ops/{seq}`) or stage it again. A staged delete of
|
||||
a record that is already gone is not an error: it counts as done, with a
|
||||
warning.
|
||||
|
||||
## Resources
|
||||
|
||||
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
|
||||
@@ -405,7 +415,7 @@ send the request with that changeset (`?changeset=` or
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| GET | `/acls` | each with `used_by` |
|
||||
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "key xfer", "localhost"], "comment": ""}` |
|
||||
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "10.0.10.101-10.0.10.199", "key xfer", "localhost"], "comment": ""}` (ranges: [concepts.md](concepts.md#model-views-zones-records-options)) |
|
||||
| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
|
||||
| GET | `/tsig-keys` | without secrets |
|
||||
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
|
||||
@@ -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/rrtypes` | record types with field hints and examples |
|
||||
| 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 |
|
||||
|
||||
### Changesets
|
||||
|
||||
+2
-2
@@ -177,8 +177,8 @@ jarl block group list
|
||||
In the web UI, the Blocking page's **Devices** panel holds the steps and
|
||||
the groups, and each list has a step.
|
||||
|
||||
- **Members** are addresses, networks (`10.10.0.0/24`), or ACLs made only
|
||||
of those (`key`, `localnets` and `!` can't be used: blocking matches
|
||||
- **Members** are addresses, networks (`10.10.0.0/24`), ranges
|
||||
(`10.10.0.20-10.10.0.49`), or ACLs made only of those (`key`, `localnets` and `!` can't be used: blocking matches
|
||||
clients by address). A device is in one group: two groups with the same
|
||||
address are refused, but a more specific address in another group wins
|
||||
(`10.10.0.0/24` in one group, `10.10.0.20` in another).
|
||||
|
||||
+29
-4
@@ -41,7 +41,7 @@ jarl whoami
|
||||
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
|
||||
| Skip TLS verification | `--insecure` | | `insecure` |
|
||||
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
|
||||
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
|
||||
| Changeset to stage into (and the default for `jarl changeset diff/apply/...`) | `-C/--changeset` | `JARL_CHANGESET` (checked: open and yours, see [Changesets](#changesets-stage-review-apply)) | |
|
||||
|
||||
Flags win over the environment, and the environment wins over the file. The
|
||||
file is `jarl/config.yaml` in the user's configuration directory, or
|
||||
@@ -345,9 +345,9 @@ reads standard input.
|
||||
export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q)
|
||||
jarl record set lab.adbraun.lol gw A 10.20.0.1
|
||||
jarl record set lab.adbraun.lol nas A 10.20.0.10
|
||||
jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff
|
||||
jarl changeset remove $JARL_CHANGESET 2 # drop operation #2
|
||||
jarl changeset apply $JARL_CHANGESET
|
||||
jarl changeset diff # validation + named-checkconf + diff
|
||||
jarl changeset remove 2 # drop operation #2
|
||||
jarl changeset apply
|
||||
unset JARL_CHANGESET
|
||||
|
||||
jarl changeset list [--status open|applied|discarded] [--mine]
|
||||
@@ -357,6 +357,29 @@ jarl history [--limit 50]
|
||||
jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset
|
||||
```
|
||||
|
||||
`show`, `diff`, `apply`, `discard` and `remove` work on the changeset you
|
||||
name, else the one in `-C`, else the one in `JARL_CHANGESET`.
|
||||
|
||||
A changeset from `JARL_CHANGESET` is checked before anything uses it,
|
||||
because an exported ID easily outlives its changeset, and IDs keep growing
|
||||
(every change, live ones included, gets the next one), so a stale or
|
||||
mistyped ID can later name a changeset that isn't yours:
|
||||
|
||||
- Staging into it, and `diff`, `apply`, `discard` and `remove` on it, need
|
||||
it to be open and yours. Otherwise the command stops and says why
|
||||
(`there is no changeset 99`, `changeset 42 is applied`, `changeset 57 is
|
||||
certbot's`) and what to do. Commands that only read (`jarl zone list`)
|
||||
and `jarl changeset create` don't care, so you can always start a new one.
|
||||
- Each command says on stderr which changeset it uses: `changeset 42 (renumber
|
||||
lab, 2 operations) from JARL_CHANGESET` (not with `-q`).
|
||||
- After `apply` or `discard`, a reminder to `unset JARL_CHANGESET`: the CLI
|
||||
can't change your shell's environment, and later changes would fail to
|
||||
stage.
|
||||
|
||||
`-C ID` and an ID argument are used as given, without these checks (jarld
|
||||
still refuses a changeset that isn't open, or another user's unless you are
|
||||
an administrator).
|
||||
|
||||
Changesets staged in the web UI show up here too, and the other way round.
|
||||
|
||||
## 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 acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
|
||||
jarl acl create dhcp 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff # ranges (concepts.md)
|
||||
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
||||
jarl acl update homelab --rename lan # references are updated
|
||||
jarl acl delete lan # refused while in use
|
||||
@@ -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 # the same: braces are added to a list
|
||||
jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol
|
||||
jarl option set allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
|
||||
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
||||
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
||||
jarl option unset forwarders
|
||||
|
||||
@@ -167,6 +167,32 @@ RFC 1982 arithmetic.
|
||||
alphabetical within a group. So the same option is in the same place in
|
||||
every view and zone. Top-level statements stay in the order they were
|
||||
added.
|
||||
* **Address ranges.** Wherever named takes an address match list (ACLs,
|
||||
views' `match-clients` and `match-destinations`, options such as
|
||||
`allow-query` or `allow-recursion`) and in blocking's client groups, Jarl
|
||||
also takes a range of addresses: `10.0.10.101-10.0.10.199`,
|
||||
`2001:db8::100-2001:db8::1ff`, `!10.0.10.150-10.0.10.160`. BIND only
|
||||
matches addresses and prefixes, so Jarl keeps the range as you wrote it
|
||||
and writes the fewest prefixes that cover exactly that range to
|
||||
named.conf, with the range as a comment above them:
|
||||
```
|
||||
acl "dhcp" {
|
||||
// 10.0.10.101-10.0.10.199
|
||||
10.0.10.101;
|
||||
10.0.10.102/31;
|
||||
10.0.10.104/29;
|
||||
10.0.10.112/28;
|
||||
10.0.10.128/26;
|
||||
10.0.10.192/29;
|
||||
};
|
||||
```
|
||||
Nothing outside the range is matched (writing such lists by hand, it is
|
||||
easy to round to `10.0.10.96/27` and let in more than you meant). Both
|
||||
ends are full addresses of the same family, and spaces around the dash
|
||||
are fine. A range takes at most 62 prefixes for IPv4 and 254 for IPv6.
|
||||
The web UI shows what a range becomes while you type it. A range in the
|
||||
IPv4-mapped space (`::ffff:0:0/96`) gets a warning: named can see IPv4
|
||||
clients as such addresses, so it may match IPv4 clients too.
|
||||
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
|
||||
and timers, default TTL, serial policy.
|
||||
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
|
||||
|
||||
@@ -10,6 +10,7 @@ internal/
|
||||
dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones
|
||||
rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog
|
||||
namedconf/ named.conf lexer, parser and printer; address match lists
|
||||
iprange/ address ranges (10.0.10.101-10.0.10.199): parsing and the exact prefix cover
|
||||
catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded
|
||||
validate/ whole-configuration validation and security lints
|
||||
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
|
||||
|
||||
+231
@@ -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
|
||||
```
|
||||
@@ -363,6 +363,9 @@ rndc reconfig
|
||||
## Monitoring
|
||||
|
||||
* `GET /healthz` (no authentication) reports that jarld is up.
|
||||
* Prometheus: `/metrics` covers named up, configuration problems, drift,
|
||||
blocklist failures, backup age, DNSSEC steps waiting at the parent and
|
||||
expiring tokens, with example alerts ([metrics.md](metrics.md)).
|
||||
* On the server, a check can use the local socket and needs no token:
|
||||
`sudo jarl status -o json`.
|
||||
* `GET /api/v1/status` with a read-only token reports named reachability,
|
||||
|
||||
@@ -175,6 +175,9 @@ jarl stats summary --since 30d -o json # export (also -o yaml)
|
||||
|
||||
**API**: see [api.md](api.md#query-statistics).
|
||||
|
||||
**Prometheus**: with `metrics.enabled`, the counters are also served at
|
||||
`/metrics` ([metrics.md](metrics.md)).
|
||||
|
||||
**Permissions**: the `stats` resource (`stats:read`). The `readonly` and
|
||||
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
||||
addresses and what they look up, so grant it with care.
|
||||
|
||||
+3
-2
@@ -67,10 +67,11 @@ showing them.
|
||||
|
||||
| Code | Severity | Rule |
|
||||
|------|----------|------|
|
||||
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets` |
|
||||
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), ranges (`10.0.10.101-10.0.10.199`: both ends full addresses of one family, start not after end), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets`. Ranges in options are checked when they're entered, and only options with address match lists take them (not `forwarders`, say) |
|
||||
| `range_mapped4` | warning | An IPv6 range that reaches into `::ffff:0:0/96`: named can see IPv4 clients as such addresses, so it may match IPv4 clients too |
|
||||
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
|
||||
| `acl_cycle` | error | ACLs that include each other |
|
||||
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match |
|
||||
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, names that look like addresses or ranges, elements that can never match (a prefix or range an earlier one already covers) |
|
||||
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) |
|
||||
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
|
||||
| `view_unreachable` | warning | A view that comes after one matching every client is never used |
|
||||
|
||||
@@ -493,6 +493,13 @@ func spaceElements(fields []string) []string {
|
||||
f += " " + fields[i+1]
|
||||
i++
|
||||
}
|
||||
case "-":
|
||||
// A range with spaces around the dash: 10.0.0.1 - 10.0.0.9.
|
||||
if len(out) > 0 && i+1 < len(fields) {
|
||||
out[len(out)-1] += " - " + fields[i+1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
case "port", "tls":
|
||||
if len(out) > 0 && i+1 < len(fields) {
|
||||
out[len(out)-1] += " " + f + " " + fields[i+1]
|
||||
|
||||
@@ -30,8 +30,14 @@ type Client struct {
|
||||
// Changeset, if set, stages changes into this changeset instead of
|
||||
// applying them.
|
||||
Changeset int64
|
||||
DryRun bool
|
||||
Force bool
|
||||
// CheckChangeset, if set, runs once before Changeset is first used,
|
||||
// and its error stops the request. The CLI checks a changeset taken
|
||||
// from JARL_CHANGESET with it.
|
||||
CheckChangeset func(ctx context.Context) error
|
||||
csChecked bool
|
||||
csErr error
|
||||
DryRun bool
|
||||
Force bool
|
||||
// Preflight lets only requests through that can't change anything:
|
||||
// reads and dry runs. Running a command on several servers uses it to
|
||||
// check the command everywhere before running it anywhere.
|
||||
@@ -243,6 +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)
|
||||
}
|
||||
|
||||
// UseChangeset returns the changeset to stage into (0 for none), once
|
||||
// CheckChangeset has accepted it.
|
||||
func (c *Client) UseChangeset(ctx context.Context) (int64, error) {
|
||||
if c.Changeset == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if c.CheckChangeset != nil && !c.csChecked {
|
||||
c.csChecked = true
|
||||
c.csErr = c.CheckChangeset(ctx)
|
||||
}
|
||||
return c.Changeset, c.csErr
|
||||
}
|
||||
|
||||
// Mutate sends a configuration change, honoring Changeset, DryRun and
|
||||
// Force.
|
||||
func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) {
|
||||
@@ -250,8 +269,12 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
|
||||
for k, v := range query {
|
||||
q[k] = v
|
||||
}
|
||||
if c.Changeset > 0 {
|
||||
q.Set("changeset", fmt.Sprint(c.Changeset))
|
||||
cs, err := c.UseChangeset(ctx)
|
||||
if err != nil {
|
||||
return &api.MutationResponse{}, err
|
||||
}
|
||||
if cs > 0 {
|
||||
q.Set("changeset", fmt.Sprint(cs))
|
||||
}
|
||||
if c.DryRun {
|
||||
q.Set("dry_run", "1")
|
||||
@@ -260,7 +283,7 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
|
||||
q.Set("force", "1")
|
||||
}
|
||||
var out api.MutationResponse
|
||||
err := c.Do(ctx, method, path, q, body, &out)
|
||||
err = c.Do(ctx, method, path, q, body, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -38,6 +39,56 @@ type Config struct {
|
||||
ACME ACME `yaml:"acme"`
|
||||
Porkbun Porkbun `yaml:"porkbun"`
|
||||
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
|
||||
@@ -323,6 +374,7 @@ func Default() *Config {
|
||||
},
|
||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
|
||||
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
|
||||
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
||||
}
|
||||
}
|
||||
@@ -394,6 +446,9 @@ func (c *Config) check() error {
|
||||
if err := c.Blocking.check(); err != nil {
|
||||
return fmt.Errorf("blocking: %w", err)
|
||||
}
|
||||
if err := c.Metrics.check(); err != nil {
|
||||
return fmt.Errorf("metrics: %w", err)
|
||||
}
|
||||
switch c.Log.Format {
|
||||
case "text", "json":
|
||||
default:
|
||||
@@ -633,4 +688,23 @@ blocking:
|
||||
refresh: 24h # check each list for changes this often (at least 1h)
|
||||
max_download_mb: 64 # refuse larger downloads
|
||||
max_entries: 1000000 # refuse lists with more domains
|
||||
|
||||
# Prometheus metrics at /metrics (docs/metrics.md): named's counters and
|
||||
# Jarl's own (blocking, zones, views), plus health gauges to alert on
|
||||
# (named up, validation problems, blocklist errors, backup age, DNSSEC
|
||||
# steps, token expiry). Off by default. /metrics is served on the
|
||||
# addresses in 'listen' at the top of this file; a scrape is allowed from
|
||||
# an address in 'allow' below, or with the token. Query counters need
|
||||
# stats.enabled.
|
||||
metrics:
|
||||
enabled: false
|
||||
# Extra addresses that serve only /metrics, over plain HTTP, e.g.
|
||||
# ["0.0.0.0:9450"] for a Prometheus server elsewhere on the network.
|
||||
listen: []
|
||||
# Addresses or prefixes that may scrape without a token.
|
||||
allow: ["127.0.0.1", "::1"]
|
||||
# A bearer token (at least 16 characters) that lets any address scrape:
|
||||
# in Prometheus, 'authorization: { credentials: TOKEN }'. Keep this file
|
||||
# readable by root and jarld only when you set it.
|
||||
token: ""
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/iprange"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
@@ -52,10 +53,16 @@ func NormalizeMember(st *model.State, m string) (string, error) {
|
||||
if p, err := netip.ParsePrefix(m); err == nil {
|
||||
return p.Masked().String(), nil
|
||||
}
|
||||
if r, ok, err := iprange.Parse(m); ok {
|
||||
if err != nil {
|
||||
return "", errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
return r.String(), nil
|
||||
}
|
||||
if a := st.ACLByName(m); a != nil {
|
||||
return a.Name, nil
|
||||
}
|
||||
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24) 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 {
|
||||
|
||||
@@ -25,15 +25,21 @@ const (
|
||||
|
||||
// Error is an engine error with details for the caller.
|
||||
type Error struct {
|
||||
Kind ErrorKind `json:"code"`
|
||||
Message string `json:"message"`
|
||||
OpSeq int `json:"op,omitempty"` // the operation that failed
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
Kind ErrorKind `json:"code"`
|
||||
Message string `json:"message"`
|
||||
OpSeq int `json:"op,omitempty"` // the operation that failed
|
||||
// OpSummary describes that operation, so people can find it in the
|
||||
// changeset (record IDs mean little to them).
|
||||
OpSummary string `json:"op_summary,omitempty"`
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.OpSeq > 0 && e.OpSummary != "" {
|
||||
return fmt.Sprintf("operation %d (%s): %s", e.OpSeq, e.OpSummary, e.Message)
|
||||
}
|
||||
if e.OpSeq > 0 {
|
||||
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
|
||||
}
|
||||
|
||||
+42
-3
@@ -11,6 +11,7 @@ import (
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/catalog"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/iprange"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
"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)
|
||||
}
|
||||
if e, ok := AsError(err); ok && e.OpSeq == 0 {
|
||||
e.OpSeq = op.Seq
|
||||
e.OpSeq, e.OpSummary = op.Seq, op.Summary
|
||||
}
|
||||
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 {
|
||||
r := st.Records[op.ID]
|
||||
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 {
|
||||
return err
|
||||
@@ -533,7 +534,10 @@ func applyRecordDelete(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if op.ID != 0 {
|
||||
r := st.Records[op.ID]
|
||||
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 {
|
||||
return err
|
||||
@@ -1141,9 +1145,44 @@ func NormalizeOption(st *model.State, scope string, scopeID int64, name, value s
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var in OptionInput
|
||||
if err := decode(op.Data, &in); err != nil {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
func hasProblem(ps []validate.Problem, code string) bool {
|
||||
for _, p := range ps {
|
||||
if p.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func describeProblems(e *Error) string {
|
||||
var out []string
|
||||
for _, p := range e.Problems {
|
||||
out = append(out, p.Message)
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
func optionReq(name, value string) Request {
|
||||
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value})
|
||||
}
|
||||
|
||||
func TestAddressRanges(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("dhcp"), Elements: &[]string{"!10.0.10.150 - 10.0.10.160", "10.0.10.101-10.0.10.199", "2001:db8::100-2001:db8::1ff"}}),
|
||||
optionReq("allow-query", "10.0.20.1 - 10.0.20.6; localhost"),
|
||||
)
|
||||
st := env.state()
|
||||
if a := st.ACLByName("dhcp"); strings.Join(a.Elements, " ") != "!10.0.10.150-10.0.10.160 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff" {
|
||||
t.Errorf("stored elements = %q", a.Elements)
|
||||
}
|
||||
if o := st.FindOption(model.ScopeOptions, 0, "allow-query"); o == nil || o.Value != "{ 10.0.20.1-10.0.20.6; localhost; }" {
|
||||
t.Errorf("stored option = %+v", o)
|
||||
}
|
||||
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
|
||||
c := string(conf)
|
||||
for _, want := range []string{
|
||||
"acl \"dhcp\" {\n\t// !10.0.10.150-10.0.10.160\n\t!10.0.10.150/31;\n\t!10.0.10.152/29;\n\t!10.0.10.160;\n\t// 10.0.10.101-10.0.10.199\n\t10.0.10.101;\n",
|
||||
"\t10.0.10.192/29;\n\t// 2001:db8::100-2001:db8::1ff\n\t2001:db8::100/120;\n};\n",
|
||||
"\tallow-query {\n\t\t// 10.0.20.1-10.0.20.6\n\t\t10.0.20.1;\n\t\t10.0.20.2/31;\n\t\t10.0.20.4/31;\n\t\t10.0.20.6;\n\t\tlocalhost;\n\t};\n",
|
||||
} {
|
||||
if !strings.Contains(c, want) {
|
||||
t.Errorf("named.conf lacks %q:\n%s", want, c)
|
||||
}
|
||||
}
|
||||
for _, l := range strings.Split(c, "\n") {
|
||||
if code, _, _ := strings.Cut(l, "//"); strings.Contains(code, "-10.0.") || strings.Contains(code, "-2001:") {
|
||||
t.Errorf("a range reached named.conf outside a comment: %q", l)
|
||||
}
|
||||
}
|
||||
|
||||
// Mistakes are caught when they're entered.
|
||||
for _, tc := range []struct {
|
||||
req Request
|
||||
want string
|
||||
}{
|
||||
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("bad"), Elements: &[]string{"10.0.10.199-10.0.10.101"}}), "start is after the end"},
|
||||
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("bad"), Elements: &[]string{"10.0.10.101-199"}}), "not a full IP address"},
|
||||
{optionReq("forwarders", "{ 192.0.2.1-192.0.2.9; }"), "only work in address match lists"},
|
||||
{optionReq("allow-recursion", "{ 10.0.0.9-10.0.0.1; }"), "start is after the end"},
|
||||
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("10.0.0.1-10.0.0.9"), Elements: &[]string{"10.0.0.1"}}), "looks like an address range"},
|
||||
} {
|
||||
if e := env.submitErr(env.admin, tc.req); !strings.Contains(e.Error(), tc.want) && !strings.Contains(describeProblems(e), tc.want) {
|
||||
t.Errorf("%s: %v %s, want %q", tc.req.Op, e, describeProblems(e), tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Validation sees what a range covers: everything is "any".
|
||||
res := env.submit(env.admin, optionReq("allow-recursion", "0.0.0.0-255.255.255.255"))
|
||||
if !hasProblem(res.Problems, "security_open_resolver") {
|
||||
t.Errorf("no open resolver warning for 0.0.0.0-255.255.255.255: %+v", res.Problems)
|
||||
}
|
||||
// IPv6 ranges in the IPv4-mapped space get a warning.
|
||||
res = env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("mapped"), Elements: &[]string{"::ffff:10.0.0.1-::ffff:10.0.0.9"}}))
|
||||
if !hasProblem(res.Problems, "range_mapped4") {
|
||||
t.Errorf("no range_mapped4 warning: %+v", res.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientGroupRanges(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
_, srv := newListServer(t, "||ads.example^\n")
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("tablets"), Elements: &[]string{"10.0.0.50-10.0.0.51"}}),
|
||||
)
|
||||
env.submit(env.admin, blocklist("ads", srv.URL+"/a.txt"), clientGroup("kids", "off", "tablets", "10.0.0.20 - 10.0.0.23"))
|
||||
if g := env.state().SortedClientGroups(); len(g) != 1 || strings.Join(g[0].Members, " ") != "tablets 10.0.0.20-10.0.0.23" {
|
||||
t.Fatalf("groups = %+v", g)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default/off.gate.jarl.invalid.rpz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"31.50.0.0.10.rpz-client-ip", "30.20.0.0.10.rpz-client-ip"} {
|
||||
if !strings.Contains(string(b), want) {
|
||||
t.Errorf("off gate lacks %s:\n%s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// Records deleted or changed after a change to them was staged (in the
|
||||
// CLI while a web UI changeset is open, say).
|
||||
func TestStaleChangeset(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, rec("example.com", "one", "A", "192.0.2.51"), rec("example.com", "two", "A", "192.0.2.52"), rec("example.com", "three", "A", "192.0.2.53"))
|
||||
st := env.state()
|
||||
one, two, three := findRec(st, "one.example.com.", "A"), findRec(st, "two.example.com.", "A"), findRec(st, "three.example.com.", "A")
|
||||
|
||||
// Two deletes staged; one of the records is deleted elsewhere first.
|
||||
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordDelete, one.ID, nil), NewRequest(OpRecordDelete, two.ID, nil)}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordDelete, one.ID, nil))
|
||||
res, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply after the record was deleted elsewhere: %v", err)
|
||||
}
|
||||
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "one.example.com. A 192.0.2.51: the record was already deleted") {
|
||||
t.Errorf("warnings = %q", res.Warnings)
|
||||
}
|
||||
if st := env.state(); st.Records[two.ID] != nil {
|
||||
t.Error("two was not deleted")
|
||||
}
|
||||
|
||||
// A staged edit of a record deleted elsewhere names the operation.
|
||||
cs, _ = env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||
env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordUpdate, three.ID, RecordInput{Data: model.Ptr("192.0.2.54")})}, env.admin)
|
||||
env.submit(env.admin, NewRequest(OpRecordDelete, three.ID, nil))
|
||||
p, err := env.e.Preview(env.ctx, cs.ID, env.admin)
|
||||
if err != nil || p.Error == nil || p.Error.OpSeq != 1 || !strings.HasPrefix(p.Error.OpSummary, "change three.example.com. A 192.0.2.53") {
|
||||
t.Fatalf("preview = %+v, %v", p, err)
|
||||
}
|
||||
_, err = env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||
if ee, ok := AsError(err); !ok || ee.Kind != KindConflict || !strings.Contains(err.Error(), "operation 1 (change three.example.com. A") || !strings.Contains(err.Error(), "remove this operation") {
|
||||
t.Errorf("apply = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Package iprange handles address ranges such as 10.0.10.101-10.0.10.199
|
||||
// or 2001:db8::100-2001:db8::1ff. BIND matches only addresses and
|
||||
// prefixes, so Jarl keeps a range as it was written and turns it into the
|
||||
// exact set of prefixes that covers it when it writes named.conf.
|
||||
package iprange
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Range is an inclusive range of addresses of one family.
|
||||
type Range struct {
|
||||
Start, End netip.Addr
|
||||
}
|
||||
|
||||
// String returns the range as Jarl writes it: START-END.
|
||||
func (r Range) String() string { return r.Start.String() + "-" + r.End.String() }
|
||||
|
||||
// Parse parses START-END, both full addresses of the same family. ok is
|
||||
// false when s isn't meant as a range (no "-", or text before it that
|
||||
// isn't an address, such as the ACL name "branch-office"); err is set when
|
||||
// it is meant as one but is wrong.
|
||||
func Parse(s string) (r Range, ok bool, err error) {
|
||||
left, right, found := strings.Cut(s, "-")
|
||||
if !found {
|
||||
return Range{}, false, nil
|
||||
}
|
||||
left, right = strings.TrimSpace(left), strings.TrimSpace(right)
|
||||
if !looksLikeAddr(left) {
|
||||
return Range{}, false, nil
|
||||
}
|
||||
start, err := netip.ParseAddr(left)
|
||||
if err != nil {
|
||||
return Range{}, true, fmt.Errorf("range %q: %q is not a full IP address", s, left)
|
||||
}
|
||||
end, err := netip.ParseAddr(right)
|
||||
if err != nil {
|
||||
if right == "" {
|
||||
return Range{}, true, fmt.Errorf("range %q has no end address", s)
|
||||
}
|
||||
return Range{}, true, fmt.Errorf("range %q: %q is not a full IP address (write both ends in full, e.g. 10.0.10.101-10.0.10.199)", s, right)
|
||||
}
|
||||
if start.Zone() != "" || end.Zone() != "" {
|
||||
return Range{}, true, fmt.Errorf("range %q: addresses with a zone (%%interface) can't be matched", s)
|
||||
}
|
||||
if start.Is4() != end.Is4() {
|
||||
return Range{}, true, fmt.Errorf("range %q mixes IPv4 and IPv6", s)
|
||||
}
|
||||
if end.Less(start) {
|
||||
return Range{}, true, fmt.Errorf("range %q: the start is after the end", s)
|
||||
}
|
||||
return Range{Start: start, End: end}, true, nil
|
||||
}
|
||||
|
||||
// looksLikeAddr reports whether s could only be meant as an address: it
|
||||
// has a colon, or it is digits and dots with at least one dot.
|
||||
func looksLikeAddr(s string) bool {
|
||||
if strings.Contains(s, ":") {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(s, ".") {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if c := s[i]; !(c >= '0' && c <= '9' || c == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Prefixes returns the fewest prefixes that together cover exactly the
|
||||
// range, in address order: 10.0.10.101-10.0.10.199 is 10.0.10.101/32,
|
||||
// 10.0.10.102/31, 10.0.10.104/29, 10.0.10.112/28, 10.0.10.128/26 and
|
||||
// 10.0.10.192/29. That is at most 62 prefixes for IPv4 and 254 for IPv6.
|
||||
func (r Range) Prefixes() []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
a := r.Start
|
||||
for {
|
||||
// The largest prefix that starts at a and ends at or before End.
|
||||
p := netip.PrefixFrom(a, a.BitLen())
|
||||
for bits := a.BitLen() - 1; bits >= 0; bits-- {
|
||||
q := netip.PrefixFrom(a, bits)
|
||||
if q.Masked().Addr() != a || r.End.Less(last(q)) {
|
||||
break
|
||||
}
|
||||
p = q
|
||||
}
|
||||
out = append(out, p)
|
||||
end := last(p)
|
||||
if end == r.End {
|
||||
return out
|
||||
}
|
||||
a = end.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// last returns the last address of a prefix.
|
||||
func last(p netip.Prefix) netip.Addr {
|
||||
b := p.Addr().AsSlice()
|
||||
for i := p.Bits(); i < len(b)*8; i++ {
|
||||
b[i/8] |= 0x80 >> (i % 8)
|
||||
}
|
||||
a, _ := netip.AddrFromSlice(b)
|
||||
return a
|
||||
}
|
||||
|
||||
// Size returns the number of addresses in the range.
|
||||
func (r Range) Size() *big.Int {
|
||||
s := new(big.Int).SetBytes(r.Start.AsSlice())
|
||||
e := new(big.Int).SetBytes(r.End.AsSlice())
|
||||
return e.Sub(e, s).Add(e, big.NewInt(1))
|
||||
}
|
||||
|
||||
// SizeText describes the size for people: "99 addresses", or a power of
|
||||
// two for very large IPv6 ranges ("about 2^80 addresses").
|
||||
func (r Range) SizeText() string {
|
||||
n := r.Size()
|
||||
if n.BitLen() <= 40 {
|
||||
if n.Int64() == 1 {
|
||||
return "1 address"
|
||||
}
|
||||
return n.String() + " addresses"
|
||||
}
|
||||
return fmt.Sprintf("about 2^%d addresses", n.BitLen()-1)
|
||||
}
|
||||
|
||||
// Mapped4 is the IPv4-mapped IPv6 space; BIND can see IPv4 clients as
|
||||
// addresses in it.
|
||||
var Mapped4 = netip.MustParsePrefix("::ffff:0:0/96")
|
||||
|
||||
// TouchesMapped4 reports whether an IPv6 range overlaps ::ffff:0:0/96.
|
||||
func (r Range) TouchesMapped4() bool {
|
||||
if r.Start.Is4() {
|
||||
return false
|
||||
}
|
||||
lo, hi := Mapped4.Addr(), last(Mapped4)
|
||||
return !(r.End.Less(lo) || hi.Less(r.Start))
|
||||
}
|
||||
|
||||
// FromPrefix returns the addresses of a prefix as a range.
|
||||
func FromPrefix(p netip.Prefix) Range {
|
||||
p = p.Masked()
|
||||
return Range{Start: p.Addr(), End: last(p)}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package iprange
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in, want, err string
|
||||
ok bool
|
||||
}{
|
||||
{in: "10.0.10.101-10.0.10.199", want: "10.0.10.101-10.0.10.199", ok: true},
|
||||
{in: "10.0.10.101 - 10.0.10.199", want: "10.0.10.101-10.0.10.199", ok: true},
|
||||
{in: "2001:DB8::100-2001:db8::1ff", want: "2001:db8::100-2001:db8::1ff", ok: true},
|
||||
{in: "10.0.0.5-10.0.0.5", want: "10.0.0.5-10.0.0.5", ok: true},
|
||||
{in: "branch-office", ok: false},
|
||||
{in: "10-net", ok: false},
|
||||
{in: "10.0.0.0/24", ok: false},
|
||||
{in: "10.0.10.101-199", ok: true, err: "not a full IP address"},
|
||||
{in: "10.0.10.101-", ok: true, err: "no end address"},
|
||||
{in: "2001:db8::100-1ff", ok: true, err: "not a full IP address"},
|
||||
{in: "10.0.0.9-10.0.0.1", ok: true, err: "start is after the end"},
|
||||
{in: "10.0.0.1-2001:db8::1", ok: true, err: "mixes IPv4 and IPv6"},
|
||||
{in: "fe80::1%eth0-fe80::9%eth0", ok: true, err: "zone"},
|
||||
{in: "10.0.300.1-10.0.300.9", ok: true, err: "not a full IP address"},
|
||||
{in: "10.0.0.1-10.0.0.9-10.0.0.20", ok: true, err: "not a full IP address"},
|
||||
} {
|
||||
r, ok, err := Parse(tc.in)
|
||||
if ok != tc.ok || (err == nil) != (tc.err == "") || err != nil && !strings.Contains(err.Error(), tc.err) {
|
||||
t.Errorf("%s: ok=%v err=%v, want ok=%v err~%q", tc.in, ok, err, tc.ok, tc.err)
|
||||
continue
|
||||
}
|
||||
if err == nil && ok && r.String() != tc.want {
|
||||
t.Errorf("%s = %s, want %s", tc.in, r, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixes(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"10.0.10.101-10.0.10.199": "10.0.10.101/32 10.0.10.102/31 10.0.10.104/29 10.0.10.112/28 10.0.10.128/26 10.0.10.192/29",
|
||||
"10.0.0.0-10.0.0.255": "10.0.0.0/24",
|
||||
"10.0.0.5-10.0.0.5": "10.0.0.5/32",
|
||||
"0.0.0.0-255.255.255.255": "0.0.0.0/0",
|
||||
"255.255.255.254-255.255.255.255": "255.255.255.254/31",
|
||||
"2001:db8::100-2001:db8::1ff": "2001:db8::100/120",
|
||||
"::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff": "::/0",
|
||||
} {
|
||||
r, _, err := Parse(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, p := range r.Prefixes() {
|
||||
got = append(got, p.String())
|
||||
}
|
||||
if strings.Join(got, " ") != want {
|
||||
t.Errorf("%s = %s, want %s", in, strings.Join(got, " "), want)
|
||||
}
|
||||
}
|
||||
// The worst cases stay small.
|
||||
if n := len(must(t, "0.0.0.1-255.255.255.254").Prefixes()); n != 62 {
|
||||
t.Errorf("worst IPv4 case = %d prefixes, want 62", n)
|
||||
}
|
||||
if n := len(must(t, "::1-ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe").Prefixes()); n != 254 {
|
||||
t.Errorf("worst IPv6 case = %d prefixes, want 254", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Every address of a random range is covered once, and nothing outside it.
|
||||
func TestPrefixesExact(t *testing.T) {
|
||||
rnd := rand.New(rand.NewSource(1))
|
||||
for i := 0; i < 300; i++ {
|
||||
lo, hi := rnd.Intn(4096), rnd.Intn(4096)
|
||||
if hi < lo {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
base := []byte{10, 20, 0, 0}
|
||||
addr := func(n int) netip.Addr {
|
||||
return netip.AddrFrom4([4]byte{base[0], base[1], byte(n >> 8), byte(n)})
|
||||
}
|
||||
r := Range{Start: addr(lo), End: addr(hi)}
|
||||
ps := r.Prefixes()
|
||||
for n := max(lo-3, 0); n <= min(hi+3, 4095); n++ {
|
||||
c := 0
|
||||
for _, p := range ps {
|
||||
if p.Contains(addr(n)) {
|
||||
c++
|
||||
}
|
||||
}
|
||||
if in := n >= lo && n <= hi; in && c != 1 || !in && c != 0 {
|
||||
t.Fatalf("%s: %s covered %d times (%v)", r, addr(n), c, ps)
|
||||
}
|
||||
}
|
||||
if got := r.Size().Int64(); got != int64(hi-lo+1) {
|
||||
t.Fatalf("%s: size %d", r, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeAndMapped(t *testing.T) {
|
||||
if s := must(t, "10.0.10.101-10.0.10.199").SizeText(); s != "99 addresses" {
|
||||
t.Errorf("size = %s", s)
|
||||
}
|
||||
if s := must(t, "2001:db8::-2001:db8:0:ffff:ffff:ffff:ffff:ffff").SizeText(); s != "about 2^80 addresses" {
|
||||
t.Errorf("size = %s", s)
|
||||
}
|
||||
if !must(t, "::ffff:10.0.0.1-::ffff:10.0.0.9").TouchesMapped4() || must(t, "2001:db8::1-2001:db8::9").TouchesMapped4() || must(t, "10.0.0.1-10.0.0.9").TouchesMapped4() {
|
||||
t.Error("TouchesMapped4")
|
||||
}
|
||||
}
|
||||
|
||||
func must(t *testing.T, s string) Range {
|
||||
t.Helper()
|
||||
r, ok, err := Parse(s)
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("%s: %v %v", s, ok, err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/iprange"
|
||||
)
|
||||
|
||||
// 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()))
|
||||
} else if p, err := netip.ParsePrefix(e); err == nil {
|
||||
out = append(out, p.Masked())
|
||||
} else if r, ok, err := iprange.Parse(e); ok && err == nil {
|
||||
out = append(out, r.Prefixes()...)
|
||||
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
|
||||
seen[acl.Name] = true
|
||||
add(acl.Elements)
|
||||
|
||||
+141
-8
@@ -6,6 +6,8 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/iprange"
|
||||
)
|
||||
|
||||
// AMKind is the kind of an address match list element.
|
||||
@@ -17,6 +19,9 @@ const (
|
||||
AMName // an ACL name (or built-in ACL)
|
||||
AMNested // { ... }
|
||||
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.
|
||||
@@ -26,10 +31,11 @@ var BuiltinACLs = map[string]bool{"any": true, "none": true, "localhost": true,
|
||||
type AMElement struct {
|
||||
Negated bool
|
||||
Kind AMKind
|
||||
Prefix netip.Prefix // AMPrefix
|
||||
Name string // AMKey, AMName
|
||||
Nested []AMElement // AMNested
|
||||
Raw string // AMOther
|
||||
Prefix netip.Prefix // AMPrefix
|
||||
Name string // AMKey, AMName
|
||||
Nested []AMElement // AMNested
|
||||
Raw string // AMOther
|
||||
Range iprange.Range // AMRange
|
||||
}
|
||||
|
||||
// String returns the element in canonical named.conf syntax, without the
|
||||
@@ -55,6 +61,8 @@ func (e AMElement) String() string {
|
||||
s = "{ " + strings.Join(parts, " ") + " }"
|
||||
case AMOther:
|
||||
s = e.Raw
|
||||
case AMRange:
|
||||
s = e.Range.String()
|
||||
}
|
||||
if e.Negated {
|
||||
return "!" + s
|
||||
@@ -137,6 +145,7 @@ func parseAMItems(items []Item) (AMElement, error) {
|
||||
if len(items) == 0 {
|
||||
return e, errors.New("empty address match element")
|
||||
}
|
||||
items = joinRange(items)
|
||||
first := items[0]
|
||||
if first.Block != nil {
|
||||
if len(items) != 1 {
|
||||
@@ -172,6 +181,16 @@ func parseAMItems(items []Item) (AMElement, error) {
|
||||
}
|
||||
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 p, ok, err := parsePrefix(word); ok {
|
||||
if err != nil {
|
||||
@@ -215,6 +234,9 @@ func CheckACLName(name string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -318,18 +340,18 @@ func AMRefs(elems []AMElement) (acls, keys []string) {
|
||||
func Shadowed(elems []AMElement) []string {
|
||||
var warns []string
|
||||
for i, e := range elems {
|
||||
if e.Kind != AMPrefix && e.Kind != AMName {
|
||||
if e.Kind != AMPrefix && e.Kind != AMRange && e.Kind != AMName {
|
||||
continue
|
||||
}
|
||||
er, eok := e.addrRange()
|
||||
for j := 0; j < i; j++ {
|
||||
prev := elems[j]
|
||||
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()))
|
||||
break
|
||||
}
|
||||
if prev.Kind == AMPrefix && e.Kind == AMPrefix &&
|
||||
prev.Prefix.Addr().Is4() == e.Prefix.Addr().Is4() &&
|
||||
prev.Prefix.Bits() <= e.Prefix.Bits() && prev.Prefix.Contains(e.Prefix.Addr()) {
|
||||
if pr, ok := prev.addrRange(); ok && eok && pr.Start.Is4() == er.Start.Is4() &&
|
||||
!er.Start.Less(pr.Start) && !pr.End.Less(er.End) {
|
||||
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
|
||||
}
|
||||
@@ -338,6 +360,117 @@ func Shadowed(elems []AMElement) []string {
|
||||
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
|
||||
// negated element would restrict it, which makes the list match everyone.
|
||||
// It is used for security lints (open resolvers, open transfers).
|
||||
|
||||
@@ -30,6 +30,9 @@ type Item struct {
|
||||
type Stmt struct {
|
||||
Items []Item
|
||||
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
|
||||
|
||||
@@ -77,6 +77,9 @@ func valueText(v *Value) string {
|
||||
|
||||
func writeStmt(sb *strings.Builder, s *Stmt, indent int) {
|
||||
tabs := strings.Repeat("\t", indent)
|
||||
if s.Comment != "" {
|
||||
sb.WriteString(tabs + "// " + s.Comment + "\n")
|
||||
}
|
||||
sb.WriteString(tabs)
|
||||
inline := Compact(s.Items)
|
||||
if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 {
|
||||
@@ -167,6 +170,9 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
|
||||
} else {
|
||||
sb.WriteString("{ ")
|
||||
for _, s := range it.Block.Stmts {
|
||||
if s.Comment != "" {
|
||||
sb.WriteString("/* " + s.Comment + " */ ")
|
||||
}
|
||||
writeItemsInline(sb, s.Items)
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package namedconf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRangeElements(t *testing.T) {
|
||||
e, err := ParseAMElement("!10.0.10.150 - 10.0.10.160;")
|
||||
if err != nil || e.Kind != AMRange || !e.Negated || e.String() != "!10.0.10.150-10.0.10.160" {
|
||||
t.Fatalf("negated range = %+v, %v", e, err)
|
||||
}
|
||||
if e, err := ParseAMElement("2001:db8::100-2001:db8::1ff"); err != nil || e.Kind != AMRange {
|
||||
t.Errorf("IPv6 range = %+v, %v", e, err)
|
||||
}
|
||||
// ACL names with dashes are still names.
|
||||
if e, err := ParseAMElement("branch-office"); err != nil || e.Kind != AMName {
|
||||
t.Errorf("name = %+v, %v", e, err)
|
||||
}
|
||||
for _, bad := range []string{"10.0.10.199-10.0.10.101", "10.0.10.101-199", "10.0.0.1-2001:db8::1"} {
|
||||
if _, err := ParseAMElement(bad); err == nil {
|
||||
t.Errorf("%s accepted", bad)
|
||||
}
|
||||
}
|
||||
if err := CheckACLName("10.0.0.1-10.0.0.9"); err == nil {
|
||||
t.Error("an ACL name that looks like a range was accepted")
|
||||
}
|
||||
var got []string
|
||||
for _, x := range e.Expand() {
|
||||
got = append(got, x.String())
|
||||
}
|
||||
if strings.Join(got, " ") != "!10.0.10.150/31 !10.0.10.152/29 !10.0.10.160" {
|
||||
t.Errorf("expand = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandRanges(t *testing.T) {
|
||||
st, err := ParseStatement(`acl "lan" { !10.0.10.150-10.0.10.160; 10.0.10.101-10.0.10.199; key xfer; branch-office; { 10.0.0.1-10.0.0.2; }; }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ExpandRanges(st)
|
||||
want := `acl "lan" {
|
||||
// !10.0.10.150-10.0.10.160
|
||||
!10.0.10.150/31;
|
||||
!10.0.10.152/29;
|
||||
!10.0.10.160;
|
||||
// 10.0.10.101-10.0.10.199
|
||||
10.0.10.101;
|
||||
10.0.10.102/31;
|
||||
10.0.10.104/29;
|
||||
10.0.10.112/28;
|
||||
10.0.10.128/26;
|
||||
10.0.10.192/29;
|
||||
key xfer;
|
||||
branch-office;
|
||||
{ /* 10.0.0.1-10.0.0.2 */ 10.0.0.1; 10.0.0.2; };
|
||||
};
|
||||
`
|
||||
if got := FormatStmt(st, 0); got != want {
|
||||
t.Errorf("got:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
// Short lists stay on one line, with the range as a /* */ comment;
|
||||
// both forms parse again.
|
||||
st, _ = ParseOptionValue("allow-query", "{ 2001:db8::100-2001:db8::1ff; localhost; }")
|
||||
ExpandRanges(st)
|
||||
got := FormatStmt(st, 0)
|
||||
if got != "allow-query { /* 2001:db8::100-2001:db8::1ff */ 2001:db8::100/120; localhost; };\n" {
|
||||
t.Errorf("inline = %q", got)
|
||||
}
|
||||
if _, err := ParseStatement(strings.TrimSuffix(strings.TrimSpace(got), ";")); err != nil {
|
||||
t.Errorf("output doesn't parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowedRanges(t *testing.T) {
|
||||
elems, _ := ParseAMListString("10.0.10.0/24; 10.0.10.101-10.0.10.199; 10.0.10.150-10.0.11.5; 10.0.11.1")
|
||||
w := Shadowed(elems)
|
||||
if len(w) != 2 || !strings.Contains(w[0], `"10.0.10.101-10.0.10.199" never matches`) || !strings.Contains(w[1], `"10.0.11.1" never matches: "10.0.10.150-10.0.11.5"`) {
|
||||
t.Errorf("shadowed = %q", w)
|
||||
}
|
||||
elems, _ = ParseAMListString("10.0.10.101-10.0.10.199; 10.0.10.120")
|
||||
if w := Shadowed(elems); len(w) != 1 || !strings.Contains(w[0], `"10.0.10.120" never matches`) {
|
||||
t.Errorf("shadowed by a range = %q", w)
|
||||
}
|
||||
elems, _ = ParseAMListString("0.0.0.0-255.255.255.255")
|
||||
if ContainsAny(elems) {
|
||||
t.Error("ContainsAny should only see expanded elements") // callers expand first
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func optionText(o *model.Option, indent int) string {
|
||||
// named-checkconf reports the problem.
|
||||
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 {
|
||||
@@ -313,7 +313,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
||||
if err != nil {
|
||||
w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n")
|
||||
} else {
|
||||
w.write(namedconf.FormatStmt(st, 0))
|
||||
w.write(formatStmt(st, 0))
|
||||
}
|
||||
w.end()
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func writeAML(w *confWriter, name string, elems []string) {
|
||||
w.write("\t" + name + " " + amlBlock(elems) + ";\n")
|
||||
return
|
||||
}
|
||||
w.write(namedconf.FormatStmt(st, 1))
|
||||
w.write(formatStmt(st, 1))
|
||||
}
|
||||
|
||||
func yesNo(b bool) string {
|
||||
@@ -507,3 +507,10 @@ func ZoneContentHash(s *model.State, z *model.Zone) string {
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// formatStmt lays out a statement for named.conf, with address ranges
|
||||
// (Jarl's own syntax) written as the prefixes that cover them.
|
||||
func formatStmt(st *namedconf.Stmt, indent int) string {
|
||||
namedconf.ExpandRanges(st)
|
||||
return namedconf.FormatStmt(st, indent)
|
||||
}
|
||||
@@ -62,13 +62,13 @@ func (h *StatsHooks) statsStatement(user *model.Option) string {
|
||||
}
|
||||
}
|
||||
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.
|
||||
return optionText(user, 0)
|
||||
}
|
||||
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
|
||||
@@ -108,7 +108,7 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
|
||||
if user != nil {
|
||||
prefix = commentLines(user.Comment)
|
||||
}
|
||||
return prefix + commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0)
|
||||
return prefix + commentBlock("//", statsComment) + formatStmt(st, 0)
|
||||
}
|
||||
|
||||
func commentLines(c string) string {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"jarl/internal/catalog"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/iprange"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/rr"
|
||||
@@ -554,6 +555,42 @@ func (s *Server) catalogRRTypes(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
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 {
|
||||
roles := map[string][]string{}
|
||||
for name, gs := range auth.RoleTemplates {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/stats"
|
||||
)
|
||||
|
||||
// scrape GETs /metrics with an optional bearer token.
|
||||
func (e *apiEnv) scrape(token string) (string, int) {
|
||||
e.t.Helper()
|
||||
r, _ := http.NewRequest("GET", e.ts.URL+"/metrics", nil)
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b), resp.StatusCode
|
||||
}
|
||||
|
||||
var (
|
||||
promSampleRe = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*(\{[a-zA-Z_][a-zA-Z0-9_]*="(?:[^"\\]|\\.)*"(,[a-zA-Z_][a-zA-Z0-9_]*="(?:[^"\\]|\\.)*")*\})? -?[0-9.eE+-]+$`)
|
||||
promTypeRe = regexp.MustCompile(`^# TYPE ([a-zA-Z_:][a-zA-Z0-9_:]*) (gauge|counter)$`)
|
||||
)
|
||||
|
||||
// checkExposition checks Prometheus's text format: valid lines, and each
|
||||
// metric's samples together under a single TYPE.
|
||||
func checkExposition(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
typed := map[string]bool{}
|
||||
cur := ""
|
||||
for _, l := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "# HELP "):
|
||||
case strings.HasPrefix(l, "# TYPE "):
|
||||
m := promTypeRe.FindStringSubmatch(l)
|
||||
if m == nil {
|
||||
t.Errorf("bad TYPE line %q", l)
|
||||
continue
|
||||
}
|
||||
if typed[m[1]] {
|
||||
t.Errorf("metric %s appears twice", m[1])
|
||||
}
|
||||
typed[m[1]], cur = true, m[1]
|
||||
default:
|
||||
if !promSampleRe.MatchString(l) {
|
||||
t.Errorf("bad sample line %q", l)
|
||||
}
|
||||
if name, _, _ := strings.Cut(strings.SplitN(l, " ", 2)[0], "{"); name != cur {
|
||||
t.Errorf("sample %q is not under its TYPE (%s)", l, cur)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsAccess(t *testing.T) {
|
||||
loopback := []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
||||
e := newAPIEnv(t)
|
||||
if _, status := e.scrape(""); status != 404 {
|
||||
t.Errorf("disabled = %d, want 404", status)
|
||||
}
|
||||
|
||||
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: loopback}})
|
||||
body, status := e.scrape("")
|
||||
if status != 200 {
|
||||
t.Fatalf("allowed scrape = %d: %s", status, body)
|
||||
}
|
||||
checkExposition(t, body)
|
||||
for _, want := range []string{`jarl_build_info{version="test"} 1`, `jarl_problems{severity="error"} 0`, `jarl_objects{kind="zones"} 0`, "jarl_open_changesets 0", "jarl_metrics_state_ok 1"} {
|
||||
if !strings.Contains(body, want+"\n") {
|
||||
t.Errorf("missing %q in:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "jarl_queries_total") {
|
||||
t.Error("query counters without statistics")
|
||||
}
|
||||
// No token is configured: one that is sent is refused.
|
||||
if _, status := e.scrape("whatever-token-123"); status != 401 {
|
||||
t.Errorf("token without metrics.token = %d, want 401", status)
|
||||
}
|
||||
|
||||
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}}})
|
||||
if body, status := e.scrape(""); status != 403 || !strings.Contains(body, "metrics.allow") {
|
||||
t.Errorf("not allowed = %d %s", status, body)
|
||||
}
|
||||
|
||||
const token = "0123456789abcdef-metrics"
|
||||
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Token: token}})
|
||||
if _, status := e.scrape(""); status != 401 {
|
||||
t.Errorf("no token = %d, want 401", status)
|
||||
}
|
||||
if _, status := e.scrape("wrong-token-0123456789"); status != 401 {
|
||||
t.Errorf("wrong token = %d, want 401", status)
|
||||
}
|
||||
if _, status := e.scrape(token); status != 200 {
|
||||
t.Errorf("right token = %d, want 200", status)
|
||||
}
|
||||
// The API's tokens don't count for /metrics.
|
||||
if _, status := e.scrape(e.token); status != 401 {
|
||||
t.Errorf("API token = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsState(t *testing.T) {
|
||||
e := newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}})
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "ads", "url": "https://lists.example/ads.txt"}, 201)
|
||||
e.srv.metricsCache.body = nil // skip the cache
|
||||
body, _ := e.scrape("")
|
||||
checkExposition(t, body)
|
||||
for _, want := range []string{`jarl_objects{kind="zones"} 1`, `jarl_blocklist_enabled{list="ads"} 1`, "jarl_blocking_paused 0"} {
|
||||
if !strings.Contains(body, want+"\n") {
|
||||
t.Errorf("missing %q in:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Cached: a change shows after metricsStateTTL.
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.org", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
if body, _ := e.scrape(""); !strings.Contains(body, `jarl_objects{kind="zones"} 1`+"\n") {
|
||||
t.Error("state was not cached")
|
||||
}
|
||||
e.srv.metricsCache.at = time.Now().Add(-metricsStateTTL)
|
||||
if body, _ := e.scrape(""); !strings.Contains(body, `jarl_objects{kind="zones"} 2`+"\n") {
|
||||
t.Errorf("state not refreshed:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsStats(t *testing.T) {
|
||||
ss, err := stats.OpenStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
opts := stats.Options{QueryLog: true, LogFile: "/nonexistent", StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour}
|
||||
c := stats.NewCollector(ss, opts, func() map[string][]string { return map[string][]string{"internal": {"example.com"}} }, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
ts := time.Now().Format("02-Jan-2006 15:04:05.000")
|
||||
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5353 (www.example.com): view internal: response: www.example.com IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)")
|
||||
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5354 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid")
|
||||
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5354 (ads.example): view internal: response: ads.example IN A NXDOMAIN 1 0 1 +E(0)K (10.10.0.2)")
|
||||
|
||||
e := newAPIEnvConfig(t, Config{Version: "test", Stats: c, Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}})
|
||||
body, status := e.scrape("")
|
||||
if status != 200 {
|
||||
t.Fatalf("scrape = %d", status)
|
||||
}
|
||||
checkExposition(t, body)
|
||||
for _, want := range []string{
|
||||
"jarl_queries_total 0", "jarl_logged_responses_total 2", `jarl_responses_by_view_total{view="internal"} 2`,
|
||||
`jarl_responses_by_zone_total{zone="example.com"} 1`, `jarl_responses_by_zone_total{zone="-"} 1`,
|
||||
"jarl_blocked_queries_total 1", `jarl_blocked_by_list_total{list="hagezi"} 1`, "jarl_stats_log_lines_total 2",
|
||||
} {
|
||||
if !strings.Contains(body, want+"\n") {
|
||||
t.Errorf("missing %q in:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsMetricsMapping(t *testing.T) {
|
||||
pw := &promWriter{}
|
||||
totals := map[string]int64{"queries": 10, "rcode:NOERROR": 8, "rcode:NXDOMAIN": 2, "qtype:A": 10, "ratelimit:slipped": 3,
|
||||
"cache:hits": 4, "rtt:<10ms": 5, "unknown:x": 1, "rcode": 7}
|
||||
srv := &stats.Server{BootTime: "2026-10-07T20:00:00.000Z", Version: "9.20.4", PID: 42, RSS: 1 << 20, CPUSeconds: 1.5, SocketsUDP: 4}
|
||||
statsMetrics(pw, totals, srv, stats.Status{})
|
||||
var buf bytes.Buffer
|
||||
pw.writeTo(&buf)
|
||||
out := buf.String()
|
||||
checkExposition(t, out)
|
||||
for _, want := range []string{
|
||||
"jarl_queries_total 10", `jarl_responses_by_rcode_total{rcode="NXDOMAIN"} 2`, `jarl_ratelimit_responses_total{action="slipped"} 3`,
|
||||
`jarl_upstream_responses_by_rtt_total{rtt="<10ms"} 5`, `jarl_cache_lookups_total{result="hits"} 4`,
|
||||
"jarl_named_start_time_seconds 1791403200", "jarl_named_cpu_seconds_total 1.5", `jarl_named_sockets{proto="udp"} 4`,
|
||||
"jarl_named_resident_memory_bytes 1048576",
|
||||
} {
|
||||
if !strings.Contains(out, want+"\n") {
|
||||
t.Errorf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "unknown") || strings.Contains(out, "jarl_responses_by_rcode_total 7") {
|
||||
t.Errorf("unknown keys exported:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromWriterEscaping(t *testing.T) {
|
||||
pw := &promWriter{}
|
||||
pw.gauge("m", "a \\ help\nline", 1, "l", "a\"b\\c\nd")
|
||||
var buf bytes.Buffer
|
||||
pw.writeTo(&buf)
|
||||
want := "# HELP m a \\\\ help\\nline\n# TYPE m gauge\nm{l=\"a\\\"b\\\\c\\nd\"} 1\n"
|
||||
if buf.String() != want {
|
||||
t.Errorf("got %q, want %q", buf.String(), want)
|
||||
}
|
||||
if got := formatFloat(1759867200.123); got != "1759867200.123" {
|
||||
t.Errorf("formatFloat = %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogRanges(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
b, status := e.req("GET", "/catalog/ranges?r=10.0.10.101-10.0.10.199,!2001:db8::100-2001:db8::1ff,branch-office,10.0.0.9-10.0.0.1", e.token, nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("status %d: %s", status, b)
|
||||
}
|
||||
var out []struct {
|
||||
Input, Range, Size, Error string
|
||||
Prefixes []string
|
||||
}
|
||||
json.Unmarshal(b, &out)
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("got %s", b)
|
||||
}
|
||||
if out[0].Range != "10.0.10.101-10.0.10.199" || len(out[0].Prefixes) != 6 || out[0].Size != "99 addresses" {
|
||||
t.Errorf("IPv4 = %+v", out[0])
|
||||
}
|
||||
if out[1].Input != "!2001:db8::100-2001:db8::1ff" || len(out[1].Prefixes) != 1 || out[1].Prefixes[0] != "2001:db8::100/120" {
|
||||
t.Errorf("IPv6 = %+v", out[1])
|
||||
}
|
||||
if out[2].Error == "" {
|
||||
t.Errorf("reversed range = %+v", out[2])
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.cfg.Version})
|
||||
})
|
||||
m.HandleFunc("GET /metrics", s.metrics)
|
||||
// Authentication
|
||||
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
||||
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/rrtypes", s.authed(s.catalogRRTypes))
|
||||
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))
|
||||
// Changesets
|
||||
m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch))
|
||||
|
||||
@@ -58,6 +58,8 @@ type Config struct {
|
||||
// name authenticator apps show (h_totp.go).
|
||||
TOTPRequire string
|
||||
TOTPIssuer string
|
||||
// Metrics configures /metrics (metrics.go).
|
||||
Metrics Metrics
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
@@ -80,6 +82,10 @@ type Server struct {
|
||||
// nsChecker, when set, replaces the default name server checker
|
||||
// (tests point it at local DNS servers).
|
||||
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
|
||||
srvs []*http.Server
|
||||
@@ -108,7 +114,7 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
||||
if log == nil {
|
||||
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
|
||||
if cfg.Stats != nil {
|
||||
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.logHTTPReq(tlsCfg != nil)
|
||||
if ready != nil {
|
||||
|
||||
@@ -114,6 +114,9 @@ type Collector struct {
|
||||
// statistics (server.go).
|
||||
proc *namedProc
|
||||
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.
|
||||
@@ -125,7 +128,7 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
|
||||
opts.Location = time.Local
|
||||
}
|
||||
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 {
|
||||
c.tail = &Tailer{Path: opts.LogFile}
|
||||
}
|
||||
@@ -286,6 +289,23 @@ func (c *Collector) count(minute int64, key string, n int64) {
|
||||
c.minutes[minute] = m
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -14,7 +14,8 @@ type Server struct {
|
||||
PID int `json:"pid,omitempty"`
|
||||
RSS int64 `json:"rss,omitempty"` // resident memory, bytes
|
||||
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.
|
||||
MemInUse int64 `json:"mem_in_use"` // named's own count of the memory it uses
|
||||
CacheRRsets int64 `json:"cache_rrsets"`
|
||||
@@ -63,6 +64,7 @@ func (c *Collector) sampleServer(minute int64, now time.Time, snap *Snapshot) {
|
||||
c.proc = nil
|
||||
} else {
|
||||
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 {
|
||||
// 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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/iprange"
|
||||
"jarl/internal/model"
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
if _, ok, err := iprange.Parse(m); ok && err == nil {
|
||||
return
|
||||
}
|
||||
acl := c.s.ACLByName(m)
|
||||
if acl == nil {
|
||||
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m)
|
||||
|
||||
@@ -126,6 +126,7 @@ func (c *checker) checkACLs() {
|
||||
c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label)
|
||||
}
|
||||
c.checkRefs(elems, label, "acl", a.ID)
|
||||
c.checkRanges(elems, label, "acl", a.ID)
|
||||
for _, w := range namedconf.Shadowed(elems) {
|
||||
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.checkRefs(elems, label+" "+field, "view", v.ID)
|
||||
c.checkRanges(elems, label+" "+field, "view", v.ID)
|
||||
for _, w := range namedconf.Shadowed(elems) {
|
||||
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
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
out = append(out, e.Expand()...)
|
||||
}
|
||||
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.
|
||||
func (c *checker) optionScope(o *model.Option) (string, string, bool) {
|
||||
switch o.Scope {
|
||||
|
||||
@@ -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;
|
||||
// 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 "".
|
||||
function checkElement(line, ctx, exclude) {
|
||||
@@ -24,6 +26,7 @@ function checkElement(line, ctx, exclude) {
|
||||
if (e.startsWith("!")) e = e.slice(1).trim();
|
||||
const lower = e.toLowerCase();
|
||||
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);
|
||||
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
|
||||
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)");
|
||||
}
|
||||
|
||||
// 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
|
||||
// one element per line (and checks the names); "inline" inserts
|
||||
// "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,
|
||||
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])),
|
||||
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") {
|
||||
const check = () => {
|
||||
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);
|
||||
check();
|
||||
}
|
||||
return h("div", { class: "aml" }, row, problems);
|
||||
return h("div", { class: "aml" }, row, problems, rangeHints(field));
|
||||
}
|
||||
@@ -30,7 +30,7 @@ async function aclDialog(a) {
|
||||
const comment = input({ value: a ? a.comment : "" });
|
||||
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("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), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge
|
||||
import { get, change, describeResult, draftQuery, can, api } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { pendingBadge } from "./zones.js";
|
||||
import { rangeHints } from "../aml.js";
|
||||
|
||||
// Well-known lists in Adblock format (they say which entries cover
|
||||
// subdomains). Sizes change; these are rough guides.
|
||||
@@ -355,7 +356,7 @@ async function del(path, what) {
|
||||
function devicesPanel(groups, steps, views, autoViews, writable) {
|
||||
const live = steps.filter((s) => s.pending !== "delete");
|
||||
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
|
||||
const members = input({ placeholder: "10.10.0.50 10.10.0.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 vp = viewPicker(views, autoViews);
|
||||
const err = h("div");
|
||||
@@ -368,7 +369,7 @@ function devicesPanel(groups, steps, views, autoViews, writable) {
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
} },
|
||||
h("div", { class: "f" }, h("label", null, "Group"), name),
|
||||
h("div", { class: "f grow" }, h("label", null, "Devices"), members),
|
||||
h("div", { class: "f grow" }, h("label", null, "Devices"), members, rangeHints(members)),
|
||||
h("div", { class: "f" }, h("label", null, "Step"), step),
|
||||
vp.el,
|
||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
|
||||
@@ -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 : "" }));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
@@ -463,7 +465,7 @@ function editGroup(g, steps, views, autoViews) {
|
||||
const vp = viewPicker(views, autoViews, ownViews(g));
|
||||
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
|
||||
["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],
|
||||
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
|
||||
["Comment", comment],
|
||||
|
||||
@@ -44,18 +44,33 @@ async function draftPanel(d) {
|
||||
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 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([
|
||||
{ label: "#", render: (o) => String(o.seq) },
|
||||
{ label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" },
|
||||
{ label: "Added", render: (o) => time(o.added_at) },
|
||||
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: async () => {
|
||||
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" },
|
||||
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: () => removeOp(o.seq) }, "Remove"), tdClass: "actions-cell" },
|
||||
], 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 () => {
|
||||
try {
|
||||
const p = await get("/changesets/" + d.id + "/preview");
|
||||
@@ -64,7 +79,7 @@ async function draftPanel(d) {
|
||||
const drift = (p.drift || []);
|
||||
forceBox.classList.toggle("hidden", drift.length === 0);
|
||||
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,
|
||||
!p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null,
|
||||
problemsList(p.problems),
|
||||
|
||||
@@ -25,7 +25,7 @@ function valueWidget(stmt, value, ctx) {
|
||||
if (kind === "address_match_list") {
|
||||
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" });
|
||||
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") {
|
||||
const t = codeArea(value || "");
|
||||
|
||||
@@ -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 body = h("div", null, h("div", { class: "form-grid" },
|
||||
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("Recursive only", mro, "If yes, only recursive queries match this view."),
|
||||
field("Comment", comment),
|
||||
|
||||
@@ -154,6 +154,8 @@ td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
|
||||
tr.editing td { background: var(--accent-soft); }
|
||||
tr.pending-add td { background: var(--add); }
|
||||
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.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
|
||||
/* 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:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||
.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. */
|
||||
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
|
||||
|
||||
+79
-1
@@ -13,7 +13,7 @@ set -u
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
WORK=${WORK:-$(mktemp -d /tmp/jarl-e2e.XXXXXX)}
|
||||
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
|
||||
B=$JARL_BIND_ROOT
|
||||
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"]
|
||||
backup:
|
||||
dir: $E/backups
|
||||
metrics:
|
||||
enabled: true
|
||||
listen: ["127.0.0.1:$METRICS"]
|
||||
token: e2e-metrics-token-0123456789
|
||||
EOF
|
||||
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; }
|
||||
@@ -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
|
||||
REV=$($J changeset revert $CS -q)
|
||||
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
|
||||
run "history" 0 $J history
|
||||
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 "option unset" 0 $J option unset allow-transfer --zone adbraun.test
|
||||
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
|
||||
contains "secret:"
|
||||
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
|
||||
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
|
||||
# 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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
contains "Drift:.*none"
|
||||
|
||||
|
||||
Reference in new issue
Block a user