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