From bdd7acfd3b36add0816e9fbc64cd82061f25bafd Mon Sep 17 00:00:00 2001 From: adam Date: Mon, 12 Oct 2026 10:38:56 +0100 Subject: [PATCH] Internet exposure checks and response rate limiting Jarl works out which views are reachable from the internet (match-clients ending in a catch-all, not private-looking addresses: an internal view of a global IPv6 prefix is still yours; a server without views: open allow-query and a non-private listen address), with a per-view override (schema 9), and checks public ones: rate limiting, open resolver, minimal-any, cache snooping, open NOTIFY and transfers, version and host name, management interfaces. Fixes are staged like any change, including rate limiting from ISC's example in log-only mode with the secondaries exempt, then enforcing. named logs what rate limiting limits (or would, in log-only mode, where its counters don't move) as query-errors; Jarl routes those lines to its log and counts them (rrl breakdown, ratelimited top lists, Prometheus jarl_ratelimit_logged_total), shown on the new Exposure page. A change that newly opens a public view up must be confirmed (accept_risk, --accept-risk, a checkbox on the Changes page; 409 confirm_risk otherwise). Findings can be acknowledged with a reason and stay visible as info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure, docs/exposure.md. Also fixes query statistics skipping TSIG-signed queries ("IP#port/key NAME" in named's log). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 ++ README.md | 9 + cmd/jarl/changes.go | 4 +- cmd/jarl/config_cmds.go | 12 +- cmd/jarl/exposure.go | 191 +++++++++ cmd/jarl/main.go | 6 +- cmd/jarl/multiview.go | 2 +- cmd/jarl/stats.go | 8 +- cmd/jarld/main.go | 2 + docs/api.md | 1 + docs/cli.md | 15 + docs/exposure.md | 171 ++++++++ docs/metrics.md | 3 +- docs/stats.md | 4 +- docs/validation.md | 15 + docs/views.md | 5 + internal/api/types.go | 3 + internal/client/client.go | 6 + internal/engine/diffops.go | 7 +- internal/engine/engine.go | 91 +++- internal/engine/errors.go | 1 + internal/engine/expand.go | 63 +++ internal/engine/exposure.go | 163 ++++++++ internal/engine/exposure_test.go | 142 +++++++ internal/engine/inputs.go | 19 + internal/engine/ops.go | 42 ++ internal/model/model.go | 42 +- internal/render/render.go | 10 +- internal/render/stats.go | 18 +- internal/server/exposure_test.go | 68 +++ internal/server/h_changes.go | 13 +- internal/server/h_exposure.go | 146 +++++++ internal/server/metrics.go | 1 + internal/server/respond.go | 2 +- internal/server/routes.go | 6 +- internal/stats/collector.go | 33 +- internal/stats/parse.go | 44 +- internal/stats/parse_test.go | 42 ++ internal/stats/query.go | 2 +- internal/store/state.go | 20 +- internal/store/store.go | 6 + internal/validate/config.go | 10 +- internal/validate/exposure.go | 387 ++++++++++++++++++ internal/validate/validate.go | 10 + internal/web/static/assets/app.js | 3 + internal/web/static/assets/pages/changes.js | 12 +- internal/web/static/assets/pages/dashboard.js | 13 +- internal/web/static/assets/pages/exposure.js | 120 ++++++ internal/web/static/assets/style.css | 12 + scripts/e2e.sh | 39 ++ 50 files changed, 2019 insertions(+), 52 deletions(-) create mode 100644 cmd/jarl/exposure.go create mode 100644 docs/exposure.md create mode 100644 internal/engine/exposure.go create mode 100644 internal/engine/exposure_test.go create mode 100644 internal/server/exposure_test.go create mode 100644 internal/server/h_exposure.go create mode 100644 internal/validate/exposure.go create mode 100644 internal/web/static/assets/pages/exposure.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 05cd2e2..00a4677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,21 @@ All notable changes to Jarl are documented here. The format follows ### Upgrading from 0.4 +- New exposure warnings (docs/exposure.md) appear for views reachable from + the internet, e.g. "no rate-limit" or "version visible" on an external + view: fix them from the Exposure page, or acknowledge them. A change + that newly opens a public view up (an open resolver, say) now needs + `--accept-risk`, or the checkbox on the Changes page. + - Once you use address ranges, the database can't go back to 0.4: its parser doesn't know them. Remove the ranges first if you need to downgrade. ### Fixed +- Query statistics skipped every query signed with a TSIG key (named + writes "/key NAME" after the client's port), such as queries with a + view's key: they are counted now. - A changeset with a staged record delete could not be applied after that record was deleted elsewhere (e.g. with the CLI), and it was hard to see which operation to remove: the error only said "record 832 does not @@ -25,6 +34,24 @@ All notable changes to Jarl are documented here. The format follows ### Added +- Internet exposure ([docs/exposure.md](docs/exposure.md)): Jarl works out + which views are reachable from the internet (match-clients ending in a + catch-all; for a server without views, open allow-query and a + non-private listen address), with a per-view override, and checks them: + response rate limiting, open resolvers, ANY answers (minimal-any), cache + snooping, open NOTIFY and zone transfers, BIND version and host name, + management interfaces. Fixes are staged like any change, including + response rate limiting from ISC's example in log-only mode, with your + secondaries exempt, and a later "start enforcing". With statistics on, + Jarl reads named's rate limiting log lines and shows what was (or would + have been) limited: counts, client networks and names (Exposure page, + `jarl stats breakdown rrl`, top lists `ratelimited`, Prometheus + `jarl_ratelimit_logged_total`). A change that newly opens a public view + up must be confirmed (checkbox on the Changes page, `--accept-risk`, API + `accept_risk`; 409 `confirm_risk` otherwise). Findings can be + acknowledged with a reason: they stay visible as info, with who, when + and why. Exposure page and dashboard card in the web UI, `jarl + exposure` (fix, ack, unack), `GET /exposure`. - `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the changeset from `-C` or `JARL_CHANGESET` when no ID is given (`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now diff --git a/README.md b/README.md index aef62eb..5ead363 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,14 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A) queries, record types, cache hit rate. Shown in the web UI and the CLI, and exportable as JSON. Stored as aggregates in a separate database with retention you set. +* **Internet exposure checks**: Jarl works out which views are reachable + from the internet (a GUA-addressed internal network still counts as + yours) and checks them: response rate limiting (set up in log-only mode + first, with what it would limit shown before you enforce it), open + resolvers, ANY amplification, cache snooping, version and host name + leaks, open management interfaces. Fixes are staged for review, changes + that open a public view up must be confirmed, and deliberate exceptions + can be acknowledged with a reason. * **Prometheus metrics** (optional): health to alert on (named down, configuration problems, drift, failing blocklists, stale backups, DNSSEC steps waiting at the parent, expiring tokens) and query counters per @@ -125,6 +133,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/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting | | [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts | | [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory | | [docs/validation.md](docs/validation.md) | Every check Jarl makes | diff --git a/cmd/jarl/changes.go b/cmd/jarl/changes.go index 52dc8b5..c94d9d1 100644 --- a/cmd/jarl/changes.go +++ b/cmd/jarl/changes.go @@ -130,7 +130,7 @@ func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) } return report(&resp) } - if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil { + if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force, AcceptRisk: c.AcceptRisk}, &resp); err != nil { return err } return report(&resp) @@ -349,7 +349,7 @@ func changesetCmd() *cobra.Command { return err } var resp api.MutationResponse - if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil { + if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp); err != nil { return err } if err := report(&resp); err != nil { diff --git a/cmd/jarl/config_cmds.go b/cmd/jarl/config_cmds.go index be26387..40f3626 100644 --- a/cmd/jarl/config_cmds.go +++ b/cmd/jarl/config_cmds.go @@ -36,7 +36,7 @@ func viewCmd() *cobra.Command { }, } var clients, dests []string - var recursiveOnly, comment string + var recursiveOnly, comment, exposure string var adopt, cascade, yes bool viewBody := func(cmd *cobra.Command) map[string]any { body := map[string]any{} @@ -60,6 +60,9 @@ func viewCmd() *cobra.Command { if f.Changed("comment") { body["comment"] = comment } + if f.Changed("exposure") { + body["exposure"] = exposure + } return body } addViewFlags := func(c *cobra.Command) { @@ -67,6 +70,7 @@ func viewCmd() *cobra.Command { c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements") c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default") c.Flags().StringVar(&comment, "comment", "", "comment") + c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)") } create := &cobra.Command{ Use: "create NAME", @@ -756,7 +760,7 @@ func settingsCmd() *cobra.Command { }, } var ns []string - var contact, policy string + var contact, policy, srvExposure string var ttl, refresh, retry, expire, neg uint32 set := &cobra.Command{ Use: "set", @@ -777,6 +781,9 @@ func settingsCmd() *cobra.Command { if f.Changed("serial-policy") { body["serial_policy"] = policy } + if f.Changed("exposure") { + body["exposure"] = srvExposure + } for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} { if f.Changed(name) { key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name] @@ -799,6 +806,7 @@ func settingsCmd() *cobra.Command { set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers") set.Flags().StringVar(&contact, "contact", "", "default SOA contact") set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime") + set.Flags().StringVar(&srvExposure, "exposure", "", "without views: is the server reachable from the internet? auto, public or private (docs/exposure.md)") set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL") set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh") set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry") diff --git a/cmd/jarl/exposure.go b/cmd/jarl/exposure.go new file mode 100644 index 0000000..d0588de --- /dev/null +++ b/cmd/jarl/exposure.go @@ -0,0 +1,191 @@ +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" +) + +type exposureFinding struct { + Severity string `json:"severity"` + Code string `json:"code"` + Message string `json:"message"` + View string `json:"view"` + Fix string `json:"fix"` +} + +type exposureReport struct { + Scopes []struct { + ViewID int64 `json:"view_id"` + View string `json:"view"` + Public bool `json:"public"` + Override string `json:"override"` + Reason string `json:"reason"` + RateLimit string `json:"rate_limit"` + Findings []exposureFinding `json:"findings"` + } `json:"scopes"` + Server []exposureFinding `json:"server"` + RateLimiting *struct { + Since string `json:"since"` + Counts []struct { + Key string + Count int64 + } `json:"counts"` + Networks []struct { + Key string + Count int64 + } `json:"networks"` + Names []struct { + Key string + Count int64 + } `json:"names"` + } `json:"rate_limiting"` +} + +// exposureScope turns "server" (or "") into the server, anything else +// into a view name. +func exposureScope(s string) map[string]any { + if s == "" || strings.EqualFold(s, "server") { + return map[string]any{} + } + return map[string]any{"view": s} +} + +func exposureCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "exposure", + Short: "What is reachable from the internet, and what to harden (docs/exposure.md)", + Long: `Shows which views (or the server, without views) are reachable from the +internet and why, their rate limiting, and the exposure findings with the +fix Jarl can stage for each.`, + Example: ` jarl exposure + jarl exposure fix external exposure_rate_limit # rate limiting, log-only first + jarl exposure fix external exposure_rate_limit_log_only # then start enforcing + jarl exposure fix server exposure_version + jarl exposure ack external security_open_resolver --reason "public resolver for the club" + jarl view update external --exposure public # override the classification`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + var r exposureReport + if err := c.Get(ctx(), "/exposure", nil, &r); err != nil { + return err + } + return output(r, func(w io.Writer) { + fmt.Fprintln(w, "VIEW\tEXPOSURE\tRATE LIMIT\tWHY") + for _, s := range r.Scopes { + name, exp := s.View, "private" + if name == "" { + name = "(server)" + } + if s.Public { + exp = "public" + } + if s.Override != "" { + exp += " (set)" + } + rl := s.RateLimit + if !s.Public && rl == "off" { + rl = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, exp, rl, s.Reason) + } + var all []exposureFinding + for _, s := range r.Scopes { + all = append(all, s.Findings...) + } + all = append(all, r.Server...) + fmt.Fprintln(w) + if len(all) == 0 { + fmt.Fprintln(w, "No findings.") + } else { + fmt.Fprintln(w, "SEVERITY\tWHERE\tCODE\tFINDING") + for _, f := range all { + where := f.View + if where == "" { + where = "server" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Severity, where, f.Code, f.Message) + if f.Fix != "" { + fmt.Fprintf(w, "\t\t\tfix: jarl exposure fix %s %s (%s)\n", where, f.Code, f.Fix) + } + } + } + if a := r.RateLimiting; a != nil && len(a.Counts) > 0 { + fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since) + for _, e := range a.Counts { + fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count) + } + for _, e := range a.Networks { + fmt.Fprintf(w, "network %s\t%d\n", e.Key, e.Count) + } + for _, e := range a.Names { + fmt.Fprintf(w, "name %s\t%d\n", e.Key, e.Count) + } + } + }) + }, + } + fix := &cobra.Command{ + Use: "fix VIEW|server CODE", + Short: "Stage (or apply) the fix Jarl suggests for a finding", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := exposureScope(args[0]) + body["code"] = args[1] + resp, err := c.Mutate(ctx(), "POST", "/exposure/fix", nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + var reason string + ack := &cobra.Command{ + Use: "ack VIEW|server CODE --reason TEXT", + Short: "Accept a finding on purpose: it stays, as info, with who and why", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := exposureScope(args[0]) + body["code"], body["reason"] = args[1], reason + resp, err := c.Mutate(ctx(), "POST", "/exposure/ack", nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + ack.Flags().StringVar(&reason, "reason", "", "why the finding is accepted (required)") + unack := &cobra.Command{ + Use: "unack VIEW|server CODE", + Short: "Take an acknowledgement back: the finding warns again", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + body := exposureScope(args[0]) + body["code"] = args[1] + resp, err := c.Mutate(ctx(), "POST", "/exposure/unack", nil, body) + if err != nil { + return err + } + return report(resp) + }, + } + cmd.AddCommand(fix, ack, unack) + return cmd +} diff --git a/cmd/jarl/main.go b/cmd/jarl/main.go index ce9de81..3dc7c1b 100644 --- a/cmd/jarl/main.go +++ b/cmd/jarl/main.go @@ -47,6 +47,7 @@ var ( flagChangeset int64 flagDryRun bool flagForce bool + flagRisk bool flagOutput string flagInsecure bool flagCACert string @@ -162,7 +163,7 @@ func newClient() (*client.Client, error) { } } } - c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce + c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk if !c.Preflight { c.OnTokenExpiry = tokenExpiryWarning(t.Name) } @@ -244,6 +245,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found, pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)") pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them") pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl") + pf.BoolVar(&flagRisk, "accept-risk", false, "confirm a change that opens a public view up, such as an open resolver (docs/exposure.md)") pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)") pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate") pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate") @@ -257,7 +259,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found, } add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd()) add("changes", applyCmd(), changesetCmd(), historyCmd()) - add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd()) + add("admin", statusCmd(), exposureCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd()) setupCompletion(root) return root } diff --git a/cmd/jarl/multiview.go b/cmd/jarl/multiview.go index 41c38a1..3b40792 100644 --- a/cmd/jarl/multiview.go +++ b/cmd/jarl/multiview.go @@ -90,7 +90,7 @@ func perView(c *client.Client, views []string, description string, do func(c *cl return nil } var resp api.MutationResponse - err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp) + err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp) if err != nil || c.DryRun { discard() } diff --git a/cmd/jarl/stats.go b/cmd/jarl/stats.go index eb74a1b..3f2a5de 100644 --- a/cmd/jarl/stats.go +++ b/cmd/jarl/stats.go @@ -128,14 +128,15 @@ export the data.`, var limit int top := &cobra.Command{ - Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]", + Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients|ratelimited|ratelimited-names]", Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names", Example: ` jarl stats top names --since 7d jarl stats top clients --limit 50 jarl stats top nxdomain # misconfigured hosts and typos jarl stats top refused # who is asking for what they may not have jarl stats top blocked # what blocking stops most (docs/blocking.md) - jarl stats top blocked-clients # the devices blocking stops most`, + jarl stats top blocked-clients # the devices blocking stops most + jarl stats top ratelimited # client networks rate limiting limits, or would (docs/exposure.md)`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { kind := stats.KindName @@ -147,6 +148,9 @@ export the data.`, if kind == "blocked-client" { kind = stats.KindBlockedClient } + if kind == "ratelimited-name" { + kind = stats.KindRateLimitedName + } } c, err := newClient() if err != nil { diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index 30e5100..af16561 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -27,6 +27,7 @@ import ( "jarl/internal/config" "jarl/internal/engine" "jarl/internal/model" + "jarl/internal/nscheck" "jarl/internal/porkbun" "jarl/internal/render" "jarl/internal/server" @@ -122,6 +123,7 @@ func openApp(allowMissingConfig bool) (*app, error) { ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout), NoReload: cfg.BIND.NoReload, Extras: renderExtras(cfg), + LocalAddrs: nscheck.LocalAddrs, }, log) eng.Limits = engine.BlocklistLimits{ Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute, diff --git a/docs/api.md b/docs/api.md index 141d412..af03fd9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -471,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 | `/exposure` | which views are reachable from the internet, the exposure findings and their fixes ([exposure.md](exposure.md#api)); `POST /exposure/fix`, `/exposure/ack`, `/exposure/unack` | | GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out | | GET | `/search?q=text` | zones, records and ACLs matching the text | diff --git a/docs/cli.md b/docs/cli.md index c5d025f..692c83a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -143,6 +143,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents. | `-n, --dry-run` | Validate and show the diff without changing anything. | | `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. | | `--force` | Overwrite BIND files that were changed outside Jarl (drift). | +| `--accept-risk` | Confirm a change that opens a public view up, such as an open resolver ([exposure.md](exposure.md)). Without it such a change stops with exit 5. | | `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). | ## Exit codes @@ -382,6 +383,20 @@ an administrator). Changesets staged in the web UI show up here too, and the other way round. +## Internet exposure + +```sh +jarl exposure # public views, findings, fixes, rate limiting +jarl exposure fix external exposure_rate_limit # rate limiting, log-only first +jarl exposure fix external exposure_rate_limit_log_only # then start enforcing +jarl exposure fix server exposure_version +jarl exposure ack external security_open_resolver --reason "public resolver, monitored" +jarl exposure unack external security_open_resolver +jarl view update external --exposure public|private|auto +``` + +See [exposure.md](exposure.md). + ## Views, ACLs, TSIG keys, options, settings ```sh diff --git a/docs/exposure.md b/docs/exposure.md new file mode 100644 index 0000000..73351d7 --- /dev/null +++ b/docs/exposure.md @@ -0,0 +1,171 @@ +# Internet exposure + +When a view (or the whole server) can be reached from the internet, a few +things matter that don't on your own network: it shouldn't help attackers +flood others with DNS answers (amplification), recurse for strangers, or +tell scanners its exact version. Jarl works out which views are public, +checks them, and stages the fixes for you to review. + +In the web UI: **Exposure** (and its card on the dashboard). In the CLI: +`jarl exposure`. In the API: `GET /api/v1/exposure`. + +## Which views are public + +A view is **public** when its `match-clients` let in anyone: after ACLs +and ranges are filled in, they end in a catch-all (`any`, `0.0.0.0/0`, +`::/0`, or a prefix wider than /8 for IPv4 or /16 for IPv6), or they are +empty, which named reads as "any". A typical external view +(`match-clients { any; }` or `{ !internal; any; }`) is public. + +A view that lists the networks it serves is **private**, whatever kind of +addresses those are. An internal view for your global IPv6 prefix +(`2a02:8013:7f3d:10::/64`) is as private as one for `192.168.1.0/24`: +listing a network says "these are mine". Jarl doesn't look at whether +addresses are private (RFC 1918, ULA) for this, because on IPv6 your own +devices have global addresses. + +A view whose `match-destinations` list only private addresses (it answers +only on a private interface) is private too. + +**A server without views** has no match list, so Jarl looks at +`allow-query` and where named listens: it is public when anyone may query +it (`allow-query` unset, or with a catch-all) **and** named listens on an +address that isn't private (a public IPv4 address or a global IPv6 +address of this host). This can't see your firewall, so it is a guess. + +**When Jarl gets it wrong**, override it per view: `public`, `private` or +`auto`. For example, behind a NAT port forward named only ever sees +private addresses although it is reachable from the internet. + +```sh +jarl view update external --exposure public +jarl settings set --exposure private # a server without views +``` + +The Exposure page shows each view's classification and the reason +("match-clients ends in any", "match-clients lists only homelab, no +catch-all"), with a selector for the override. + +## The checks + +They run on public views only, and are warnings (they don't stop you +applying) unless acknowledged. + +| Code | What | Fix Jarl can stage | +|------|------|-------------------| +| `exposure_rate_limit` | No response rate limiting: the view can be used to flood others with its answers (DNS amplification) | `rate-limit { responses-per-second 10; log-only yes; exempt-clients { your secondaries }; }` | +| `exposure_rate_limit_log_only` (info) | Rate limiting only logs | The same block without `log-only`: start enforcing | +| `security_open_resolver` | Recursion for anyone: an open resolver | `recursion no` in that view | +| `exposure_minimal_any` | ANY queries get full answers, a favourite for amplification | `minimal-any yes` | +| `exposure_cache_snooping` | `allow-query-cache` lets anyone see what your clients look up | `allow-query-cache { none; }` | +| `security_open_transfer` | Zone transfers for anyone | none: allow your secondaries or a TSIG key | +| `exposure_open_notify` | `allow-notify` accepts NOTIFY from anyone | none: allow your primaries | +| `exposure_version` | `dig CH TXT version.bind` shows the exact BIND version | `version none` | +| `exposure_hostname` | `dig CH TXT hostname.bind` shows the host name | `hostname none` | +| `exposure_management` | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone | none: use 127.0.0.1 or an allow list | + +The last three are server-wide: they are checked when any view is public. + +A **fix** goes into your pending changes like any edit (or is applied at +once in live mode), so you see the named.conf diff before it reaches named: + +```sh +jarl exposure # classification, findings, fixes +jarl exposure fix external exposure_rate_limit +jarl exposure fix server exposure_version +``` + +## Response rate limiting + +Response rate limiting (RRL, BIND's `rate-limit`) counts identical +responses sent to the same client network. Past the limit, named drops +some and "slips" others: it sends a short reply asking the client to retry +over TCP (or with a DNS cookie). A real client does; the spoofed victim of +an amplification attack never sees the answers it didn't ask for. ISC +recommends it for authoritative servers only, so Jarl only suggests it for +public views, never for your internal resolver. + +Jarl starts from ISC's example, 10 identical responses per second per +client network, and exempts the servers your zones notify and allow +transfers to (`also-notify` and `allow-transfer` addresses, through ACLs +too): limiting your own secondaries is the classic RRL mistake. + +**Rollout in two steps:** + +1. The fix turns rate limiting on with `log-only yes`. named answers + everyone as before, but logs every response it *would* have limited. + With [query statistics](stats.md) on, Jarl reads those lines: the + Exposure page shows how many responses would have been limited, and + which client networks and names. Check that your secondaries, + monitoring, or a big shared resolver aren't among them (add them to + `exempt-clients` if they are). +2. When you're happy, **Start enforcing** (the fix of + `exposure_rate_limit_log_only`) removes `log-only`. + +In log-only mode named's own counters don't move: what would be limited +is only in its log (category `query-errors`). That is why the activity +needs query statistics; without them, look at named's log for +`would rate limit`. When rate limiting is configured and statistics are +on, Jarl adds `query-errors` to its log channel (keeping named's syslog +destination for it). + +The statistics also count it: the `rrl` breakdown (`drop`, `slip`, +`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top +lists, and in Prometheus `jarl_ratelimit_logged_total{action}` next to +named's own `jarl_ratelimit_responses_total` ([metrics.md](metrics.md)). + +You can tune the block like any option (`jarl option set rate-limit '{ +responses-per-second 20; window 15; exempt-clients { 192.0.2.53; }; }' +--view external`); the BIND ARM describes every setting. + +## Confirming a change that opens a view up + +A change that *introduces* an open resolver, open zone transfers, cache +snooping, open NOTIFY or an open management interface on a public view +must be confirmed: + +- **Web UI:** the Changes page shows the finding with a checkbox (*I + understand: …*); **Apply** stays disabled until it is ticked. +- **CLI:** the command stops (exit 5) and says why; run it again with + `--accept-risk`. +- **API:** `accept_risk: true` in the apply body, or `?accept_risk=1` on a + change applied at once; without it the answer is 409 `confirm_risk`. + Previews (`GET /changesets/{id}/preview`) list them as `risks`. + +Only changes that newly open something up ask: an unrelated change to a +view that is already open, or taking back an acknowledgement, doesn't. + +## Acknowledging a finding + +Some findings are deliberate: a public resolver for a club's network, a +visible version string. Acknowledge them with a reason: + +```sh +jarl exposure ack external security_open_resolver --reason "public resolver for the club, monitored" +jarl exposure ack server exposure_version --reason "version shown on purpose" +jarl exposure unack external security_open_resolver +``` + +The finding doesn't disappear: it stays visible as **info**, with who +accepted it, when and why (*open resolver … (acknowledged by adam on 12 +Oct 2026: public resolver for the club, monitored)*), on the Exposure page, +the dashboard and in `jarl check`. An acknowledgement covers that one +finding on that one view (or the server); the same finding elsewhere +warns as usual. Acknowledging is a configuration change like any other, +so it is in the history and audit log, and can be reverted. + +Jarl isn't designed to run an open resolver, and an acknowledgement makes +one deliberate and visible, not safe: a public resolver also needs abuse +monitoring, response size limits and usually a machine of its own. + +## API + +| Method | Path | Body | +|--------|------|------| +| GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`) | +| POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change | +| POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` | +| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` | + +The override is a view's `exposure` field (`PATCH /views/{id}`), or the +settings' for a server without views (`PATCH /settings`). diff --git a/docs/metrics.md b/docs/metrics.md index f031a2c..b954317 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -148,7 +148,8 @@ for them. | `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_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped` (named's counters; they don't move in log-only mode). | +| `jarl_ratelimit_logged_total{action}` | Rate limited responses from named's log: `drop`, `slip`, and in log-only mode `would-drop`, `would-slip` ([exposure.md](exposure.md#response-rate-limiting)). | | `jarl_upstream_responses_by_rtt_total{rtt}` | Upstream answers during recursion by round-trip time (`<10ms` … `>1600ms`). | | `jarl_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. | | `jarl_cache_lookups_total{result}` | `hits` and `misses`. | diff --git a/docs/stats.md b/docs/stats.md index 2057ca6..9c6595e 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -103,6 +103,7 @@ lists, views, zones). named's own counters can't be filtered. | `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones | | `responses` | response log | lines read (a health check) | | `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) | +| `rrl:drop`, `rrl:slip`, `rrl:would-drop`, `rrl:would-slip` | response log | responses [rate limiting](exposure.md#response-rate-limiting) limited, or would have in log-only mode (named's own `ratelimit:*` counters don't move then) | **Server** (sampled every minute; stored as gauges, `g:KEY` the sum of the samples and `gn:KEY` their number, so a chart shows the average): @@ -167,7 +168,8 @@ jarl stats top refused # who asks for what they may not have jarl stats timeline --since 6h # sparkline; --table for every bucket jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL jarl stats heatmap --since 28d -jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit +jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit | rrl +jarl stats top ratelimited # client networks rate limiting limits (or would) jarl stats server # memory, CPU, cache, clients, host, disks now jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week jarl stats summary --since 30d -o json # export (also -o yaml) diff --git a/docs/validation.md b/docs/validation.md index 04401b2..5061813 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -124,11 +124,26 @@ From [blocking.md](blocking.md). ## Security lints +These and the exposure checks below only look at views that are +reachable from the internet ([exposure.md](exposure.md)); without views, +the open resolver and open transfer checks look at the server. Any of them +can be acknowledged (it then shows as info, with who accepted it and why), +and a change that newly introduces an open resolver, open transfers, cache +snooping, open NOTIFY or an open management interface on a public view +must be confirmed (`--accept-risk`). + | Code | Severity | What it catches | |------|----------|-----------------| | `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks | | `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key | | `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead | +| `exposure_rate_limit` | warning | A public view without response rate limiting (`rate-limit`) | +| `exposure_rate_limit_log_only` | info | Rate limiting in log-only mode: check what it would limit, then enforce | +| `exposure_minimal_any` | warning | A public view answering ANY queries in full (`minimal-any` off) | +| `exposure_cache_snooping` | warning | A public view without recursion whose cache anyone may query (`allow-query-cache`) | +| `exposure_open_notify` | warning | A public view accepting NOTIFY from anyone (`allow-notify`) | +| `exposure_version`, `exposure_hostname` | warning | The server is public and shows its BIND version or host name to anyone (`version none`, `hostname none`) | +| `exposure_management` | warning | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone, while the server is public | ## What only BIND checks diff --git a/docs/views.md b/docs/views.md index c086482..798ab03 100644 --- a/docs/views.md +++ b/docs/views.md @@ -81,6 +81,11 @@ avoids named-checkconf's warning that `allow-recursion` is still active in a view with recursion off. Jarl warns (`security_open_resolver`) if a view ever offers recursion to everyone. +Jarl counts the external view as reachable from the internet (its +match-clients end in `any`) and checks it: rate limiting, ANY queries, +the version string and more. See the **Exposure** page or `jarl exposure`, +and [exposure.md](exposure.md) to set up response rate limiting. + ### 4. Give the external view its zones **Different data per view (split DNS).** Copy the zone, then change what diff --git a/internal/api/types.go b/internal/api/types.go index da245d0..6cec821 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -208,6 +208,9 @@ type BatchRequest struct { Ops []ChangeRequest `json:"ops"` DryRun bool `json:"dry_run,omitempty"` Force bool `json:"force,omitempty"` + // AcceptRisk confirms a change that opens the server up + // (docs/exposure.md). + AcceptRisk bool `json:"accept_risk,omitempty"` // Apply (for POST /changesets) applies the new changeset at once. Apply bool `json:"apply,omitempty"` } diff --git a/internal/client/client.go b/internal/client/client.go index dd34341..fc840b8 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -38,6 +38,9 @@ type Client struct { csErr error DryRun bool Force bool + // AcceptRisk confirms changes that open the server up + // (docs/exposure.md). + AcceptRisk bool // Preflight lets only requests through that can't change anything: // reads and dry runs. Running a command on several servers uses it to // check the command everywhere before running it anywhere. @@ -282,6 +285,9 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu if c.Force { q.Set("force", "1") } + if c.AcceptRisk { + q.Set("accept_risk", "1") + } var out api.MutationResponse err = c.Do(ctx, method, path, q, body, &out) return &out, err diff --git a/internal/engine/diffops.go b/internal/engine/diffops.go index 1366e36..86cac1c 100644 --- a/internal/engine/diffops.go +++ b/internal/engine/diffops.go @@ -3,6 +3,7 @@ package engine import ( "fmt" "reflect" + "slices" "sort" "jarl/internal/dnsname" @@ -23,7 +24,8 @@ func DiffOps(from, to *model.State) []model.Op { if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) { s := to.Settings add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName, - Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings") + Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy, + Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings") } for _, k := range sortedKeys(to.Keys) { @@ -47,7 +49,8 @@ func DiffOps(from, to *model.State) []model.Op { for _, k := range sortedKeys(to.Views) { n := to.Views[k] in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)), - MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)} + MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position), + Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))} if n.MatchRecursiveOnly != nil { in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly) } else { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 1a20767..4718b88 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -15,6 +15,7 @@ import ( "io/fs" "log/slog" "net/http" + "net/netip" "os" "path/filepath" "slices" @@ -56,6 +57,10 @@ type Config struct { NoReload bool // SkipCheck disables named-checkconf (only for tests without BIND). SkipCheck bool + // LocalAddrs returns this host's addresses, for working out whether a + // server without views is reachable from the internet + // (docs/exposure.md). nil: none known. + LocalAddrs func() []netip.Addr } // Engine applies changesets. @@ -110,17 +115,20 @@ type Result struct { DryRun bool `json:"dry_run,omitempty"` Valid bool `json:"valid"` Problems []validate.Problem `json:"problems"` // problems this change introduces - Existing []validate.Problem `json:"existing_problems,omitempty"` - Check []CheckMessage `json:"check,omitempty"` - CheckOK bool `json:"check_ok"` - Diff string `json:"diff"` - Files []string `json:"files_changed"` - Serials []SerialChange `json:"serials,omitempty"` - Reloaded []string `json:"reloaded,omitempty"` - Warnings []string `json:"warnings,omitempty"` - Drift []DriftEntry `json:"drift,omitempty"` - Ops []model.Op `json:"ops,omitempty"` - Error *Error `json:"error,omitempty"` + // Risks are the introduced problems that open the server up; applying + // needs AcceptRisk (docs/exposure.md). + Risks []validate.Problem `json:"risks,omitempty"` + Existing []validate.Problem `json:"existing_problems,omitempty"` + Check []CheckMessage `json:"check,omitempty"` + CheckOK bool `json:"check_ok"` + Diff string `json:"diff"` + Files []string `json:"files_changed"` + Serials []SerialChange `json:"serials,omitempty"` + Reloaded []string `json:"reloaded,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Drift []DriftEntry `json:"drift,omitempty"` + Ops []model.Op `json:"ops,omitempty"` + Error *Error `json:"error,omitempty"` } // stored is what is kept in a changeset's result column. @@ -138,6 +146,9 @@ type ApplyOptions struct { Force bool // NoReload writes the files without reloading named. NoReload bool + // AcceptRisk confirms a change that opens the server up, such as an + // open resolver on a public view (validate.RiskCodes). + AcceptRisk bool // wipeZone lets WipeDNSSEC switch signing off for this zone at once. wipeZone int64 } @@ -180,7 +191,46 @@ func ValidateOptions(st *model.State) validate.Options { } func (e *Engine) validate(st *model.State) []validate.Problem { - return validate.State(st, ValidateOptions(st)) + return validate.State(st, e.validateOptions(st)) +} + +func (e *Engine) validateOptions(st *model.State) validate.Options { + opts := ValidateOptions(st) + if e.cfg.LocalAddrs != nil { + opts.LocalAddrs = e.cfg.LocalAddrs() + } + return opts +} + +// Validate checks a state as applying it would. +func (e *Engine) Validate(st *model.State) []validate.Problem { return e.validate(st) } + +// Exposures says which views (or the server, without views) are +// reachable from the internet, and why (docs/exposure.md). +func (e *Engine) Exposures(st *model.State) []validate.Exposure { + return validate.Exposures(st, e.validateOptions(st)) +} + +// risks returns the introduced problems that open a public view (or a +// public server) up: risky warnings that weren't there before at all. One +// that was there already, acknowledged (taking the acknowledgement back) +// or not, opens nothing new. +func risks(before, introduced []validate.Problem, public map[string]bool) []validate.Problem { + type key struct { + code, objType, view string + id int64 + } + had := map[key]bool{} + for _, p := range before { + had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] = true + } + var out []validate.Problem + for _, p := range introduced { + if p.Severity == validate.Warning && validate.RiskCodes[p.Code] && public[p.View] && !had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] { + out = append(out, p) + } + } + return out } // ---- changeset management @@ -389,6 +439,8 @@ type plan struct { base, work *model.State introduced []validate.Problem existing []validate.Problem + before []validate.Problem // the problems before the change + public map[string]bool // public views by name ("": the server), after the change serials []SerialChange out *render.Output changed []*render.File @@ -419,6 +471,13 @@ func (pl *plan) files() []string { func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) { pl := &plan{base: base, work: work, verify: map[int64]uint32{}} before := e.validate(base) + pl.before = before + pl.public = map[string]bool{} + for _, x := range e.Exposures(work) { + pl.public[x.View] = x.Public + // Server-wide findings (no view) count when anything is public. + pl.public[""] = pl.public[""] || x.Public + } after := e.validate(work) pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...) introducedKeys := map[string]bool{} @@ -652,6 +711,7 @@ func (pl *plan) result() *Result { Files: pl.files(), Serials: pl.serials, Drift: pl.drift, + Risks: risks(pl.before, pl.introduced, pl.public), } r.Valid = r.Valid && r.CheckOK if r.Files == nil { @@ -781,6 +841,13 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque if opts.DryRun { return res, nil } + if rs := risks(pl.before, pl.introduced, pl.public); len(rs) > 0 && !opts.AcceptRisk { + msg := rs[0].Message + if len(rs) > 1 { + msg += fmt.Sprintf(" (and %d more)", len(rs)-1) + } + return res, &Error{Kind: KindRisk, Message: "this change opens the server up: " + msg + "; apply it again with accept_risk to confirm (jarl: --accept-risk), or acknowledge the finding for good", Problems: rs} + } if len(pl.drift) > 0 && !opts.Force { var paths []string for _, d := range pl.drift { diff --git a/internal/engine/errors.go b/internal/engine/errors.go index 3a16fbe..4932c44 100644 --- a/internal/engine/errors.go +++ b/internal/engine/errors.go @@ -21,6 +21,7 @@ const ( KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed KindDrift ErrorKind = "drift" // files were changed outside Jarl KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet + KindRisk ErrorKind = "confirm_risk" // the change opens the server up and needs confirming ) // Error is an engine error with details for the caller. diff --git a/internal/engine/expand.go b/internal/engine/expand.go index 8ca239c..9ddab8e 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -228,6 +228,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) { return []model.Op{x.versioned(req, o.Version, summary)}, nil case OpSettingsUpdate: return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil + case OpExposureAck, OpExposureUnack: + return x.exposureAck(req, req.Op == OpExposureAck) case OpBlocklistCreate: return x.blocklistCreate(req) case OpBlocklistUpdate, OpBlocklistDelete: @@ -1089,3 +1091,64 @@ func disabledZoneEffect(st *model.State, z *model.Zone) string { } return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where) } + +// exposureAck acknowledges an exposure finding of a view or the server, +// or takes the acknowledgement back: the view's (or the settings') +// acknowledgements are updated as a whole. +func (x *expander) exposureAck(req Request, ack bool) ([]model.Op, error) { + var in AckInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + in.Code = strings.TrimSpace(in.Code) + if !validate.Ackable(in.Code) { + return nil, errf(KindBadRequest, "%q is not an exposure finding that can be acknowledged (exposure_... or security_open_...)", in.Code) + } + if ack && strings.TrimSpace(in.Reason) == "" { + return nil, errf(KindBadRequest, "say why %s is accepted (a reason is required)", in.Code) + } + var v *model.View + switch { + case in.ViewID != 0: + v = x.st.Views[in.ViewID] + case in.View != "": + v = x.st.ViewByName(in.View) + } + if (in.ViewID != 0 || in.View != "") && v == nil { + return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID))) + } + acks := x.st.Settings.Acknowledged + where := "the server" + if v != nil { + acks, where = v.Acknowledged, "view "+v.Name + } + var out []model.Acknowledgement + for _, a := range acks { + if a.Code != in.Code { + out = append(out, a) + } + } + summary := "acknowledge " + in.Code + " for " + where + ": " + strings.TrimSpace(in.Reason) + if ack { + out = append(out, model.Acknowledgement{Code: in.Code, Reason: strings.TrimSpace(in.Reason), By: x.p.Name(), At: x.now}) + } else { + if len(out) == len(acks) { + return nil, errf(KindConflict, "%s isn't acknowledged for %s", in.Code, where) + } + summary = "take back the acknowledgement of " + in.Code + " for " + where + } + if out == nil { + out = []model.Acknowledgement{} + } + if v != nil { + return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{Acknowledged: &out}, summary)}, nil + } + return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{Acknowledged: &out}, summary)}, nil +} + +func firstNonEmptyStr(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/internal/engine/exposure.go b/internal/engine/exposure.go new file mode 100644 index 0000000..bb5e3c5 --- /dev/null +++ b/internal/engine/exposure.go @@ -0,0 +1,163 @@ +package engine + +import ( + "net/netip" + "slices" + "strings" + + "jarl/internal/model" + "jarl/internal/namedconf" +) + +// ExposureFix returns the requests that fix an exposure finding of a view +// (viewID 0: the server), and what they do, or nil when there is no +// automatic fix: what to allow instead (your secondaries, your hosts) +// is for you to decide. The requests go through a changeset like any +// edit (docs/exposure.md). +func ExposureFix(st *model.State, viewID int64, code string) ([]Request, string) { + v := st.Views[viewID] + scope, scopeID := model.ScopeOptions, int64(0) + if v != nil { + scope, scopeID = model.ScopeView, v.ID + } + set := func(name, value string) Request { + if o := st.FindOption(scope, scopeID, name); o != nil { + return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value}) + } + return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, ScopeID: &scopeID, Name: &name, Value: &value}) + } + server := func(name, value string) Request { + if o := st.FindOption(model.ScopeOptions, 0, name); o != nil { + return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value}) + } + return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value}) + } + switch code { + case "exposure_rate_limit": + value := RecommendedRateLimit(st, v) + return []Request{set("rate-limit", value)}, "start rate limiting in log-only mode: rate-limit " + value + case "exposure_rate_limit_log_only": + o := st.FindOption(scope, scopeID, "rate-limit") + if o == nil { + o = st.FindOption(model.ScopeOptions, 0, "rate-limit") + } + if o == nil { + return nil, "" + } + value := withoutLogOnly(o.Value) + return []Request{NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})}, "start enforcing the rate limit: rate-limit " + value + case "exposure_minimal_any": + return []Request{set("minimal-any", "yes")}, "answer ANY queries minimally: minimal-any yes" + case "exposure_cache_snooping": + return []Request{set("allow-query-cache", "{ none; }")}, "close the cache to everyone: allow-query-cache { none; }" + case "exposure_version": + return []Request{server("version", "none")}, "hide BIND's version: version none" + case "exposure_hostname": + return []Request{server("hostname", "none")}, "hide the host name: hostname none" + case "security_open_resolver": + if v == nil { + return nil, "" // a server without views is also your own resolver + } + return []Request{set("recursion", "no")}, "turn recursion off in view " + v.Name + ": recursion no" + } + return nil, "" +} + +// RecommendedRateLimit is the rate-limit block Jarl suggests for a public +// view (v nil: the server): ISC's example of 10 identical responses per +// second per client network, in log-only mode until you have looked at +// what it would limit, with your secondaries exempt so zone transfers +// and their queries are never limited. +func RecommendedRateLimit(st *model.State, v *model.View) string { + parts := []string{"responses-per-second 10;", "log-only yes;"} + if ex := secondaries(st, v); len(ex) > 0 { + parts = append(parts, "exempt-clients { "+strings.Join(ex, "; ")+"; };") + } + return "{ " + strings.Join(parts, " ") + " }" +} + +// secondaries returns the addresses the view's zones notify and allow +// transfers to (ACLs resolved), the servers that rate limiting must leave +// alone. +func secondaries(st *model.State, v *model.View) []string { + var opts []*model.Option + add := func(scope string, id int64) { + for _, name := range []string{"also-notify", "allow-transfer"} { + if o := st.FindOption(scope, id, name); o != nil { + opts = append(opts, o) + } + } + } + add(model.ScopeOptions, 0) + vid := int64(0) + if v != nil { + vid = v.ID + add(model.ScopeView, v.ID) + } + for _, z := range st.SortedZones() { + if z.ViewID == vid { + add(model.ScopeZone, z.ID) + } + } + seen := map[string]bool{} + var out []string + var addElems func(list []string, depth int) + addElems = func(list []string, depth int) { + for _, s := range list { + e, err := namedconf.ParseAMElement(s) + if err != nil || e.Negated { + continue + } + switch e.Kind { + case namedconf.AMPrefix, namedconf.AMRange: + if t := e.String(); !seen[t] { + seen[t] = true + out = append(out, t) + } + case namedconf.AMName: + if a := st.ACLByName(e.Name); a != nil && depth < 10 { + addElems(a.Elements, depth+1) + } + } + } + } + for _, o := range opts { + stmt, err := namedconf.ParseOptionValue(o.Name, o.Value) + if err != nil || stmt.FirstBlock() == nil { + continue + } + var list []string + for _, s := range stmt.FirstBlock().Stmts { + if o.Name == "also-notify" { + // "ADDRESS [port N] [key K]": the address only. + if len(s.Items) > 0 && s.Items[0].Value != nil { + if _, err := netip.ParseAddr(s.Items[0].Value.Text); err == nil { + list = append(list, s.Items[0].Value.Text) + } + } + continue + } + list = append(list, namedconf.Compact(s.Items)) + } + addElems(list, 0) + } + slices.Sort(out) + return out +} + +// withoutLogOnly removes "log-only ...;" from a rate-limit block. +func withoutLogOnly(value string) string { + st, err := namedconf.ParseOptionValue("rate-limit", value) + if err != nil || st.FirstBlock() == nil { + return value + } + b := st.FirstBlock() + var keep []*namedconf.Stmt + for _, s := range b.Stmts { + if s.Keyword() != "log-only" { + keep = append(keep, s) + } + } + b.Stmts = keep + return namedconf.Compact(st.Items[1:]) +} diff --git a/internal/engine/exposure_test.go b/internal/engine/exposure_test.go new file mode 100644 index 0000000..4c84113 --- /dev/null +++ b/internal/engine/exposure_test.go @@ -0,0 +1,142 @@ +package engine + +import ( + "net/netip" + "strings" + "testing" + + "jarl/internal/model" + "jarl/internal/validate" +) + +func problemFor(ps []validate.Problem, code, view string) *validate.Problem { + for i, p := range ps { + if p.Code == code && (view == "" || p.View == view) { + return &ps[i] + } + } + return nil +} + +func TestExposure(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, + // An internal network with a global IPv6 prefix is still yours. + NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("internal-clients"), Elements: &[]string{"2a02:8013:7f3d:10::/64", "192.168.1.0/24"}}), + optionReq("allow-transfer", "{ 192.0.2.53; }"), + ) + env.submit(env.admin, + NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"internal-clients", "localhost"}, AdoptZones: true}), + NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"!internal-clients", "any"}}), + NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("dmz"), MatchClients: &[]string{"any"}, MatchDestinations: &[]string{"10.9.0.53"}}), + ) + st := env.state() + exps := map[string]validate.Exposure{} + for _, x := range env.e.Exposures(st) { + exps[x.View] = x + } + if x := exps["internal"]; x.Public || !strings.Contains(x.Reason, "no catch-all") { + t.Errorf("internal = %+v", x) + } + if x := exps["external"]; !x.Public || x.Reason != "match-clients ends in any" { + t.Errorf("external = %+v", x) + } + if x := exps["dmz"]; x.Public || !strings.Contains(x.Reason, "match-destinations") { + t.Errorf("dmz = %+v", x) + } + + ps := env.e.validate(st) + for _, code := range []string{"exposure_rate_limit", "exposure_minimal_any"} { + if problemFor(ps, code, "external") == nil { + t.Errorf("no %s for external", code) + } + if problemFor(ps, code, "internal") != nil || problemFor(ps, code, "dmz") != nil { + t.Errorf("%s for a private view", code) + } + } + for _, code := range []string{"exposure_version", "exposure_hostname"} { + if problemFor(ps, code, "") == nil { + t.Errorf("no %s", code) + } + } + + // The rate limit fix starts in log-only mode and exempts the + // secondaries. + ext := st.ViewByName("external") + reqs, desc := ExposureFix(st, ext.ID, "exposure_rate_limit") + if len(reqs) != 1 || !strings.Contains(desc, "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.53; }; }") { + t.Fatalf("fix = %v %q", reqs, desc) + } + env.submit(env.admin, reqs...) + ps = env.e.validate(env.state()) + if problemFor(ps, "exposure_rate_limit", "external") != nil { + t.Error("rate limit warning stays after the fix") + } + if p := problemFor(ps, "exposure_rate_limit_log_only", "external"); p == nil || p.Severity != validate.Info { + t.Errorf("log-only = %+v", p) + } + reqs, desc = ExposureFix(env.state(), ext.ID, "exposure_rate_limit_log_only") + if !strings.Contains(desc, "{ responses-per-second 10; exempt-clients { 192.0.2.53; }; }") { + t.Errorf("enforce = %q", desc) + } + env.submit(env.admin, reqs...) + if p := problemFor(env.e.validate(env.state()), "exposure_rate_limit_log_only", "external"); p != nil { + t.Errorf("still log-only: %+v", p) + } + + // Opening the server up needs confirming. + open := optionReqView("allow-recursion", "{ any; }", ext.ID) + if e := env.submitErr(env.admin, open); e.Kind != KindRisk || !strings.Contains(e.Message, "open resolver") { + t.Fatalf("open resolver without confirming: %+v", e) + } + if _, err := env.e.Submit(env.ctx, []Request{open}, env.admin, "test", "", ApplyOptions{AcceptRisk: true}); err != nil { + t.Fatalf("with accept risk: %v", err) + } + // Acknowledged, it stays as info, with who and why. + env.submit(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver", Reason: "public resolver for the club"})) + p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external") + if p == nil || p.Severity != validate.Info || p.Acknowledged == nil || !strings.Contains(p.Message, "acknowledged by admin on 7 Oct 2026: public resolver for the club") { + t.Errorf("acknowledged = %+v", p) + } + if e := env.submitErr(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver"})); e.Kind != KindBadRequest { + t.Errorf("ack without a reason: %+v", e) + } + // Reverting the acknowledgement brings the warning back. + env.submit(env.admin, NewRequest(OpExposureUnack, 0, AckInput{View: "external", Code: "security_open_resolver"})) + if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p == nil || p.Severity != validate.Warning { + t.Errorf("after unack = %+v", p) + } + // Marking a view private takes it out of the checks. + env.submit(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("private")})) + if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p != nil { + t.Errorf("private view still checked: %+v", p) + } + if e := env.submitErr(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("internet")})); e.Kind != KindBadRequest { + t.Errorf("bad exposure: %+v", e) + } +} + +// A server without views is public only when named listens on an +// address that isn't private. +func TestServerExposure(t *testing.T) { + env := newEnv(t) + setupZone(env) + if x := env.e.Exposures(env.state())[0]; x.Public { + t.Errorf("no addresses known = %+v", x) + } + env.e.cfg.LocalAddrs = func() []netip.Addr { + return []netip.Addr{netip.MustParseAddr("192.168.1.2"), netip.MustParseAddr("2a02:8013:7f3d:10::53")} + } + if x := env.e.Exposures(env.state())[0]; !x.Public || !strings.Contains(x.Reason, "2a02:8013:7f3d:10::53") { + t.Errorf("GUA address = %+v", x) + } + env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Exposure: model.Ptr("private")})) + if x := env.e.Exposures(env.state())[0]; x.Public || x.Override != "private" { + t.Errorf("marked private = %+v", x) + } +} + +func optionReqView(name, value string, viewID int64) Request { + return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &viewID, Name: &name, Value: &value}) +} diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index 1aac1d6..db3f7f5 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -51,8 +51,21 @@ const ( // operations. OpRecordDisable = "record.disable" OpRecordEnable = "record.enable" + // Acknowledging an exposure finding (docs/exposure.md), and taking it + // back, become view.update or settings.update operations. + OpExposureAck = "exposure.ack" + OpExposureUnack = "exposure.unack" ) +// AckInput acknowledges (or un-acknowledges) an exposure finding of a view +// (by ID or name), or of the server (neither given). +type AckInput struct { + ViewID int64 `json:"view_id,omitempty"` + View string `json:"view,omitempty"` + Code string `json:"code"` + Reason string `json:"reason,omitempty"` +} + // OpKinds lists all operation kinds. var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete, @@ -184,6 +197,9 @@ type ViewInput struct { AdoptZones bool `json:"adopt_zones,omitempty"` // Cascade (delete) also deletes the view's zones. Cascade bool `json:"cascade,omitempty"` + // Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md). + Exposure *string `json:"exposure,omitempty"` + Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"` } // ReorderInput sets the order of views. @@ -227,6 +243,9 @@ type SettingsInput struct { Expire *uint32 `json:"expire,omitempty"` NegativeTTL *uint32 `json:"negative_ttl,omitempty"` SerialPolicy *string `json:"serial_policy,omitempty"` + // Exposure and Acknowledged are the server's (docs/exposure.md). + Exposure *string `json:"exposure,omitempty"` + Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"` } // DeleteInput carries options for delete operations. diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 58b81a0..e5515e5 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -765,9 +765,41 @@ func setViewFields(v *model.View, in *ViewInput) error { if in.Position != nil { v.Position = *in.Position } + if in.Exposure != nil { + e, err := normalizeExposure(*in.Exposure) + if err != nil { + return err + } + v.Exposure = e + } + if in.Acknowledged != nil { + v.Acknowledged = cleanAcks(*in.Acknowledged) + } return nil } +// normalizeExposure checks an exposure override. +func normalizeExposure(s string) (string, error) { + switch e := strings.ToLower(strings.TrimSpace(s)); e { + case "", "auto": + return model.ExposureAuto, nil + case model.ExposurePublic, model.ExposurePrivate: + return e, nil + } + return "", errf(KindBadRequest, "exposure must be auto, public or private, not %q", s) +} + +// cleanAcks drops empty acknowledgements (nil for none). +func cleanAcks(acks []model.Acknowledgement) []model.Acknowledgement { + var out []model.Acknowledgement + for _, a := range acks { + if a.Code != "" { + out = append(out, a) + } + } + return out +} + func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error { var in ViewInput if err := decode(op.Data, &in); err != nil { @@ -1329,6 +1361,16 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error { if in.SerialPolicy != nil { s.SerialPolicy = strings.ToLower(*in.SerialPolicy) } + if in.Exposure != nil { + e, err := normalizeExposure(*in.Exposure) + if err != nil { + return err + } + s.Exposure = e + } + if in.Acknowledged != nil { + s.Acknowledged = cleanAcks(*in.Acknowledged) + } s.Version++ return nil } diff --git a/internal/model/model.go b/internal/model/model.go index 0a247da..f8fbc4c 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -50,9 +50,43 @@ type View struct { MatchDestinations []string `json:"match_destinations"` MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"` Comment string `json:"comment"` + // Exposure overrides whether the view counts as reachable from the + // internet (docs/exposure.md): "" works it out, ExposurePublic or + // ExposurePrivate say so. + Exposure string `json:"exposure,omitempty"` + // Acknowledged are exposure findings accepted on purpose; they are + // shown as info instead of warnings. + Acknowledged []Acknowledgement `json:"acknowledged,omitempty"` Meta } +// Exposure overrides. +const ( + ExposureAuto = "" + ExposurePublic = "public" + ExposurePrivate = "private" +) + +// Acknowledgement accepts one exposure finding (by its code) for a view, +// or for the server: the finding stays, as info, with who accepted it, +// when and why. +type Acknowledgement struct { + Code string `json:"code"` + Reason string `json:"reason"` + By string `json:"by"` + At time.Time `json:"at"` +} + +// FindAck returns the acknowledgement of code in acks, if any. +func FindAck(acks []Acknowledgement, code string) *Acknowledgement { + for i := range acks { + if acks[i].Code == code { + return &acks[i] + } + } + return nil +} + // ACL is a named address match list. type ACL struct { ID int64 `json:"id"` @@ -217,7 +251,11 @@ type Settings struct { BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"` // BlockingPausedIndefinitely pauses blocking until someone resumes it. BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"` - Version int `json:"version"` + // Exposure and Acknowledged are the server's own (without views, and + // for server-wide findings such as a visible version): see View. + Exposure string `json:"exposure,omitempty"` + Acknowledged []Acknowledgement `json:"acknowledged,omitempty"` + Version int `json:"version"` } // BlockingPaused reports whether blocking is paused, for a time or until @@ -289,10 +327,12 @@ func (s *State) Clone() *State { ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)), } c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers) + c.Settings.Acknowledged = slices.Clone(s.Settings.Acknowledged) for id, v := range s.Views { n := *v n.MatchClients = slices.Clone(v.MatchClients) n.MatchDestinations = slices.Clone(v.MatchDestinations) + n.Acknowledged = slices.Clone(v.Acknowledged) if v.MatchRecursiveOnly != nil { b := *v.MatchRecursiveOnly n.MatchRecursiveOnly = &b diff --git a/internal/render/render.go b/internal/render/render.go index e91663f..306e55e 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -272,6 +272,12 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa pzs := PolicyZones(s, p) out.Policy = pzs logRewrites := logResponses && len(pzs) > 0 + // Rate limiting logs what it limits (or would, in log-only mode) as + // query-errors; the statistics count it (docs/exposure.md). + logRRL := false + for _, o := range s.Options { + logRRL = logRRL || logResponses && o.Name == "rate-limit" + } sawStats, sawLogging := false, false if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 { w.write("\n") @@ -282,7 +288,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa w.write(st.statsStatement(o)) sawStats = true case logResponses && o.Name == "logging" && !sawLogging: - w.write(st.loggingStatement(o, logRewrites)) + w.write(st.loggingStatement(o, logRewrites, logRRL)) sawLogging = true default: w.write(optionText(o, 0)) @@ -297,7 +303,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa w.write(st.statsStatement(nil)) } if logResponses && !sawLogging { - w.write(st.loggingStatement(nil, logRewrites)) + w.write(st.loggingStatement(nil, logRewrites, logRRL)) } w.end() } diff --git a/internal/render/stats.go b/internal/render/stats.go index a675158..61ef9f8 100644 --- a/internal/render/stats.go +++ b/internal/render/stats.go @@ -72,9 +72,10 @@ func (h *StatsHooks) statsStatement(user *model.Option) string { } // loggingStatement returns the logging statement with Jarl's channel and -// the responses category (and rpz, for blocking's rewrites), merged into -// the user's own if there is one. -func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string { +// the responses category (rpz too, for blocking's rewrites, and +// query-errors for rate limiting), merged into the user's own if there is +// one. +func (h *StatsHooks) loggingStatement(user *model.Option, rpz, rrl bool) string { var st *namedconf.Stmt if user != nil { var err error @@ -92,6 +93,9 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string { if rpz { categories = append(categories, "rpz") } + if rrl { + categories = append(categories, "query-errors") + } for _, cat := range categories { merged := false for _, s := range b.Stmts { @@ -101,7 +105,13 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string { } } if !merged { - b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, namedconf.NewBlock(namedconf.NewStmt(StatsChannelName)))) + channels := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName)) + if cat == "query-errors" { + // Naming the category takes it away from named's + // default destination: keep that too. + channels.Stmts = append(channels.Stmts, namedconf.NewStmt("default_syslog")) + } + b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, channels)) } } prefix := "" diff --git a/internal/server/exposure_test.go b/internal/server/exposure_test.go new file mode 100644 index 0000000..9b97764 --- /dev/null +++ b/internal/server/exposure_test.go @@ -0,0 +1,68 @@ +package server + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestExposureAPI(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) + e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"192.168.1.0/24", "localhost"}, "adopt_zones": true}, 201) + ext := e.do("POST", "/views", e.token, map[string]any{"name": "external", "match_clients": []string{"any"}}, 201)["object"].(map[string]any)["id"] + + type report struct { + Scopes []struct { + View string `json:"view"` + Reason string `json:"reason"` + RateLimit string `json:"rate_limit"` + Public bool `json:"public"` + Findings []struct { + Code, Severity, Fix string + } + } + Server []struct{ Code, Fix string } + } + get := func() report { + b, status := e.req("GET", "/exposure", e.token, nil) + if status != 200 { + t.Fatalf("exposure = %d %s", status, b) + } + var r report + json.Unmarshal(b, &r) + return r + } + r := get() + if len(r.Scopes) != 2 || r.Scopes[0].View != "internal" || r.Scopes[0].Public || !r.Scopes[1].Public { + t.Fatalf("scopes = %+v", r.Scopes) + } + var codes []string + for _, f := range r.Scopes[1].Findings { + codes = append(codes, f.Code) + if f.Code == "exposure_rate_limit" && !strings.Contains(f.Fix, "log-only yes") { + t.Errorf("rate limit fix = %q", f.Fix) + } + } + if !strings.Contains(strings.Join(codes, " "), "exposure_rate_limit") || len(r.Server) < 2 { + t.Errorf("findings = %v, server = %+v", codes, r.Server) + } + e.do("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_rate_limit"}, 201) + if r := get(); r.Scopes[1].RateLimit != "log-only" { + t.Errorf("rate limit = %q", r.Scopes[1].RateLimit) + } + if _, status := e.req("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_open_notify"}); status != 400 { + t.Errorf("fix without an automatic fix = %d", status) + } + // Opening a public view up needs confirming. + b, status := e.req("POST", "/options", e.token, map[string]any{"scope": "view", "scope_id": ext, "name": "allow-recursion", "value": "any"}) + if status != 409 || !strings.Contains(string(b), "confirm_risk") { + t.Errorf("open resolver = %d %s", status, b) + } + e.do("POST", "/exposure/ack", e.token, map[string]any{"view": "external", "code": "exposure_minimal_any", "reason": "testing"}, 201) + for _, f := range get().Scopes[1].Findings { + if f.Code == "exposure_minimal_any" && f.Severity != "info" { + t.Errorf("acknowledged finding = %+v", f) + } + } +} diff --git a/internal/server/h_changes.go b/internal/server/h_changes.go index 9a51be6..1c83565 100644 --- a/internal/server/h_changes.go +++ b/internal/server/h_changes.go @@ -61,6 +61,9 @@ func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Prin if in.Force { q.Set("force", "1") } + if in.AcceptRisk { + q.Set("accept_risk", "1") + } r.URL.RawQuery = q.Encode() return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil) } @@ -106,7 +109,7 @@ func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth } cs = res.Changeset if in.Apply { - out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force}) + out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force, AcceptRisk: in.AcceptRisk}) if err != nil { s.writeErr(w, r, err, out) return nil @@ -214,8 +217,9 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth. return err } var in struct { - DryRun bool `json:"dry_run"` - Force bool `json:"force"` + DryRun bool `json:"dry_run"` + Force bool `json:"force"` + AcceptRisk bool `json:"accept_risk"` } if body, err := readBody(r); err != nil { return err @@ -224,7 +228,8 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth. return badRequest("invalid JSON: %v", err) } } - res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force")}) + res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force"), + AcceptRisk: in.AcceptRisk || queryBool(r, "accept_risk")}) if err != nil { s.writeErr(w, r, err, res) return nil diff --git a/internal/server/h_exposure.go b/internal/server/h_exposure.go new file mode 100644 index 0000000..f17ad33 --- /dev/null +++ b/internal/server/h_exposure.go @@ -0,0 +1,146 @@ +package server + +import ( + "net/http" + "time" + + "jarl/internal/auth" + "jarl/internal/engine" + "jarl/internal/model" + "jarl/internal/stats" + "jarl/internal/validate" +) + +// exposureFinding is an exposure problem with the fix Jarl can stage for +// it ("" when there is none). +type exposureFinding struct { + validate.Problem + Fix string `json:"fix,omitempty"` +} + +type exposureScope struct { + validate.Exposure + RateLimit string `json:"rate_limit"` // off, log-only or enforcing + Findings []exposureFinding `json:"findings"` +} + +type exposureReport struct { + Scopes []exposureScope `json:"scopes"` + // Server holds server-wide findings (version, host name, management + // interfaces). + Server []exposureFinding `json:"server"` + // RateLimiting is what rate limiting limited, or would have, in the + // last 24 hours (with query statistics on). + RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"` +} + +type rateLimitActivity struct { + Since string `json:"since"` + Counts []stats.Entry `json:"counts"` // drop, slip, would-drop, would-slip + Networks []stats.Entry `json:"networks"` // client networks, most limited first + Names []stats.Entry `json:"names"` // "name TYPE" +} + +// exposure: GET /exposure reports which views are reachable from the +// internet and what the exposure checks found (docs/exposure.md). +func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil { + return err + } + st, err := s.store.LoadState(r.Context()) + if err != nil { + return err + } + problems := s.eng.Validate(st) + out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}} + finding := func(pr validate.Problem, viewID int64) exposureFinding { + _, desc := engine.ExposureFix(st, viewID, pr.Code) + return exposureFinding{Problem: pr, Fix: desc} + } + scopes := map[string]int{} + for _, x := range s.eng.Exposures(st) { + sc := exposureScope{Exposure: x, RateLimit: "off", Findings: []exposureFinding{}} + v := st.Views[x.ViewID] + o := st.FindOption(model.ScopeOptions, 0, "rate-limit") + if v != nil { + if vo := st.FindOption(model.ScopeView, v.ID, "rate-limit"); vo != nil { + o = vo + } + } + if o != nil { + sc.RateLimit = "enforcing" + if validate.LogOnly(o) { + sc.RateLimit = "log-only" + } + } + scopes[x.View] = len(out.Scopes) + out.Scopes = append(out.Scopes, sc) + } + for _, pr := range problems { + if !validate.Ackable(pr.Code) { + continue + } + if pr.ObjectType == "view" || pr.ObjectType == "zone" { + if i, ok := scopes[pr.View]; ok { + out.Scopes[i].Findings = append(out.Scopes[i].Findings, finding(pr, out.Scopes[i].ViewID)) + continue + } + } + out.Server = append(out.Server, finding(pr, 0)) + } + if s.statsQ != nil && p.Can(auth.Action{Resource: auth.ResStats}) { + ctx, since := r.Context(), 24*time.Hour + a := &rateLimitActivity{Since: "24h"} + a.Counts, _ = s.statsQ.Breakdown(ctx, "rrl", since) + a.Networks, _ = s.statsQ.Top(ctx, stats.KindRateLimited, since, 10) + a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10) + out.RateLimiting = a + } + writeJSON(w, http.StatusOK, out) + return nil +} + +// exposureFix: POST /exposure/fix {"view_id": 3, "code": "..."} stages (or +// applies) the fix for a finding, like any other change. +func (s *Server) exposureFix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + var in struct { + ViewID int64 `json:"view_id"` + View string `json:"view"` + Code string `json:"code"` + } + if err := decodeJSON(r, &in); err != nil { + return err + } + st, err := s.store.LoadState(r.Context()) + if err != nil { + return err + } + if in.View != "" { + v := st.ViewByName(in.View) + if v == nil { + return notFound("no view %s", in.View) + } + in.ViewID = v.ID + } + reqs, desc := engine.ExposureFix(st, in.ViewID, in.Code) + if reqs == nil { + return badRequest("there is no automatic fix for %s: what to allow is yours to decide; change the option yourself, or acknowledge the finding", in.Code) + } + return s.mutate(w, r, p, desc, reqs, nil) +} + +// exposureAck: POST /exposure/ack {"view": "external", "code": "...", +// "reason": "..."} accepts a finding (unack takes it back). +func (s *Server) exposureAck(ack bool) handler { + return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + var in engine.AckInput + if err := decodeJSON(r, &in); err != nil { + return err + } + op := engine.OpExposureUnack + if ack { + op = engine.OpExposureAck + } + return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil) + } +} diff --git a/internal/server/metrics.go b/internal/server/metrics.go index 49cab92..c8bebfb 100644 --- a/internal/server/metrics.go +++ b/internal/server/metrics.go @@ -297,6 +297,7 @@ var counterMetrics = map[string]struct{ name, label, help string }{ "answer": {"jarl_answers_total", "source", "Answers that were authoritative or not (recursive)."}, "transport": {"jarl_queries_by_transport_total", "transport", "Queries over UDP and TCP."}, "ratelimit": {"jarl_ratelimit_responses_total", "action", "Responses rate limiting dropped or slipped (truncated)."}, + "rrl": {"jarl_ratelimit_logged_total", "action", "Rate limited responses from named's log: drop and slip, or would-drop and would-slip in log-only mode."}, "rtt": {"jarl_upstream_responses_by_rtt_total", "rtt", "Responses from other servers during recursion, by round-trip time."}, "dnssec": {"jarl_dnssec_validations_total", "result", "DNSSEC validations of recursive answers by result."}, "cache": {"jarl_cache_lookups_total", "result", "Cache lookups that hit or missed."}, diff --git a/internal/server/respond.go b/internal/server/respond.go index 4f3fd93..d474a4e 100644 --- a/internal/server/respond.go +++ b/internal/server/respond.go @@ -62,7 +62,7 @@ func engineStatus(k engine.ErrorKind) int { return http.StatusForbidden case engine.KindNotFound: return http.StatusNotFound - case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted: + case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted, engine.KindRisk: return http.StatusConflict case engine.KindValidation: return http.StatusUnprocessableEntity diff --git a/internal/server/routes.go b/internal/server/routes.go index bf57515..95bbe10 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -162,6 +162,10 @@ func (s *Server) routes() { m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true)) m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false)) // Query statistics + m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure)) + m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix)) + m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true))) + m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false))) m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus)) m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary)) m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop)) @@ -252,7 +256,7 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)}) return nil } - opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")} + opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")} res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts) if err != nil { s.writeErr(w, r, err, res) diff --git a/internal/stats/collector.go b/internal/stats/collector.go index 45d4b8f..775fd83 100644 --- a/internal/stats/collector.go +++ b/internal/stats/collector.go @@ -26,10 +26,15 @@ const ( KindBlocked = "blocked" // names blocked (docs/blocking.md) // KindBlockedClient: clients with the most blocked queries. KindBlockedClient = "blocked_client" + // KindRateLimited: client networks whose responses rate limiting + // limited (or would have, in log-only mode); KindRateLimitedName: the + // names and types they asked for (docs/exposure.md). + KindRateLimited = "ratelimited" + KindRateLimitedName = "ratelimited_name" ) // Kinds lists the top-list kinds. -var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient} +var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName} // OtherZone is the zone key for names outside every local zone (answered // by recursion, or refused). @@ -337,6 +342,8 @@ func (c *Collector) handleLine(line string) { if !ok { if rw, ok := ParseRewrite(line, c.opts.Location); ok { c.handleRewrite(rw) + } else if rl, ok := ParseRateLimit(line, c.opts.Location); ok { + c.handleRateLimit(rl) } return } @@ -416,6 +423,30 @@ func (c *Collector) handleRewrite(rw Rewrite) { hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++ } +// handleRateLimit counts a response rate limiting limited, or would have: +// "rrl:drop", "rrl:slip", "rrl:would-drop" or "rrl:would-slip", and the +// client network and name in the hour's top lists. +func (c *Collector) handleRateLimit(rl RateLimit) { + for _, p := range c.opts.Exclude { + if p.Contains(rl.Client) { + return + } + } + key := "rrl:" + rl.Action + if rl.Would { + key = "rrl:would-" + rl.Action + } + minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time) + c.count(minute, key, 1) + hours := c.hours + if hour < c.liveHour { + hours = c.late + } + hours[hour] = c.hourMaps(hours, hour) + hours[hour][KindRateLimited][rl.Prefix]++ + hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++ +} + // policySource names what a rewrite came from: a blocklist's name, "rules" // for the rules entered in Jarl, or the policy zone for others. func policySource(via string) string { diff --git a/internal/stats/parse.go b/internal/stats/parse.go index a36b41c..df84456 100644 --- a/internal/stats/parse.go +++ b/internal/stats/parse.go @@ -33,17 +33,55 @@ type Rewrite struct { // // 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2) // -// The "view NAME: " part is missing when no views are configured. -var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `) +// The "view NAME: " part is missing when no views are configured, and a +// query signed with a TSIG key has "/key NAME" after the port. +var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `) // rewriteRe matches named's RPZ rewrite lines (category rpz), logged just // before the response line of the same query: // // 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid -var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`) +var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`) + +// rateLimitRe matches named's response rate limiting lines (category +// query-errors), when it limits a response or, in log-only mode, would: +// +// 12-Oct-2026 09:56:35.996 client @0x70a2... 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4) +var rateLimitRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?(would )?rate limit (drop|slip) (?:\S+ )?response to (\S+) for (\S+) \S+ (\S+)`) + +// RateLimit is a response that rate limiting dropped or slipped (sent +// truncated, so a real client retries over TCP), or would have in +// log-only mode. +type RateLimit struct { + Time time.Time + Client netip.Addr + View string + Would bool // log-only: nothing was limited + Action string // drop or slip + Prefix string // the client network named counts, e.g. 192.0.2.0/24 + Name string // the query name, normalized like Event.Name + Type string +} const logTimeLayout = "02-Jan-2006 15:04:05.000" +// ParseRateLimit parses a rate limiting log line. +func ParseRateLimit(line string, loc *time.Location) (rl RateLimit, ok bool) { + m := rateLimitRe.FindStringSubmatch(line) + if m == nil { + return rl, false + } + t, err := time.ParseInLocation(logTimeLayout, m[1], loc) + if err != nil { + return rl, false + } + client, err := netip.ParseAddr(m[2]) + if err != nil { + return rl, false + } + return RateLimit{Time: t, Client: client.Unmap(), View: m[4], Would: m[5] != "", Action: m[6], Prefix: m[7], Name: normalizeName(m[8]), Type: m[9]}, true +} + // ParseResponse parses a response log line. ok is false for other lines // (named also logs to the channel when it starts and rotates files). func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) { diff --git a/internal/stats/parse_test.go b/internal/stats/parse_test.go index f8271aa..29ceff6 100644 --- a/internal/stats/parse_test.go +++ b/internal/stats/parse_test.go @@ -1,6 +1,7 @@ package stats import ( + "net/netip" "testing" "time" ) @@ -42,3 +43,44 @@ func TestParseResponse(t *testing.T) { } } } + +func TestParseRateLimit(t *testing.T) { + loc := time.UTC + for _, tc := range []struct { + line string + want RateLimit + views bool + }{ + {line: "12-Oct-2026 09:56:35.996 client @0x70a247adf000 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)", + want: RateLimit{Would: true, Action: "slip", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}}, + {line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 127.0.0.2#35687 (www.rrl.test): rate limit drop response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)", + want: RateLimit{Action: "drop", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}}, + {line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 2001:db8::7#35687 (Adbraun.lol): view external: rate limit drop response to 2001:db8::/56 for adbraun.lol IN ANY (0ce833a4)", + want: RateLimit{View: "external", Action: "drop", Prefix: "2001:db8::/56", Name: "adbraun.lol", Type: "ANY"}}, + } { + rl, ok := ParseRateLimit(tc.line, loc) + rl.Time, rl.Client = time.Time{}, netip.Addr{} + if !ok || rl != tc.want { + t.Errorf("%s:\n got %+v %v\nwant %+v", tc.line, rl, ok, tc.want) + } + } + if _, ok := ParseRateLimit("12-Oct-2026 09:56:35.996 rate-limit: info: would limit responses to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)", loc); ok { + t.Error("the start-of-limiting line counted as a response") + } +} + +// Signed queries have "/key NAME" after the client's port. +func TestParseSigned(t *testing.T) { + loc := time.UTC + ev, ok := ParseResponse("12-Oct-2026 10:11:06.490 client @0x7a22f754b000 127.0.0.1#34745/key jarl-view-external (ns1.adbraun.test): view external: response: ns1.adbraun.test IN A NOERROR 1 0 2 +E(0)SK (127.0.0.1)", loc) + if !ok || ev.View != "external" || ev.Name != "ns1.adbraun.test" || ev.Port != "34745" { + t.Errorf("signed response = %+v %v", ev, ok) + } + rl, ok := ParseRateLimit("12-Oct-2026 10:11:06.599 client @0x7a22f754b000 127.0.0.1#34856/key jarl-view-external (ns1.adbraun.test): view external: would rate limit slip response to 127.0.0.0/24 for ns1.adbraun.test IN A (e777e288)", loc) + if !ok || rl.View != "external" || !rl.Would || rl.Action != "slip" || rl.Prefix != "127.0.0.0/24" { + t.Errorf("signed rate limit = %+v %v", rl, ok) + } + if _, ok := ParseRewrite("09-Oct-2026 23:49:13.089 client @0x7bd607320000 127.0.0.1#56905/key k1 (x.ads.example): view internal: rpz QNAME NXDOMAIN rewrite x.ads.example/A/IN via x.ads.example.rules.jarl.invalid", loc); !ok { + t.Error("signed rewrite not parsed") + } +} diff --git a/internal/stats/query.go b/internal/stats/query.go index a4728db..dba9b97 100644 --- a/internal/stats/query.go +++ b/internal/stats/query.go @@ -256,7 +256,7 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati } // Groups are the counter groups Breakdown accepts. -var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "blocklist"} +var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "rrl", "blocklist"} // Timeline is a set of series over time. type Timeline struct { diff --git a/internal/store/state.go b/internal/store/state.go index 70b4938..b92d8f6 100644 --- a/internal/store/state.go +++ b/internal/store/state.go @@ -31,20 +31,23 @@ func loadState(ctx context.Context, e execer) (*model.State, error) { } } - rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by FROM views`) + rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`) if err != nil { return nil, err } for rows.Next() { v := &model.View{} - var mc, md, ca, ua string + var mc, md, acks, ca, ua string var mro sql.NullInt64 - if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil { + if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil { rows.Close() return nil, err } json.Unmarshal([]byte(mc), &v.MatchClients) json.Unmarshal([]byte(md), &v.MatchDestinations) + if acks != "[]" && acks != "" { + json.Unmarshal([]byte(acks), &v.Acknowledged) + } if mro.Valid { v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0) } @@ -264,8 +267,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error { if v.MatchRecursiveOnly != nil { mro = boolInt(*v.MatchRecursiveOnly) } - _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy) + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy) return err }, func(id int64) error { return del(ctx, e, "views", id) }); err != nil { return err @@ -344,6 +347,13 @@ func nonNilIDs(ids []int64) []int64 { return ids } +func nonNilAcks(a []model.Acknowledgement) []model.Acknowledgement { + if a == nil { + return []model.Acknowledgement{} + } + return a +} + func nonNil(s []string) []string { if s == nil { return []string{} diff --git a/internal/store/store.go b/internal/store/store.go index e617a07..f022de7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -326,6 +326,12 @@ CREATE TABLE client_groups ( version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '' ); +`, + // 9: internet exposure (docs/exposure.md): a view's override and its + // acknowledged findings. + ` +ALTER TABLE views ADD COLUMN exposure TEXT NOT NULL DEFAULT ''; +ALTER TABLE views ADD COLUMN acknowledged TEXT NOT NULL DEFAULT '[]'; `, } diff --git a/internal/validate/config.go b/internal/validate/config.go index fe71de4..f3a67cd 100644 --- a/internal/validate/config.go +++ b/internal/validate/config.go @@ -563,10 +563,15 @@ func (c *checker) checkSecurity() { for _, v := range views { ctxs = append(ctxs, ctx{v, "view " + v.Name}) } + exposure := map[int64]Exposure{} + for _, x := range c.exposures() { + exposure[x.ViewID] = x + } for _, x := range ctxs { // A view only reachable from some clients (e.g. "internal") can // recurse for everyone inside it without being an open resolver. - if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) { + // Without views, only a server marked private is left out. + if x := exposure[viewID(x.v)]; !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) { continue } if c.effectiveBool(x.v, "recursion", true) { @@ -608,6 +613,9 @@ func (c *checker) checkSecurity() { p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl") p.Zone, p.View = z.Name, c.s.ViewName(z) } + if x, ok := exposure[z.ViewID]; ok && !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) { + continue // only reachable by your own clients + } if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) { p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key") p.Zone, p.View = z.Name, c.s.ViewName(z) diff --git a/internal/validate/exposure.go b/internal/validate/exposure.go new file mode 100644 index 0000000..316d9e6 --- /dev/null +++ b/internal/validate/exposure.go @@ -0,0 +1,387 @@ +package validate + +import ( + "fmt" + "net/netip" + "strings" + + "jarl/internal/model" + "jarl/internal/namedconf" +) + +// Exposure says whether a view, or the server without views, can be +// reached from the internet (docs/exposure.md). The exposure checks only +// look at public ones. +type Exposure struct { + ViewID int64 `json:"view_id"` // 0: the server (no views) + View string `json:"view,omitempty"` + Public bool `json:"public"` + Override string `json:"override,omitempty"` // public or private, set by hand + Reason string `json:"reason"` +} + +// Label names the scope in messages: "view external" or "the server". +func (e Exposure) Label() string { + if e.ViewID == 0 { + return "the server" + } + return "view " + e.View +} + +// RiskCodes are the findings that open the server up rather than leave it +// less hardened: a change that introduces one must be confirmed +// (ApplyOptions.AcceptRisk). +var RiskCodes = map[string]bool{ + "security_open_resolver": true, + "security_open_transfer": true, + "exposure_cache_snooping": true, + "exposure_open_notify": true, + "exposure_management": true, +} + +// Ackable reports whether a finding can be acknowledged. +func Ackable(code string) bool { + return strings.HasPrefix(code, "exposure_") || strings.HasPrefix(code, "security_open_") +} + +// Exposures classifies every view (or the server, without views). +func Exposures(s *model.State, opts Options) []Exposure { + c := &checker{s: s, opts: opts} + return c.exposures() +} + +func (c *checker) exposures() []Exposure { + views := c.s.OrderedViews() + if len(views) == 0 { + return []Exposure{c.serverExposure()} + } + var out []Exposure + for _, v := range views { + out = append(out, c.viewExposure(v)) + } + return out +} + +func (c *checker) viewExposure(v *model.View) Exposure { + e := Exposure{ViewID: v.ID, View: v.Name} + switch v.Exposure { + case model.ExposurePublic: + e.Public, e.Override, e.Reason = true, v.Exposure, "marked public" + return e + case model.ExposurePrivate: + e.Override, e.Reason = v.Exposure, "marked private" + return e + } + clients := c.flatten(v.MatchClients, 0) + what, open := catchAll(clients) + if len(v.MatchClients) == 0 { + what, open = "nothing (so any client)", true + } + if !open { + e.Reason = "match-clients lists only " + describeList(v.MatchClients) + ", no catch-all" + return e + } + if len(v.MatchDestinations) > 0 { + if dst := c.flatten(v.MatchDestinations, 0); onlyPrivate(dst) { + e.Reason = "match-destinations lists only private addresses (" + describeList(v.MatchDestinations) + ")" + return e + } + } + e.Public, e.Reason = true, "match-clients ends in "+what + return e +} + +// serverExposure classifies a server without views: public when anyone +// may query it and it listens on an address that isn't private. +func (c *checker) serverExposure() Exposure { + e := Exposure{} + switch c.s.Settings.Exposure { + case model.ExposurePublic: + e.Public, e.Override, e.Reason = true, model.ExposurePublic, "marked public" + return e + case model.ExposurePrivate: + e.Override, e.Reason = model.ExposurePrivate, "marked private" + return e + } + if aml, _, set := c.effectiveAML(nil, "allow-query"); set { + if _, open := catchAll(aml); !open { + e.Reason = "allow-query has no catch-all" + return e + } + } + addr, ok := c.publicListenAddr() + if !ok { + e.Reason = "named listens on private addresses only" + return e + } + e.Public, e.Reason = true, "anyone may query it, and named listens on "+addr + return e +} + +// publicListenAddr returns an address named listens on that isn't +// private, from listen-on/listen-on-v6 and this host's addresses. +func (c *checker) publicListenAddr() (string, bool) { + for _, name := range []string{"listen-on", "listen-on-v6"} { + opts := []*model.Option{} + for _, o := range c.s.ScopedOptions(model.ScopeOptions, 0) { + if o.Name == name { + opts = append(opts, o) + } + } + if len(opts) == 0 { + // named listens on every interface by default. + if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok { + return a, true + } + continue + } + for _, o := range opts { + elems := c.amlOf(o) + if _, open := catchAll(elems); open { + if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok { + return a, true + } + continue + } + for _, e := range elems { + if e.Kind == namedconf.AMPrefix && !e.Negated && !privatePrefix(e.Prefix) { + return e.Prefix.Addr().String(), true + } + } + } + } + return "", false +} + +// publicLocalAddr returns one of this host's addresses that isn't +// private (none known in tests). +func (c *checker) publicLocalAddr(v6 bool) (string, bool) { + for _, a := range c.opts.LocalAddrs { + a = a.Unmap() + if a.Is6() == v6 && !privatePrefix(netip.PrefixFrom(a, a.BitLen())) { + return a.String(), true + } + } + return "", false +} + +// catchAll reports whether a list lets in clients from anywhere: a +// non-negated "any", or a prefix so wide (shorter than /8 for IPv4, /16 +// for IPv6) that it holds most of the internet. Negated elements before +// it only take some clients out. +func catchAll(elems []namedconf.AMElement) (string, bool) { + for _, e := range elems { + if e.Negated { + continue + } + switch { + case e.Kind == namedconf.AMName && e.Name == "any": + return "any", true + case e.Kind == namedconf.AMName && e.Name == "none": + return "", false + case e.Kind == namedconf.AMPrefix && e.Prefix.Addr().Is4() && e.Prefix.Bits() < 8, + e.Kind == namedconf.AMPrefix && !e.Prefix.Addr().Is4() && e.Prefix.Bits() < 16: + return e.Prefix.String(), true + } + } + return "", false +} + +var privateBlocks = []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"), +} + +func privatePrefix(p netip.Prefix) bool { + a := p.Addr().Unmap() + for _, b := range privateBlocks { + if b.Addr().Is4() == a.Is4() && b.Contains(a) && p.Bits() >= b.Bits() { + return true + } + } + return false +} + +// onlyPrivate reports whether every address element is private (names +// such as localhost and localnets count as private). +func onlyPrivate(elems []namedconf.AMElement) bool { + for _, e := range elems { + if e.Negated { + continue + } + switch e.Kind { + case namedconf.AMPrefix: + if !privatePrefix(e.Prefix) { + return false + } + case namedconf.AMName: + if e.Name == "any" { + return false + } + case namedconf.AMOther, namedconf.AMNested: + return false + } + } + return true +} + +func describeList(list []string) string { + if len(list) > 4 { + return strings.Join(list[:4], ", ") + ", …" + } + return strings.Join(list, ", ") +} + +// checkExposure runs the exposure checks on public views (or the server). +func (c *checker) checkExposure() { + exps := c.exposures() + anyPublic := false + for _, x := range exps { + if !x.Public { + continue + } + anyPublic = true + v := c.s.Views[x.ViewID] + label := x.Label() + warn := func(code, format string, args ...any) { + p := c.warnf(code, "view", x.ViewID, "%s is reachable from the internet (%s): %s", label, x.Reason, fmt.Sprintf(format, args...)) + p.View = x.View + } + if o := c.option(v, "rate-limit"); o == nil { + warn("exposure_rate_limit", "it has no rate-limit, so it can be used to flood others with its answers (DNS amplification); start response rate limiting in log-only mode") + } else if LogOnly(o) { + p := c.infof("exposure_rate_limit_log_only", "view", x.ViewID, "%s: rate limiting is in log-only mode: named only logs what it would limit; check that nobody legitimate would be limited, then start enforcing", label) + p.View = x.View + } + if !c.effectiveBool(v, "minimal-any", false) { + warn("exposure_minimal_any", "ANY queries get full answers, a favourite for amplification; set minimal-any yes") + } + if !c.effectiveBool(v, "recursion", true) { + if aml, _, set := c.effectiveAML(v, "allow-query-cache"); set { + if _, open := catchAll(aml); open { + warn("exposure_cache_snooping", "anyone may query its cache (allow-query-cache), which shows what your clients look up; set allow-query-cache { none; }") + } + } + } + if aml, _, set := c.effectiveAML(v, "allow-notify"); set { + if _, open := catchAll(aml); open { + warn("exposure_open_notify", "anyone may send it NOTIFY messages (allow-notify); allow only your primaries") + } + } + } + if !anyPublic { + return + } + server := func(code, format string, args ...any) { + c.warnf(code, "server", 0, format, args...) + } + if c.s.FindOption(model.ScopeOptions, 0, "version") == nil { + server("exposure_version", "the server is reachable from the internet and tells anyone its exact BIND version (dig CH TXT version.bind), which helps attackers pick exploits; set version none") + } + if c.s.FindOption(model.ScopeOptions, 0, "hostname") == nil { + server("exposure_hostname", "the server is reachable from the internet and tells anyone its host name (dig CH TXT hostname.bind); set hostname none") + } + for _, name := range []string{"controls", "statistics-channels"} { + for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) { + if o.Name != name { + continue + } + if addr, ok := openManagement(o); ok { + c.warnf("exposure_management", "option", o.ID, "%s listens on %s and accepts anyone there; management interfaces must not be reachable from the internet: use 127.0.0.1, or an allow list of your own hosts", name, addr) + } + } + } +} + +// option returns the option name for a view, or the server's. +func (c *checker) option(v *model.View, name string) *model.Option { + if v != nil { + if o := c.s.FindOption(model.ScopeView, v.ID, name); o != nil { + return o + } + } + return c.s.FindOption(model.ScopeOptions, 0, name) +} + +// LogOnly reports whether a rate-limit option is in log-only mode. +func LogOnly(o *model.Option) bool { + st, err := namedconf.ParseOptionValue(o.Name, o.Value) + if err != nil || st.FirstBlock() == nil { + return false + } + for _, s := range st.FirstBlock().Stmts { + if s.Keyword() == "log-only" && len(s.Items) == 2 && s.Items[1].Value != nil { + switch strings.ToLower(s.Items[1].Value.Text) { + case "yes", "true", "1": + return true + } + } + } + return false +} + +// openManagement reports an inet clause of controls or +// statistics-channels on an address that isn't loopback, open to anyone. +func openManagement(o *model.Option) (string, bool) { + st, err := namedconf.ParseOptionValue(o.Name, o.Value) + if err != nil || st.FirstBlock() == nil { + return "", false + } + for _, s := range st.FirstBlock().Stmts { + if s.Keyword() != "inet" || len(s.Items) < 2 || s.Items[1].Value == nil { + continue + } + addr := s.Items[1].Value.Text + if a, err := netip.ParseAddr(addr); err == nil && a.IsLoopback() { + continue + } + for i, it := range s.Items { + if it.Value != nil && strings.EqualFold(it.Value.Text, "allow") && i+1 < len(s.Items) && s.Items[i+1].Block != nil { + var elems []namedconf.AMElement + for _, as := range s.Items[i+1].Block.Stmts { + if e, err := namedconf.ParseAMElement(namedconf.Compact(as.Items)); err == nil { + elems = append(elems, e.Expand()...) + } + } + if _, open := catchAll(elems); open { + return addr, true + } + } + } + } + return "", false +} + +// applyAcks turns acknowledged findings into info, saying who accepted +// them, when and why. A finding of a view (or of a zone in it) uses the +// view's acknowledgements, anything else the server's. +func (c *checker) applyAcks() { + for i := range c.problems { + p := &c.problems[i] + if p.Severity != Warning || !Ackable(p.Code) { + continue + } + acks := c.s.Settings.Acknowledged + if v := c.problemView(p); v != nil { + acks = v.Acknowledged + } + if a := model.FindAck(acks, p.Code); a != nil { + p.Severity = Info + p.Acknowledged = a + p.Message += fmt.Sprintf(" (acknowledged by %s on %s: %s)", a.By, a.At.Format("2 Jan 2006"), a.Reason) + } + } +} + +func (c *checker) problemView(p *Problem) *model.View { + switch p.ObjectType { + case "view": + return c.s.Views[p.ObjectID] + case "zone": + if z := c.s.Zones[p.ObjectID]; z != nil { + return c.s.Views[z.ViewID] + } + } + return nil +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 7f18b86..9968f90 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -11,6 +11,7 @@ package validate import ( "fmt" + "net/netip" "sort" "strings" @@ -37,6 +38,9 @@ type Problem struct { Zone string `json:"zone,omitempty"` View string `json:"view,omitempty"` Name string `json:"name,omitempty"` + // Acknowledged is set when an exposure finding was accepted on + // purpose (it is then info, not a warning). + Acknowledged *model.Acknowledgement `json:"acknowledged,omitempty"` } // Key identifies a problem independently of object IDs, so problems can be @@ -67,6 +71,10 @@ type Options struct { // ExternalKeysUnknown is set when an included file could not be read, // so references to unknown keys are not reported. ExternalKeysUnknown bool + // LocalAddrs are this host's addresses: a server without views is + // public when named listens on one that isn't private + // (docs/exposure.md). + LocalAddrs []netip.Addr } // Result holds the problems found. @@ -140,8 +148,10 @@ func State(s *model.State, opts Options) []Problem { c.checkZones() c.checkOptions() c.checkSecurity() + c.checkExposure() c.checkDNSSEC() c.checkBlocking() + c.applyAcks() sortProblems(c.problems) return c.problems } diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index 07f2463..870134f 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -18,6 +18,7 @@ import * as account from "./pages/account.js"; import * as search from "./pages/search.js"; import * as stats from "./pages/stats.js"; import * as blocking from "./pages/blocking.js"; +import * as exposure from "./pages/exposure.js"; const routes = [ { pattern: /^$/, page: dashboard, nav: "dashboard" }, @@ -37,6 +38,7 @@ const routes = [ { pattern: /^search$/, page: search, nav: "" }, { pattern: /^stats$/, page: stats, nav: "stats" }, { pattern: /^blocking$/, page: blocking, nav: "blocking" }, + { pattern: /^exposure$/, page: exposure, nav: "exposure" }, ]; const navItems = [ @@ -45,6 +47,7 @@ const navItems = [ ["zones", "#/zones", "Zones"], ["blocking", "#/blocking", "Blocking"], ["views", "#/views", "Views"], + ["exposure", "#/exposure", "Exposure"], ["acls", "#/acls", "ACLs"], ["keys", "#/keys", "TSIG keys"], ["options", "#/options", "Server options"], diff --git a/internal/web/static/assets/pages/changes.js b/internal/web/static/assets/pages/changes.js index d586cdb..ab56fcf 100644 --- a/internal/web/static/assets/pages/changes.js +++ b/internal/web/static/assets/pages/changes.js @@ -43,6 +43,10 @@ async function draftPanel(d) { let valid = false; 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"); + // Changes that open a public view up must be confirmed (docs/exposure.md). + const riskBox = h("label", { class: "check risk-confirm hidden" }, h("input", { type: "checkbox" }), ""); + let risky = false; + riskBox.querySelector("input").addEventListener("change", () => { applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked); }); const ops = d.ops || []; const removeOp = async (seq) => { try { @@ -75,7 +79,10 @@ async function draftPanel(d) { try { const p = await get("/changesets/" + d.id + "/preview"); valid = p.valid && !p.error; - applyBtn.disabled = !valid; + risky = (p.risks || []).length > 0; + riskBox.classList.toggle("hidden", !risky); + if (risky) riskBox.lastChild.textContent = " I understand: " + p.risks.map((x) => x.message).join("; "); + applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked); const drift = (p.drift || []); forceBox.classList.toggle("hidden", drift.length === 0); mount(preview, @@ -97,7 +104,7 @@ async function draftPanel(d) { try { if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); const force = forceBox.querySelector("input").checked; - const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force } }); + const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force, accept_risk: riskBox.querySelector("input").checked } }); const res = r.result || {}; toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : "")); (r.warnings || []).forEach((w) => toast(w, "warn", 10000)); @@ -119,6 +126,7 @@ async function draftPanel(d) { h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")), desc, h("div", { style: { marginTop: "10px" } }, opsTable), + riskBox, h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox), result), h("div", { class: "panel" }, h("h2", null, "Review"), preview)); diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js index e706cb9..c47b067 100644 --- a/internal/web/static/assets/pages/dashboard.js +++ b/internal/web/static/assets/pages/dashboard.js @@ -2,7 +2,7 @@ import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from ". import { get, state } from "../api.js"; export async function render(el) { - const [st, mine] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]); + const [st, mine, exposure] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => []), get("/exposure").catch(() => null)]); const s = st.engine || {}; // The user's own changesets with something in them: what the Changes page shows. const pending = mine.filter((c) => (c.ops || []).length).length; @@ -16,6 +16,7 @@ export async function render(el) { card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")), card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"), st.backup ? backupCard(st.backup) : null, + exposure ? exposureCard(exposure) : null, ); const last = s.last_applied; const parts = [h("div", { class: "page-head" }, @@ -106,6 +107,16 @@ function backupCard(b) { return card("Backup", value, sub); } +// exposureCard sums up the internet exposure page. +function exposureCard(r) { + const pub = r.scopes.filter((s) => s.public); + let warn = r.server.filter((f) => f.severity === "warning").length; + for (const s of r.scopes) warn += s.findings.filter((f) => f.severity === "warning").length; + const value = !pub.length ? badge("nothing public", "ok") : warn ? badge(plural(warn, "warning"), "warn") : badge("hardened", "ok"); + const sub = pub.length ? "public: " + pub.map((s) => s.view || "the server").join(", ") : "no view is reachable from the internet"; + return h("a", { class: "card link", href: "#/exposure" }, h("div", { class: "label" }, "Exposure"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub)); +} + function card(label, value, sub) { return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null); } diff --git a/internal/web/static/assets/pages/exposure.js b/internal/web/static/assets/pages/exposure.js new file mode 100644 index 0000000..59c92a9 --- /dev/null +++ b/internal/web/static/assets/pages/exposure.js @@ -0,0 +1,120 @@ +// Internet exposure (docs/exposure.md): which views are reachable from +// the internet, why, and what to harden, with fixes staged like any edit +// and findings that can be accepted on purpose. +import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural } from "../ui.js"; +import { get, change, describeResult, can } from "../api.js"; +import { rerender } from "../app.js"; + +const sevKind = { warning: "warn", error: "err", info: "" }; + +export async function render(el) { + const r = await get("/exposure"); + const writable = can("options", true); + const parts = [h("div", { class: "page-head" }, + h("div", { class: "grow" }, h("h1", null, "Internet exposure"), + h("div", { class: "muted" }, "Which views can be reached from the internet, and what to harden on them. A view is public when its match-clients let in anyone (they end in any), whatever kind of addresses your own networks use. Fixes are staged as pending changes for you to review.")))]; + + parts.push(h("div", { class: "panel" }, h("h2", null, "Views"), table([ + { label: "View", render: (s) => s.view ? h("a", { href: "#/views/" + s.view_id }, s.view) : h("span", { class: "muted" }, "the server (no views)") }, + { label: "Exposure", render: (s) => [badge(s.public ? "public" : "private", s.public ? "warn" : "ok"), s.override ? h("span", { class: "muted small" }, " set by hand") : null] }, + { label: "Why", render: (s) => h("span", { class: "small" }, s.reason) }, + { label: "Rate limiting", render: (s) => s.public ? badge(s.rate_limit, { off: "err", "log-only": "warn", enforcing: "ok" }[s.rate_limit]) : h("span", { class: "muted" }, "–") }, + { label: "", render: (s) => writable ? overrideSelect(s) : null, tdClass: "actions-cell" }, + ], r.scopes))); + + const all = []; + for (const s of r.scopes) for (const f of s.findings) all.push({ ...f, scope: s.view || "server", viewID: s.view_id }); + for (const f of r.server) all.push({ ...f, scope: "server", viewID: 0 }); + parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")), + all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check."))); + + if (r.rate_limiting) parts.push(activity(r.rate_limiting, r.scopes)); + mount(el, parts); +} + +function overrideSelect(s) { + const sel = select([["", "Work it out"], ["public", "Public"], ["private", "Private"]], s.override || ""); + sel.title = "Override the classification when Jarl gets it wrong (e.g. a port forward, where named only sees private addresses)"; + sel.addEventListener("change", async () => { + try { + const r = s.view_id + ? await change("PATCH", "/views/" + s.view_id, { exposure: sel.value || "auto" }) + : await change("PATCH", "/settings", { exposure: sel.value || "auto" }); + toast(describeResult(r)); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } + }); + return sel; +} + +function findingRow(f, writable) { + const acked = f.acknowledged; + const actions = writable ? h("div", { class: "actions" }, + f.fix && !acked ? h("button", { class: "small primary", title: f.fix, onclick: () => fix(f) }, "Fix") : null, + acked + ? h("button", { class: "small", onclick: () => unack(f) }, "Take back") + : h("button", { class: "small", onclick: () => ack(f) }, "Acknowledge")) : null; + return h("div", { class: "finding " + (acked ? "acked" : f.severity) }, + h("div", { class: "grow" }, + h("div", null, badge(acked ? "acknowledged" : f.severity, acked ? "" : sevKind[f.severity]), " ", h("strong", null, f.scope), " ", h("span", { class: "mono small muted" }, f.code)), + h("div", null, f.message), + f.fix && !acked ? h("div", { class: "small muted" }, "Fix: " + f.fix) : null), + actions); +} + +async function fix(f) { + try { + const r = await change("POST", "/exposure/fix", { view_id: f.viewID, code: f.code }); + toast(describeResult(r)); + (r.warnings || []).forEach((w) => toast(w, "warn", 8000)); + rerender(); + } catch (e) { toast(e.message, "err", 10000); } +} + +async function ack(f) { + const reason = input({ placeholder: "e.g. public resolver for the club's network, monitored", style: { width: "100%" } }); + const err = h("div"); + await modal("Acknowledge " + f.code, h("div", null, + h("p", null, f.message), + h("p", { class: "muted small" }, "The finding stays visible, as info, with your name, the date and your reason. Jarl warns again if it comes up somewhere else."), + field("Why is this accepted?", reason), err), [ + { label: "Cancel", value: null }, + { label: "Acknowledge", kind: "primary", onclick: async () => { + try { + const r = await change("POST", "/exposure/ack", { view_id: f.viewID, code: f.code, reason: reason.value.trim() }); + toast(describeResult(r)); + rerender(); + return true; + } catch (e) { mount(err, errorBox(e)); return false; } + } }, + ]); +} + +async function unack(f) { + try { + const r = await change("POST", "/exposure/unack", { view_id: f.viewID, code: f.code }); + toast(describeResult(r)); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } +} + +// activity shows what rate limiting limited, or would have in log-only +// mode, so you can check nobody legitimate is hit before enforcing. +function activity(a, scopes) { + const counts = Object.fromEntries((a.counts || []).map((e) => [e.key, e.count])); + const would = (counts["would-drop"] || 0) + (counts["would-slip"] || 0); + const did = (counts["drop"] || 0) + (counts["slip"] || 0); + const logOnly = scopes.some((s) => s.public && s.rate_limit === "log-only"); + const any = scopes.some((s) => s.public && s.rate_limit !== "off"); + if (!any) return null; + return h("div", { class: "panel" }, h("h2", null, "Rate limiting, last " + a.since), + !would && !did ? h("div", { class: "empty" }, "Nothing was limited" + (logOnly ? " (or would have been)" : "") + ". If you just turned it on, give it a day.") : [ + h("p", null, + did ? plural(did, "response") + " limited (" + (counts["drop"] || 0) + " dropped, " + (counts["slip"] || 0) + " slipped). " : "", + would ? plural(would, "response") + " would have been limited in log-only mode (" + (counts["would-drop"] || 0) + " dropped, " + (counts["would-slip"] || 0) + " slipped)." : ""), + h("p", { class: "muted small" }, "Dropped responses get no answer; slipped ones get a short reply asking the client to retry over TCP (or with a DNS cookie), which a real client does and a spoofed victim of an attack never sees."), + logOnly ? h("p", { class: "muted small" }, "Check the networks below: your secondaries, monitoring, or a big shared resolver shouldn't be among them. If they are, add them to exempt-clients; otherwise start enforcing.") : null, + h("div", { class: "chart-grid" }, + table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []), + table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]); +} diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index ae8f260..1b2d1a4 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -489,3 +489,15 @@ details.view-picker summary { cursor: pointer; padding: 5px 8px; border: 1px sol details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } ul.plain li { padding: 3px 0; } .query-details { margin: 8px 0; } + +/* Internet exposure findings */ +.findings { display: flex; flex-direction: column; gap: 8px; } +.finding { display: flex; gap: 12px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line); border-left-width: 3px; border-radius: 6px; } +.finding.warning { border-left-color: var(--warn); } +.finding.error { border-left-color: var(--err); } +.finding.acked, .finding.info { border-left-color: var(--line); color: var(--muted); } +.finding .actions { flex-shrink: 0; } +.risk-confirm { margin-top: 8px; } + +a.card.link { color: inherit; text-decoration: none; } +a.card.link:hover { border-color: var(--accent, var(--link)); } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 85730b1..8b8b548 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -366,6 +366,45 @@ expect_dig "unsigned from here: the internal view" "127.0.0.1" ns1.adbraun.test expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A run "view key shows a dig command" 0 $J view key external contains "dig -y hmac-sha256:jarl-view-external:" +# Internet exposure (docs/exposure.md): external lets in anyone, internal +# only homelab. +run "exposure" 0 $J exposure +contains "^internal *private" +contains "^external *public *off" +contains "exposure_rate_limit " +contains "fix: jarl exposure fix external exposure_rate_limit" +run "fix: rate limiting, log-only first" 0 $J exposure fix external exposure_rate_limit +run "named.conf has it" 0 grep -A2 'view "external"' $E/etc-jarl/named.conf +contains "rate-limit { responses-per-second 10; log-only yes; };" +run "exposure: log-only now" 0 $J exposure +contains "^external *public *log-only" +contains "exposure_rate_limit_log_only" +run "fix: start enforcing" 0 $J exposure fix external exposure_rate_limit_log_only +contains "responses-per-second 10; }" +# A burst of identical queries in the external view (signed with its key): +# some are dropped. The internal view has no rate limit. +DROPS=0 +for i in $(seq 60); do + $DIG +tries=1 +time=1 +ignore -k $E/external.key -p $DNS @127.0.0.1 ns1.adbraun.test A | grep -q "status:" || DROPS=$((DROPS+1)) +done +if [ $DROPS -gt 0 ]; then ok "rate limiting dropped $DROPS of 60"; else bad "rate limiting dropped nothing"; fi +INTERNAL=$(for i in $(seq 30); do $DIG +tries=1 +time=1 +short -p $DNS @127.0.0.1 ns1.adbraun.test A; done | grep -c 127.0.0.1) +if [ "$INTERNAL" = 30 ]; then ok "the internal view isn't limited"; else bad "the internal view answered $INTERNAL of 30"; fi +run "remove the rate limit again" 0 $J option unset rate-limit --view external +run "an open resolver on a public view needs confirming (exit 5)" 5 $J option set allow-recursion any --view external +contains "accept-risk" +run "with --accept-risk" 0 $J option set allow-recursion any --view external --accept-risk +run "acknowledge it" 0 $J exposure ack external security_open_resolver --reason "e2e: on purpose" +run "acknowledged: info, with the reason" 0 $J exposure +contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose" +run "take it back" 0 $J exposure unack external security_open_resolver +run "close the resolver again" 0 $J option unset allow-recursion --view external +run "fix: hide the version" 0 $J exposure fix server exposure_version +expect_dig "version.bind is hidden" "" -c CH version.bind TXT +run "mark external private" 0 $J view update external --exposure private +run "exposure: private by hand" 0 $J exposure +contains "^external *private (set)" +run "back to auto" 0 $J view update external --exposure auto # named-checkconf's own warnings stay on the dashboard after the apply. run "option set recursion no (external)" 0 $J option set recursion no --view external run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external