Files
jarl/internal/validate/validate.go
T
abrown38andClaude Opus 5.5 bd6b8778e4 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:03:13 +00:00

185 lines
5.1 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"
"sort"
"strings"
"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"`
}
// 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 " + 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
}
// 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
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.RecordsByZone(), keyNames: map[string]bool{}}
for _, k := range s.Keys {
c.keyNames[k.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()
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, ".")
}