Files
jarl/internal/engine/expand.go
T
adamandClaude Opus 5.5 7be0762f48 Server statistics, multi-view record commands, and rc.3 fixes
Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
  sockets and client counts against their limits (statistics channel,
  rndc status), host memory and load, and free disk space. Memory and
  CPU are kept over time as gauges (sum and sample count, averaged in
  timelines). Statistics page Server section, jarl stats server,
  GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
  change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
  "Until I resume", {"indefinitely": true}).

Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
  when given without them.
- The dashboard lists named-checkconf's warnings about the running
  configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
  failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:03:05 +01:00

1068 lines
30 KiB
Go

package engine
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"strings"
"time"
"github.com/miekg/dns"
"jarl/internal/auth"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
"jarl/internal/validate"
)
// IDAllocator reserves object IDs.
type IDAllocator func(ctx context.Context, n int) ([]int64, error)
type expander struct {
ctx context.Context
st *model.State // working state: base plus the changeset's ops so far
alloc IDAllocator
p *auth.Principal
now time.Time
warnings []string
}
func (x *expander) ids(n int) ([]int64, error) {
ids, err := x.alloc(x.ctx, n)
if err != nil {
return nil, fmt.Errorf("allocating IDs: %w", err)
}
return ids, nil
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func newOp(kind string, id int64, base int, data any, summary string) model.Op {
op := model.Op{Kind: kind, ID: id, BaseVersion: base, Summary: summary}
if data != nil {
op.Data = mustJSON(data)
}
return op
}
// resolveZone finds the zone a record request refers to.
func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, error) {
if zoneID != 0 {
z := x.st.Zones[zoneID]
if z == nil {
return nil, errf(KindNotFound, "zone %d does not exist", zoneID)
}
return z, nil
}
if zone == "" {
return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)")
}
return FindZoneRef(x.st, zone, view)
}
// FindZoneRef finds a zone by name and optional view name.
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
name, err := ParseZoneName(zone)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
if view != "" {
v := st.ViewByName(view)
if v == nil {
return nil, errf(KindNotFound, "view %q does not exist", view)
}
z := st.FindZone(name, v.ID)
if z == nil {
return nil, errf(KindNotFound, "zone %s does not exist in view %s", dnsname.Display(name), v.Name)
}
return z, nil
}
zs := st.ZonesNamed(name)
switch len(zs) {
case 0:
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
case 1:
return zs[0], nil
}
var views []string
for _, z := range zs {
views = append(views, st.ViewName(z))
}
return nil, errf(KindBadRequest, "zone %s exists in several views (%s); say which view", dnsname.Display(name), strings.Join(views, ", "))
}
func zoneDesc(st *model.State, z *model.Zone) string {
s := dnsname.Display(z.Name)
if v := st.ViewName(z); v != "" {
s += " [" + v + "]"
}
return s
}
func ttlText(o model.Opt[uint32]) string {
if o.Set && !o.Null {
return fmt.Sprintf(" %d", o.Value)
}
return ""
}
// InitialSerial returns the first serial for a new zone.
func InitialSerial(policy string, now time.Time) uint32 {
switch policy {
case model.SerialIncrement:
return 1
case model.SerialUnixtime:
return uint32(now.Unix())
}
return dateSerial(now)
}
func dateSerial(now time.Time) uint32 {
y, m, d := now.UTC().Date()
return uint32(y*1000000 + int(m)*10000 + d*100)
}
func (x *expander) expand(req Request) ([]model.Op, error) {
switch req.Op {
case OpZoneCreate:
return x.zoneCreate(req)
case OpZoneUpdate, OpZoneDelete:
z := x.st.Zones[req.ID]
if z == nil {
return nil, errf(KindNotFound, "zone %d does not exist", req.ID)
}
verb := "update"
if req.Op == OpZoneDelete {
verb = "delete"
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
verb += fmt.Sprintf(" (with its %d records)", n)
}
} else if d := zoneToggle(req, z); d != nil {
if *d {
x.warnings = append(x.warnings, disabledZoneEffect(x.st, z))
return []model.Op{x.versioned(req, z.Version, "disable zone "+zoneDesc(x.st, z))}, nil
}
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
}
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
case OpRecordCreate:
return x.recordCreate(req)
case OpRecordUpdate:
return x.recordUpdate(req)
case OpRecordDelete:
return x.recordDelete(req)
case OpRRSetReplace:
return x.rrsetReplace(req)
case OpRecordDisable, OpRecordEnable:
return x.recordSetDisabled(req, req.Op == OpRecordDisable)
case OpViewCreate:
return x.viewCreate(req)
case OpViewUpdate, OpViewDelete:
v := x.st.Views[req.ID]
if v == nil {
return nil, errf(KindNotFound, "view %d does not exist", req.ID)
}
return []model.Op{x.versioned(req, v.Version, strings.TrimPrefix(req.Op, "view.")+" view "+v.Name)}, nil
case OpViewReorder:
var in ReorderInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
var names []string
for _, id := range in.IDs {
if v := x.st.Views[id]; v != nil {
names = append(names, v.Name)
}
}
return []model.Op{newOp(OpViewReorder, 0, 0, in, "reorder views: "+strings.Join(names, ", "))}, nil
case OpACLCreate:
return x.simpleCreate(req, OpACLCreate, func(data json.RawMessage) (string, error) {
var in ACLInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil {
return "", errf(KindBadRequest, "an ACL needs a name")
}
return "create ACL " + *in.Name, nil
})
case OpACLUpdate, OpACLDelete:
a := x.st.ACLs[req.ID]
if a == nil {
return nil, errf(KindNotFound, "ACL %d does not exist", req.ID)
}
return []model.Op{x.versioned(req, a.Version, strings.TrimPrefix(req.Op, "acl.")+" ACL "+a.Name)}, nil
case OpTSIGCreate:
return x.tsigCreate(req)
case OpTSIGUpdate, OpTSIGDelete:
k := x.st.Keys[req.ID]
if k == nil {
return nil, errf(KindNotFound, "TSIG key %d does not exist", req.ID)
}
return []model.Op{x.versioned(req, k.Version, strings.TrimPrefix(req.Op, "tsig.")+" TSIG key "+k.Name)}, nil
case OpOptionCreate:
return x.optionCreate(req)
case OpOptionUpdate, OpOptionDelete:
o := x.st.Options[req.ID]
if o == nil {
return nil, errf(KindNotFound, "option %d does not exist", req.ID)
}
summary := strings.TrimPrefix(req.Op, "option.") + " " + optionLabel(x.st, o)
if req.Op == OpOptionUpdate {
var in OptionInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Value != nil {
summary += ": " + o.Value + " → " + *in.Value
}
}
return []model.Op{x.versioned(req, o.Version, summary)}, nil
case OpSettingsUpdate:
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
case OpBlocklistCreate:
return x.blocklistCreate(req)
case OpBlocklistUpdate, OpBlocklistDelete:
b := x.st.Blocklists[req.ID]
if b == nil {
return nil, errf(KindNotFound, "blocklist %d does not exist", req.ID)
}
verb := "change"
if req.Op == OpBlocklistDelete {
verb = "delete"
}
return []model.Op{x.versioned(req, b.Version, verb+" blocklist "+b.Name)}, nil
case OpBlockingPause:
var in BlockingPauseInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
summary := "resume blocking"
if in.Until != nil {
summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04")
} else if in.Indefinitely {
summary = "pause blocking until resumed"
}
return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil
case OpBlockRuleCreate:
return x.blockRuleCreate(req)
case OpBlockRuleUpdate, OpBlockRuleDelete:
r := x.st.BlockRules[req.ID]
if r == nil {
return nil, errf(KindNotFound, "rule %d does not exist", req.ID)
}
verb := "change rule: "
if req.Op == OpBlockRuleDelete {
verb = "delete rule: "
}
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
}
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
}
func newOpRaw(kind string, id int64, base int, data json.RawMessage, summary string) model.Op {
return model.Op{Kind: kind, ID: id, BaseVersion: base, Data: data, Summary: summary}
}
// versioned builds an update/delete operation, recording the object's
// version for conflict detection.
func (x *expander) versioned(req Request, current int, summary string) model.Op {
base := req.BaseVersion
if base == 0 {
base = current
}
return newOpRaw(req.Op, req.ID, base, req.Data, summary)
}
func (x *expander) simpleCreate(req Request, kind string, describe func(json.RawMessage) (string, error)) ([]model.Op, error) {
summary, err := describe(req.Data)
if err != nil {
return nil, err
}
ids, err := x.ids(1)
if err != nil {
return nil, err
}
return []model.Op{newOpRaw(kind, ids[0], 0, req.Data, summary)}, nil
}
func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
var in ZoneInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Name == nil || strings.TrimSpace(*in.Name) == "" {
return nil, errf(KindBadRequest, "a zone needs a name")
}
name, err := ParseZoneName(*in.Name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
in.Name = &name
if in.Type == nil {
in.Type = model.Ptr(model.ZonePrimary)
}
zt, err := normalizeZoneType(*in.Type)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
in.Type = &zt
if in.View != nil {
if *in.View != "" {
v := x.st.ViewByName(*in.View)
if v == nil {
return nil, errf(KindNotFound, "view %q does not exist", *in.View)
}
in.ViewID = &v.ID
}
in.View = nil
}
nameservers := in.Nameservers
records := in.Records
cloneFrom := in.CloneFrom
zoneOpts := in.Options
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
set := x.st.Settings
var src *model.Zone
if cloneFrom != 0 {
src = x.st.Zones[cloneFrom]
if src == nil {
return nil, errf(KindNotFound, "zone %d (clone_from) does not exist", cloneFrom)
}
if src.Name != name {
return nil, errf(KindBadRequest, "clone_from must be a zone with the same name in another view (it is %s)", dnsname.Display(src.Name))
}
if !x.p.Can(zoneAction(x.st, src, false)) || !x.p.Can(recordAction(x.st, src, "", "", false)) {
return nil, errf(KindForbidden, "permission denied: you can't read zone %s", zoneDesc(x.st, src))
}
}
if zt == model.ZonePrimary {
if len(nameservers) == 0 && src == nil {
nameservers = set.Nameservers
}
if len(nameservers) == 0 && src == nil {
return nil, errf(KindBadRequest, "a primary zone needs at least one name server: give nameservers, or set default name servers in the settings")
}
if in.SOA == nil {
in.SOA = &SOAInput{}
}
s := in.SOA
if src != nil {
// Copy the source zone's SOA and TTL unless overridden.
if s.MName == nil {
s.MName = model.Ptr(src.SOA.MName)
}
if s.RName == nil {
s.RName = model.Ptr(src.SOA.RName)
}
if s.Refresh == nil {
s.Refresh = model.Ptr(src.SOA.Refresh)
}
if s.Retry == nil {
s.Retry = model.Ptr(src.SOA.Retry)
}
if s.Expire == nil {
s.Expire = model.Ptr(src.SOA.Expire)
}
if s.Minimum == nil {
s.Minimum = model.Ptr(src.SOA.Minimum)
}
if in.DefaultTTL == nil {
in.DefaultTTL = model.Ptr(src.DefaultTTL)
}
}
if s.MName == nil {
first, err := dnsname.Owner(nameservers[0], name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
s.MName = &first
}
if s.RName == nil {
if set.RName != "" {
s.RName = model.Ptr(set.RName)
} else {
s.RName = model.Ptr("hostmaster." + name)
}
}
if s.Refresh == nil {
s.Refresh = model.Ptr(set.Refresh)
}
if s.Retry == nil {
s.Retry = model.Ptr(set.Retry)
}
if s.Expire == nil {
s.Expire = model.Ptr(set.Expire)
}
if s.Minimum == nil {
s.Minimum = model.Ptr(set.NegativeTTL)
}
if in.DefaultTTL == nil {
in.DefaultTTL = model.Ptr(set.DefaultTTL)
}
if s.Serial == nil {
policy := set.SerialPolicy
if in.SerialPolicy != nil && *in.SerialPolicy != "" {
policy = *in.SerialPolicy
}
serial := InitialSerial(policy, x.now)
if src != nil && serialGreater(src.SOA.Serial, serial) {
serial = src.SOA.Serial
}
s.Serial = &serial
}
} else if len(nameservers) > 0 || len(records) > 0 || src != nil {
return nil, errf(KindBadRequest, "nameservers, records and clone_from only apply to primary zones")
}
type recSpec struct {
in RecordInput
summary string
}
var recs []recSpec
seen := map[string]bool{}
addRec := func(ri RecordInput) error {
tmp := &model.Zone{Name: name, Type: zt}
owner, p, err := parseRecord(tmp, deref(ri.Name), deref(ri.Type), deref(ri.Data))
if err != nil {
return err
}
k := owner + "|" + p.Type + "|" + strings.ToLower(p.Data)
if seen[k] {
return nil
}
seen[k] = true
ri.Name, ri.Type, ri.Data = &owner, &p.Type, &p.Data
recs = append(recs, recSpec{ri, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(ri.TTL), p.Type, p.Data)})
return nil
}
for _, ns := range nameservers {
target, err := dnsname.Owner(ns, name)
if err != nil {
return nil, errf(KindBadRequest, "name server %q: %v", ns, err)
}
if err := addRec(RecordInput{Name: model.Ptr("@"), Type: model.Ptr("NS"), Data: model.Ptr(target)}); err != nil {
return nil, err
}
}
if src != nil {
for _, r := range x.st.ZoneRecords(src.ID) {
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
if r.TTL != nil {
ri.TTL = model.Some(*r.TTL)
} else {
ri.TTL = model.Opt[uint32]{Set: true, Null: true}
}
if err := addRec(ri); err != nil {
return nil, err
}
}
}
for _, ri := range records {
ri.ZoneID, ri.Zone, ri.View = 0, "", ""
if err := addRec(ri); err != nil {
return nil, err
}
}
ids, err := x.ids(1 + len(recs) + len(zoneOpts))
if err != nil {
return nil, err
}
summary := fmt.Sprintf("create %s zone %s", zt, dnsname.Display(name))
if in.ViewID != nil && *in.ViewID != 0 {
if v := x.st.Views[*in.ViewID]; v != nil {
summary += " in view " + v.Name
}
}
if src != nil {
summary += " (copy of " + zoneDesc(x.st, src) + ")"
}
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
for i, zo := range zoneOpts {
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
if zo.Comment != "" {
oi.Comment = model.Ptr(zo.Comment)
}
ops = append(ops, newOp(OpOptionCreate, ids[1+len(recs)+i], 0, oi, fmt.Sprintf("set zone %s option %s %s", dnsname.Display(name), zo.Name, zo.Value)))
}
for i, r := range recs {
r.in.ZoneID = ids[0]
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
}
return ops, nil
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// reverseZoneFor finds the primary reverse zone for addr in a view.
func reverseZoneFor(st *model.State, viewID int64, addr netip.Addr) (*model.Zone, string) {
name := dnsname.ReverseName(addr)
var best *model.Zone
for _, z := range st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && dnsname.IsSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
return best, name
}
func addrOf(p *rr.Parsed) (netip.Addr, bool) {
switch v := p.RR.(type) {
case *dns.A:
a, ok := netip.AddrFromSlice(v.A.To4())
return a, ok
case *dns.AAAA:
a, ok := netip.AddrFromSlice(v.AAAA)
return a, ok
}
return netip.Addr{}, false
}
// ptrOps returns operations creating the PTR record for an address record.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) {
addr, ok := addrOf(p)
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
return nil, nil
}
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) {
if r.Disabled {
in := RecordInput{Disabled: model.Ptr(false)}
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
}
return nil, nil // already there
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
}
}
ids, err := x.ids(1)
if err != nil {
return nil, err
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
}
// ptrSetDisabled returns operations disabling or enabling the PTR record
// of an address record.
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
}
var ops []model.Op
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
in := RecordInput{Disabled: model.Ptr(disabled)}
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data)))
}
}
return ops
}
func enableWord(disabled bool) string {
if disabled {
return "disable"
}
return "enable"
}
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
}
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
// The stored operation holds the canonical data, so applying it doesn't
// notice again.
func (x *expander) parseWarnings(owner string, p *rr.Parsed) {
for _, w := range p.Warnings {
x.warnings = append(x.warnings, fmt.Sprintf("%s %s: %s", dnsname.Display(owner), p.Type, w))
}
}
func (x *expander) recordCreate(req Request) ([]model.Op, error) {
var in RecordInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
if in.Name == nil || in.Type == nil || in.Data == nil {
return nil, errf(KindBadRequest, "a record needs a name, a type and data")
}
owner, p, err := parseRecord(z, *in.Name, *in.Type, *in.Data)
if err != nil {
return nil, err
}
x.parseWarnings(owner, p)
ptr := in.PTR
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
ids, err := x.ids(1)
if err != nil {
return nil, err
}
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
if ptr {
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
r := x.st.Records[req.ID]
if r == nil {
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
}
z := x.st.Zones[r.ZoneID]
var in RecordInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
ptr := in.PTR
in.PTR = false
in.Zone, in.View = "", ""
name, typ, data := r.Name, r.Type, r.Data
if in.Name != nil {
name = *in.Name
}
if in.Type != nil {
typ = *in.Type
}
if in.Data != nil {
data = *in.Data
}
owner, p, err := parseRecord(z, name, typ, data)
if err != nil {
return nil, err
}
x.parseWarnings(owner, p)
if in.Name != nil {
in.Name = &owner
}
if in.Type != nil {
in.Type = &p.Type
}
if in.Data != nil {
in.Data = &p.Data
}
var changes []string
if owner != r.Name {
changes = append(changes, "name "+r.Name+" → "+owner)
}
if p.Type != r.Type {
changes = append(changes, "type "+r.Type+" → "+p.Type)
}
if p.Data != r.Data {
changes = append(changes, "data "+r.Data+" → "+p.Data)
}
if in.TTL.Set {
old := "default"
if r.TTL != nil {
old = fmt.Sprint(*r.TTL)
}
nw := "default"
if !in.TTL.Null {
nw = fmt.Sprint(in.TTL.Value)
}
if old != nw {
changes = append(changes, "ttl "+old+" → "+nw)
}
}
if in.Comment != nil && *in.Comment != r.Comment {
changes = append(changes, "comment")
}
toggled := in.Disabled != nil && *in.Disabled != r.Disabled
summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data)
switch {
case toggled && len(changes) == 0:
summary = fmt.Sprintf("%s %s %s %s", enableWord(*in.Disabled), r.Name, r.Type, r.Data)
case toggled:
changes = append(changes, enableWord(*in.Disabled)+"d")
fallthrough
case len(changes) > 0:
summary += ": " + strings.Join(changes, ", ")
}
base := req.BaseVersion
if base == 0 {
base = r.Version
}
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
}
moved := p.Data != r.Data || owner != r.Name
if ptr && moved {
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if !disabled {
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
}
if ptr && !moved && toggled {
if _, ok := addrOf(p); !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
}
return ops, nil
}
func (x *expander) recordDelete(req Request) ([]model.Op, error) {
var in RecordInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if req.ID != 0 {
r := x.st.Records[req.ID]
if r == nil {
if in.MissingOK {
return nil, nil
}
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
}
base := req.BaseVersion
if base == 0 {
base = r.Version
}
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
if in.PTR {
z := x.st.Zones[r.ZoneID]
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
return ops, nil
}
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
if in.Name == nil || in.Type == nil {
return nil, errf(KindBadRequest, "deleting by match needs a name and a type (or give the record id)")
}
owner, err := dnsname.Owner(*in.Name, z.Name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
typ, _, err := rr.NormalizeType(*in.Type)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
what := owner + " " + typ
var parsed *rr.Parsed
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
_, p, err := parseRecord(z, owner, typ, *in.Data)
if err != nil {
return nil, err
}
in.Data = &p.Data
what += " " + p.Data
parsed = p
} else {
in.Data = nil
what += " (all)"
}
if in.MissingOK {
what += " (if present)"
}
ptr := in.PTR
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type = &owner, &typ
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
if ptr && parsed != nil {
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
}
return ops, nil
}
// recordSetDisabled expands record.disable and record.enable: the record
// with the request's ID, or the records matching zone, name, type and
// (optionally) data.
func (x *expander) recordSetDisabled(req Request, disabled bool) ([]model.Op, error) {
var in RecordInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
var recs []*model.Record
if req.ID != 0 {
r := x.st.Records[req.ID]
if r == nil {
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
}
recs = append(recs, r)
} else {
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
if in.Name == nil || in.Type == nil {
return nil, errf(KindBadRequest, "finding records needs a name and a type (or give the record id)")
}
owner, err := dnsname.Owner(*in.Name, z.Name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
typ, _, err := rr.NormalizeType(*in.Type)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
data, what := "", owner+" "+typ
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
_, p, err := parseRecord(z, owner, typ, *in.Data)
if err != nil {
return nil, err
}
data, what = p.Data, what+" "+p.Data
}
for _, r := range x.st.ZoneRecords(z.ID) {
if r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) {
recs = append(recs, r)
}
}
if len(recs) == 0 {
if in.MissingOK {
return nil, nil
}
return nil, errf(KindNotFound, "no record %s in zone %s", dnsname.Display(what), dnsname.Display(z.Name))
}
}
var ops []model.Op
for _, r := range recs {
if r.Disabled == disabled {
continue // already so
}
base := 0
if req.ID != 0 {
base = req.BaseVersion
}
more, err := x.recordUpdate(Request{Op: OpRecordUpdate, ID: r.ID, BaseVersion: base, Data: mustJSON(RecordInput{Disabled: model.Ptr(disabled), PTR: in.PTR})})
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
if len(ops) == 0 && !in.MissingOK {
what := fmt.Sprintf("%s %s %s is", recs[0].Name, recs[0].Type, recs[0].Data)
if len(recs) > 1 {
what = fmt.Sprintf("all %d %s %s records are", len(recs), recs[0].Name, recs[0].Type)
}
return nil, errf(KindConflict, "%s already %sd", what, enableWord(disabled))
}
return ops, nil
}
func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
var in RRSetInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
owner, err := dnsname.Owner(in.Name, z.Name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
typ, _, err := rr.NormalizeType(in.Type)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
var canon []string
for _, d := range in.Data {
_, p, err := parseRecord(z, owner, typ, d)
if err != nil {
return nil, err
}
x.parseWarnings(owner, p)
canon = append(canon, p.Data)
}
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
if len(canon) > 0 {
in.NewIDs, err = x.ids(len(canon))
if err != nil {
return nil, err
}
}
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
}
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
}
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
var in ViewInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Name == nil || strings.TrimSpace(*in.Name) == "" {
return nil, errf(KindBadRequest, "a view needs a name")
}
adopt := in.AdoptZones
in.AdoptZones = false
ids, err := x.ids(1)
if err != nil {
return nil, err
}
ops := []model.Op{newOp(OpViewCreate, ids[0], 0, in, "create view "+*in.Name)}
if adopt {
for _, z := range x.st.ZonesInView(0) {
ops = append(ops, newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{ViewID: model.Ptr(ids[0])}, "move zone "+dnsname.Display(z.Name)+" into view "+*in.Name))
}
}
return ops, nil
}
func (x *expander) tsigCreate(req Request) ([]model.Op, error) {
var in TSIGInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Name == nil {
return nil, errf(KindBadRequest, "a TSIG key needs a name")
}
alg := "hmac-sha256"
if in.Algorithm != nil {
alg = strings.ToLower(*in.Algorithm)
}
in.Algorithm = &alg
if in.Secret == nil || *in.Secret == "" {
n := validate.TSIGAlgorithms[alg]
if n == 0 {
return nil, errf(KindBadRequest, "unsupported TSIG algorithm %q", alg)
}
in.Secret = model.Ptr(auth.GenerateTSIGSecret(n))
}
ids, err := x.ids(1)
if err != nil {
return nil, err
}
return []model.Op{newOp(OpTSIGCreate, ids[0], 0, in, "create TSIG key "+model.NormalizeKeyName(*in.Name)+" ("+alg+")")}, nil
}
func (x *expander) optionCreate(req Request) ([]model.Op, error) {
var in OptionInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Scope == nil || in.Name == nil {
return nil, errf(KindBadRequest, "an option needs a scope and a name")
}
scopeID := int64(0)
if in.ScopeID != nil {
scopeID = *in.ScopeID
}
val := ""
if in.Value != nil {
val = *in.Value
}
n, v, err := NormalizeOption(x.st, *in.Scope, scopeID, *in.Name, val)
if err != nil {
return nil, err
}
in.Name, in.Value = &n, &v
ids, err := x.ids(1)
if err != nil {
return nil, err
}
o := &model.Option{Scope: *in.Scope, ScopeID: scopeID, Name: n}
return []model.Op{newOp(OpOptionCreate, ids[0], 0, in, "set "+optionLabel(x.st, o)+" "+v)}, nil
}
// RedactOps hides TSIG secrets in operation data.
func RedactOps(ops []model.Op) []model.Op {
out := make([]model.Op, len(ops))
for i, op := range ops {
out[i] = op
if op.Kind != OpTSIGCreate && op.Kind != OpTSIGUpdate || len(op.Data) == 0 {
continue
}
var m map[string]any
if json.Unmarshal(op.Data, &m) == nil {
if _, ok := m["secret"]; ok {
m["secret"] = "(hidden)"
out[i].Data = mustJSON(m)
}
}
}
return out
}
// zoneToggle returns the new disabled state when a zone update only
// disables or enables the zone (nil otherwise).
func zoneToggle(req Request, z *model.Zone) *bool {
var in ZoneInput
if decode(req.Data, &in) != nil || in.Disabled == nil || *in.Disabled == z.Disabled {
return nil
}
only := ZoneInput{Disabled: in.Disabled}
if string(mustJSON(in)) != string(mustJSON(only)) {
return nil
}
return in.Disabled
}
// disabledZoneEffect says what clients get once named no longer loads a
// zone: in a view that offers recursion, answers from recursion (the
// internet's version of the zone, if it has one); elsewhere, REFUSED.
func disabledZoneEffect(st *model.State, z *model.Zone) string {
where := "this server"
if v := st.ViewName(z); v != "" {
where = "view " + v
}
if st.RecursionOn(z.ViewID) {
return fmt.Sprintf("once zone %s is disabled, clients of %s that may use recursion get its names from recursion: the internet's version of the zone, if there is one", dnsname.Display(z.Name), where)
}
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
}