Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1136 lines
36 KiB
Go
1136 lines
36 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
|
|
// 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)
|
|
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)
|
|
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)
|
|
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
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|