- A zone update that changes nothing stages nothing: saving a zone's settings unchanged says "Nothing changed" instead of staging an empty change. Staging operations that change nothing is a 400 "nothing to change". - The settings form showed the SOA MNAME without its final dot, which the server reads as relative to the zone, so saving wg.adbraun.lol turned ns1.adbraun.lol. into ns1.adbraun.lol.wg.adbraun.lol. Names outside the zone keep the dot, and MNAME is only sent when edited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1578 lines
47 KiB
Go
1578 lines
47 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
|
|
}
|
|
|
|
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) {
|
|
ops, err := x.expandOne(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch req.Op {
|
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable, OpZoneFillPTR:
|
|
// Changes to linked zones reach the other zones of the link.
|
|
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
|
|
}
|
|
|
|
// ptrOps returns operations creating the PTR record for an address record.
|
|
//
|
|
// auto is set when the zone's auto_ptr asked rather than the request: a
|
|
// missing reverse zone isn't worth a warning then (most addresses have
|
|
// none), and an address whose PTR record names another host keeps it.
|
|
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) {
|
|
addr, ok := addrOf(p)
|
|
if !ok {
|
|
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
|
}
|
|
var ops []model.Op
|
|
found := false
|
|
for _, vid := range ptrViews(x.st, z) {
|
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
|
if rz == nil {
|
|
continue
|
|
}
|
|
found = true
|
|
more, err := x.ptrCreateIn(rz, ptrName, owner, auto)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
if !found && !auto {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
// ptrViews are the views whose reverse zones get the PTR records of zone
|
|
// z's address records: z's own, and, for a linked zone, the other views
|
|
// of the link (the record is in all of them).
|
|
func ptrViews(st *model.State, z *model.Zone) []int64 {
|
|
out := []int64{z.ViewID}
|
|
for _, o := range st.LinkedWith(z) {
|
|
if !slices.Contains(out, o.ViewID) {
|
|
out = append(out, o.ViewID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
|
// in reverse zone rz (none when it is there; enabling it when disabled).
|
|
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
|
|
var other string
|
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
|
if r.Name == ptrName && r.Type == "PTR" {
|
|
if strings.EqualFold(r.Data, owner) {
|
|
if r.Disabled {
|
|
in := RecordInput{Disabled: model.Ptr(false)}
|
|
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
|
|
}
|
|
return nil, nil // already there
|
|
}
|
|
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)}
|
|
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
|
|
}
|
|
|
|
// ptrSetDisabled returns operations disabling or enabling the PTR record
|
|
// of an address record.
|
|
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
|
|
addr, ok := addrOf(p)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var ops []model.Op
|
|
for _, vid := range ptrViews(x.st, z) {
|
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
|
if rz == nil {
|
|
continue
|
|
}
|
|
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
|
|
}
|
|
return ops
|
|
}
|
|
|
|
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
|
|
var ops []model.Op
|
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
|
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
|
in := RecordInput{Disabled: model.Ptr(disabled)}
|
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data)))
|
|
}
|
|
}
|
|
return ops
|
|
}
|
|
|
|
func enableWord(disabled bool) string {
|
|
if disabled {
|
|
return "disable"
|
|
}
|
|
return "enable"
|
|
}
|
|
|
|
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
|
|
addr, ok := addrOf(p)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var ops []model.Op
|
|
for _, vid := range ptrViews(x.st, z) {
|
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
|
if rz == nil {
|
|
continue
|
|
}
|
|
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
|
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
|
|
}
|
|
return ops
|
|
}
|
|
|
|
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
|
// The stored operation holds the canonical data, so applying it doesn't
|
|
// notice again.
|
|
func (x *expander) parseWarnings(owner string, p *rr.Parsed) {
|
|
for _, w := range p.Warnings {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("%s %s: %s", dnsname.Display(owner), p.Type, w))
|
|
}
|
|
}
|
|
|
|
func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|
var in RecordInput
|
|
if err := decode(req.Data, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if in.Name == nil || in.Type == nil || in.Data == nil {
|
|
return nil, errf(KindBadRequest, "a record needs a name, a type and data")
|
|
}
|
|
owner, p, err := parseRecord(z, *in.Name, *in.Type, *in.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
x.parseWarnings(owner, p)
|
|
ptr, auto := in.PTR, !in.PTR && autoPTR(z, owner, p)
|
|
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
|
|
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
|
if ptr || auto && (in.Disabled == nil || !*in.Disabled) {
|
|
more, err := x.ptrCreate(z, owner, p, auto)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|
r := x.st.Records[req.ID]
|
|
if r == nil {
|
|
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
|
|
}
|
|
z := x.st.Zones[r.ZoneID]
|
|
var in RecordInput
|
|
if err := decode(req.Data, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
ptr := in.PTR
|
|
in.PTR = false
|
|
in.Zone, in.View = "", ""
|
|
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))
|
|
}
|
|
disabled := r.Disabled
|
|
if in.Disabled != nil {
|
|
disabled = *in.Disabled
|
|
}
|
|
moved := p.Data != r.Data || owner != r.Name || p.Type != r.Type
|
|
if !ptr && z.AutoPTR {
|
|
// The zone keeps PTR records in step (docs/reverse.md): as if
|
|
// asked, for whichever of the old and new record is an address.
|
|
old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data)
|
|
oldAuto := err == nil && autoPTR(z, r.Name, old)
|
|
newAuto := autoPTR(z, owner, p)
|
|
switch {
|
|
case moved:
|
|
if oldAuto {
|
|
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
|
}
|
|
if newAuto && !disabled {
|
|
more, err := x.ptrCreate(z, owner, p, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
case toggled && newAuto && disabled:
|
|
ops = append(ops, x.ptrSetDisabled(z, owner, p, true)...)
|
|
case toggled && newAuto:
|
|
// Enables the PTR record, or adds it when it is missing.
|
|
more, err := x.ptrCreate(z, owner, p, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
return ops, nil
|
|
}
|
|
if ptr && moved {
|
|
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
|
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
|
}
|
|
if !disabled {
|
|
more, err := x.ptrCreate(z, owner, p, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
}
|
|
if ptr && !moved && toggled {
|
|
if _, ok := addrOf(p); !ok {
|
|
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
|
}
|
|
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
|
}
|
|
if ptr && !moved && !toggled && !disabled {
|
|
// Nothing else changes: make sure the PTR record is there (the
|
|
// reverse zone may be newer than the record).
|
|
more, err := x.ptrCreate(z, owner, p, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
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))}
|
|
z := x.st.Zones[r.ZoneID]
|
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) {
|
|
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
|
}
|
|
return ops, nil
|
|
}
|
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if in.Name == nil || in.Type == nil {
|
|
return nil, errf(KindBadRequest, "deleting by match needs a name and a type (or give the record id)")
|
|
}
|
|
owner, err := dnsname.Owner(*in.Name, z.Name)
|
|
if err != nil {
|
|
return nil, errf(KindBadRequest, "%v", err)
|
|
}
|
|
typ, _, err := rr.NormalizeType(*in.Type)
|
|
if err != nil {
|
|
return nil, errf(KindBadRequest, "%v", err)
|
|
}
|
|
what := owner + " " + typ
|
|
var parsed *rr.Parsed
|
|
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
|
|
_, p, err := parseRecord(z, owner, typ, *in.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
in.Data = &p.Data
|
|
what += " " + p.Data
|
|
parsed = p
|
|
} else {
|
|
in.Data = nil
|
|
what += " (all)"
|
|
}
|
|
if in.MissingOK {
|
|
what += " (if present)"
|
|
}
|
|
ptr := in.PTR
|
|
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
|
in.Name, in.Type = &owner, &typ
|
|
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
|
|
switch {
|
|
case ptr && parsed != nil:
|
|
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
|
|
case z.AutoPTR:
|
|
// Every matching record's PTR record goes too.
|
|
for _, r := range x.st.RRset(z.ID, owner, typ) {
|
|
if parsed != nil && r.Data != parsed.Data {
|
|
continue
|
|
}
|
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && autoPTR(z, r.Name, p) {
|
|
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
|
}
|
|
}
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
// recordSetDisabled expands record.disable and record.enable: the record
|
|
// with the request's ID, or the records matching zone, name, type and
|
|
// (optionally) data.
|
|
func (x *expander) recordSetDisabled(req Request, disabled bool) ([]model.Op, error) {
|
|
var in RecordInput
|
|
if err := decode(req.Data, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
var recs []*model.Record
|
|
if req.ID != 0 {
|
|
r := x.st.Records[req.ID]
|
|
if r == nil {
|
|
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
|
|
}
|
|
recs = append(recs, r)
|
|
} else {
|
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if in.Name == nil || in.Type == nil {
|
|
return nil, errf(KindBadRequest, "finding records needs a name and a type (or give the record id)")
|
|
}
|
|
owner, err := dnsname.Owner(*in.Name, z.Name)
|
|
if err != nil {
|
|
return nil, errf(KindBadRequest, "%v", err)
|
|
}
|
|
typ, _, err := rr.NormalizeType(*in.Type)
|
|
if err != nil {
|
|
return nil, errf(KindBadRequest, "%v", err)
|
|
}
|
|
data, what := "", owner+" "+typ
|
|
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
|
|
_, p, err := parseRecord(z, owner, typ, *in.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, what = p.Data, what+" "+p.Data
|
|
}
|
|
for _, r := range x.st.ZoneRecords(z.ID) {
|
|
if r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) {
|
|
recs = append(recs, r)
|
|
}
|
|
}
|
|
if len(recs) == 0 {
|
|
if in.MissingOK {
|
|
return nil, nil
|
|
}
|
|
return nil, errf(KindNotFound, "no record %s in zone %s", dnsname.Display(what), dnsname.Display(z.Name))
|
|
}
|
|
}
|
|
var ops []model.Op
|
|
for _, r := range recs {
|
|
if r.Disabled == disabled {
|
|
continue // already so
|
|
}
|
|
base := 0
|
|
if req.ID != 0 {
|
|
base = req.BaseVersion
|
|
}
|
|
more, err := x.recordUpdate(Request{Op: OpRecordUpdate, ID: r.ID, BaseVersion: base, Data: mustJSON(RecordInput{Disabled: model.Ptr(disabled), PTR: in.PTR})})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
if len(ops) == 0 && !in.MissingOK {
|
|
what := fmt.Sprintf("%s %s %s is", recs[0].Name, recs[0].Type, recs[0].Data)
|
|
if len(recs) > 1 {
|
|
what = fmt.Sprintf("all %d %s %s records are", len(recs), recs[0].Name, recs[0].Type)
|
|
}
|
|
return nil, errf(KindConflict, "%s already %sd", what, enableWord(disabled))
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
|
var in RRSetInput
|
|
if err := decode(req.Data, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
owner, err := dnsname.Owner(in.Name, z.Name)
|
|
if err != nil {
|
|
return nil, errf(KindBadRequest, "%v", err)
|
|
}
|
|
typ, _, err := rr.NormalizeType(in.Type)
|
|
if err != nil {
|
|
return nil, errf(KindBadRequest, "%v", err)
|
|
}
|
|
var canon []string
|
|
for _, d := range in.Data {
|
|
_, p, err := parseRecord(z, owner, typ, d)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
x.parseWarnings(owner, p)
|
|
canon = append(canon, p.Data)
|
|
}
|
|
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
|
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)
|
|
}
|
|
ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary))
|
|
if z.AutoPTR && (typ == "A" || typ == "AAAA") && !strings.HasPrefix(owner, "*.") {
|
|
// PTR records follow the addresses that go and come.
|
|
old := map[string]bool{}
|
|
for _, r := range x.st.RRset(z.ID, owner, typ) {
|
|
old[r.Data] = true
|
|
if !slices.Contains(canon, r.Data) {
|
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
|
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
|
}
|
|
}
|
|
}
|
|
for _, d := range canon {
|
|
if old[d] {
|
|
continue
|
|
}
|
|
if _, p, err := parseRecord(z, owner, typ, d); err == nil {
|
|
more, err := x.ptrCreate(z, owner, p, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
}
|
|
}
|
|
return ops, 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)
|
|
}
|