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>
1640 lines
49 KiB
Go
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)
|
|
}
|