Files
jarl/internal/engine/expand.go
T
adamandClaude Opus 5.5 2fa9828bcf Reverse zones: list other views' addresses, clearer wording
The zone page and jarl zone show list the addresses only other views'
records use (ptr_other_views_list), with the name each would get and the
view it comes from, and say plainly that they aren't missing here. The
page's button reads 'Add PTR records for them in view X'. zone show
lists the missing PTRs too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00

1640 lines
49 KiB
Go

package engine
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"reflect"
"slices"
"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
// exposures classifies the views as public or private (nil in tests:
// every view private).
exposures func(*model.State) []validate.Exposure
// ptrForced are the address sets whose PTR records the request being
// expanded asked to update (ptrForce).
ptrForced map[rrKey]bool
// sharing are the local sets the request deletes so that the shared
// version reaches them (dropOverrides).
sharing map[rrKey]bool
}
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, func(z *model.Zone) bool { return x.p.CanSeeZone(z.Name, x.st.ViewName(z)) })
}
// FindZoneRef finds a zone by name and optional view name. Without a
// view, a name in several views needs one, counting only the zones sees
// allows (nil: all): a token for one view finds that view's zone.
func FindZoneRef(st *model.State, zone, view string, sees func(*model.Zone) bool) (*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)
if sees != nil && len(zs) > 1 {
var vis []*model.Zone
for _, z := range zs {
if sees(z) {
vis = append(vis, z)
}
}
zs = vis // none: not found, without naming the views
}
st.SortByView(zs)
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) {
x.ptrForced, x.sharing = nil, nil
ops, err := x.expandOne(req)
if err != nil {
return nil, err
}
switch req.Op {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
// Changes to linked zones reach the other zones of the link, then
// PTR records follow the addresses in each view.
if ops, err = x.linkSync(ops); err != nil {
return nil, err
}
return x.ptrSync(ops)
case OpZoneFillPTR:
return x.linkSync(ops)
}
return ops, nil
}
func (x *expander) expandOne(req Request) ([]model.Op, error) {
switch req.Op {
case OpZoneLink:
return x.zoneLink(req)
case OpZoneUnlink:
return x.zoneUnlink(req)
case OpZoneCreate:
return x.zoneCreate(req)
case OpZoneFillPTR:
return x.zoneFillPTR(req)
case OpReverseDismiss, OpReverseUndismiss:
return x.reverseDismiss(req, req.Op == OpReverseDismiss)
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
}
ops := []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}
if req.Op == OpZoneDelete {
if rest := x.st.LinkedWith(z); len(rest) == 1 {
ops = append(ops, x.unlinkOps(rest[0], "unlink zone "+zoneDesc(x.st, rest[0])+": the zone it was linked with is deleted")...)
}
} else {
var in ZoneInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.LinkGroup != nil && *in.LinkGroup != z.LinkGroup {
return nil, errf(KindBadRequest, "link zones with zone.link and zone.unlink (jarl zone link / unlink)")
}
if in.LinkWith != "" {
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
}
// An update that leaves the zone as it is stages nothing (a
// settings form saved without changes).
if zoneUnchanged(x.st, z, &in) {
return nil, nil
}
}
return ops, 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 OpExposureAck, OpExposureUnack:
return x.exposureAck(req, req.Op == OpExposureAck)
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
case OpBlockStepCreate:
return x.blockStepCreate(req)
case OpBlockStepUpdate, OpBlockStepDelete:
s := x.st.BlockSteps[req.ID]
if s == nil {
return nil, errf(KindNotFound, "blocking step %d does not exist", req.ID)
}
verb := "change blocking step "
if req.Op == OpBlockStepDelete {
verb = "delete blocking step "
}
return []model.Op{x.versioned(req, s.Version, verb+s.Name)}, nil
case OpClientGroupCreate:
return x.clientGroupCreate(req)
case OpClientGroupUpdate, OpClientGroupDelete:
g := x.st.ClientGroups[req.ID]
if g == nil {
return nil, errf(KindNotFound, "client group %d does not exist", req.ID)
}
verb := "change client group "
if req.Op == OpClientGroupDelete {
verb = "delete client group "
}
return []model.Op{x.versioned(req, g.Version, verb+g.Name)}, 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
viewGiven := in.View != nil || in.ViewID != nil
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
}
if !viewGiven && in.LinkWith == "" && in.CloneFrom == 0 && dnsname.IsReverse(name) {
// A reverse zone belongs in the private view whose records use
// its addresses (docs/reverse.md).
v, err := reverseViewFor(x.st, x.publicViews(), name)
if err != nil {
return nil, err
}
if v != nil {
in.ViewID = &v.ID
}
}
var moveOps []model.Op
var moved []*model.Record
if zt == model.ZonePrimary {
// The records a parent zone in the view has under the new zone
// move into it: named answers those names from the new zone, so
// left behind they would never be served again.
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
if parent := x.parentZone(name, vid); parent != nil {
moved = x.recordsUnder(parent, name)
switch {
case len(moved) == 0:
case parent.LinkGroup != 0:
x.warnings = append(x.warnings, fmt.Sprintf("zone %s is linked, so its %d records under %s stay there (hidden by the new zone): move them by hand", zoneDesc(x.st, parent), len(moved), dnsname.Display(name)))
moved = nil
default:
for _, r := range moved {
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data)}
if r.TTL != nil {
ri.TTL = model.Some(*r.TTL)
}
if r.Comment != "" {
ri.Comment = model.Ptr(r.Comment)
}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
in.Records = append(in.Records, ri)
moveOps = append(moveOps, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("move %s %s %s from zone %s into the new zone", r.Name, r.Type, r.Data, zoneDesc(x.st, parent))))
}
}
}
}
fill, fillOther := in.Fill, in.FillOtherViews
in.Fill, in.FillOtherViews = false, false
if fill {
if zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "fill only applies to primary zones")
}
if _, ok := dnsname.PrefixFromReverse(name); !ok {
return nil, errf(KindBadRequest, "fill only applies to reverse zones of whole octets or nibbles (like 10.0.10.0/24)")
}
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
fills := PTRFills(x.st, vid, name, moved, fillOther)
if len(fills) == 0 {
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
}
if !fillOther {
if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" {
x.warnings = append(x.warnings, h)
}
}
x.warnings = append(x.warnings, fillWarnings(fills)...)
for _, f := range fills {
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
}
}
nameservers := in.Nameservers
records := in.Records
cloneFrom := in.CloneFrom
zoneOpts := in.Options
linkWith := in.LinkWith
in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil
if linkWith != "" {
if cloneFrom != 0 {
return nil, errf(KindBadRequest, "give clone_from or link_with, not both")
}
v := x.st.ViewByName(linkWith)
if v == nil {
return nil, errf(KindNotFound, "view %q does not exist", linkWith)
}
o := x.st.FindZone(name, v.ID)
if o == nil {
return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name))
}
if o.Type != model.ZonePrimary || zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "only primary zones can be linked")
}
cloneFrom = o.ID
}
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
}
}
var linkOps []model.Op
if src != nil && linkWith != "" {
// The new zone joins src's link, or starts one with src: then
// all of src's records become shared.
group := src.LinkGroup
if group == 0 {
group = src.ID
linkOps = x.shareAllOps(src, group)
}
in.LinkGroup = &group
}
if src != nil {
for _, r := range x.st.ZoneRecords(src.ID) {
if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 {
continue // local to src's view
}
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
if linkWith != "" {
id := r.SharedID
if id == 0 {
id = r.ID
}
ri.SharedID = &id
}
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 && linkWith != "" {
summary += " (linked with " + zoneDesc(x.st, src) + ")"
} else if src != nil {
summary += " (copy of " + zoneDesc(x.st, src) + ")"
}
ops := append(linkOps, 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 append(ops, moveOps...), nil
}
// parentZone returns the most specific primary zone in a view that
// contains name (not name itself).
func (x *expander) parentZone(name string, viewID int64) *model.Zone {
var best *model.Zone
for _, z := range x.st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && dnsname.IsStrictSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
return best
}
// recordsUnder returns a zone's records at or below name, except a
// delegation's NS and DS records and its glue, which belong to the parent.
func (x *expander) recordsUnder(z *model.Zone, name string) []*model.Record {
all := x.st.ZoneRecords(z.ID)
var ns []string
for _, r := range all {
if r.Name == name && r.Type == "NS" {
ns = append(ns, strings.ToLower(r.Data))
}
}
var out []*model.Record
for _, r := range all {
if !dnsname.IsSubdomain(r.Name, name) {
continue
}
if r.Name == name && (r.Type == "NS" || r.Type == "DS") {
continue
}
if (r.Type == "A" || r.Type == "AAAA") && slices.Contains(ns, strings.ToLower(r.Name)) {
continue // glue
}
out = append(out, r)
}
return out
}
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
}
// PTR records follow address records per view (docs/reverse.md): after a
// request's operations (and those keeping linked zones in step), ptrSync
// compares each touched A or AAAA set before and after, in every view it
// is in, and changes the PTR records of that view's reverse zone to match.
// So a record local to one view of a linked zone changes PTR records in
// that view only, and an address that goes from one view keeps its PTR
// record in the views that still have it.
// ptrForce marks a name and type whose PTR records the request asked to
// update (ptr: true), in its zone and the zones linked with it.
func (x *expander) ptrForce(z *model.Zone, name, typ string) {
if x.ptrForced == nil {
x.ptrForced = map[rrKey]bool{}
}
x.ptrForced[rrKey{z.ID, name, typ}] = true
for _, o := range x.st.LinkedWith(z) {
x.ptrForced[rrKey{o.ID, name, typ}] = true
}
}
// Whether a set has an address: none, only in disabled records, enabled.
const (
addrNone = iota
addrDisabled
addrEnabled
)
// addrStates returns the addresses of a zone's A or AAAA set.
func addrStates(st *model.State, k rrKey) map[netip.Addr]int {
out := map[netip.Addr]int{}
for _, r := range st.RRset(k.zone, k.name, k.typ) {
a, err := netip.ParseAddr(r.Data)
if err != nil {
continue
}
s := addrDisabled
if !r.Disabled {
s = addrEnabled
}
out[a.Unmap()] = max(out[a.Unmap()], s)
}
return out
}
// ptrSync returns ops followed by the PTR changes they call for: for the
// zone's auto_ptr, or because the request asked (ptrForce).
func (x *expander) ptrSync(ops []model.Op) ([]model.Op, error) {
forced := x.ptrForced
x.ptrForced = nil
work := x.st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range ops {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
keys := affectedKeys(x.st, work, ops)
for _, k := range sortedForced(forced) {
if !slices.Contains(keys, k) {
keys = append(keys, k)
}
}
var extra []model.Op
for _, k := range keys {
z := work.Zones[k.zone]
if z == nil || (k.typ != "A" && k.typ != "AAAA") || strings.HasPrefix(k.name, "*.") {
continue
}
force := forced[k]
if !force && !z.AutoPTR {
continue
}
before, after := addrStates(x.st, k), addrStates(work, k)
var addrs []netip.Addr
for _, m := range []map[netip.Addr]int{before, after} {
for a := range m {
if !slices.Contains(addrs, a) {
addrs = append(addrs, a)
}
}
}
slices.SortFunc(addrs, func(a, b netip.Addr) int { return a.Compare(b) })
for _, a := range addrs {
was, is := before[a], after[a]
if !force && (was == is || was == addrNone && is == addrDisabled) {
continue
}
rz, ptrName := reverseZoneFor(work, z.ViewID, a)
if rz == nil {
if force && is == addrEnabled {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in %s, so no PTR record was added there (create one: jarl zone create %s/%d%s)",
a, viewOrServer(work, z.ViewID), a, a.BitLen(), viewFlag(work, z.ViewID)))
}
continue
}
more, err := x.ptrChange(work, rz, ptrName, k.name, is, !force)
if err != nil {
return nil, err
}
for _, op := range more {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
extra = append(extra, more...)
}
}
if len(extra) == 0 {
return ops, nil
}
// The PTR records are local (see ptrChange), so this only keeps a
// linked reverse zone consistent.
extra, err := x.linkSync(extra)
if err != nil {
return nil, err
}
return append(ops, extra...), nil
}
func sortedForced(m map[rrKey]bool) []rrKey {
var out []rrKey
for k := range m {
out = append(out, k)
}
slices.SortFunc(out, func(a, b rrKey) int {
if a.zone != b.zone {
return int(a.zone - b.zone)
}
if a.name != b.name {
return strings.Compare(a.name, b.name)
}
return strings.Compare(a.typ, b.typ)
})
return out
}
func viewFlag(st *model.State, viewID int64) string {
if v := st.Views[viewID]; v != nil {
return " --view " + v.Name
}
return ""
}
// ptrChange returns the operations that give reverse zone rz's PTR
// records at ptrName for host owner the state of its address: none
// (deleted), disabled, or enabled (added when missing).
//
// In a linked reverse zone the PTR records are local to each view, since
// each view's follow its own address records: a shared set at ptrName
// is made local in every view of the link first, as each view has it.
func (x *expander) ptrChange(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
nwarn := len(x.warnings)
ops, err := x.ptrChangeIn(st, rz, ptrName, owner, state, auto)
if err != nil || len(ops) == 0 || rz.LinkGroup == 0 {
return ops, err
}
var local []model.Op
for _, m := range st.LinkMembers(rz.LinkGroup) {
for _, r := range st.RRset(m.ID, ptrName, "PTR") {
if r.SharedID != 0 {
local = append(local, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
fmt.Sprintf("linked: %s PTR %s becomes local to %s (PTR records follow each view's own address records)", r.Name, r.Data, zoneDesc(st, m))))
}
}
}
if len(local) == 0 {
return ops, nil
}
// Again on the local records (their versions changed).
scratch := st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range local {
if err := applyOp(scratch, op, c); err != nil {
return nil, err
}
}
x.warnings = x.warnings[:nwarn]
ops, err = x.ptrChangeIn(scratch, rz, ptrName, owner, state, auto)
if err != nil {
return nil, err
}
return append(local, ops...), nil
}
func (x *expander) ptrChangeIn(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
var ops []model.Op
switch state {
case addrNone:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) {
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("delete %s PTR %s", r.Name, r.Data)))
}
}
case addrDisabled:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) && !r.Disabled {
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{Disabled: model.Ptr(true)}, fmt.Sprintf("disable %s PTR %s", r.Name, r.Data)))
}
}
default:
return x.ptrCreateIn(st, rz, ptrName, owner, auto)
}
return ops, nil
}
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled).
// auto is set when the zone's auto_ptr asked rather than the request: an
// address whose PTR record names another host keeps it then.
func (x *expander) ptrCreateIn(st *model.State, rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
var other string
for _, r := range st.RRset(rz.ID, ptrName, "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
}
if !r.Disabled {
other = r.Data
}
}
if other != "" {
if auto {
// One name per address: the one it has stays.
return nil, nil
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, other))
}
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)}
if rz.LinkGroup != 0 {
in.SharedID = model.Ptr(int64(0))
}
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
}
func enableWord(disabled bool) string {
if disabled {
return "disable"
}
return "enable"
}
// 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)
if in.PTR {
if _, ok := addrOf(p); !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
x.ptrForce(z, owner, p.Type)
}
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
}
summary := fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data)
in.SharedID = nil
if z.LinkGroup != 0 {
local, err := x.localFor(z, owner, p.Type, in.Local)
if err != nil {
return nil, err
}
if local {
in.SharedID = model.Ptr(int64(0))
summary += " (local to view " + x.st.ViewName(z) + ")"
} else {
if x.st.HasLocal(z.ID, owner, p.Type) {
return nil, errf(KindConflict, "%s %s is local in view %s; share the set first (jarl record update ID --shared) or add this one as local", dnsname.Display(owner), p.Type, x.st.ViewName(z))
}
in.SharedID = &ids[0]
}
} else if in.Local != nil && !*in.Local {
return nil, notLinked(x.st, z)
}
// local on a zone that isn't linked: its records are local anyway.
in.Local = nil
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, 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 = "", ""
if in.SharedID != nil {
return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared")
}
var pre []model.Op
if in.Local != nil {
if z.LinkGroup == 0 && !*in.Local {
return nil, notLinked(x.st, z)
}
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
// The whole set of this name and type changes sides.
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
id := int64(0)
what := fmt.Sprintf("make %s %s local to view %s", o.Name, o.Type, x.st.ViewName(z))
if !want {
id = o.ID
what = fmt.Sprintf("share %s %s %s with the linked views", o.Name, o.Type, o.Data)
}
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
}
}
if z.LinkGroup != 0 && !*in.Local {
// Sharing a version makes it every view's, also when it is
// shared already and another view overrides it.
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
}
in.Local = nil
if string(mustJSON(in)) == "{}" {
return pre, nil
}
}
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, ", ")
}
if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) {
return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z))
}
base := req.BaseVersion
if base == 0 {
base = r.Version
}
if len(pre) > 0 {
base = 0 // the record was just updated by pre
}
ops := pre
if string(mustJSON(in)) != "{}" || !ptr {
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
}
if ptr {
// The PTR records of the old and the new record, whichever is an
// address (ptrSync).
_, isAddr := addrOf(p)
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
if _, ok := addrOf(old); ok {
isAddr = true
x.ptrForce(z, r.Name, r.Type)
}
}
if !isAddr {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
if _, ok := addrOf(p); ok {
x.ptrForce(z, owner, p.Type)
}
}
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 {
x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
}
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)"
}
if in.PTR && parsed != nil {
x.ptrForce(z, owner, typ)
}
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type = &owner, &typ
return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, 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
where := ""
var pre []model.Op
if z.LinkGroup != 0 {
local, err := x.localFor(z, owner, typ, in.Local)
if err != nil {
return nil, err
}
in.Shared = model.Ptr(!local)
if local {
where = " (local to view " + x.st.ViewName(z) + ")"
} else if in.Local != nil {
// Explicitly shared: this version becomes every view's.
pre = x.dropOverrides(z, owner, typ)
}
} else if in.Local != nil && !*in.Local {
return nil, notLinked(x.st, z)
}
in.Local = nil
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]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where)
if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
}
return append(pre, 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)
}
// exposureAck acknowledges an exposure finding of a view or the server,
// or takes the acknowledgement back: the view's (or the settings')
// acknowledgements are updated as a whole.
func (x *expander) exposureAck(req Request, ack bool) ([]model.Op, error) {
var in AckInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
in.Code = strings.TrimSpace(in.Code)
if !validate.Ackable(in.Code) {
return nil, errf(KindBadRequest, "%q is not an exposure finding that can be acknowledged (exposure_... or security_open_...)", in.Code)
}
if ack && strings.TrimSpace(in.Reason) == "" {
return nil, errf(KindBadRequest, "say why %s is accepted (a reason is required)", in.Code)
}
var v *model.View
switch {
case in.ViewID != 0:
v = x.st.Views[in.ViewID]
case in.View != "":
v = x.st.ViewByName(in.View)
}
if (in.ViewID != 0 || in.View != "") && v == nil {
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
}
acks := x.st.Settings.Acknowledged
where := "the server"
if v != nil {
acks, where = v.Acknowledged, "view "+v.Name
}
var out []model.Acknowledgement
for _, a := range acks {
if a.Code != in.Code {
out = append(out, a)
}
}
summary := "acknowledge " + in.Code + " for " + where + ": " + strings.TrimSpace(in.Reason)
if ack {
out = append(out, model.Acknowledgement{Code: in.Code, Reason: strings.TrimSpace(in.Reason), By: x.p.Name(), At: x.now})
} else {
if len(out) == len(acks) {
return nil, errf(KindConflict, "%s isn't acknowledged for %s", in.Code, where)
}
summary = "take back the acknowledgement of " + in.Code + " for " + where
}
if out == nil {
out = []model.Acknowledgement{}
}
if v != nil {
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{Acknowledged: &out}, summary)}, nil
}
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{Acknowledged: &out}, summary)}, nil
}
func firstNonEmptyStr(a, b string) string {
if a != "" {
return a
}
return b
}
// notLinked refuses sharing a record of a zone that isn't linked.
func notLinked(st *model.State, z *model.Zone) error {
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
}
// zoneUnchanged reports whether an update would leave the zone as it is.
// Errors are left for applying the update to report.
func zoneUnchanged(st *model.State, z *model.Zone, in *ZoneInput) bool {
if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 {
return false
}
c := st.Clone()
n := c.Zones[z.ID]
if err := setZoneFields(c, n, in, false); err != nil {
return false
}
return reflect.DeepEqual(n, z)
}