// 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" "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 } // 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 } // 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 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 // 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, 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 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) 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.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 && 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.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 } // Rules zones 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.Contains(baseOut.Policy, z) { 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.cfg.Extras) 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, } 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 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 { 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"` } // 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(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 } // 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 }