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
+2005 -38

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