Records, zones and findings now order all-digit labels numerically, so reverse zones list 2 before 101. model.CanonicalLess is replaced by model.NameLess; other labels keep byte order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
5.8 KiB
Go
205 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.checkLinked()
|
|
c.checkTLSFiles()
|
|
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.NameLess(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, ".")
|
|
}
|