The zone's Name servers tab no longer waits for the other servers before
showing: it lists each query as a dig command line with a waiting
counter, fills in the answers as they arrive, then shows the result with
the queries under "Queries sent". Results (serials and delegation) are
kept for 10 minutes so revisiting the tab is instant; "Check again" asks
anew, and a check keeps running when the tab is left.
GET /zones/{zone}/serials?stream=1 streams the queries as
newline-delimited JSON (nscheck.Checker.Watch); without it the endpoint
is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1245 lines
40 KiB
Go
1245 lines
40 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
|
|
// 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
|
|
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 {
|
|
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 _, 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
|
|
}
|
|
}
|
|
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)
|
|
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(), 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
|
|
}
|
|
}
|
|
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
|
|
}
|