Files
jarl/internal/validate/validate.go
T
adamandClaude Opus 5.5 d18fe18ccd Sort numeric labels by value
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>
2026-10-20 08:49:58 +01:00

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, ".")
}