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 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 10:38:56 +01:00
1 parent cf550bd512
commit bdd7acfd3b
50 files changed
+2019 -52

No files matched your search

+27
View File
@@ -8,12 +8,21 @@ All notable changes to Jarl are documented here. The format follows
### Upgrading from 0.4 ### 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 - 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 parser doesn't know them. Remove the ranges first if you need to
downgrade. downgrade.
### Fixed ### 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 - 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 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 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 ### 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 - `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the
changeset from `-C` or `JARL_CHANGESET` when no ID is given changeset from `-C` or `JARL_CHANGESET` when no ID is given
(`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now (`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now
+9
View File
@@ -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, queries, record types, cache hit rate. Shown in the web UI and the CLI,
and exportable as JSON. Stored as aggregates in a separate database with and exportable as JSON. Stored as aggregates in a separate database with
retention you set. retention you set.
* **Internet exposure checks**: Jarl works out which views are reachable
from the internet (a GUA-addressed internal network still counts as
yours) and checks them: response rate limiting (set up in log-only mode
first, with what it would limit shown before you enforce it), open
resolvers, ANY amplification, cache snooping, version and host name
leaks, open management interfaces. Fixes are staged for review, changes
that open a public view up must be confirmed, and deliberate exceptions
can be acknowledged with a reason.
* **Prometheus metrics** (optional): health to alert on (named down, * **Prometheus metrics** (optional): health to alert on (named down,
configuration problems, drift, failing blocklists, stale backups, DNSSEC configuration problems, drift, failing blocklists, stale backups, DNSSEC
steps waiting at the parent, expiring tokens) and query counters per 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/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik | | [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention | | [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
| [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts | | [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory | | [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
| [docs/validation.md](docs/validation.md) | Every check Jarl makes | | [docs/validation.md](docs/validation.md) | Every check Jarl makes |
+2 -2
View File
@@ -130,7 +130,7 @@ func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest)
} }
return report(&resp) return report(&resp)
} }
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil { if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force, AcceptRisk: c.AcceptRisk}, &resp); err != nil {
return err return err
} }
return report(&resp) return report(&resp)
@@ -349,7 +349,7 @@ func changesetCmd() *cobra.Command {
return err return err
} }
var resp api.MutationResponse 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 return err
} }
if err := report(&resp); err != nil { if err := report(&resp); err != nil {
+10 -2
View File
@@ -36,7 +36,7 @@ func viewCmd() *cobra.Command {
}, },
} }
var clients, dests []string var clients, dests []string
var recursiveOnly, comment string var recursiveOnly, comment, exposure string
var adopt, cascade, yes bool var adopt, cascade, yes bool
viewBody := func(cmd *cobra.Command) map[string]any { viewBody := func(cmd *cobra.Command) map[string]any {
body := map[string]any{} body := map[string]any{}
@@ -60,6 +60,9 @@ func viewCmd() *cobra.Command {
if f.Changed("comment") { if f.Changed("comment") {
body["comment"] = comment body["comment"] = comment
} }
if f.Changed("exposure") {
body["exposure"] = exposure
}
return body return body
} }
addViewFlags := func(c *cobra.Command) { addViewFlags := func(c *cobra.Command) {
@@ -67,6 +70,7 @@ func viewCmd() *cobra.Command {
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements") c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default") c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
c.Flags().StringVar(&comment, "comment", "", "comment") c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
} }
create := &cobra.Command{ create := &cobra.Command{
Use: "create NAME", Use: "create NAME",
@@ -756,7 +760,7 @@ func settingsCmd() *cobra.Command {
}, },
} }
var ns []string var ns []string
var contact, policy string var contact, policy, srvExposure string
var ttl, refresh, retry, expire, neg uint32 var ttl, refresh, retry, expire, neg uint32
set := &cobra.Command{ set := &cobra.Command{
Use: "set", Use: "set",
@@ -777,6 +781,9 @@ func settingsCmd() *cobra.Command {
if f.Changed("serial-policy") { if f.Changed("serial-policy") {
body["serial_policy"] = policy body["serial_policy"] = policy
} }
if f.Changed("exposure") {
body["exposure"] = srvExposure
}
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} { for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
if f.Changed(name) { if f.Changed(name) {
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name] key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
@@ -799,6 +806,7 @@ func settingsCmd() *cobra.Command {
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers") set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
set.Flags().StringVar(&contact, "contact", "", "default SOA contact") set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime") set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
set.Flags().StringVar(&srvExposure, "exposure", "", "without views: is the server reachable from the internet? auto, public or private (docs/exposure.md)")
set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL") set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh") set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry") set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
+191
View File
@@ -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
}
+4 -2
View File
@@ -47,6 +47,7 @@ var (
flagChangeset int64 flagChangeset int64
flagDryRun bool flagDryRun bool
flagForce bool flagForce bool
flagRisk bool
flagOutput string flagOutput string
flagInsecure bool flagInsecure bool
flagCACert string flagCACert string
@@ -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 { if !c.Preflight {
c.OnTokenExpiry = tokenExpiryWarning(t.Name) 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.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them") pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl") pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
pf.BoolVar(&flagRisk, "accept-risk", false, "confirm a change that opens a public view up, such as an open resolver (docs/exposure.md)")
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)") pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate") pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate") pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
@@ -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("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
add("changes", applyCmd(), changesetCmd(), historyCmd()) add("changes", applyCmd(), changesetCmd(), historyCmd())
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd()) add("admin", statusCmd(), exposureCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
setupCompletion(root) setupCompletion(root)
return root return root
} }
+1 -1
View File
@@ -90,7 +90,7 @@ func perView(c *client.Client, views []string, description string, do func(c *cl
return nil return nil
} }
var resp api.MutationResponse var resp api.MutationResponse
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp) err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp)
if err != nil || c.DryRun { if err != nil || c.DryRun {
discard() discard()
} }
+6 -2
View File
@@ -128,14 +128,15 @@ export the data.`,
var limit int var limit int
top := &cobra.Command{ top := &cobra.Command{
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]", Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients|ratelimited|ratelimited-names]",
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names", Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
Example: ` jarl stats top names --since 7d Example: ` jarl stats top names --since 7d
jarl stats top clients --limit 50 jarl stats top clients --limit 50
jarl stats top nxdomain # misconfigured hosts and typos jarl stats top nxdomain # misconfigured hosts and typos
jarl stats top refused # who is asking for what they may not have jarl stats top refused # who is asking for what they may not have
jarl stats top blocked # what blocking stops most (docs/blocking.md) jarl stats top blocked # what blocking stops most (docs/blocking.md)
jarl stats top blocked-clients # the devices blocking stops most`, jarl stats top blocked-clients # the devices blocking stops most
jarl stats top ratelimited # client networks rate limiting limits, or would (docs/exposure.md)`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
kind := stats.KindName kind := stats.KindName
@@ -147,6 +148,9 @@ export the data.`,
if kind == "blocked-client" { if kind == "blocked-client" {
kind = stats.KindBlockedClient kind = stats.KindBlockedClient
} }
if kind == "ratelimited-name" {
kind = stats.KindRateLimitedName
}
} }
c, err := newClient() c, err := newClient()
if err != nil { if err != nil {
+2
View File
@@ -27,6 +27,7 @@ import (
"jarl/internal/config" "jarl/internal/config"
"jarl/internal/engine" "jarl/internal/engine"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/nscheck"
"jarl/internal/porkbun" "jarl/internal/porkbun"
"jarl/internal/render" "jarl/internal/render"
"jarl/internal/server" "jarl/internal/server"
@@ -122,6 +123,7 @@ func openApp(allowMissingConfig bool) (*app, error) {
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout), ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
NoReload: cfg.BIND.NoReload, NoReload: cfg.BIND.NoReload,
Extras: renderExtras(cfg), Extras: renderExtras(cfg),
LocalAddrs: nscheck.LocalAddrs,
}, log) }, log)
eng.Limits = engine.BlocklistLimits{ eng.Limits = engine.BlocklistLimits{
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute, Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
+1
View File
@@ -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/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
| GET | `/catalog/rrtypes` | record types with field hints and examples | | GET | `/catalog/rrtypes` | record types with field hints and examples |
| GET | `/catalog/grants` | resources, roles and the grant syntax | | GET | `/catalog/grants` | resources, roles and the grant syntax |
| GET | `/exposure` | which views are reachable from the internet, the exposure findings and their fixes ([exposure.md](exposure.md#api)); `POST /exposure/fix`, `/exposure/ack`, `/exposure/unack` |
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out | | GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
| GET | `/search?q=text` | zones, records and ACLs matching the text | | GET | `/search?q=text` | zones, records and ACLs matching the text |
+15
View File
@@ -143,6 +143,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
| `-n, --dry-run` | Validate and show the diff without changing anything. | | `-n, --dry-run` | Validate and show the diff without changing anything. |
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. | | `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). | | `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
| `--accept-risk` | Confirm a change that opens a public view up, such as an open resolver ([exposure.md](exposure.md)). Without it such a change stops with exit 5. |
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). | | `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
## Exit codes ## Exit codes
@@ -382,6 +383,20 @@ an administrator).
Changesets staged in the web UI show up here too, and the other way round. Changesets staged in the web UI show up here too, and the other way round.
## Internet exposure
```sh
jarl exposure # public views, findings, fixes, rate limiting
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
jarl exposure fix server exposure_version
jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
jarl exposure unack external security_open_resolver
jarl view update external --exposure public|private|auto
```
See [exposure.md](exposure.md).
## Views, ACLs, TSIG keys, options, settings ## Views, ACLs, TSIG keys, options, settings
```sh ```sh
+171
View File
@@ -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`).
+2 -1
View File
@@ -148,7 +148,8 @@ for them.
| `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. | | `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. |
| `jarl_answers_total{source}` | `authoritative` or `recursive`. | | `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_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_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_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. |
| `jarl_cache_lookups_total{result}` | `hits` and `misses`. | | `jarl_cache_lookups_total{result}` | `hits` and `misses`. |
+3 -1
View File
@@ -103,6 +103,7 @@ lists, views, zones). named's own counters can't be filtered.
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones | | `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
| `responses` | response log | lines read (a health check) | | `responses` | response log | lines read (a health check) |
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) | | `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
| `rrl:drop`, `rrl:slip`, `rrl:would-drop`, `rrl:would-slip` | response log | responses [rate limiting](exposure.md#response-rate-limiting) limited, or would have in log-only mode (named's own `ratelimit:*` counters don't move then) |
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of **Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
the samples and `gn:KEY` their number, so a chart shows the average): the samples and `gn:KEY` their number, so a chart shows the average):
@@ -167,7 +168,8 @@ jarl stats top refused # who asks for what they may not have
jarl stats timeline --since 6h # sparkline; --table for every bucket jarl stats timeline --since 6h # sparkline; --table for every bucket
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
jarl stats heatmap --since 28d jarl stats heatmap --since 28d
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit | rrl
jarl stats top ratelimited # client networks rate limiting limits (or would)
jarl stats server # memory, CPU, cache, clients, host, disks now jarl stats server # memory, CPU, cache, clients, host, disks now
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
jarl stats summary --since 30d -o json # export (also -o yaml) jarl stats summary --since 30d -o json # export (also -o yaml)
+15
View File
@@ -124,11 +124,26 @@ From [blocking.md](blocking.md).
## Security lints ## Security lints
These and the exposure checks below only look at views that are
reachable from the internet ([exposure.md](exposure.md)); without views,
the open resolver and open transfer checks look at the server. Any of them
can be acknowledged (it then shows as info, with who accepted it and why),
and a change that newly introduces an open resolver, open transfers, cache
snooping, open NOTIFY or an open management interface on a public view
must be confirmed (`--accept-risk`).
| Code | Severity | What it catches | | Code | Severity | What it catches |
|------|----------|-----------------| |------|----------|-----------------|
| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks | | `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks |
| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key | | `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key |
| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead | | `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead |
| `exposure_rate_limit` | warning | A public view without response rate limiting (`rate-limit`) |
| `exposure_rate_limit_log_only` | info | Rate limiting in log-only mode: check what it would limit, then enforce |
| `exposure_minimal_any` | warning | A public view answering ANY queries in full (`minimal-any` off) |
| `exposure_cache_snooping` | warning | A public view without recursion whose cache anyone may query (`allow-query-cache`) |
| `exposure_open_notify` | warning | A public view accepting NOTIFY from anyone (`allow-notify`) |
| `exposure_version`, `exposure_hostname` | warning | The server is public and shows its BIND version or host name to anyone (`version none`, `hostname none`) |
| `exposure_management` | warning | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone, while the server is public |
## What only BIND checks ## What only BIND checks
+5
View File
@@ -81,6 +81,11 @@ avoids named-checkconf's warning that `allow-recursion` is still active in a
view with recursion off. Jarl warns (`security_open_resolver`) if a view view with recursion off. Jarl warns (`security_open_resolver`) if a view
ever offers recursion to everyone. ever offers recursion to everyone.
Jarl counts the external view as reachable from the internet (its
match-clients end in `any`) and checks it: rate limiting, ANY queries,
the version string and more. See the **Exposure** page or `jarl exposure`,
and [exposure.md](exposure.md) to set up response rate limiting.
### 4. Give the external view its zones ### 4. Give the external view its zones
**Different data per view (split DNS).** Copy the zone, then change what **Different data per view (split DNS).** Copy the zone, then change what
+3
View File
@@ -208,6 +208,9 @@ type BatchRequest struct {
Ops []ChangeRequest `json:"ops"` Ops []ChangeRequest `json:"ops"`
DryRun bool `json:"dry_run,omitempty"` DryRun bool `json:"dry_run,omitempty"`
Force bool `json:"force,omitempty"` Force bool `json:"force,omitempty"`
// AcceptRisk confirms a change that opens the server up
// (docs/exposure.md).
AcceptRisk bool `json:"accept_risk,omitempty"`
// Apply (for POST /changesets) applies the new changeset at once. // Apply (for POST /changesets) applies the new changeset at once.
Apply bool `json:"apply,omitempty"` Apply bool `json:"apply,omitempty"`
} }
+6
View File
@@ -38,6 +38,9 @@ type Client struct {
csErr error csErr error
DryRun bool DryRun bool
Force bool Force bool
// AcceptRisk confirms changes that open the server up
// (docs/exposure.md).
AcceptRisk bool
// Preflight lets only requests through that can't change anything: // Preflight lets only requests through that can't change anything:
// reads and dry runs. Running a command on several servers uses it to // reads and dry runs. Running a command on several servers uses it to
// check the command everywhere before running it anywhere. // check the command everywhere before running it anywhere.
@@ -282,6 +285,9 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
if c.Force { if c.Force {
q.Set("force", "1") q.Set("force", "1")
} }
if c.AcceptRisk {
q.Set("accept_risk", "1")
}
var out api.MutationResponse var out api.MutationResponse
err = c.Do(ctx, method, path, q, body, &out) err = c.Do(ctx, method, path, q, body, &out)
return &out, err return &out, err
+5 -2
View File
@@ -3,6 +3,7 @@ package engine
import ( import (
"fmt" "fmt"
"reflect" "reflect"
"slices"
"sort" "sort"
"jarl/internal/dnsname" "jarl/internal/dnsname"
@@ -23,7 +24,8 @@ func DiffOps(from, to *model.State) []model.Op {
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) { if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
s := to.Settings s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName, add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings") Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings")
} }
for _, k := range sortedKeys(to.Keys) { for _, k := range sortedKeys(to.Keys) {
@@ -47,7 +49,8 @@ func DiffOps(from, to *model.State) []model.Op {
for _, k := range sortedKeys(to.Views) { for _, k := range sortedKeys(to.Views) {
n := to.Views[k] n := to.Views[k]
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)), in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)} MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
if n.MatchRecursiveOnly != nil { if n.MatchRecursiveOnly != nil {
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly) in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
} else { } else {
+79 -12
View File
@@ -15,6 +15,7 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"net/netip"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -56,6 +57,10 @@ type Config struct {
NoReload bool NoReload bool
// SkipCheck disables named-checkconf (only for tests without BIND). // SkipCheck disables named-checkconf (only for tests without BIND).
SkipCheck bool SkipCheck bool
// LocalAddrs returns this host's addresses, for working out whether a
// server without views is reachable from the internet
// (docs/exposure.md). nil: none known.
LocalAddrs func() []netip.Addr
} }
// Engine applies changesets. // Engine applies changesets.
@@ -110,17 +115,20 @@ type Result struct {
DryRun bool `json:"dry_run,omitempty"` DryRun bool `json:"dry_run,omitempty"`
Valid bool `json:"valid"` Valid bool `json:"valid"`
Problems []validate.Problem `json:"problems"` // problems this change introduces Problems []validate.Problem `json:"problems"` // problems this change introduces
Existing []validate.Problem `json:"existing_problems,omitempty"` // Risks are the introduced problems that open the server up; applying
Check []CheckMessage `json:"check,omitempty"` // needs AcceptRisk (docs/exposure.md).
CheckOK bool `json:"check_ok"` Risks []validate.Problem `json:"risks,omitempty"`
Diff string `json:"diff"` Existing []validate.Problem `json:"existing_problems,omitempty"`
Files []string `json:"files_changed"` Check []CheckMessage `json:"check,omitempty"`
Serials []SerialChange `json:"serials,omitempty"` CheckOK bool `json:"check_ok"`
Reloaded []string `json:"reloaded,omitempty"` Diff string `json:"diff"`
Warnings []string `json:"warnings,omitempty"` Files []string `json:"files_changed"`
Drift []DriftEntry `json:"drift,omitempty"` Serials []SerialChange `json:"serials,omitempty"`
Ops []model.Op `json:"ops,omitempty"` Reloaded []string `json:"reloaded,omitempty"`
Error *Error `json:"error,omitempty"` Warnings []string `json:"warnings,omitempty"`
Drift []DriftEntry `json:"drift,omitempty"`
Ops []model.Op `json:"ops,omitempty"`
Error *Error `json:"error,omitempty"`
} }
// stored is what is kept in a changeset's result column. // stored is what is kept in a changeset's result column.
@@ -138,6 +146,9 @@ type ApplyOptions struct {
Force bool Force bool
// NoReload writes the files without reloading named. // NoReload writes the files without reloading named.
NoReload bool NoReload bool
// AcceptRisk confirms a change that opens the server up, such as an
// open resolver on a public view (validate.RiskCodes).
AcceptRisk bool
// wipeZone lets WipeDNSSEC switch signing off for this zone at once. // wipeZone lets WipeDNSSEC switch signing off for this zone at once.
wipeZone int64 wipeZone int64
} }
@@ -180,7 +191,46 @@ func ValidateOptions(st *model.State) validate.Options {
} }
func (e *Engine) validate(st *model.State) []validate.Problem { func (e *Engine) validate(st *model.State) []validate.Problem {
return validate.State(st, ValidateOptions(st)) return validate.State(st, e.validateOptions(st))
}
func (e *Engine) validateOptions(st *model.State) validate.Options {
opts := ValidateOptions(st)
if e.cfg.LocalAddrs != nil {
opts.LocalAddrs = e.cfg.LocalAddrs()
}
return opts
}
// Validate checks a state as applying it would.
func (e *Engine) Validate(st *model.State) []validate.Problem { return e.validate(st) }
// Exposures says which views (or the server, without views) are
// reachable from the internet, and why (docs/exposure.md).
func (e *Engine) Exposures(st *model.State) []validate.Exposure {
return validate.Exposures(st, e.validateOptions(st))
}
// risks returns the introduced problems that open a public view (or a
// public server) up: risky warnings that weren't there before at all. One
// that was there already, acknowledged (taking the acknowledgement back)
// or not, opens nothing new.
func risks(before, introduced []validate.Problem, public map[string]bool) []validate.Problem {
type key struct {
code, objType, view string
id int64
}
had := map[key]bool{}
for _, p := range before {
had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] = true
}
var out []validate.Problem
for _, p := range introduced {
if p.Severity == validate.Warning && validate.RiskCodes[p.Code] && public[p.View] && !had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] {
out = append(out, p)
}
}
return out
} }
// ---- changeset management // ---- changeset management
@@ -389,6 +439,8 @@ type plan struct {
base, work *model.State base, work *model.State
introduced []validate.Problem introduced []validate.Problem
existing []validate.Problem existing []validate.Problem
before []validate.Problem // the problems before the change
public map[string]bool // public views by name ("": the server), after the change
serials []SerialChange serials []SerialChange
out *render.Output out *render.Output
changed []*render.File changed []*render.File
@@ -419,6 +471,13 @@ func (pl *plan) files() []string {
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) { func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
pl := &plan{base: base, work: work, verify: map[int64]uint32{}} pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
before := e.validate(base) before := e.validate(base)
pl.before = before
pl.public = map[string]bool{}
for _, x := range e.Exposures(work) {
pl.public[x.View] = x.Public
// Server-wide findings (no view) count when anything is public.
pl.public[""] = pl.public[""] || x.Public
}
after := e.validate(work) after := e.validate(work)
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...) pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
introducedKeys := map[string]bool{} introducedKeys := map[string]bool{}
@@ -652,6 +711,7 @@ func (pl *plan) result() *Result {
Files: pl.files(), Files: pl.files(),
Serials: pl.serials, Serials: pl.serials,
Drift: pl.drift, Drift: pl.drift,
Risks: risks(pl.before, pl.introduced, pl.public),
} }
r.Valid = r.Valid && r.CheckOK r.Valid = r.Valid && r.CheckOK
if r.Files == nil { if r.Files == nil {
@@ -781,6 +841,13 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
if opts.DryRun { if opts.DryRun {
return res, nil return res, nil
} }
if rs := risks(pl.before, pl.introduced, pl.public); len(rs) > 0 && !opts.AcceptRisk {
msg := rs[0].Message
if len(rs) > 1 {
msg += fmt.Sprintf(" (and %d more)", len(rs)-1)
}
return res, &Error{Kind: KindRisk, Message: "this change opens the server up: " + msg + "; apply it again with accept_risk to confirm (jarl: --accept-risk), or acknowledge the finding for good", Problems: rs}
}
if len(pl.drift) > 0 && !opts.Force { if len(pl.drift) > 0 && !opts.Force {
var paths []string var paths []string
for _, d := range pl.drift { for _, d := range pl.drift {
+1
View File
@@ -21,6 +21,7 @@ const (
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
KindDrift ErrorKind = "drift" // files were changed outside Jarl KindDrift ErrorKind = "drift" // files were changed outside Jarl
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
KindRisk ErrorKind = "confirm_risk" // the change opens the server up and needs confirming
) )
// Error is an engine error with details for the caller. // Error is an engine error with details for the caller.
+63
View File
@@ -228,6 +228,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
return []model.Op{x.versioned(req, o.Version, summary)}, nil return []model.Op{x.versioned(req, o.Version, summary)}, nil
case OpSettingsUpdate: case OpSettingsUpdate:
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
case OpExposureAck, OpExposureUnack:
return x.exposureAck(req, req.Op == OpExposureAck)
case OpBlocklistCreate: case OpBlocklistCreate:
return x.blocklistCreate(req) return x.blocklistCreate(req)
case OpBlocklistUpdate, OpBlocklistDelete: case OpBlocklistUpdate, OpBlocklistDelete:
@@ -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) 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
}
+163
View File
@@ -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:])
}
+142
View File
@@ -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})
}
+19
View File
@@ -51,8 +51,21 @@ const (
// operations. // operations.
OpRecordDisable = "record.disable" OpRecordDisable = "record.disable"
OpRecordEnable = "record.enable" OpRecordEnable = "record.enable"
// Acknowledging an exposure finding (docs/exposure.md), and taking it
// back, become view.update or settings.update operations.
OpExposureAck = "exposure.ack"
OpExposureUnack = "exposure.unack"
) )
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
// (by ID or name), or of the server (neither given).
type AckInput struct {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Code string `json:"code"`
Reason string `json:"reason,omitempty"`
}
// OpKinds lists all operation kinds. // OpKinds lists all operation kinds.
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete, OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
@@ -184,6 +197,9 @@ type ViewInput struct {
AdoptZones bool `json:"adopt_zones,omitempty"` AdoptZones bool `json:"adopt_zones,omitempty"`
// Cascade (delete) also deletes the view's zones. // Cascade (delete) also deletes the view's zones.
Cascade bool `json:"cascade,omitempty"` Cascade bool `json:"cascade,omitempty"`
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
} }
// ReorderInput sets the order of views. // ReorderInput sets the order of views.
@@ -227,6 +243,9 @@ type SettingsInput struct {
Expire *uint32 `json:"expire,omitempty"` Expire *uint32 `json:"expire,omitempty"`
NegativeTTL *uint32 `json:"negative_ttl,omitempty"` NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
SerialPolicy *string `json:"serial_policy,omitempty"` SerialPolicy *string `json:"serial_policy,omitempty"`
// Exposure and Acknowledged are the server's (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
} }
// DeleteInput carries options for delete operations. // DeleteInput carries options for delete operations.
+42
View File
@@ -765,9 +765,41 @@ func setViewFields(v *model.View, in *ViewInput) error {
if in.Position != nil { if in.Position != nil {
v.Position = *in.Position v.Position = *in.Position
} }
if in.Exposure != nil {
e, err := normalizeExposure(*in.Exposure)
if err != nil {
return err
}
v.Exposure = e
}
if in.Acknowledged != nil {
v.Acknowledged = cleanAcks(*in.Acknowledged)
}
return nil return nil
} }
// normalizeExposure checks an exposure override.
func normalizeExposure(s string) (string, error) {
switch e := strings.ToLower(strings.TrimSpace(s)); e {
case "", "auto":
return model.ExposureAuto, nil
case model.ExposurePublic, model.ExposurePrivate:
return e, nil
}
return "", errf(KindBadRequest, "exposure must be auto, public or private, not %q", s)
}
// cleanAcks drops empty acknowledgements (nil for none).
func cleanAcks(acks []model.Acknowledgement) []model.Acknowledgement {
var out []model.Acknowledgement
for _, a := range acks {
if a.Code != "" {
out = append(out, a)
}
}
return out
}
func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error { func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error {
var in ViewInput var in ViewInput
if err := decode(op.Data, &in); err != nil { if err := decode(op.Data, &in); err != nil {
@@ -1329,6 +1361,16 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
if in.SerialPolicy != nil { if in.SerialPolicy != nil {
s.SerialPolicy = strings.ToLower(*in.SerialPolicy) s.SerialPolicy = strings.ToLower(*in.SerialPolicy)
} }
if in.Exposure != nil {
e, err := normalizeExposure(*in.Exposure)
if err != nil {
return err
}
s.Exposure = e
}
if in.Acknowledged != nil {
s.Acknowledged = cleanAcks(*in.Acknowledged)
}
s.Version++ s.Version++
return nil return nil
} }
+41 -1
View File
@@ -50,9 +50,43 @@ type View struct {
MatchDestinations []string `json:"match_destinations"` MatchDestinations []string `json:"match_destinations"`
MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"` MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"`
Comment string `json:"comment"` Comment string `json:"comment"`
// Exposure overrides whether the view counts as reachable from the
// internet (docs/exposure.md): "" works it out, ExposurePublic or
// ExposurePrivate say so.
Exposure string `json:"exposure,omitempty"`
// Acknowledged are exposure findings accepted on purpose; they are
// shown as info instead of warnings.
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
Meta Meta
} }
// Exposure overrides.
const (
ExposureAuto = ""
ExposurePublic = "public"
ExposurePrivate = "private"
)
// Acknowledgement accepts one exposure finding (by its code) for a view,
// or for the server: the finding stays, as info, with who accepted it,
// when and why.
type Acknowledgement struct {
Code string `json:"code"`
Reason string `json:"reason"`
By string `json:"by"`
At time.Time `json:"at"`
}
// FindAck returns the acknowledgement of code in acks, if any.
func FindAck(acks []Acknowledgement, code string) *Acknowledgement {
for i := range acks {
if acks[i].Code == code {
return &acks[i]
}
}
return nil
}
// ACL is a named address match list. // ACL is a named address match list.
type ACL struct { type ACL struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -217,7 +251,11 @@ type Settings struct {
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"` BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
// BlockingPausedIndefinitely pauses blocking until someone resumes it. // BlockingPausedIndefinitely pauses blocking until someone resumes it.
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"` BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
Version int `json:"version"` // Exposure and Acknowledged are the server's own (without views, and
// for server-wide findings such as a visible version): see View.
Exposure string `json:"exposure,omitempty"`
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
Version int `json:"version"`
} }
// BlockingPaused reports whether blocking is paused, for a time or until // BlockingPaused reports whether blocking is paused, for a time or until
@@ -289,10 +327,12 @@ func (s *State) Clone() *State {
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)), ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
} }
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers) c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
c.Settings.Acknowledged = slices.Clone(s.Settings.Acknowledged)
for id, v := range s.Views { for id, v := range s.Views {
n := *v n := *v
n.MatchClients = slices.Clone(v.MatchClients) n.MatchClients = slices.Clone(v.MatchClients)
n.MatchDestinations = slices.Clone(v.MatchDestinations) n.MatchDestinations = slices.Clone(v.MatchDestinations)
n.Acknowledged = slices.Clone(v.Acknowledged)
if v.MatchRecursiveOnly != nil { if v.MatchRecursiveOnly != nil {
b := *v.MatchRecursiveOnly b := *v.MatchRecursiveOnly
n.MatchRecursiveOnly = &b n.MatchRecursiveOnly = &b
+8 -2
View File
@@ -272,6 +272,12 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
pzs := PolicyZones(s, p) pzs := PolicyZones(s, p)
out.Policy = pzs out.Policy = pzs
logRewrites := logResponses && len(pzs) > 0 logRewrites := logResponses && len(pzs) > 0
// Rate limiting logs what it limits (or would, in log-only mode) as
// query-errors; the statistics count it (docs/exposure.md).
logRRL := false
for _, o := range s.Options {
logRRL = logRRL || logResponses && o.Name == "rate-limit"
}
sawStats, sawLogging := false, false sawStats, sawLogging := false, false
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 { if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
w.write("\n") w.write("\n")
@@ -282,7 +288,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
w.write(st.statsStatement(o)) w.write(st.statsStatement(o))
sawStats = true sawStats = true
case logResponses && o.Name == "logging" && !sawLogging: case logResponses && o.Name == "logging" && !sawLogging:
w.write(st.loggingStatement(o, logRewrites)) w.write(st.loggingStatement(o, logRewrites, logRRL))
sawLogging = true sawLogging = true
default: default:
w.write(optionText(o, 0)) w.write(optionText(o, 0))
@@ -297,7 +303,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
w.write(st.statsStatement(nil)) w.write(st.statsStatement(nil))
} }
if logResponses && !sawLogging { if logResponses && !sawLogging {
w.write(st.loggingStatement(nil, logRewrites)) w.write(st.loggingStatement(nil, logRewrites, logRRL))
} }
w.end() w.end()
} }
+14 -4
View File
@@ -72,9 +72,10 @@ func (h *StatsHooks) statsStatement(user *model.Option) string {
} }
// loggingStatement returns the logging statement with Jarl's channel and // loggingStatement returns the logging statement with Jarl's channel and
// the responses category (and rpz, for blocking's rewrites), merged into // the responses category (rpz too, for blocking's rewrites, and
// the user's own if there is one. // query-errors for rate limiting), merged into the user's own if there is
func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string { // one.
func (h *StatsHooks) loggingStatement(user *model.Option, rpz, rrl bool) string {
var st *namedconf.Stmt var st *namedconf.Stmt
if user != nil { if user != nil {
var err error var err error
@@ -92,6 +93,9 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
if rpz { if rpz {
categories = append(categories, "rpz") categories = append(categories, "rpz")
} }
if rrl {
categories = append(categories, "query-errors")
}
for _, cat := range categories { for _, cat := range categories {
merged := false merged := false
for _, s := range b.Stmts { for _, s := range b.Stmts {
@@ -101,7 +105,13 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
} }
} }
if !merged { if !merged {
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, namedconf.NewBlock(namedconf.NewStmt(StatsChannelName)))) channels := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))
if cat == "query-errors" {
// Naming the category takes it away from named's
// default destination: keep that too.
channels.Stmts = append(channels.Stmts, namedconf.NewStmt("default_syslog"))
}
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, channels))
} }
} }
prefix := "" prefix := ""
+68
View File
@@ -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)
}
}
}
+9 -4
View File
@@ -61,6 +61,9 @@ func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Prin
if in.Force { if in.Force {
q.Set("force", "1") q.Set("force", "1")
} }
if in.AcceptRisk {
q.Set("accept_risk", "1")
}
r.URL.RawQuery = q.Encode() r.URL.RawQuery = q.Encode()
return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil) return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil)
} }
@@ -106,7 +109,7 @@ func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth
} }
cs = res.Changeset cs = res.Changeset
if in.Apply { if in.Apply {
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force}) out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force, AcceptRisk: in.AcceptRisk})
if err != nil { if err != nil {
s.writeErr(w, r, err, out) s.writeErr(w, r, err, out)
return nil return nil
@@ -214,8 +217,9 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
return err return err
} }
var in struct { var in struct {
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
Force bool `json:"force"` Force bool `json:"force"`
AcceptRisk bool `json:"accept_risk"`
} }
if body, err := readBody(r); err != nil { if body, err := readBody(r); err != nil {
return err return err
@@ -224,7 +228,8 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
return badRequest("invalid JSON: %v", err) return badRequest("invalid JSON: %v", err)
} }
} }
res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force")}) res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force"),
AcceptRisk: in.AcceptRisk || queryBool(r, "accept_risk")})
if err != nil { if err != nil {
s.writeErr(w, r, err, res) s.writeErr(w, r, err, res)
return nil return nil
+146
View File
@@ -0,0 +1,146 @@
package server
import (
"net/http"
"time"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/stats"
"jarl/internal/validate"
)
// exposureFinding is an exposure problem with the fix Jarl can stage for
// it ("" when there is none).
type exposureFinding struct {
validate.Problem
Fix string `json:"fix,omitempty"`
}
type exposureScope struct {
validate.Exposure
RateLimit string `json:"rate_limit"` // off, log-only or enforcing
Findings []exposureFinding `json:"findings"`
}
type exposureReport struct {
Scopes []exposureScope `json:"scopes"`
// Server holds server-wide findings (version, host name, management
// interfaces).
Server []exposureFinding `json:"server"`
// RateLimiting is what rate limiting limited, or would have, in the
// last 24 hours (with query statistics on).
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
}
type rateLimitActivity struct {
Since string `json:"since"`
Counts []stats.Entry `json:"counts"` // drop, slip, would-drop, would-slip
Networks []stats.Entry `json:"networks"` // client networks, most limited first
Names []stats.Entry `json:"names"` // "name TYPE"
}
// exposure: GET /exposure reports which views are reachable from the
// internet and what the exposure checks found (docs/exposure.md).
func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
problems := s.eng.Validate(st)
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
finding := func(pr validate.Problem, viewID int64) exposureFinding {
_, desc := engine.ExposureFix(st, viewID, pr.Code)
return exposureFinding{Problem: pr, Fix: desc}
}
scopes := map[string]int{}
for _, x := range s.eng.Exposures(st) {
sc := exposureScope{Exposure: x, RateLimit: "off", Findings: []exposureFinding{}}
v := st.Views[x.ViewID]
o := st.FindOption(model.ScopeOptions, 0, "rate-limit")
if v != nil {
if vo := st.FindOption(model.ScopeView, v.ID, "rate-limit"); vo != nil {
o = vo
}
}
if o != nil {
sc.RateLimit = "enforcing"
if validate.LogOnly(o) {
sc.RateLimit = "log-only"
}
}
scopes[x.View] = len(out.Scopes)
out.Scopes = append(out.Scopes, sc)
}
for _, pr := range problems {
if !validate.Ackable(pr.Code) {
continue
}
if pr.ObjectType == "view" || pr.ObjectType == "zone" {
if i, ok := scopes[pr.View]; ok {
out.Scopes[i].Findings = append(out.Scopes[i].Findings, finding(pr, out.Scopes[i].ViewID))
continue
}
}
out.Server = append(out.Server, finding(pr, 0))
}
if s.statsQ != nil && p.Can(auth.Action{Resource: auth.ResStats}) {
ctx, since := r.Context(), 24*time.Hour
a := &rateLimitActivity{Since: "24h"}
a.Counts, _ = s.statsQ.Breakdown(ctx, "rrl", since)
a.Networks, _ = s.statsQ.Top(ctx, stats.KindRateLimited, since, 10)
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
out.RateLimiting = a
}
writeJSON(w, http.StatusOK, out)
return nil
}
// exposureFix: POST /exposure/fix {"view_id": 3, "code": "..."} stages (or
// applies) the fix for a finding, like any other change.
func (s *Server) exposureFix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in struct {
ViewID int64 `json:"view_id"`
View string `json:"view"`
Code string `json:"code"`
}
if err := decodeJSON(r, &in); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
if in.View != "" {
v := st.ViewByName(in.View)
if v == nil {
return notFound("no view %s", in.View)
}
in.ViewID = v.ID
}
reqs, desc := engine.ExposureFix(st, in.ViewID, in.Code)
if reqs == nil {
return badRequest("there is no automatic fix for %s: what to allow is yours to decide; change the option yourself, or acknowledge the finding", in.Code)
}
return s.mutate(w, r, p, desc, reqs, nil)
}
// exposureAck: POST /exposure/ack {"view": "external", "code": "...",
// "reason": "..."} accepts a finding (unack takes it back).
func (s *Server) exposureAck(ack bool) handler {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.AckInput
if err := decodeJSON(r, &in); err != nil {
return err
}
op := engine.OpExposureUnack
if ack {
op = engine.OpExposureAck
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
}
}
+1
View File
@@ -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)."}, "answer": {"jarl_answers_total", "source", "Answers that were authoritative or not (recursive)."},
"transport": {"jarl_queries_by_transport_total", "transport", "Queries over UDP and TCP."}, "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)."}, "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."}, "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."}, "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."}, "cache": {"jarl_cache_lookups_total", "result", "Cache lookups that hit or missed."},
+1 -1
View File
@@ -62,7 +62,7 @@ func engineStatus(k engine.ErrorKind) int {
return http.StatusForbidden return http.StatusForbidden
case engine.KindNotFound: case engine.KindNotFound:
return http.StatusNotFound return http.StatusNotFound
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted: case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted, engine.KindRisk:
return http.StatusConflict return http.StatusConflict
case engine.KindValidation: case engine.KindValidation:
return http.StatusUnprocessableEntity return http.StatusUnprocessableEntity
+5 -1
View File
@@ -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}/present", s.httpreq(true))
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false)) m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
// Query statistics // Query statistics
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus)) m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary)) m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop)) m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
@@ -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)}) writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
return nil return nil
} }
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")} opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")}
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts) res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
if err != nil { if err != nil {
s.writeErr(w, r, err, res) s.writeErr(w, r, err, res)
+32 -1
View File
@@ -26,10 +26,15 @@ const (
KindBlocked = "blocked" // names blocked (docs/blocking.md) KindBlocked = "blocked" // names blocked (docs/blocking.md)
// KindBlockedClient: clients with the most blocked queries. // KindBlockedClient: clients with the most blocked queries.
KindBlockedClient = "blocked_client" KindBlockedClient = "blocked_client"
// KindRateLimited: client networks whose responses rate limiting
// limited (or would have, in log-only mode); KindRateLimitedName: the
// names and types they asked for (docs/exposure.md).
KindRateLimited = "ratelimited"
KindRateLimitedName = "ratelimited_name"
) )
// Kinds lists the top-list kinds. // Kinds lists the top-list kinds.
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient} var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName}
// OtherZone is the zone key for names outside every local zone (answered // OtherZone is the zone key for names outside every local zone (answered
// by recursion, or refused). // by recursion, or refused).
@@ -337,6 +342,8 @@ func (c *Collector) handleLine(line string) {
if !ok { if !ok {
if rw, ok := ParseRewrite(line, c.opts.Location); ok { if rw, ok := ParseRewrite(line, c.opts.Location); ok {
c.handleRewrite(rw) c.handleRewrite(rw)
} else if rl, ok := ParseRateLimit(line, c.opts.Location); ok {
c.handleRateLimit(rl)
} }
return return
} }
@@ -416,6 +423,30 @@ func (c *Collector) handleRewrite(rw Rewrite) {
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++ hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
} }
// handleRateLimit counts a response rate limiting limited, or would have:
// "rrl:drop", "rrl:slip", "rrl:would-drop" or "rrl:would-slip", and the
// client network and name in the hour's top lists.
func (c *Collector) handleRateLimit(rl RateLimit) {
for _, p := range c.opts.Exclude {
if p.Contains(rl.Client) {
return
}
}
key := "rrl:" + rl.Action
if rl.Would {
key = "rrl:would-" + rl.Action
}
minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time)
c.count(minute, key, 1)
hours := c.hours
if hour < c.liveHour {
hours = c.late
}
hours[hour] = c.hourMaps(hours, hour)
hours[hour][KindRateLimited][rl.Prefix]++
hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++
}
// policySource names what a rewrite came from: a blocklist's name, "rules" // policySource names what a rewrite came from: a blocklist's name, "rules"
// for the rules entered in Jarl, or the policy zone for others. // for the rules entered in Jarl, or the policy zone for others.
func policySource(via string) string { func policySource(via string) string {
+41 -3
View File
@@ -33,17 +33,55 @@ type Rewrite struct {
// //
// 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2) // 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)
// //
// The "view NAME: " part is missing when no views are configured. // The "view NAME: " part is missing when no views are configured, and a
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `) // query signed with a TSIG key has "/key NAME" after the port.
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `)
// rewriteRe matches named's RPZ rewrite lines (category rpz), logged just // rewriteRe matches named's RPZ rewrite lines (category rpz), logged just
// before the response line of the same query: // before the response line of the same query:
// //
// 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid // 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid
var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`) var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`)
// rateLimitRe matches named's response rate limiting lines (category
// query-errors), when it limits a response or, in log-only mode, would:
//
// 12-Oct-2026 09:56:35.996 client @0x70a2... 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)
var rateLimitRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?(would )?rate limit (drop|slip) (?:\S+ )?response to (\S+) for (\S+) \S+ (\S+)`)
// RateLimit is a response that rate limiting dropped or slipped (sent
// truncated, so a real client retries over TCP), or would have in
// log-only mode.
type RateLimit struct {
Time time.Time
Client netip.Addr
View string
Would bool // log-only: nothing was limited
Action string // drop or slip
Prefix string // the client network named counts, e.g. 192.0.2.0/24
Name string // the query name, normalized like Event.Name
Type string
}
const logTimeLayout = "02-Jan-2006 15:04:05.000" const logTimeLayout = "02-Jan-2006 15:04:05.000"
// ParseRateLimit parses a rate limiting log line.
func ParseRateLimit(line string, loc *time.Location) (rl RateLimit, ok bool) {
m := rateLimitRe.FindStringSubmatch(line)
if m == nil {
return rl, false
}
t, err := time.ParseInLocation(logTimeLayout, m[1], loc)
if err != nil {
return rl, false
}
client, err := netip.ParseAddr(m[2])
if err != nil {
return rl, false
}
return RateLimit{Time: t, Client: client.Unmap(), View: m[4], Would: m[5] != "", Action: m[6], Prefix: m[7], Name: normalizeName(m[8]), Type: m[9]}, true
}
// ParseResponse parses a response log line. ok is false for other lines // ParseResponse parses a response log line. ok is false for other lines
// (named also logs to the channel when it starts and rotates files). // (named also logs to the channel when it starts and rotates files).
func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) { func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) {
+42
View File
@@ -1,6 +1,7 @@
package stats package stats
import ( import (
"net/netip"
"testing" "testing"
"time" "time"
) )
@@ -42,3 +43,44 @@ func TestParseResponse(t *testing.T) {
} }
} }
} }
func TestParseRateLimit(t *testing.T) {
loc := time.UTC
for _, tc := range []struct {
line string
want RateLimit
views bool
}{
{line: "12-Oct-2026 09:56:35.996 client @0x70a247adf000 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)",
want: RateLimit{Would: true, Action: "slip", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}},
{line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 127.0.0.2#35687 (www.rrl.test): rate limit drop response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)",
want: RateLimit{Action: "drop", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}},
{line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 2001:db8::7#35687 (Adbraun.lol): view external: rate limit drop response to 2001:db8::/56 for adbraun.lol IN ANY (0ce833a4)",
want: RateLimit{View: "external", Action: "drop", Prefix: "2001:db8::/56", Name: "adbraun.lol", Type: "ANY"}},
} {
rl, ok := ParseRateLimit(tc.line, loc)
rl.Time, rl.Client = time.Time{}, netip.Addr{}
if !ok || rl != tc.want {
t.Errorf("%s:\n got %+v %v\nwant %+v", tc.line, rl, ok, tc.want)
}
}
if _, ok := ParseRateLimit("12-Oct-2026 09:56:35.996 rate-limit: info: would limit responses to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)", loc); ok {
t.Error("the start-of-limiting line counted as a response")
}
}
// Signed queries have "/key NAME" after the client's port.
func TestParseSigned(t *testing.T) {
loc := time.UTC
ev, ok := ParseResponse("12-Oct-2026 10:11:06.490 client @0x7a22f754b000 127.0.0.1#34745/key jarl-view-external (ns1.adbraun.test): view external: response: ns1.adbraun.test IN A NOERROR 1 0 2 +E(0)SK (127.0.0.1)", loc)
if !ok || ev.View != "external" || ev.Name != "ns1.adbraun.test" || ev.Port != "34745" {
t.Errorf("signed response = %+v %v", ev, ok)
}
rl, ok := ParseRateLimit("12-Oct-2026 10:11:06.599 client @0x7a22f754b000 127.0.0.1#34856/key jarl-view-external (ns1.adbraun.test): view external: would rate limit slip response to 127.0.0.0/24 for ns1.adbraun.test IN A (e777e288)", loc)
if !ok || rl.View != "external" || !rl.Would || rl.Action != "slip" || rl.Prefix != "127.0.0.0/24" {
t.Errorf("signed rate limit = %+v %v", rl, ok)
}
if _, ok := ParseRewrite("09-Oct-2026 23:49:13.089 client @0x7bd607320000 127.0.0.1#56905/key k1 (x.ads.example): view internal: rpz QNAME NXDOMAIN rewrite x.ads.example/A/IN via x.ads.example.rules.jarl.invalid", loc); !ok {
t.Error("signed rewrite not parsed")
}
}
+1 -1
View File
@@ -256,7 +256,7 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati
} }
// Groups are the counter groups Breakdown accepts. // Groups are the counter groups Breakdown accepts.
var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "blocklist"} var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "rrl", "blocklist"}
// Timeline is a set of series over time. // Timeline is a set of series over time.
type Timeline struct { type Timeline struct {
+15 -5
View File
@@ -31,20 +31,23 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
} }
} }
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by FROM views`) rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for rows.Next() {
v := &model.View{} v := &model.View{}
var mc, md, ca, ua string var mc, md, acks, ca, ua string
var mro sql.NullInt64 var mro sql.NullInt64
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil { if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
json.Unmarshal([]byte(mc), &v.MatchClients) json.Unmarshal([]byte(mc), &v.MatchClients)
json.Unmarshal([]byte(md), &v.MatchDestinations) json.Unmarshal([]byte(md), &v.MatchDestinations)
if acks != "[]" && acks != "" {
json.Unmarshal([]byte(acks), &v.Acknowledged)
}
if mro.Valid { if mro.Valid {
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0) v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
} }
@@ -264,8 +267,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
if v.MatchRecursiveOnly != nil { if v.MatchRecursiveOnly != nil {
mro = boolInt(*v.MatchRecursiveOnly) mro = boolInt(*v.MatchRecursiveOnly)
} }
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy) v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
return err return err
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
return err return err
@@ -344,6 +347,13 @@ func nonNilIDs(ids []int64) []int64 {
return ids return ids
} }
func nonNilAcks(a []model.Acknowledgement) []model.Acknowledgement {
if a == nil {
return []model.Acknowledgement{}
}
return a
}
func nonNil(s []string) []string { func nonNil(s []string) []string {
if s == nil { if s == nil {
return []string{} return []string{}
+6
View File
@@ -326,6 +326,12 @@ CREATE TABLE client_groups (
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, 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 '' 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 '[]';
`, `,
} }
+9 -1
View File
@@ -563,10 +563,15 @@ func (c *checker) checkSecurity() {
for _, v := range views { for _, v := range views {
ctxs = append(ctxs, ctx{v, "view " + v.Name}) ctxs = append(ctxs, ctx{v, "view " + v.Name})
} }
exposure := map[int64]Exposure{}
for _, x := range c.exposures() {
exposure[x.ViewID] = x
}
for _, x := range ctxs { for _, x := range ctxs {
// A view only reachable from some clients (e.g. "internal") can // A view only reachable from some clients (e.g. "internal") can
// recurse for everyone inside it without being an open resolver. // recurse for everyone inside it without being an open resolver.
if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) { // Without views, only a server marked private is left out.
if x := exposure[viewID(x.v)]; !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
continue continue
} }
if c.effectiveBool(x.v, "recursion", true) { if c.effectiveBool(x.v, "recursion", true) {
@@ -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 := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
p.Zone, p.View = z.Name, c.s.ViewName(z) p.Zone, p.View = z.Name, c.s.ViewName(z)
} }
if x, ok := exposure[z.ViewID]; ok && !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
continue // only reachable by your own clients
}
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) { if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key") p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
p.Zone, p.View = z.Name, c.s.ViewName(z) p.Zone, p.View = z.Name, c.s.ViewName(z)
+387
View File
@@ -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
}
+10
View File
@@ -11,6 +11,7 @@ package validate
import ( import (
"fmt" "fmt"
"net/netip"
"sort" "sort"
"strings" "strings"
@@ -37,6 +38,9 @@ type Problem struct {
Zone string `json:"zone,omitempty"` Zone string `json:"zone,omitempty"`
View string `json:"view,omitempty"` View string `json:"view,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
// Acknowledged is set when an exposure finding was accepted on
// purpose (it is then info, not a warning).
Acknowledged *model.Acknowledgement `json:"acknowledged,omitempty"`
} }
// Key identifies a problem independently of object IDs, so problems can be // Key identifies a problem independently of object IDs, so problems can be
@@ -67,6 +71,10 @@ type Options struct {
// ExternalKeysUnknown is set when an included file could not be read, // ExternalKeysUnknown is set when an included file could not be read,
// so references to unknown keys are not reported. // so references to unknown keys are not reported.
ExternalKeysUnknown bool ExternalKeysUnknown bool
// LocalAddrs are this host's addresses: a server without views is
// public when named listens on one that isn't private
// (docs/exposure.md).
LocalAddrs []netip.Addr
} }
// Result holds the problems found. // Result holds the problems found.
@@ -140,8 +148,10 @@ func State(s *model.State, opts Options) []Problem {
c.checkZones() c.checkZones()
c.checkOptions() c.checkOptions()
c.checkSecurity() c.checkSecurity()
c.checkExposure()
c.checkDNSSEC() c.checkDNSSEC()
c.checkBlocking() c.checkBlocking()
c.applyAcks()
sortProblems(c.problems) sortProblems(c.problems)
return c.problems return c.problems
} }
+3
View File
@@ -18,6 +18,7 @@ import * as account from "./pages/account.js";
import * as search from "./pages/search.js"; import * as search from "./pages/search.js";
import * as stats from "./pages/stats.js"; import * as stats from "./pages/stats.js";
import * as blocking from "./pages/blocking.js"; import * as blocking from "./pages/blocking.js";
import * as exposure from "./pages/exposure.js";
const routes = [ const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" }, { pattern: /^$/, page: dashboard, nav: "dashboard" },
@@ -37,6 +38,7 @@ const routes = [
{ pattern: /^search$/, page: search, nav: "" }, { pattern: /^search$/, page: search, nav: "" },
{ pattern: /^stats$/, page: stats, nav: "stats" }, { pattern: /^stats$/, page: stats, nav: "stats" },
{ pattern: /^blocking$/, page: blocking, nav: "blocking" }, { pattern: /^blocking$/, page: blocking, nav: "blocking" },
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
]; ];
const navItems = [ const navItems = [
@@ -45,6 +47,7 @@ const navItems = [
["zones", "#/zones", "Zones"], ["zones", "#/zones", "Zones"],
["blocking", "#/blocking", "Blocking"], ["blocking", "#/blocking", "Blocking"],
["views", "#/views", "Views"], ["views", "#/views", "Views"],
["exposure", "#/exposure", "Exposure"],
["acls", "#/acls", "ACLs"], ["acls", "#/acls", "ACLs"],
["keys", "#/keys", "TSIG keys"], ["keys", "#/keys", "TSIG keys"],
["options", "#/options", "Server options"], ["options", "#/options", "Server options"],
+10 -2
View File
@@ -43,6 +43,10 @@ async function draftPanel(d) {
let valid = false; let valid = false;
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes"); const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl"); const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
// Changes that open a public view up must be confirmed (docs/exposure.md).
const riskBox = h("label", { class: "check risk-confirm hidden" }, h("input", { type: "checkbox" }), "");
let risky = false;
riskBox.querySelector("input").addEventListener("change", () => { applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked); });
const ops = d.ops || []; const ops = d.ops || [];
const removeOp = async (seq) => { const removeOp = async (seq) => {
try { try {
@@ -75,7 +79,10 @@ async function draftPanel(d) {
try { try {
const p = await get("/changesets/" + d.id + "/preview"); const p = await get("/changesets/" + d.id + "/preview");
valid = p.valid && !p.error; valid = p.valid && !p.error;
applyBtn.disabled = !valid; risky = (p.risks || []).length > 0;
riskBox.classList.toggle("hidden", !risky);
if (risky) riskBox.lastChild.textContent = " I understand: " + p.risks.map((x) => x.message).join("; ");
applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked);
const drift = (p.drift || []); const drift = (p.drift || []);
forceBox.classList.toggle("hidden", drift.length === 0); forceBox.classList.toggle("hidden", drift.length === 0);
mount(preview, mount(preview,
@@ -97,7 +104,7 @@ async function draftPanel(d) {
try { try {
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
const force = forceBox.querySelector("input").checked; const force = forceBox.querySelector("input").checked;
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force } }); const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force, accept_risk: riskBox.querySelector("input").checked } });
const res = r.result || {}; const res = r.result || {};
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : "")); toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000)); (r.warnings || []).forEach((w) => toast(w, "warn", 10000));
@@ -119,6 +126,7 @@ async function draftPanel(d) {
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")), h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
desc, desc,
h("div", { style: { marginTop: "10px" } }, opsTable), h("div", { style: { marginTop: "10px" } }, opsTable),
riskBox,
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox), h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
result), result),
h("div", { class: "panel" }, h("h2", null, "Review"), preview)); h("div", { class: "panel" }, h("h2", null, "Review"), preview));
+12 -1
View File
@@ -2,7 +2,7 @@ import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from ".
import { get, state } from "../api.js"; import { get, state } from "../api.js";
export async function render(el) { export async function render(el) {
const [st, mine] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]); const [st, mine, exposure] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => []), get("/exposure").catch(() => null)]);
const s = st.engine || {}; const s = st.engine || {};
// The user's own changesets with something in them: what the Changes page shows. // The user's own changesets with something in them: what the Changes page shows.
const pending = mine.filter((c) => (c.ops || []).length).length; const pending = mine.filter((c) => (c.ops || []).length).length;
@@ -16,6 +16,7 @@ export async function render(el) {
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")), card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"), card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
st.backup ? backupCard(st.backup) : null, st.backup ? backupCard(st.backup) : null,
exposure ? exposureCard(exposure) : null,
); );
const last = s.last_applied; const last = s.last_applied;
const parts = [h("div", { class: "page-head" }, const parts = [h("div", { class: "page-head" },
@@ -106,6 +107,16 @@ function backupCard(b) {
return card("Backup", value, sub); return card("Backup", value, sub);
} }
// exposureCard sums up the internet exposure page.
function exposureCard(r) {
const pub = r.scopes.filter((s) => s.public);
let warn = r.server.filter((f) => f.severity === "warning").length;
for (const s of r.scopes) warn += s.findings.filter((f) => f.severity === "warning").length;
const value = !pub.length ? badge("nothing public", "ok") : warn ? badge(plural(warn, "warning"), "warn") : badge("hardened", "ok");
const sub = pub.length ? "public: " + pub.map((s) => s.view || "the server").join(", ") : "no view is reachable from the internet";
return h("a", { class: "card link", href: "#/exposure" }, h("div", { class: "label" }, "Exposure"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
}
function card(label, value, sub) { function card(label, value, sub) {
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null); return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
} }
@@ -0,0 +1,120 @@
// Internet exposure (docs/exposure.md): which views are reachable from
// the internet, why, and what to harden, with fixes staged like any edit
// and findings that can be accepted on purpose.
import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural } from "../ui.js";
import { get, change, describeResult, can } from "../api.js";
import { rerender } from "../app.js";
const sevKind = { warning: "warn", error: "err", info: "" };
export async function render(el) {
const r = await get("/exposure");
const writable = can("options", true);
const parts = [h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Internet exposure"),
h("div", { class: "muted" }, "Which views can be reached from the internet, and what to harden on them. A view is public when its match-clients let in anyone (they end in any), whatever kind of addresses your own networks use. Fixes are staged as pending changes for you to review.")))];
parts.push(h("div", { class: "panel" }, h("h2", null, "Views"), table([
{ label: "View", render: (s) => s.view ? h("a", { href: "#/views/" + s.view_id }, s.view) : h("span", { class: "muted" }, "the server (no views)") },
{ label: "Exposure", render: (s) => [badge(s.public ? "public" : "private", s.public ? "warn" : "ok"), s.override ? h("span", { class: "muted small" }, " set by hand") : null] },
{ label: "Why", render: (s) => h("span", { class: "small" }, s.reason) },
{ label: "Rate limiting", render: (s) => s.public ? badge(s.rate_limit, { off: "err", "log-only": "warn", enforcing: "ok" }[s.rate_limit]) : h("span", { class: "muted" }, "–") },
{ label: "", render: (s) => writable ? overrideSelect(s) : null, tdClass: "actions-cell" },
], r.scopes)));
const all = [];
for (const s of r.scopes) for (const f of s.findings) all.push({ ...f, scope: s.view || "server", viewID: s.view_id });
for (const f of r.server) all.push({ ...f, scope: "server", viewID: 0 });
parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")),
all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check.")));
if (r.rate_limiting) parts.push(activity(r.rate_limiting, r.scopes));
mount(el, parts);
}
function overrideSelect(s) {
const sel = select([["", "Work it out"], ["public", "Public"], ["private", "Private"]], s.override || "");
sel.title = "Override the classification when Jarl gets it wrong (e.g. a port forward, where named only sees private addresses)";
sel.addEventListener("change", async () => {
try {
const r = s.view_id
? await change("PATCH", "/views/" + s.view_id, { exposure: sel.value || "auto" })
: await change("PATCH", "/settings", { exposure: sel.value || "auto" });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
});
return sel;
}
function findingRow(f, writable) {
const acked = f.acknowledged;
const actions = writable ? h("div", { class: "actions" },
f.fix && !acked ? h("button", { class: "small primary", title: f.fix, onclick: () => fix(f) }, "Fix") : null,
acked
? h("button", { class: "small", onclick: () => unack(f) }, "Take back")
: h("button", { class: "small", onclick: () => ack(f) }, "Acknowledge")) : null;
return h("div", { class: "finding " + (acked ? "acked" : f.severity) },
h("div", { class: "grow" },
h("div", null, badge(acked ? "acknowledged" : f.severity, acked ? "" : sevKind[f.severity]), " ", h("strong", null, f.scope), " ", h("span", { class: "mono small muted" }, f.code)),
h("div", null, f.message),
f.fix && !acked ? h("div", { class: "small muted" }, "Fix: " + f.fix) : null),
actions);
}
async function fix(f) {
try {
const r = await change("POST", "/exposure/fix", { view_id: f.viewID, code: f.code });
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
rerender();
} catch (e) { toast(e.message, "err", 10000); }
}
async function ack(f) {
const reason = input({ placeholder: "e.g. public resolver for the club's network, monitored", style: { width: "100%" } });
const err = h("div");
await modal("Acknowledge " + f.code, h("div", null,
h("p", null, f.message),
h("p", { class: "muted small" }, "The finding stays visible, as info, with your name, the date and your reason. Jarl warns again if it comes up somewhere else."),
field("Why is this accepted?", reason), err), [
{ label: "Cancel", value: null },
{ label: "Acknowledge", kind: "primary", onclick: async () => {
try {
const r = await change("POST", "/exposure/ack", { view_id: f.viewID, code: f.code, reason: reason.value.trim() });
toast(describeResult(r));
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
]);
}
async function unack(f) {
try {
const r = await change("POST", "/exposure/unack", { view_id: f.viewID, code: f.code });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
// activity shows what rate limiting limited, or would have in log-only
// mode, so you can check nobody legitimate is hit before enforcing.
function activity(a, scopes) {
const counts = Object.fromEntries((a.counts || []).map((e) => [e.key, e.count]));
const would = (counts["would-drop"] || 0) + (counts["would-slip"] || 0);
const did = (counts["drop"] || 0) + (counts["slip"] || 0);
const logOnly = scopes.some((s) => s.public && s.rate_limit === "log-only");
const any = scopes.some((s) => s.public && s.rate_limit !== "off");
if (!any) return null;
return h("div", { class: "panel" }, h("h2", null, "Rate limiting, last " + a.since),
!would && !did ? h("div", { class: "empty" }, "Nothing was limited" + (logOnly ? " (or would have been)" : "") + ". If you just turned it on, give it a day.") : [
h("p", null,
did ? plural(did, "response") + " limited (" + (counts["drop"] || 0) + " dropped, " + (counts["slip"] || 0) + " slipped). " : "",
would ? plural(would, "response") + " would have been limited in log-only mode (" + (counts["would-drop"] || 0) + " dropped, " + (counts["would-slip"] || 0) + " slipped)." : ""),
h("p", { class: "muted small" }, "Dropped responses get no answer; slipped ones get a short reply asking the client to retry over TCP (or with a DNS cookie), which a real client does and a spoofed victim of an attack never sees."),
logOnly ? h("p", { class: "muted small" }, "Check the networks below: your secondaries, monitoring, or a big shared resolver shouldn't be among them. If they are, add them to exempt-clients; otherwise start enforcing.") : null,
h("div", { class: "chart-grid" },
table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []),
table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]);
}
+12
View File
@@ -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); } details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
ul.plain li { padding: 3px 0; } ul.plain li { padding: 3px 0; }
.query-details { margin: 8px 0; } .query-details { margin: 8px 0; }
/* Internet exposure findings */
.findings { display: flex; flex-direction: column; gap: 8px; }
.finding { display: flex; gap: 12px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line); border-left-width: 3px; border-radius: 6px; }
.finding.warning { border-left-color: var(--warn); }
.finding.error { border-left-color: var(--err); }
.finding.acked, .finding.info { border-left-color: var(--line); color: var(--muted); }
.finding .actions { flex-shrink: 0; }
.risk-confirm { margin-top: 8px; }
a.card.link { color: inherit; text-decoration: none; }
a.card.link:hover { border-color: var(--accent, var(--link)); }
+39
View File
@@ -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 expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
run "view key shows a dig command" 0 $J view key external run "view key shows a dig command" 0 $J view key external
contains "dig -y hmac-sha256:jarl-view-external:" contains "dig -y hmac-sha256:jarl-view-external:"
# Internet exposure (docs/exposure.md): external lets in anyone, internal
# only homelab.
run "exposure" 0 $J exposure
contains "^internal *private"
contains "^external *public *off"
contains "exposure_rate_limit "
contains "fix: jarl exposure fix external exposure_rate_limit"
run "fix: rate limiting, log-only first" 0 $J exposure fix external exposure_rate_limit
run "named.conf has it" 0 grep -A2 'view "external"' $E/etc-jarl/named.conf
contains "rate-limit { responses-per-second 10; log-only yes; };"
run "exposure: log-only now" 0 $J exposure
contains "^external *public *log-only"
contains "exposure_rate_limit_log_only"
run "fix: start enforcing" 0 $J exposure fix external exposure_rate_limit_log_only
contains "responses-per-second 10; }"
# A burst of identical queries in the external view (signed with its key):
# some are dropped. The internal view has no rate limit.
DROPS=0
for i in $(seq 60); do
$DIG +tries=1 +time=1 +ignore -k $E/external.key -p $DNS @127.0.0.1 ns1.adbraun.test A | grep -q "status:" || DROPS=$((DROPS+1))
done
if [ $DROPS -gt 0 ]; then ok "rate limiting dropped $DROPS of 60"; else bad "rate limiting dropped nothing"; fi
INTERNAL=$(for i in $(seq 30); do $DIG +tries=1 +time=1 +short -p $DNS @127.0.0.1 ns1.adbraun.test A; done | grep -c 127.0.0.1)
if [ "$INTERNAL" = 30 ]; then ok "the internal view isn't limited"; else bad "the internal view answered $INTERNAL of 30"; fi
run "remove the rate limit again" 0 $J option unset rate-limit --view external
run "an open resolver on a public view needs confirming (exit 5)" 5 $J option set allow-recursion any --view external
contains "accept-risk"
run "with --accept-risk" 0 $J option set allow-recursion any --view external --accept-risk
run "acknowledge it" 0 $J exposure ack external security_open_resolver --reason "e2e: on purpose"
run "acknowledged: info, with the reason" 0 $J exposure
contains "info *external *security_open_resolver .*acknowledged by .*e2e: on purpose"
run "take it back" 0 $J exposure unack external security_open_resolver
run "close the resolver again" 0 $J option unset allow-recursion --view external
run "fix: hide the version" 0 $J exposure fix server exposure_version
expect_dig "version.bind is hidden" "" -c CH version.bind TXT
run "mark external private" 0 $J view update external --exposure private
run "exposure: private by hand" 0 $J exposure
contains "^external *private (set)"
run "back to auto" 0 $J view update external --exposure auto
# named-checkconf's own warnings stay on the dashboard after the apply. # named-checkconf's own warnings stay on the dashboard after the apply.
run "option set recursion no (external)" 0 $J option set recursion no --view external run "option set recursion no (external)" 0 $J option set recursion no --view external
run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external