Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.
A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.
Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf550bd512
commit
bdd7acfd3b
50 files changed
+2019
-52
No files matched your search
+79
-12
@@ -15,6 +15,7 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -56,6 +57,10 @@ type Config struct {
|
||||
NoReload bool
|
||||
// SkipCheck disables named-checkconf (only for tests without BIND).
|
||||
SkipCheck bool
|
||||
// LocalAddrs returns this host's addresses, for working out whether a
|
||||
// server without views is reachable from the internet
|
||||
// (docs/exposure.md). nil: none known.
|
||||
LocalAddrs func() []netip.Addr
|
||||
}
|
||||
|
||||
// Engine applies changesets.
|
||||
@@ -110,17 +115,20 @@ type Result struct {
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
Problems []validate.Problem `json:"problems"` // problems this change introduces
|
||||
Existing []validate.Problem `json:"existing_problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
CheckOK bool `json:"check_ok"`
|
||||
Diff string `json:"diff"`
|
||||
Files []string `json:"files_changed"`
|
||||
Serials []SerialChange `json:"serials,omitempty"`
|
||||
Reloaded []string `json:"reloaded,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Drift []DriftEntry `json:"drift,omitempty"`
|
||||
Ops []model.Op `json:"ops,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
// Risks are the introduced problems that open the server up; applying
|
||||
// needs AcceptRisk (docs/exposure.md).
|
||||
Risks []validate.Problem `json:"risks,omitempty"`
|
||||
Existing []validate.Problem `json:"existing_problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
CheckOK bool `json:"check_ok"`
|
||||
Diff string `json:"diff"`
|
||||
Files []string `json:"files_changed"`
|
||||
Serials []SerialChange `json:"serials,omitempty"`
|
||||
Reloaded []string `json:"reloaded,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Drift []DriftEntry `json:"drift,omitempty"`
|
||||
Ops []model.Op `json:"ops,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// stored is what is kept in a changeset's result column.
|
||||
@@ -138,6 +146,9 @@ type ApplyOptions struct {
|
||||
Force bool
|
||||
// NoReload writes the files without reloading named.
|
||||
NoReload bool
|
||||
// AcceptRisk confirms a change that opens the server up, such as an
|
||||
// open resolver on a public view (validate.RiskCodes).
|
||||
AcceptRisk bool
|
||||
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
|
||||
wipeZone int64
|
||||
}
|
||||
@@ -180,7 +191,46 @@ func ValidateOptions(st *model.State) validate.Options {
|
||||
}
|
||||
|
||||
func (e *Engine) validate(st *model.State) []validate.Problem {
|
||||
return validate.State(st, ValidateOptions(st))
|
||||
return validate.State(st, e.validateOptions(st))
|
||||
}
|
||||
|
||||
func (e *Engine) validateOptions(st *model.State) validate.Options {
|
||||
opts := ValidateOptions(st)
|
||||
if e.cfg.LocalAddrs != nil {
|
||||
opts.LocalAddrs = e.cfg.LocalAddrs()
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// Validate checks a state as applying it would.
|
||||
func (e *Engine) Validate(st *model.State) []validate.Problem { return e.validate(st) }
|
||||
|
||||
// Exposures says which views (or the server, without views) are
|
||||
// reachable from the internet, and why (docs/exposure.md).
|
||||
func (e *Engine) Exposures(st *model.State) []validate.Exposure {
|
||||
return validate.Exposures(st, e.validateOptions(st))
|
||||
}
|
||||
|
||||
// risks returns the introduced problems that open a public view (or a
|
||||
// public server) up: risky warnings that weren't there before at all. One
|
||||
// that was there already, acknowledged (taking the acknowledgement back)
|
||||
// or not, opens nothing new.
|
||||
func risks(before, introduced []validate.Problem, public map[string]bool) []validate.Problem {
|
||||
type key struct {
|
||||
code, objType, view string
|
||||
id int64
|
||||
}
|
||||
had := map[key]bool{}
|
||||
for _, p := range before {
|
||||
had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] = true
|
||||
}
|
||||
var out []validate.Problem
|
||||
for _, p := range introduced {
|
||||
if p.Severity == validate.Warning && validate.RiskCodes[p.Code] && public[p.View] && !had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- changeset management
|
||||
@@ -389,6 +439,8 @@ type plan struct {
|
||||
base, work *model.State
|
||||
introduced []validate.Problem
|
||||
existing []validate.Problem
|
||||
before []validate.Problem // the problems before the change
|
||||
public map[string]bool // public views by name ("": the server), after the change
|
||||
serials []SerialChange
|
||||
out *render.Output
|
||||
changed []*render.File
|
||||
@@ -419,6 +471,13 @@ func (pl *plan) files() []string {
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
||||
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
||||
before := e.validate(base)
|
||||
pl.before = before
|
||||
pl.public = map[string]bool{}
|
||||
for _, x := range e.Exposures(work) {
|
||||
pl.public[x.View] = x.Public
|
||||
// Server-wide findings (no view) count when anything is public.
|
||||
pl.public[""] = pl.public[""] || x.Public
|
||||
}
|
||||
after := e.validate(work)
|
||||
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
||||
introducedKeys := map[string]bool{}
|
||||
@@ -652,6 +711,7 @@ func (pl *plan) result() *Result {
|
||||
Files: pl.files(),
|
||||
Serials: pl.serials,
|
||||
Drift: pl.drift,
|
||||
Risks: risks(pl.before, pl.introduced, pl.public),
|
||||
}
|
||||
r.Valid = r.Valid && r.CheckOK
|
||||
if r.Files == nil {
|
||||
@@ -781,6 +841,13 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
if opts.DryRun {
|
||||
return res, nil
|
||||
}
|
||||
if rs := risks(pl.before, pl.introduced, pl.public); len(rs) > 0 && !opts.AcceptRisk {
|
||||
msg := rs[0].Message
|
||||
if len(rs) > 1 {
|
||||
msg += fmt.Sprintf(" (and %d more)", len(rs)-1)
|
||||
}
|
||||
return res, &Error{Kind: KindRisk, Message: "this change opens the server up: " + msg + "; apply it again with accept_risk to confirm (jarl: --accept-risk), or acknowledge the finding for good", Problems: rs}
|
||||
}
|
||||
if len(pl.drift) > 0 && !opts.Force {
|
||||
var paths []string
|
||||
for _, d := range pl.drift {
|
||||
|
||||
Reference in new issue
Block a user