Files
jarl/internal/engine/engine.go
T
abrown38andClaude Opus 5.5 3b7bee67f8 Add optional query statistics
Off by default (stats in jarl.yaml). When enabled, Jarl adds a loopback
statistics channel and a response log to named's configuration (merged
into existing logging/statistics-channels statements) and switches
response logging with rndc responselog, because the responselog option
only counts when named starts. jarld polls named's counters every minute
and tails the response log (resuming after restarts and across
rotation). It stores aggregates, never individual queries, in a separate
SQLite database: per-minute, hourly and daily counters (queries, result
codes, record types, views, zones, cache hits, upstream latency, DNSSEC)
and hourly/daily top lists (names, clients, NXDOMAIN and SERVFAIL names,
refused clients), each with its own retention. Clients can be anonymized
or excluded.

- Web UI: Statistics page with KPI tiles, queries and failed answers over
  time, a weekday x hour heatmap, top lists and breakdowns; SVG charts
  with hover/keyboard readouts, a table view and light/dark palettes
  validated for colour-vision deficiency.
- CLI: jarl stats status|summary|top|timeline|heatmap|breakdown.
- API: /api/v1/stats/*, behind a new "stats" permission resource.
- jarld brings named's configuration up to date with jarl.yaml at
  startup (engine.SyncConfig), never overwriting hand-edited files.
- resync now refuses to overwrite drifted files without force, as
  documented.
- tmpfiles.d creates /var/log/named (allowed by named's AppArmor profile
  on Debian 13 and Ubuntu).
- The e2e harness runs named with -f like the Debian unit.
- docs/stats.md, plus API, CLI, configuration, permissions and README
  updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:21:34 +00:00

1187 lines
38 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"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"jarl/internal/auth"
"jarl/internal/bind"
"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
}
// 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
}
// 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,
}
}
// 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
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
}
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, ValidateOptions(st))
}
// ---- 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
serials []SerialChange
out *render.Output
changed []*render.File
removed []string
diff string
check *bind.Result
checkMsgs []CheckMessage
drift []DriftEntry
configChanged bool
reloadZones []*model.Zone
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) (*plan, error) {
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
before := e.validate(base)
after := e.validate(work)
pl.introduced = validate.Introduced(before, after)
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)
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
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 {
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.cfg.Extras)
changedPaths := map[string]bool{}
for _, f := range pl.changed {
changedPaths[f.Path] = true
}
for _, z := range work.SortedZones() {
if z.Type != model.ZonePrimary {
continue
}
path := pl.out.ZoneFiles[z.ID]
old := base.Zones[z.ID]
existedInNamed := old != nil && old.Type == model.ZonePrimary && 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
}
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.cfg.Extras)
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
}
}
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,
}
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())
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())
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 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.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
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)
}
}
}
// 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))
}
// 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())
if err != nil {
return nil, err
}
res := pl.result()
res.DryRun = opts.DryRun
if validate.HasErrors(pl.existing) {
// Resync deploys the stored state as is; it must be valid.
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(pl.existing), Problems: validate.Errors(pl.existing)}
}
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 {
pl.reloadZones = append(pl.reloadZones, z)
pl.verify[z.ID] = z.SOA.Serial
}
}
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 {
if err := tx.SetDeployedFiles(deployed); err != nil {
return err
}
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), nil))
})
if err != nil {
return res, err
}
res.Applied = true
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(), "")
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"`
Problems []validate.Problem `json:"problems"`
Counts map[string]int `json:"counts"`
Paths render.Paths `json:"paths"`
MainConfig string `json:"main_config"`
}
// 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)
}
if list, err := e.store.ListChangesets(ctx, store.ChangesetFilter{Status: model.ChangesetOpen, Limit: 1000}); err == nil {
s.OpenChangesets = len(list)
}
s.Problems = nonNilProblems(e.validate(st))
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
}