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>
203 lines
5.8 KiB
Go
203 lines
5.8 KiB
Go
// Package validate checks a complete configuration State for problems
|
|
// before it is rendered and handed to BIND.
|
|
//
|
|
// Rules come from the DNS RFCs (CNAME exclusivity, glue, DNAME, ...), from
|
|
// the checks named itself performs when loading a zone (check-names,
|
|
// check-integrity, record limits), and from operational experience (open
|
|
// resolvers, open zone transfers). named-checkconf and named-checkzone are
|
|
// run as well before anything is applied; this package exists so that
|
|
// problems are found early and explained in terms of Jarl objects.
|
|
package validate
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"sort"
|
|
"strings"
|
|
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// Severity of a problem.
|
|
type Severity string
|
|
|
|
const (
|
|
Error Severity = "error" // blocks applying
|
|
Warning Severity = "warning" // shown, does not block
|
|
Info Severity = "info"
|
|
)
|
|
|
|
// Problem is a single validation finding.
|
|
type Problem struct {
|
|
Severity Severity `json:"severity"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
ObjectType string `json:"object_type,omitempty"` // view, zone, record, acl, key, option, settings
|
|
ObjectID int64 `json:"object_id,omitempty"`
|
|
Zone string `json:"zone,omitempty"`
|
|
View string `json:"view,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
// Acknowledged is set when an exposure finding was accepted on
|
|
// purpose (it is then info, not a warning).
|
|
Acknowledged *model.Acknowledgement `json:"acknowledged,omitempty"`
|
|
}
|
|
|
|
// Key identifies a problem independently of object IDs, so problems can be
|
|
// compared between two states.
|
|
func (p Problem) Key() string {
|
|
return string(p.Severity) + "|" + p.Code + "|" + p.View + "|" + p.Zone + "|" + p.Name + "|" + p.Message
|
|
}
|
|
|
|
func (p Problem) String() string {
|
|
loc := ""
|
|
if p.Zone != "" {
|
|
loc = "zone " + dnsname.Display(p.Zone)
|
|
if p.View != "" {
|
|
loc += " [view " + p.View + "]"
|
|
}
|
|
loc += ": "
|
|
} else if p.View != "" {
|
|
loc = "view " + p.View + ": "
|
|
}
|
|
return string(p.Severity) + ": " + loc + p.Message
|
|
}
|
|
|
|
// Options adjust validation.
|
|
type Options struct {
|
|
// ExternalKeys are TSIG key names defined outside Jarl, in files
|
|
// pulled in by top-level include statements (e.g. rndc-key).
|
|
ExternalKeys []string
|
|
// ExternalKeysUnknown is set when an included file could not be read,
|
|
// so references to unknown keys are not reported.
|
|
ExternalKeysUnknown bool
|
|
// LocalAddrs are this host's addresses: a server without views is
|
|
// public when named listens on one that isn't private
|
|
// (docs/exposure.md).
|
|
LocalAddrs []netip.Addr
|
|
}
|
|
|
|
// Result holds the problems found.
|
|
type Result struct {
|
|
Problems []Problem `json:"problems"`
|
|
}
|
|
|
|
// HasErrors reports whether any problem is an error.
|
|
func HasErrors(ps []Problem) bool {
|
|
for _, p := range ps {
|
|
if p.Severity == Error {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Errors returns only the errors.
|
|
func Errors(ps []Problem) []Problem {
|
|
var out []Problem
|
|
for _, p := range ps {
|
|
if p.Severity == Error {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Introduced returns the problems in after that are not in before.
|
|
func Introduced(before, after []Problem) []Problem {
|
|
seen := map[string]bool{}
|
|
for _, p := range before {
|
|
seen[p.Key()] = true
|
|
}
|
|
var out []Problem
|
|
for _, p := range after {
|
|
if !seen[p.Key()] {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type checker struct {
|
|
s *model.State
|
|
opts Options
|
|
problems []Problem
|
|
// indexes
|
|
zoneRecords map[int64][]*model.Record // served records (not disabled)
|
|
allRecords map[int64][]*model.Record
|
|
keyNames map[string]bool
|
|
}
|
|
|
|
// State validates the complete state.
|
|
func State(s *model.State, opts Options) []Problem {
|
|
c := &checker{s: s, opts: opts, zoneRecords: s.ServedRecordsByZone(), allRecords: s.RecordsByZone(), keyNames: map[string]bool{}}
|
|
for _, k := range s.Keys {
|
|
c.keyNames[k.Name] = true
|
|
}
|
|
// The views' own keys (render/viewkeys.go) may be referred to too.
|
|
for _, v := range s.Views {
|
|
c.keyNames[model.NormalizeKeyName(model.ViewKeyName(v.Name))] = true
|
|
}
|
|
for _, k := range opts.ExternalKeys {
|
|
c.keyNames[model.NormalizeKeyName(k)] = true
|
|
}
|
|
c.checkSettings()
|
|
c.checkKeys()
|
|
c.checkACLs()
|
|
c.checkViews()
|
|
c.checkZones()
|
|
c.checkOptions()
|
|
c.checkSecurity()
|
|
c.checkExposure()
|
|
c.checkDNSSEC()
|
|
c.checkBlocking()
|
|
c.applyAcks()
|
|
sortProblems(c.problems)
|
|
return c.problems
|
|
}
|
|
|
|
func (c *checker) add(p Problem) { c.problems = append(c.problems, p) }
|
|
|
|
func (c *checker) errf(code, objType string, id int64, format string, args ...any) *Problem {
|
|
c.add(Problem{Severity: Error, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)})
|
|
return &c.problems[len(c.problems)-1]
|
|
}
|
|
|
|
func (c *checker) warnf(code, objType string, id int64, format string, args ...any) *Problem {
|
|
c.add(Problem{Severity: Warning, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)})
|
|
return &c.problems[len(c.problems)-1]
|
|
}
|
|
|
|
func (c *checker) infof(code, objType string, id int64, format string, args ...any) *Problem {
|
|
c.add(Problem{Severity: Info, Code: code, ObjectType: objType, ObjectID: id, Message: fmt.Sprintf(format, args...)})
|
|
return &c.problems[len(c.problems)-1]
|
|
}
|
|
|
|
func sortProblems(ps []Problem) {
|
|
rank := map[Severity]int{Error: 0, Warning: 1, Info: 2}
|
|
sort.SliceStable(ps, func(i, j int) bool {
|
|
a, b := ps[i], ps[j]
|
|
if rank[a.Severity] != rank[b.Severity] {
|
|
return rank[a.Severity] < rank[b.Severity]
|
|
}
|
|
if a.View != b.View {
|
|
return a.View < b.View
|
|
}
|
|
if a.Zone != b.Zone {
|
|
return a.Zone < b.Zone
|
|
}
|
|
if a.Name != b.Name {
|
|
return model.CanonicalLess(a.Name, b.Name)
|
|
}
|
|
return a.Message < b.Message
|
|
})
|
|
}
|
|
|
|
// zoneLabel returns "name [view]" for messages.
|
|
func (c *checker) zoneLabel(z *model.Zone) string {
|
|
if v := c.s.ViewName(z); v != "" {
|
|
return strings.TrimSuffix(z.Name, ".") + " [view " + v + "]"
|
|
}
|
|
return strings.TrimSuffix(z.Name, ".")
|
|
}
|