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