Auto-PTR (and --ptr) changed PTR records in every linked view, whatever view the address record was in. ptrSync now compares each touched A/AAAA set before and after, per view, and changes only that view's reverse zone. PTR records in linked reverse zones are local (fill too); a shared PTR set is made local in each view before one view changes it. Deleting a linked zone's local records no longer brings the shared ones back into the view: the name and type are gone from that view and the other views keep theirs, local to each. Share still restores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1623 lines
48 KiB
Go
1623 lines
48 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)
|
|
}
|
|
|
|
// 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) {
|
|
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 := in.Fill
|
|
in.Fill = 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)
|
|
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)))
|
|
}
|
|
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)
|
|
}
|