Files
adamandClaude Opus 5.5 9fc23310bd Add blocking: blocklists and block/allow rules with BIND response policy zones
jarld downloads blocklists (Adblock, hosts or plain domains), checks them
daily and reloads named only when a list changed; your own block and
allow rules take precedence, and the most specific rule wins. Lists and
rules apply per view (default: every view that offers recursion). Web UI
Blocking page with name checks, jarl blocklist and jarl block, the
/blocking API, blocked counts in the statistics. Database schema 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:19 +00:00

1022 lines
28 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)
}
}
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 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
}