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
@@ -3,6 +3,7 @@ package engine
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
@@ -23,7 +24,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
||||
s := to.Settings
|
||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings")
|
||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
|
||||
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings")
|
||||
}
|
||||
|
||||
for _, k := range sortedKeys(to.Keys) {
|
||||
@@ -47,7 +49,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
for _, k := range sortedKeys(to.Views) {
|
||||
n := to.Views[k]
|
||||
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
||||
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)}
|
||||
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
|
||||
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
|
||||
if n.MatchRecursiveOnly != nil {
|
||||
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
||||
} else {
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
|
||||
KindDrift ErrorKind = "drift" // files were changed outside Jarl
|
||||
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
|
||||
KindRisk ErrorKind = "confirm_risk" // the change opens the server up and needs confirming
|
||||
)
|
||||
|
||||
// Error is an engine error with details for the caller.
|
||||
|
||||
@@ -228,6 +228,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
||||
case OpSettingsUpdate:
|
||||
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
||||
case OpExposureAck, OpExposureUnack:
|
||||
return x.exposureAck(req, req.Op == OpExposureAck)
|
||||
case OpBlocklistCreate:
|
||||
return x.blocklistCreate(req)
|
||||
case OpBlocklistUpdate, OpBlocklistDelete:
|
||||
@@ -1089,3 +1091,64 @@ func disabledZoneEffect(st *model.State, z *model.Zone) string {
|
||||
}
|
||||
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
|
||||
}
|
||||
|
||||
// exposureAck acknowledges an exposure finding of a view or the server,
|
||||
// or takes the acknowledgement back: the view's (or the settings')
|
||||
// acknowledgements are updated as a whole.
|
||||
func (x *expander) exposureAck(req Request, ack bool) ([]model.Op, error) {
|
||||
var in AckInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Code = strings.TrimSpace(in.Code)
|
||||
if !validate.Ackable(in.Code) {
|
||||
return nil, errf(KindBadRequest, "%q is not an exposure finding that can be acknowledged (exposure_... or security_open_...)", in.Code)
|
||||
}
|
||||
if ack && strings.TrimSpace(in.Reason) == "" {
|
||||
return nil, errf(KindBadRequest, "say why %s is accepted (a reason is required)", in.Code)
|
||||
}
|
||||
var v *model.View
|
||||
switch {
|
||||
case in.ViewID != 0:
|
||||
v = x.st.Views[in.ViewID]
|
||||
case in.View != "":
|
||||
v = x.st.ViewByName(in.View)
|
||||
}
|
||||
if (in.ViewID != 0 || in.View != "") && v == nil {
|
||||
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
|
||||
}
|
||||
acks := x.st.Settings.Acknowledged
|
||||
where := "the server"
|
||||
if v != nil {
|
||||
acks, where = v.Acknowledged, "view "+v.Name
|
||||
}
|
||||
var out []model.Acknowledgement
|
||||
for _, a := range acks {
|
||||
if a.Code != in.Code {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
summary := "acknowledge " + in.Code + " for " + where + ": " + strings.TrimSpace(in.Reason)
|
||||
if ack {
|
||||
out = append(out, model.Acknowledgement{Code: in.Code, Reason: strings.TrimSpace(in.Reason), By: x.p.Name(), At: x.now})
|
||||
} else {
|
||||
if len(out) == len(acks) {
|
||||
return nil, errf(KindConflict, "%s isn't acknowledged for %s", in.Code, where)
|
||||
}
|
||||
summary = "take back the acknowledgement of " + in.Code + " for " + where
|
||||
}
|
||||
if out == nil {
|
||||
out = []model.Acknowledgement{}
|
||||
}
|
||||
if v != nil {
|
||||
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{Acknowledged: &out}, summary)}, nil
|
||||
}
|
||||
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{Acknowledged: &out}, summary)}, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -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.
|
||||
OpRecordDisable = "record.disable"
|
||||
OpRecordEnable = "record.enable"
|
||||
// Acknowledging an exposure finding (docs/exposure.md), and taking it
|
||||
// back, become view.update or settings.update operations.
|
||||
OpExposureAck = "exposure.ack"
|
||||
OpExposureUnack = "exposure.unack"
|
||||
)
|
||||
|
||||
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||
// (by ID or name), or of the server (neither given).
|
||||
type AckInput struct {
|
||||
ViewID int64 `json:"view_id,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// OpKinds lists all operation kinds.
|
||||
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
||||
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
||||
@@ -184,6 +197,9 @@ type ViewInput struct {
|
||||
AdoptZones bool `json:"adopt_zones,omitempty"`
|
||||
// Cascade (delete) also deletes the view's zones.
|
||||
Cascade bool `json:"cascade,omitempty"`
|
||||
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
|
||||
Exposure *string `json:"exposure,omitempty"`
|
||||
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||
}
|
||||
|
||||
// ReorderInput sets the order of views.
|
||||
@@ -227,6 +243,9 @@ type SettingsInput struct {
|
||||
Expire *uint32 `json:"expire,omitempty"`
|
||||
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
|
||||
SerialPolicy *string `json:"serial_policy,omitempty"`
|
||||
// Exposure and Acknowledged are the server's (docs/exposure.md).
|
||||
Exposure *string `json:"exposure,omitempty"`
|
||||
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteInput carries options for delete operations.
|
||||
|
||||
@@ -765,9 +765,41 @@ func setViewFields(v *model.View, in *ViewInput) error {
|
||||
if in.Position != nil {
|
||||
v.Position = *in.Position
|
||||
}
|
||||
if in.Exposure != nil {
|
||||
e, err := normalizeExposure(*in.Exposure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.Exposure = e
|
||||
}
|
||||
if in.Acknowledged != nil {
|
||||
v.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeExposure checks an exposure override.
|
||||
func normalizeExposure(s string) (string, error) {
|
||||
switch e := strings.ToLower(strings.TrimSpace(s)); e {
|
||||
case "", "auto":
|
||||
return model.ExposureAuto, nil
|
||||
case model.ExposurePublic, model.ExposurePrivate:
|
||||
return e, nil
|
||||
}
|
||||
return "", errf(KindBadRequest, "exposure must be auto, public or private, not %q", s)
|
||||
}
|
||||
|
||||
// cleanAcks drops empty acknowledgements (nil for none).
|
||||
func cleanAcks(acks []model.Acknowledgement) []model.Acknowledgement {
|
||||
var out []model.Acknowledgement
|
||||
for _, a := range acks {
|
||||
if a.Code != "" {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error {
|
||||
var in ViewInput
|
||||
if err := decode(op.Data, &in); err != nil {
|
||||
@@ -1329,6 +1361,16 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if in.SerialPolicy != nil {
|
||||
s.SerialPolicy = strings.ToLower(*in.SerialPolicy)
|
||||
}
|
||||
if in.Exposure != nil {
|
||||
e, err := normalizeExposure(*in.Exposure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Exposure = e
|
||||
}
|
||||
if in.Acknowledged != nil {
|
||||
s.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||
}
|
||||
s.Version++
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user