Files
adamandClaude Opus 5.5 bdd7acfd3b 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>
2026-10-12 10:38:56 +01:00

1389 lines
45 KiB
Go

// Package engine applies changes to the configuration.
//
// Every change goes through a changeset: a list of operations that is
// validated as a whole, rendered to BIND configuration, checked with
// named-checkconf, written to disk and loaded into named. Only when named
// has loaded the new configuration is the database updated. If anything
// fails, the previous files are restored and nothing is recorded.
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"net/netip"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"time"
"jarl/internal/auth"
"jarl/internal/bind"
"jarl/internal/blocking"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
"jarl/internal/render"
"jarl/internal/store"
"jarl/internal/validate"
)
// Config configures the engine.
type Config struct {
Paths render.Paths
// Extras are statements added from jarld's configuration, such as the
// statistics channel and response log for query statistics.
Extras render.Extras
// MainConfig is the configuration file named loads (on Debian
// /etc/bind/named.conf). Jarl is "adopted" once it includes Jarl's
// named.conf. If it is Jarl's named.conf itself, Jarl is always
// adopted.
MainConfig string
// StagingDir is where configurations are rendered for named-checkconf.
StagingDir string
// FileGroup is the group of generated files (e.g. "bind").
FileGroup string
// ReloadTimeout bounds how long Jarl waits for named to load zones.
ReloadTimeout time.Duration
// NoReload writes files but never runs rndc (for setups without a
// running named, and tests).
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.
type Engine struct {
store *store.Store
tools *bind.Tools
cfg Config
log *slog.Logger
deploy *Deployer
mu sync.Mutex // serializes applies
Now func() time.Time
// Blocklist downloads (blocklists.go).
blMu sync.Mutex // serializes downloads
blKick chan struct{} // a list was added or changed
HTTP *http.Client // for downloads; nil: a default client
Limits BlocklistLimits
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go)
seedMu sync.Mutex
seed []byte // the view keys' seed (viewkeys.go)
}
// New creates an engine.
func New(st *store.Store, tools *bind.Tools, cfg Config, log *slog.Logger) *Engine {
if cfg.ReloadTimeout == 0 {
cfg.ReloadTimeout = 15 * time.Second
}
if log == nil {
log = slog.Default()
}
return &Engine{
store: st, tools: tools, cfg: cfg, log: log,
deploy: &Deployer{Group: cfg.FileGroup, NamedWritable: []string{cfg.Paths.ZoneDir, cfg.Paths.CacheDir}},
Now: time.Now,
blKick: make(chan struct{}, 1),
Limits: DefaultBlocklistLimits(),
}
}
// Config returns the engine configuration.
func (e *Engine) Config() Config { return e.cfg }
// Store returns the engine's store.
func (e *Engine) Store() *store.Store { return e.store }
// Result describes a preview or an applied changeset.
type Result struct {
ChangesetID int64 `json:"changeset_id,omitempty"`
Applied bool `json:"applied"`
DryRun bool `json:"dry_run,omitempty"`
Valid bool `json:"valid"`
Problems []validate.Problem `json:"problems"` // problems this change introduces
// 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.
type stored struct {
Serials []SerialChange `json:"serials,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Files []string `json:"files_changed,omitempty"`
Undo []model.Op `json:"undo,omitempty"`
}
// ApplyOptions modify Apply and Submit.
type ApplyOptions struct {
DryRun bool
// Force overwrites files that were changed outside Jarl.
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
}
func (e *Engine) replay(base *model.State, ops []model.Op, p *auth.Principal) (*model.State, []string, error) {
work := base.Clone()
c := &applyCtx{p: p, now: e.Now()}
for _, op := range ops {
if err := applyOp(work, op, c); err != nil {
return nil, c.warnings, err
}
}
return work, c.warnings, nil
}
// ValidateOptions returns validation options for st, finding TSIG keys
// defined in files that top-level include statements pull in.
func ValidateOptions(st *model.State) validate.Options {
var opts validate.Options
for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) {
if o.Name != "include" {
continue
}
path := strings.Trim(strings.TrimSpace(o.Value), `"`)
b, err := os.ReadFile(path)
if err != nil {
opts.ExternalKeysUnknown = true
continue
}
stmts, err := namedconf.Parse(path, string(b))
if err != nil {
opts.ExternalKeysUnknown = true
continue
}
for _, s := range namedconf.Find(stmts, "key") {
opts.ExternalKeys = append(opts.ExternalKeys, s.Arg(0))
}
}
return opts
}
func (e *Engine) validate(st *model.State) []validate.Problem {
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
func (e *Engine) loadChangeset(ctx context.Context, id int64, p *auth.Principal, write bool) (*model.Changeset, error) {
cs, err := e.store.GetChangeset(ctx, id)
if errors.Is(err, store.ErrNotFound) {
return nil, errf(KindNotFound, "changeset %d does not exist", id)
}
if err != nil {
return nil, err
}
if !CanAccessChangeset(p, cs, write) {
return nil, errf(KindForbidden, "changeset %d belongs to %s", id, cs.Username)
}
if write && cs.Status != model.ChangesetOpen {
return nil, errf(KindConflict, "changeset %d is %s and can't be changed", id, cs.Status)
}
return cs, nil
}
// CanAccessChangeset reports whether p may see (or modify) a changeset.
func CanAccessChangeset(p *auth.Principal, cs *model.Changeset, write bool) bool {
if p.IsAdmin() || (p.User != nil && cs.UserID == p.User.ID) {
return true
}
return !write && p.Can(auth.Action{Resource: auth.ResAudit})
}
// CreateChangeset starts a new open changeset.
func (e *Engine) CreateChangeset(ctx context.Context, p *auth.Principal, via, description string) (*model.Changeset, error) {
cs := &model.Changeset{Description: description, UserID: p.User.ID, Username: p.Name(), Via: via, Status: model.ChangesetOpen}
if err := e.store.CreateChangeset(ctx, cs); err != nil {
return nil, err
}
e.audit(ctx, p, "changeset.create", "changeset", cs.ID, cs.Description, cs.ID, "success", "", nil)
return cs, nil
}
// StageResult is returned when operations are added to a changeset.
type StageResult struct {
Changeset *model.Changeset `json:"changeset"`
Added []model.Op `json:"added"`
Warnings []string `json:"warnings,omitempty"`
Problems []validate.Problem `json:"problems,omitempty"` // warnings introduced
}
// expandAll expands and applies requests to work, returning the new
// operations.
func (e *Engine) expandAll(ctx context.Context, work *model.State, reqs []Request, p *auth.Principal) ([]model.Op, []string, error) {
var ops []model.Op
var warnings []string
c := &applyCtx{p: p, now: e.Now()}
for i, req := range reqs {
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now()}
more, err := x.expand(req)
if err != nil {
if ee, ok := AsError(err); ok && len(reqs) > 1 {
ee.Message = fmt.Sprintf("request %d (%s): %s", i+1, req.Op, ee.Message)
}
return nil, nil, err
}
warnings = append(warnings, x.warnings...)
for _, op := range more {
if err := applyOp(work, op, c); err != nil {
if ee, ok := AsError(err); ok {
ee.OpSeq = 0
if len(reqs) > 1 {
ee.Message = fmt.Sprintf("request %d (%s): %s", i+1, req.Op, ee.Message)
}
}
return nil, nil, err
}
}
ops = append(ops, more...)
}
return ops, append(warnings, c.warnings...), nil
}
// Stage adds requests to an open changeset. Requests that would introduce
// errors (for example an A record next to a CNAME) are rejected.
func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.Principal) (*StageResult, error) {
cs, err := e.loadChangeset(ctx, csID, p, true)
if err != nil {
return nil, err
}
base, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
work, _, err := e.replay(base, cs.Ops, p)
if err != nil {
return nil, errf(KindConflict, "the changeset no longer applies to the current configuration (%v); remove the failing operation first", err)
}
before := e.validate(work)
ops, warnings, err := e.expandAll(ctx, work, reqs, p)
if err != nil {
return nil, err
}
intro := validate.Introduced(before, e.validate(work))
if validate.HasErrors(intro) {
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
}
now := e.Now()
for i := range ops {
ops[i].Seq = nextSeq(cs.Ops) + i
ops[i].AddedAt = now
}
cs.Ops = append(cs.Ops, ops...)
if err := e.store.UpdateChangeset(ctx, cs); err != nil {
return nil, err
}
for _, op := range ops {
e.audit(ctx, p, "changeset.stage", "changeset", cs.ID, op.Summary, cs.ID, "success", "", nil)
}
var warnProblems []validate.Problem
for _, pr := range intro {
if pr.Severity != validate.Error {
warnProblems = append(warnProblems, pr)
}
}
return &StageResult{Changeset: cs, Added: ops, Warnings: warnings, Problems: warnProblems}, nil
}
func nextSeq(ops []model.Op) int {
m := 0
for _, op := range ops {
if op.Seq > m {
m = op.Seq
}
}
return m + 1
}
func describeErrors(ps []validate.Problem) string {
errs := validate.Errors(ps)
if len(errs) == 0 {
return "validation failed"
}
if len(errs) == 1 {
return errs[0].Message
}
return fmt.Sprintf("%s (and %d more problems)", errs[0].Message, len(errs)-1)
}
// RemoveOps removes operations (by sequence number) from an open changeset.
func (e *Engine) RemoveOps(ctx context.Context, csID int64, seqs []int, p *auth.Principal) (*model.Changeset, error) {
cs, err := e.loadChangeset(ctx, csID, p, true)
if err != nil {
return nil, err
}
drop := map[int]bool{}
for _, s := range seqs {
drop[s] = true
}
var kept []model.Op
found := 0
for _, op := range cs.Ops {
if drop[op.Seq] {
found++
e.audit(ctx, p, "changeset.unstage", "changeset", cs.ID, op.Summary, cs.ID, "success", "", nil)
continue
}
kept = append(kept, op)
}
if found != len(drop) {
return nil, errf(KindNotFound, "operation not found in changeset %d", csID)
}
if kept == nil {
kept = []model.Op{}
}
cs.Ops = kept
if err := e.store.UpdateChangeset(ctx, cs); err != nil {
return nil, err
}
return cs, nil
}
// UpdateDescription changes an open changeset's description.
func (e *Engine) UpdateDescription(ctx context.Context, csID int64, desc string, p *auth.Principal) (*model.Changeset, error) {
cs, err := e.loadChangeset(ctx, csID, p, true)
if err != nil {
return nil, err
}
cs.Description = desc
return cs, e.store.UpdateChangeset(ctx, cs)
}
// Discard abandons an open changeset.
func (e *Engine) Discard(ctx context.Context, csID int64, p *auth.Principal) error {
cs, err := e.loadChangeset(ctx, csID, p, true)
if err != nil {
return err
}
cs.Status = model.ChangesetDiscarded
if err := e.store.UpdateChangeset(ctx, cs); err != nil {
return err
}
e.audit(ctx, p, "changeset.discard", "changeset", cs.ID, cs.Description, cs.ID, "success", "", nil)
return nil
}
// ---- planning
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
removed []string
diff string
check *bind.Result
checkMsgs []CheckMessage
drift []DriftEntry
configChanged bool
reloadZones []*model.Zone
reloadPolicy []render.PolicyZone // rules zones whose file changed
verify map[int64]uint32
newZones bool
backup *Backup
reloaded bool
}
func (pl *plan) files() []string {
var out []string
for _, f := range pl.changed {
out = append(out, f.Path)
}
out = append(out, pl.removed...)
sort.Strings(out)
return out
}
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{}
for _, p := range pl.introduced {
introducedKeys[p.Key()] = true
}
for _, p := range after {
if !introducedKeys[p.Key()] {
pl.existing = append(pl.existing, p)
}
}
if validate.HasErrors(pl.introduced) {
return pl, nil
}
pl.serials = bumpSerials(base, work, e.Now(), user)
guarded, problems := e.guardSerials(ctx, base, work, pl.serials, user)
pl.serials = append(pl.serials, guarded...)
if validate.HasErrors(problems) {
pl.introduced = append(pl.introduced, problems...)
return pl, nil
}
pl.out = render.Render(work, e.cfg.Paths, e.extras(ctx))
deployed, err := e.store.DeployedFiles(ctx)
if err != nil {
return nil, err
}
pl.drift = CheckDrift(deployed)
var diff strings.Builder
for _, f := range pl.out.Files {
old, existed, err := readFile(f.Path)
if err != nil {
return nil, err
}
if existed && string(old) == string(f.Content) {
continue
}
pl.changed = append(pl.changed, f)
diff.WriteString(fileDiff(f.Path, old, existed, f.Content, false, f.Secret))
if f.Kind == render.KindConfig || f.Kind == render.KindKeys {
pl.configChanged = true
}
}
for path, df := range deployed {
if pl.out.File(path) != nil || df.Kind != render.KindZone && df.Kind != render.KindPolicy {
continue
}
old, existed, _ := readFile(path)
if existed {
pl.removed = append(pl.removed, path)
diff.WriteString(fileDiff(path, old, true, nil, true, false))
}
}
sort.Strings(pl.removed)
pl.diff = diff.String()
// Which zones named must reload, and which serials to verify.
baseOut := render.Render(base, e.cfg.Paths, e.extras(ctx))
changedPaths := map[string]bool{}
for _, f := range pl.changed {
changedPaths[f.Path] = true
}
for _, z := range work.SortedZones() {
if z.Type != model.ZonePrimary || z.Disabled {
continue // disabled zones aren't in named: nothing to reload
}
path := pl.out.ZoneFiles[z.ID]
old := base.Zones[z.ID]
existedInNamed := old != nil && old.Type == model.ZonePrimary && !old.Disabled && baseOut.ZoneFiles[old.ID] == path
if !existedInNamed {
pl.newZones = true
pl.verify[z.ID] = z.SOA.Serial
continue
}
if changedPaths[path] && render.ZoneContentHash(base, old) != render.ZoneContentHash(work, z) || old.SOA.Serial != z.SOA.Serial {
pl.reloadZones = append(pl.reloadZones, z)
pl.verify[z.ID] = z.SOA.Serial
}
}
if len(work.Zones) != len(base.Zones) {
pl.configChanged = true
}
// Rules zones and gates whose content changed. New ones come with a
// change to named.conf, and reconfig loads them.
for _, z := range pl.out.Policy {
if z.ListID != 0 || !changedPaths[z.File] {
continue
}
if slices.ContainsFunc(baseOut.Policy, func(o render.PolicyZone) bool {
return o.Name == z.Name && o.ViewID == z.ViewID && o.File == z.File
}) {
pl.reloadPolicy = append(pl.reloadPolicy, z)
}
}
if !e.cfg.SkipCheck {
if err := e.stagingCheck(ctx, pl); err != nil {
return nil, err
}
}
return pl, nil
}
// stagingCheck renders work into a staging directory and runs
// named-checkconf -z on it.
func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
if err := os.MkdirAll(e.cfg.StagingDir, 0o750); err != nil {
return fmt.Errorf("creating staging directory: %w", err)
}
dir, err := os.MkdirTemp(e.cfg.StagingDir, "check-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
sout := render.Render(pl.work, sp, e.extras(ctx))
for _, d := range sout.Dirs {
if err := os.MkdirAll(d, 0o750); err != nil {
return err
}
}
for _, f := range sout.Files {
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
return err
}
if err := os.WriteFile(f.Path, f.Content, 0o640); err != nil {
return err
}
}
// Blocklists as empty policy zones: their content is checked when
// they are downloaded.
for _, path := range sout.Blocklists {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
if err := os.WriteFile(path, []byte(blocking.Placeholder()), 0o640); err != nil {
return err
}
}
main, err := e.stagingMain(dir, sp.MainConfig())
if err != nil {
return err
}
res, err := e.tools.CheckConfig(ctx, main, true)
if err != nil {
return &Error{Kind: KindBIND, Message: "running named-checkconf failed: " + err.Error()}
}
pl.check = res
// Map staging paths back to objects and production paths.
zoneByFile := map[string]int64{}
for id, path := range sout.ZoneFiles {
zoneByFile[path] = id
}
replacer := strings.NewReplacer(sp.ConfigDir, e.cfg.Paths.ConfigDir, sp.ZoneDir, e.cfg.Paths.ZoneDir, sp.CacheDir, e.cfg.Paths.CacheDir, dir, "(staging)")
for _, m := range res.Messages {
cm := CheckMessage{Message: m}
if span, ok := sout.Lookup(m.File, m.Line); ok {
cm.ObjectType, cm.ObjectID, cm.Object = span.ObjectType, span.ObjectID, span.Label
} else if id, ok := zoneByFile[m.File]; ok {
z := pl.work.Zones[id]
cm.ObjectType, cm.ObjectID, cm.Object = "zone", id, "zone "+zoneDesc(pl.work, z)
}
cm.File = replacer.Replace(cm.File)
cm.Text = replacer.Replace(cm.Text)
pl.checkMsgs = append(pl.checkMsgs, cm)
}
return nil
}
// includesPath reports whether config text includes path.
func includesPath(text, path string) bool {
stmts, err := namedconf.Parse("", text)
if err != nil {
return strings.Contains(text, `"`+path+`"`)
}
for _, s := range namedconf.Find(stmts, "include") {
if filepath.Clean(s.Arg(0)) == filepath.Clean(path) {
return true
}
}
return false
}
// stagingMain returns the main configuration file to check: named's own
// main file with Jarl's include pointed at the staging copy, so anything
// else in it is checked too.
func (e *Engine) stagingMain(dir, stagingJarl string) (string, error) {
main := e.cfg.MainConfig
if main == "" || filepath.Clean(main) == filepath.Clean(e.cfg.Paths.MainConfig()) {
return stagingJarl, nil
}
b, err := os.ReadFile(main)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return stagingJarl, nil
}
// Not readable by Jarl: check Jarl's configuration alone.
e.log.Warn("cannot read main named configuration; checking Jarl's configuration alone", "path", main, "err", err)
return stagingJarl, nil
}
text := string(b)
if !includesPath(text, e.cfg.Paths.MainConfig()) {
return stagingJarl, nil
}
text = strings.ReplaceAll(text, `"`+e.cfg.Paths.MainConfig()+`"`, `"`+stagingJarl+`"`)
path := filepath.Join(dir, "main.conf")
return path, os.WriteFile(path, []byte(text), 0o640)
}
// Adopted reports whether named loads Jarl's configuration.
func (e *Engine) Adopted() (bool, error) {
main := e.cfg.MainConfig
if main == "" || filepath.Clean(main) == filepath.Clean(e.cfg.Paths.MainConfig()) {
return true, nil
}
b, err := os.ReadFile(main)
if err != nil {
return false, err
}
return includesPath(string(b), e.cfg.Paths.MainConfig()), nil
}
func (pl *plan) result() *Result {
r := &Result{
Valid: !validate.HasErrors(pl.introduced),
Problems: nonNilProblems(pl.introduced),
Existing: pl.existing,
Check: pl.checkMsgs,
CheckOK: pl.check == nil || pl.check.OK,
Diff: pl.diff,
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 {
r.Files = []string{}
}
return r
}
func nonNilProblems(ps []validate.Problem) []validate.Problem {
if ps == nil {
return []validate.Problem{}
}
return ps
}
// Preview validates a changeset and shows what applying it would do.
func (e *Engine) Preview(ctx context.Context, csID int64, p *auth.Principal) (*Result, error) {
cs, err := e.loadChangeset(ctx, csID, p, false)
if err != nil {
return nil, err
}
base, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
res := &Result{ChangesetID: cs.ID, DryRun: true, Ops: RedactOps(cs.Ops), Problems: []validate.Problem{}, Files: []string{}}
work, warnings, err := e.replay(base, cs.Ops, p)
if err != nil {
ee, ok := AsError(err)
if !ok {
return nil, err
}
res.Error = ee
return res, nil
}
pl, err := e.makePlan(ctx, base, work, p.Name(), ApplyOptions{})
if err != nil {
return nil, err
}
r := pl.result()
r.ChangesetID, r.DryRun, r.Ops, r.Warnings = cs.ID, true, RedactOps(cs.Ops), warnings
return r, nil
}
// Apply applies an open changeset.
func (e *Engine) Apply(ctx context.Context, csID int64, p *auth.Principal, opts ApplyOptions) (*Result, error) {
cs, err := e.loadChangeset(ctx, csID, p, true)
if err != nil {
return nil, err
}
if len(cs.Ops) == 0 {
return nil, errf(KindBadRequest, "changeset %d is empty", csID)
}
return e.applyOps(ctx, cs, nil, p, opts)
}
// Submit expands requests into a new changeset and applies it at once.
// Nothing is stored if it fails.
func (e *Engine) Submit(ctx context.Context, reqs []Request, p *auth.Principal, via, description string, opts ApplyOptions) (*Result, error) {
cs := &model.Changeset{Description: description, UserID: p.User.ID, Username: p.Name(), Via: via, CreatedAt: e.Now()}
return e.applyOps(ctx, cs, reqs, p, opts)
}
func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Request, p *auth.Principal, opts ApplyOptions) (res *Result, err error) {
e.mu.Lock()
defer e.mu.Unlock()
defer func() {
if err != nil {
outcome := "failure"
if ee, ok := AsError(err); ok && ee.Kind == KindForbidden {
outcome = "denied"
}
summary := cs.Description
if summary == "" && len(cs.Ops) > 0 {
summary = cs.Ops[0].Summary
}
if !opts.DryRun {
e.audit(ctx, p, "changeset.apply", "changeset", cs.ID, summary, cs.ID, outcome, err.Error(), nil)
}
}
}()
base, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
var work *model.State
var warnings []string
if reqs != nil {
work = base.Clone()
var ops []model.Op
ops, warnings, err = e.expandAll(ctx, work, reqs, p)
if err != nil {
return nil, err
}
now := e.Now()
for i := range ops {
ops[i].Seq, ops[i].AddedAt = i+1, now
}
cs.Ops = ops
if len(ops) == 0 {
return nil, errf(KindBadRequest, "nothing to do")
}
} else {
work, warnings, err = e.replay(base, cs.Ops, p)
if err != nil {
return nil, err
}
}
if cs.Description == "" && len(cs.Ops) > 0 {
cs.Description = cs.Ops[0].Summary
if len(cs.Ops) > 1 {
cs.Description += fmt.Sprintf(" (+%d more)", len(cs.Ops)-1)
}
}
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
if err != nil {
return nil, err
}
res = pl.result()
res.ChangesetID, res.Warnings, res.Ops, res.DryRun = cs.ID, warnings, RedactOps(cs.Ops), opts.DryRun
if validate.HasErrors(pl.introduced) {
return res, &Error{Kind: KindValidation, Message: describeErrors(pl.introduced), Problems: validate.Errors(pl.introduced)}
}
if pl.check != nil && !pl.check.OK {
return res, &Error{Kind: KindValidation, Message: "named-checkconf rejected the new configuration: " + firstCheckError(pl.checkMsgs), Check: pl.checkMsgs}
}
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 {
paths = append(paths, d.Path+" ("+d.Reason+")")
}
return res, &Error{Kind: KindDrift, Message: "files managed by Jarl were changed outside Jarl: " + strings.Join(paths, ", ") + "; review them and apply with force to overwrite"}
}
reload := !e.cfg.NoReload && !opts.NoReload
if reload {
if adopted, aerr := e.Adopted(); aerr != nil || !adopted {
// named doesn't read Jarl's files yet, so there is nothing
// to reload; the files are ready for 'jarld adopt'.
reload = false
res.Warnings = append(res.Warnings, fmt.Sprintf("named does not load Jarl's configuration yet (%s does not include %s): the files were written but named was not reloaded; run 'jarld adopt' to switch named over", e.cfg.MainConfig, e.cfg.Paths.MainConfig()))
}
}
if err := e.deployAndReload(ctx, pl, res, reload); err != nil {
return res, err
}
// Record everything.
undo := DiffOps(work, base)
st := stored{Serials: pl.serials, Warnings: warnings, Files: res.Files, Undo: undo}
cs.Result = mustJSON(st)
cs.Diff = pl.diff
cs.AppliedBy = p.Name()
deployed := deployedList(pl.out, cs.ID, e.Now())
err = e.store.InTx(ctx, func(tx *store.Tx) error {
if err := tx.SaveState(base, work); err != nil {
return err
}
if cs.ID == 0 {
if err := tx.InsertAppliedChangeset(cs); err != nil {
return err
}
} else if err := tx.MarkApplied(cs); err != nil {
return err
}
for _, d := range deployed {
d.ChangesetID = cs.ID
}
if err := tx.SetDeployedFiles(deployed); err != nil {
return err
}
details, _ := json.Marshal(map[string]any{"ops": RedactOps(cs.Ops), "serials": pl.serials, "files": res.Files})
return tx.AddAudit(e.auditEvent(p, "changeset.apply", "changeset", cs.ID, cs.Description, cs.ID, "success", "", details))
})
if err != nil {
e.log.Error("recording applied changeset failed; restoring previous configuration", "err", err)
e.rollback(ctx, pl)
return res, fmt.Errorf("recording the change failed (the previous configuration was restored): %w", err)
}
res.ChangesetID = cs.ID
res.Applied = true
e.removeDerived(pl)
for _, op := range cs.Ops {
if strings.HasPrefix(op.Kind, "blocklist.") || op.Kind == OpBlockingPause {
e.KickBlocklists()
break
}
}
e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description)
return res, nil
}
func firstCheckError(msgs []CheckMessage) string {
for _, m := range msgs {
if m.Error {
s := m.Text
if m.Object != "" {
s = m.Object + ": " + s
}
return s
}
}
return "see the check output"
}
func deployedList(out *render.Output, csID int64, now time.Time) []*model.DeployedFile {
var list []*model.DeployedFile
for _, f := range out.Files {
list = append(list, &model.DeployedFile{Path: f.Path, SHA256: sha(f.Content), Size: int64(len(f.Content)), Kind: f.Kind, WrittenAt: now, ChangesetID: csID})
}
return list
}
// deployAndReload writes the files and (if reload) makes named load them.
// On failure the previous files are restored.
func (e *Engine) deployAndReload(ctx context.Context, pl *plan, res *Result, reload bool) error {
backup, err := e.deploy.Install(pl.changed, pl.removed, pl.out.Dirs)
if err != nil {
return &Error{Kind: KindBIND, Message: "writing configuration files failed (nothing was changed): " + err.Error()}
}
pl.backup = backup
if err := e.ensureBlocklistFiles(pl.out.Blocklists); err != nil {
e.log.Warn("creating a blocklist's empty zone file failed", "err", err)
}
pl.reloaded = reload
if !reload {
return nil
}
reloaded, err := e.reload(ctx, pl)
res.Reloaded = reloaded
if err != nil {
e.log.Error("reloading named failed; restoring previous configuration", "err", err)
e.rollback(ctx, pl)
return &Error{Kind: KindBIND, Message: err.Error() + " (the previous configuration was restored)"}
}
return nil
}
func (e *Engine) rollback(ctx context.Context, pl *plan) {
if err := pl.backup.Restore(); err != nil {
e.log.Error("restoring files failed", "err", err)
}
if !pl.reloaded {
return
}
if pl.configChanged || pl.newZones {
if err := e.tools.Reconfig(ctx); err != nil {
e.log.Error("rndc reconfig after restore failed", "err", err)
}
}
for _, z := range pl.reloadZones {
if err := e.tools.ReloadZone(ctx, z.Name, e.namedView(pl.base, pl.base.Zones[z.ID])); err != nil {
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
}
}
for _, z := range pl.reloadPolicy {
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
e.log.Error("rndc reload after restore failed", "zone", z.Name, "err", err)
}
}
}
// namedView returns the view name to pass to rndc for a zone ("" without
// views).
func (e *Engine) namedView(st *model.State, z *model.Zone) string {
if z == nil {
return ""
}
return st.ViewName(z)
}
func (e *Engine) reload(ctx context.Context, pl *plan) ([]string, error) {
var done []string
if pl.configChanged || pl.newZones {
if err := e.tools.Reconfig(ctx); err != nil {
return done, fmt.Errorf("named rejected the new configuration: %v", err)
}
done = append(done, "reconfig")
}
for _, z := range pl.reloadZones {
if err := e.tools.ReloadZone(ctx, z.Name, e.namedView(pl.work, z)); err != nil {
return done, fmt.Errorf("reloading zone %s failed: %v", zoneDesc(pl.work, z), err)
}
done = append(done, "reload "+zoneDesc(pl.work, z))
}
for _, z := range pl.reloadPolicy {
label := "blocking rules"
if z.View != "" {
label += " [view " + z.View + "]"
}
if err := e.tools.ReloadZone(ctx, z.Name, z.View); err != nil {
return done, fmt.Errorf("reloading %s failed: %v", label, err)
}
done = append(done, "reload "+label)
}
// named reloads zones asynchronously and reports load errors only in
// its log, so wait until it serves the new serials.
deadline := time.Now().Add(e.cfg.ReloadTimeout)
pending := map[int64]uint32{}
for id, s := range pl.verify {
pending[id] = s
}
var lastErr error
for len(pending) > 0 {
for id, want := range pending {
z := pl.work.Zones[id]
st, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(pl.work, z))
if err == nil && st.HasSerial && st.Serial == want {
delete(pending, id)
continue
}
if err != nil {
lastErr = err
} else {
lastErr = fmt.Errorf("named serves serial %d", st.Serial)
}
}
if len(pending) == 0 {
break
}
if time.Now().After(deadline) {
var names []string
for id, want := range pending {
names = append(names, fmt.Sprintf("%s (expected serial %d)", zoneDesc(pl.work, pl.work.Zones[id]), want))
}
sort.Strings(names)
return done, fmt.Errorf("named did not load %s within %s: %v; check named's log (journalctl -u named)", strings.Join(names, ", "), e.cfg.ReloadTimeout, lastErr)
}
select {
case <-ctx.Done():
return done, ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
return done, nil
}
// ---- revert, resync, check
// Revert creates an open changeset that undoes an applied changeset.
func (e *Engine) Revert(ctx context.Context, csID int64, p *auth.Principal) (*model.Changeset, error) {
cs, err := e.loadChangeset(ctx, csID, p, false)
if err != nil {
return nil, err
}
if cs.Status != model.ChangesetApplied {
return nil, errf(KindConflict, "only applied changesets can be reverted")
}
var st stored
if len(cs.Result) > 0 {
if err := json.Unmarshal(cs.Result, &st); err != nil {
return nil, err
}
}
if len(st.Undo) == 0 {
return nil, errf(KindConflict, "changeset %d has nothing to undo", csID)
}
rev := &model.Changeset{Description: fmt.Sprintf("revert changeset #%d: %s", cs.ID, cs.Description), UserID: p.User.ID, Username: p.Name(), Via: "revert", Status: model.ChangesetOpen, Ops: st.Undo}
now := e.Now()
for i := range rev.Ops {
rev.Ops[i].Seq, rev.Ops[i].AddedAt = i+1, now
}
if err := e.store.CreateChangeset(ctx, rev); err != nil {
return nil, err
}
e.audit(ctx, p, "changeset.revert", "changeset", rev.ID, rev.Description, cs.ID, "success", "", nil)
return rev, nil
}
// Resync renders the current configuration and deploys it, overwriting
// whatever is on disk (used for the first deployment and to repair
// drift).
func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOptions) (*Result, error) {
e.mu.Lock()
defer e.mu.Unlock()
base, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
work := base.Clone()
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
if err != nil {
return nil, err
}
res := pl.result()
res.DryRun = opts.DryRun
if validate.HasErrors(pl.existing) || validate.HasErrors(pl.introduced) {
// Resync deploys the stored state as is; it must be valid.
ps := append(append([]validate.Problem{}, pl.existing...), pl.introduced...)
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(ps), Problems: validate.Errors(ps)}
}
if pl.check != nil && !pl.check.OK {
return res, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration: " + firstCheckError(pl.checkMsgs), Check: pl.checkMsgs}
}
if opts.DryRun {
return res, nil
}
if len(pl.drift) > 0 && !opts.Force {
var paths []string
for _, d := range pl.drift {
paths = append(paths, d.Path+" ("+d.Reason+")")
}
return res, &Error{Kind: KindDrift, Message: "files managed by Jarl were changed outside Jarl: " + strings.Join(paths, ", ") + "; resync with force to overwrite them"}
}
// Verify every primary zone and reload them all.
pl.configChanged = true
pl.reloadZones = nil
for _, z := range work.SortedZones() {
if z.Type == model.ZonePrimary && !z.Disabled {
pl.reloadZones = append(pl.reloadZones, z)
pl.verify[z.ID] = z.SOA.Serial
}
}
pl.reloadPolicy = nil
for _, z := range pl.out.Policy {
if z.ListID == 0 {
pl.reloadPolicy = append(pl.reloadPolicy, z)
}
}
reload := !e.cfg.NoReload && !opts.NoReload
if adopted, aerr := e.Adopted(); reload && (aerr != nil || !adopted) {
reload = false
res.Warnings = append(res.Warnings, "named does not load Jarl's configuration yet; files written without reloading named")
}
if err := e.deployAndReload(ctx, pl, res, reload); err != nil {
return res, err
}
deployed := deployedList(pl.out, 0, e.Now())
err = e.store.InTx(ctx, func(tx *store.Tx) error {
// Serials raised above named's (guardSerials) are kept.
if len(pl.serials) > 0 {
if err := tx.SaveState(base, work); err != nil {
return err
}
}
if err := tx.SetDeployedFiles(deployed); err != nil {
return err
}
var details json.RawMessage
if len(pl.serials) > 0 {
details, _ = json.Marshal(map[string]any{"serials": pl.serials})
}
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), details))
})
if err != nil {
return res, err
}
res.Applied = true
e.removeDerived(pl)
return res, nil
}
// SyncConfig rewrites named's configuration when it differs from what the
// database and jarld's own configuration produce, for example after query
// statistics were switched on or off in jarl.yaml. Files changed by hand
// are never overwritten. It reports whether anything was written.
func (e *Engine) SyncConfig(ctx context.Context) (bool, *Result, error) {
dry, err := e.Resync(ctx, auth.System, ApplyOptions{DryRun: true})
if err != nil || len(dry.Files) == 0 {
return false, dry, err
}
res, err := e.Resync(ctx, auth.System, ApplyOptions{})
return err == nil, res, err
}
// SetResponseLogging turns named's response logging on or off with rndc.
// The responselog option only takes effect when named starts, so query
// statistics switch it at runtime. It reports whether it changed.
func (e *Engine) SetResponseLogging(ctx context.Context, on bool) (bool, error) {
if e.cfg.NoReload || e.tools == nil {
return false, nil
}
out, err := e.tools.RNDCRun(ctx, "status")
if err != nil {
return false, err
}
want := "response logging is OFF"
if !on {
want = "response logging is ON"
}
if !strings.Contains(out, want) {
return false, nil // already as wanted (or too old a named to say)
}
arg := "on"
if !on {
arg = "off"
}
if _, err := e.tools.RNDCRun(ctx, "responselog", arg); err != nil {
return false, err
}
return true, nil
}
// CheckCurrent validates the stored configuration and runs named-checkconf
// on it without changing anything.
func (e *Engine) CheckCurrent(ctx context.Context) (*Result, error) {
base, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
pl, err := e.makePlan(ctx, base, base.Clone(), "", ApplyOptions{})
if err != nil {
return nil, err
}
res := pl.result()
res.DryRun = true
res.Problems = nonNilProblems(pl.existing)
res.Existing = nil
res.Valid = !validate.HasErrors(pl.existing) && res.CheckOK
return res, nil
}
// Status summarizes the state of Jarl and named.
type Status struct {
Adopted bool `json:"adopted"`
AdoptError string `json:"adopt_error,omitempty"`
Named *bind.ServerStatus `json:"named,omitempty"`
NamedError string `json:"named_error,omitempty"`
BINDVersion string `json:"bind_version,omitempty"`
Drift []DriftEntry `json:"drift"`
Deployed bool `json:"deployed"`
LastApplied *model.Changeset `json:"last_applied,omitempty"`
OpenChangesets int `json:"open_changesets"` // with at least one operation
Problems []validate.Problem `json:"problems"`
Counts map[string]int `json:"counts"`
Paths render.Paths `json:"paths"`
MainConfig string `json:"main_config"`
}
// NamedStatus asks named for its status (rndc status).
func (e *Engine) NamedStatus(ctx context.Context) (*bind.ServerStatus, error) {
if e.cfg.NoReload || e.tools == nil {
return nil, errors.New("named is not managed by this jarld (no_reload)")
}
return e.tools.Status(ctx)
}
// Status returns the current status.
func (e *Engine) Status(ctx context.Context) (*Status, error) {
st, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
s := &Status{Paths: e.cfg.Paths, MainConfig: e.cfg.MainConfig, Drift: []DriftEntry{}}
s.Adopted, err = e.Adopted()
if err != nil {
s.AdoptError = err.Error()
}
if !e.cfg.NoReload && e.tools != nil {
ns, err := e.tools.Status(ctx)
if err != nil {
s.NamedError = err.Error()
} else {
s.Named = ns
}
}
if e.tools != nil {
if v, err := e.tools.Version(ctx); err == nil {
s.BINDVersion = v
}
}
deployed, err := e.store.DeployedFiles(ctx)
if err != nil {
return nil, err
}
s.Deployed = len(deployed) > 0
s.Drift = append(s.Drift, CheckDrift(deployed)...)
if list, err := e.store.ListChangesets(ctx, store.ChangesetFilter{Status: model.ChangesetApplied, Limit: 1}); err == nil && len(list) > 0 {
s.LastApplied = list[0]
s.LastApplied.Ops = RedactOps(s.LastApplied.Ops)
}
// Only changesets with something in them: an empty one (a web UI draft
// whose edit was rejected, say) has nothing to review.
if list, err := e.store.ListChangesets(ctx, store.ChangesetFilter{Status: model.ChangesetOpen, Limit: 1000}); err == nil {
for _, cs := range list {
if len(cs.Ops) > 0 {
s.OpenChangesets++
}
}
}
s.Problems = nonNilProblems(append(e.validate(st), e.namedProblems(ctx, st, deployed)...))
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
return s, nil
}
// ---- audit
func (e *Engine) auditEvent(p *auth.Principal, action, objType string, objID int64, objName string, csID int64, outcome, msg string, details json.RawMessage) *model.AuditEvent {
ev := &model.AuditEvent{Time: e.Now(), Action: action, ObjectType: objType, ObjectID: objID, ObjectName: objName, ChangesetID: csID, Outcome: outcome, Message: msg, Details: details}
if p != nil {
if p.User != nil {
ev.UserID, ev.Username = p.User.ID, p.User.Username
}
ev.Auth, ev.IP, ev.UserAgent = p.Method, p.IP, p.Agent
if p.Token != nil {
ev.TokenID, ev.TokenName = p.Token.ID, p.Token.Name
}
}
return ev
}
func (e *Engine) audit(ctx context.Context, p *auth.Principal, action, objType string, objID int64, objName string, csID int64, outcome, msg string, details json.RawMessage) {
ev := e.auditEvent(p, action, objType, objID, objName, csID, outcome, msg, details)
if err := e.store.AddAudit(ctx, ev); err != nil {
e.log.Error("writing audit event failed", "err", err, "action", action)
}
e.log.Info("audit", "action", action, "user", ev.Username, "auth", ev.Auth, "token", ev.TokenName, "ip", ev.IP, "object", objName, "changeset", csID, "outcome", outcome, "message", msg)
}
// Audit records an audit event (used by other packages).
func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, objType string, objID int64, objName, outcome, msg string) {
e.audit(ctx, p, action, objType, objID, objName, 0, outcome, msg, nil)
}
// ZoneLabel is exported for other packages.
func ZoneLabel(st *model.State, z *model.Zone) string { return zoneDesc(st, z) }
// Display is a helper for names.
func Display(n string) string { return dnsname.Display(n) }
// WorkingState returns the configuration as it would be after applying an
// open changeset (work), and the current configuration (base).
func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal) (work, base *model.State, err error) {
cs, err := e.loadChangeset(ctx, csID, p, false)
if err != nil {
return nil, nil, err
}
base, err = e.store.LoadState(ctx)
if err != nil {
return nil, nil, err
}
if cs.Status != model.ChangesetOpen {
return base, base, nil
}
work, _, err = e.replay(base, cs.Ops, p)
if err != nil {
return nil, nil, err
}
return work, base, nil
}
// LoadedSerial returns the serial named serves for zone z, from rndc
// zonestatus: for a zone named signs, the signed zone's serial (which
// secondaries and the rest of the world see), not that of the zone file
// Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't
// report a serial (the zone isn't loaded yet).
// LoadedSerialCommand is the command LoadedSerial runs, or "" when it
// doesn't ask named.
func (e *Engine) LoadedSerialCommand(st *model.State, z *model.Zone) string {
if e.cfg.NoReload || e.tools == nil {
return ""
}
return bind.ZoneCommand("zonestatus", z.Name, e.namedView(st, z))
}
func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) {
if e.cfg.NoReload || e.tools == nil {
return 0, false, nil
}
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(st, z))
if err != nil {
return 0, false, err
}
serial, ok = zs.ServedSerial()
return serial, ok, nil
}