Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1242 lines
32 KiB
Go
1242 lines
32 KiB
Go
package engine
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"jarl/internal/auth"
|
|
"jarl/internal/catalog"
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/model"
|
|
"jarl/internal/namedconf"
|
|
"jarl/internal/rr"
|
|
"jarl/internal/validate"
|
|
)
|
|
|
|
const maxTTL = 2147483647
|
|
|
|
// applyCtx carries what applying an operation needs.
|
|
type applyCtx struct {
|
|
p *auth.Principal
|
|
now time.Time
|
|
warnings []string
|
|
}
|
|
|
|
func (c *applyCtx) user() string { return c.p.Name() }
|
|
|
|
func (c *applyCtx) require(a auth.Action) error {
|
|
if c.p.Can(a) {
|
|
return nil
|
|
}
|
|
return errf(KindForbidden, "permission denied: this needs %s", a)
|
|
}
|
|
|
|
func (c *applyCtx) warn(format string, args ...any) {
|
|
c.warnings = append(c.warnings, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func decode(data json.RawMessage, v any) error {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
return errf(KindBadRequest, "invalid operation data: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkVersion(op model.Op, current int, what string) error {
|
|
if op.BaseVersion != 0 && op.BaseVersion != current {
|
|
return errf(KindConflict, "%s was changed by someone else after this change was staged (version %d, now %d); review it and stage the change again", what, op.BaseVersion, current)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applyOp applies one stored operation to st.
|
|
func applyOp(st *model.State, op model.Op, c *applyCtx) error {
|
|
var err error
|
|
switch op.Kind {
|
|
case OpZoneCreate:
|
|
err = applyZoneCreate(st, op, c)
|
|
case OpZoneUpdate:
|
|
err = applyZoneUpdate(st, op, c)
|
|
case OpZoneDelete:
|
|
err = applyZoneDelete(st, op, c)
|
|
case OpRecordCreate:
|
|
err = applyRecordCreate(st, op, c)
|
|
case OpRecordUpdate:
|
|
err = applyRecordUpdate(st, op, c)
|
|
case OpRecordDelete:
|
|
err = applyRecordDelete(st, op, c)
|
|
case OpRRSetReplace:
|
|
err = applyRRSetReplace(st, op, c)
|
|
case OpViewCreate:
|
|
err = applyViewCreate(st, op, c)
|
|
case OpViewUpdate:
|
|
err = applyViewUpdate(st, op, c)
|
|
case OpViewDelete:
|
|
err = applyViewDelete(st, op, c)
|
|
case OpViewReorder:
|
|
err = applyViewReorder(st, op, c)
|
|
case OpACLCreate, OpACLUpdate:
|
|
err = applyACLPut(st, op, c)
|
|
case OpACLDelete:
|
|
err = applyACLDelete(st, op, c)
|
|
case OpTSIGCreate, OpTSIGUpdate:
|
|
err = applyTSIGPut(st, op, c)
|
|
case OpTSIGDelete:
|
|
err = applyTSIGDelete(st, op, c)
|
|
case OpOptionCreate, OpOptionUpdate:
|
|
err = applyOptionPut(st, op, c)
|
|
case OpOptionDelete:
|
|
err = applyOptionDelete(st, op, c)
|
|
case OpSettingsUpdate:
|
|
err = applySettings(st, op, c)
|
|
default:
|
|
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
|
|
}
|
|
if e, ok := AsError(err); ok && e.OpSeq == 0 {
|
|
e.OpSeq = op.Seq
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ---- zones
|
|
|
|
func zoneAction(st *model.State, z *model.Zone, write bool) auth.Action {
|
|
return auth.Action{Resource: auth.ResZones, Write: write, Zone: z.Name, View: st.ViewName(z)}
|
|
}
|
|
|
|
// ParseZoneName accepts a zone name or a CIDR prefix (for reverse zones).
|
|
func ParseZoneName(s string) (string, error) {
|
|
s = strings.TrimSpace(s)
|
|
if strings.Contains(s, "/") {
|
|
if p, err := netip.ParsePrefix(s); err == nil {
|
|
return dnsname.ReverseZone(p)
|
|
}
|
|
}
|
|
a, err := dnsname.ToASCII(s)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
name := dnsname.Fqdn(a)
|
|
if err := dnsname.CheckZoneName(name); err != nil {
|
|
return "", fmt.Errorf("invalid zone name %q: %v", s, err)
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
func normalizeZoneType(t string) (string, error) {
|
|
t = strings.ToLower(strings.TrimSpace(t))
|
|
if s, ok := catalog.ZoneTypeSynonyms[t]; ok {
|
|
t = s
|
|
}
|
|
for _, zt := range model.ZoneTypes {
|
|
if t == zt {
|
|
return t, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("unknown zone type %q (use one of %s)", t, strings.Join(model.ZoneTypes, ", "))
|
|
}
|
|
|
|
func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool) error {
|
|
if in.Name != nil {
|
|
name, err := ParseZoneName(*in.Name)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
if !creating && name != z.Name {
|
|
return errf(KindBadRequest, "zones can't be renamed; create a new zone (it can copy the records with clone_from) and delete the old one")
|
|
}
|
|
z.Name = name
|
|
}
|
|
if in.ViewID != nil {
|
|
if *in.ViewID != 0 && st.Views[*in.ViewID] == nil {
|
|
return errf(KindNotFound, "view %d does not exist", *in.ViewID)
|
|
}
|
|
z.ViewID = *in.ViewID
|
|
} else if in.View != nil {
|
|
if *in.View == "" {
|
|
z.ViewID = 0
|
|
} else if v := st.ViewByName(*in.View); v != nil {
|
|
z.ViewID = v.ID
|
|
} else {
|
|
return errf(KindNotFound, "view %q does not exist", *in.View)
|
|
}
|
|
}
|
|
if in.Type != nil {
|
|
t, err := normalizeZoneType(*in.Type)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
z.Type = t
|
|
}
|
|
if in.InView != nil {
|
|
z.InView = strings.TrimSpace(*in.InView)
|
|
}
|
|
if in.File != nil {
|
|
z.File = strings.TrimSpace(*in.File)
|
|
}
|
|
if s := in.SOA; s != nil {
|
|
if s.MName != nil {
|
|
n, err := dnsname.Owner(*s.MName, z.Name)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "SOA mname: %v", err)
|
|
}
|
|
z.SOA.MName = n
|
|
}
|
|
if s.RName != nil {
|
|
mb, err := dnsname.MailboxFromEmail(*s.RName)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "SOA rname: %v", err)
|
|
}
|
|
z.SOA.RName = mb
|
|
}
|
|
if s.Serial != nil {
|
|
z.SOA.Serial = *s.Serial
|
|
}
|
|
set := func(dst *uint32, v *uint32, name string) error {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if *v > maxTTL {
|
|
return errf(KindBadRequest, "SOA %s must be at most 2147483647", name)
|
|
}
|
|
*dst = *v
|
|
return nil
|
|
}
|
|
for _, f := range []struct {
|
|
dst *uint32
|
|
v *uint32
|
|
name string
|
|
}{{&z.SOA.Refresh, s.Refresh, "refresh"}, {&z.SOA.Retry, s.Retry, "retry"}, {&z.SOA.Expire, s.Expire, "expire"}, {&z.SOA.Minimum, s.Minimum, "minimum"}} {
|
|
if err := set(f.dst, f.v, f.name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if in.DefaultTTL != nil {
|
|
if *in.DefaultTTL > maxTTL {
|
|
return errf(KindBadRequest, "default TTL must be at most 2147483647")
|
|
}
|
|
z.DefaultTTL = *in.DefaultTTL
|
|
}
|
|
if in.SerialPolicy != nil {
|
|
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
|
|
}
|
|
if in.Comment != nil {
|
|
z.Comment = *in.Comment
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyZoneCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|
var in ZoneInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
if op.ID == 0 || st.Zones[op.ID] != nil {
|
|
return errf(KindConflict, "zone ID %d is already in use", op.ID)
|
|
}
|
|
if in.Name == nil || in.Type == nil {
|
|
return errf(KindBadRequest, "a new zone needs a name and a type")
|
|
}
|
|
z := &model.Zone{ID: op.ID}
|
|
if err := setZoneFields(st, z, &in, true); err != nil {
|
|
return err
|
|
}
|
|
if err := c.require(zoneAction(st, z, true)); err != nil {
|
|
return err
|
|
}
|
|
if existing := st.FindZone(z.Name, z.ViewID); existing != nil {
|
|
where := ""
|
|
if v := st.ViewName(z); v != "" {
|
|
where = " in view " + v
|
|
}
|
|
return errf(KindConflict, "zone %s already exists%s", dnsname.Display(z.Name), where)
|
|
}
|
|
z.Touch(c.user(), c.now)
|
|
st.Zones[z.ID] = z
|
|
return nil
|
|
}
|
|
|
|
func applyZoneUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
|
z := st.Zones[op.ID]
|
|
if z == nil {
|
|
return errf(KindNotFound, "zone %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, z.Version, "zone "+dnsname.Display(z.Name)); err != nil {
|
|
return err
|
|
}
|
|
if err := c.require(zoneAction(st, z, true)); err != nil {
|
|
return err
|
|
}
|
|
var in ZoneInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 {
|
|
return errf(KindBadRequest, "nameservers, records, options and clone_from can only be used when creating a zone")
|
|
}
|
|
oldView := z.ViewID
|
|
if err := setZoneFields(st, z, &in, false); err != nil {
|
|
return err
|
|
}
|
|
if z.ViewID != oldView {
|
|
if other := st.FindZone(z.Name, z.ViewID); other != nil && other.ID != z.ID {
|
|
return errf(KindConflict, "the target view already has a zone %s", dnsname.Display(z.Name))
|
|
}
|
|
if err := c.require(zoneAction(st, z, true)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
z.Touch(c.user(), c.now)
|
|
return nil
|
|
}
|
|
|
|
func applyZoneDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|
z := st.Zones[op.ID]
|
|
if z == nil {
|
|
return errf(KindNotFound, "zone %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, z.Version, "zone "+dnsname.Display(z.Name)); err != nil {
|
|
return err
|
|
}
|
|
if err := c.require(zoneAction(st, z, true)); err != nil {
|
|
return err
|
|
}
|
|
deleteZone(st, z.ID)
|
|
return nil
|
|
}
|
|
|
|
func deleteZone(st *model.State, id int64) {
|
|
for rid, r := range st.Records {
|
|
if r.ZoneID == id {
|
|
delete(st.Records, rid)
|
|
}
|
|
}
|
|
for oid, o := range st.Options {
|
|
if o.Scope == model.ScopeZone && o.ScopeID == id {
|
|
delete(st.Options, oid)
|
|
}
|
|
}
|
|
delete(st.Zones, id)
|
|
}
|
|
|
|
// ---- records
|
|
|
|
func recordAction(st *model.State, z *model.Zone, name, typ string, write bool) auth.Action {
|
|
return auth.Action{Resource: auth.ResRecords, Write: write, Zone: z.Name, View: st.ViewName(z), Name: name, Type: typ}
|
|
}
|
|
|
|
func recordLabel(r *model.Record) string {
|
|
return fmt.Sprintf("record %s %s %s", r.Name, r.Type, r.Data)
|
|
}
|
|
|
|
// parseRecord resolves and validates name, type and data for zone z.
|
|
func parseRecord(z *model.Zone, name, typ, data string) (string, *rr.Parsed, error) {
|
|
if !z.HasRecords() {
|
|
return "", nil, errf(KindBadRequest, "zone %s is a %s zone; records can only be managed in primary zones", dnsname.Display(z.Name), z.Type)
|
|
}
|
|
owner, err := dnsname.Owner(name, z.Name)
|
|
if err != nil {
|
|
return "", nil, errf(KindBadRequest, "%v", err)
|
|
}
|
|
if err := dnsname.CheckOwner(owner); err != nil {
|
|
return "", nil, errf(KindBadRequest, "invalid name %q: %v", name, err)
|
|
}
|
|
if !dnsname.IsSubdomain(owner, z.Name) {
|
|
return "", nil, errf(KindBadRequest, "%s is not inside zone %s", dnsname.Display(owner), dnsname.Display(z.Name))
|
|
}
|
|
p, err := rr.Parse(z.Name, owner, typ, data)
|
|
if err != nil {
|
|
return "", nil, errf(KindBadRequest, "%s %s: %v", dnsname.Display(owner), strings.ToUpper(typ), err)
|
|
}
|
|
return owner, p, nil
|
|
}
|
|
|
|
func rrsetTTL(st *model.State, zoneID int64, name, typ string, exceptID int64) (*uint32, bool) {
|
|
for _, r := range st.Records {
|
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID {
|
|
if r.TTL == nil {
|
|
return nil, true
|
|
}
|
|
t := *r.TTL
|
|
return &t, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|
var in RecordInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
if op.ID == 0 || st.Records[op.ID] != nil {
|
|
return errf(KindConflict, "record ID %d is already in use", op.ID)
|
|
}
|
|
z := st.Zones[in.ZoneID]
|
|
if z == nil {
|
|
return errf(KindNotFound, "zone %d does not exist", in.ZoneID)
|
|
}
|
|
if in.Name == nil || in.Type == nil || in.Data == nil {
|
|
return 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 err
|
|
}
|
|
if err := c.require(recordAction(st, z, owner, p.Type, true)); err != nil {
|
|
return err
|
|
}
|
|
r := &model.Record{ID: op.ID, ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data}
|
|
switch {
|
|
case in.TTL.Set && !in.TTL.Null:
|
|
if in.TTL.Value > maxTTL {
|
|
return errf(KindBadRequest, "TTL must be at most 2147483647")
|
|
}
|
|
r.TTL = model.Ptr(in.TTL.Value)
|
|
case !in.TTL.Set:
|
|
// Join an existing RRset with its TTL.
|
|
if ttl, ok := rrsetTTL(st, z.ID, owner, p.Type, 0); ok {
|
|
r.TTL = ttl
|
|
}
|
|
}
|
|
if in.Comment != nil {
|
|
r.Comment = *in.Comment
|
|
}
|
|
for _, w := range p.Warnings {
|
|
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
|
}
|
|
r.Touch(c.user(), c.now)
|
|
st.Records[r.ID] = r
|
|
return nil
|
|
}
|
|
|
|
func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
|
r := st.Records[op.ID]
|
|
if r == nil {
|
|
return errf(KindNotFound, "record %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
|
return err
|
|
}
|
|
z := st.Zones[r.ZoneID]
|
|
if z == nil {
|
|
return errf(KindNotFound, "the record's zone does not exist")
|
|
}
|
|
if err := c.require(recordAction(st, z, r.Name, r.Type, true)); err != nil {
|
|
return err
|
|
}
|
|
var in RecordInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
if in.ZoneID != 0 && in.ZoneID != r.ZoneID {
|
|
return errf(KindBadRequest, "records can't be moved to another zone; delete and re-create them")
|
|
}
|
|
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
|
|
} else if in.Type != nil && !strings.EqualFold(*in.Type, r.Type) {
|
|
return errf(KindBadRequest, "changing the type of a record needs new data too")
|
|
}
|
|
if in.Name != nil || in.Type != nil || in.Data != nil {
|
|
owner, p, err := parseRecord(z, name, typ, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if owner != r.Name || p.Type != r.Type {
|
|
if err := c.require(recordAction(st, z, owner, p.Type, true)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, w := range p.Warnings {
|
|
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
|
}
|
|
moved := owner != r.Name || p.Type != r.Type
|
|
r.Name, r.Type, r.Data = owner, p.Type, p.Data
|
|
if moved && !in.TTL.Set {
|
|
if ttl, ok := rrsetTTL(st, z.ID, owner, p.Type, r.ID); ok {
|
|
r.TTL = ttl
|
|
}
|
|
}
|
|
}
|
|
if in.TTL.Set {
|
|
if in.TTL.Null {
|
|
r.TTL = nil
|
|
} else {
|
|
if in.TTL.Value > maxTTL {
|
|
return errf(KindBadRequest, "TTL must be at most 2147483647")
|
|
}
|
|
r.TTL = model.Ptr(in.TTL.Value)
|
|
}
|
|
}
|
|
if in.Comment != nil {
|
|
r.Comment = *in.Comment
|
|
}
|
|
r.Touch(c.user(), c.now)
|
|
return nil
|
|
}
|
|
|
|
func applyRecordDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|
if op.ID != 0 {
|
|
r := st.Records[op.ID]
|
|
if r == nil {
|
|
return errf(KindNotFound, "record %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, r.Version, recordLabel(r)); err != nil {
|
|
return err
|
|
}
|
|
z := st.Zones[r.ZoneID]
|
|
if z != nil {
|
|
if err := c.require(recordAction(st, z, r.Name, r.Type, true)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
delete(st.Records, r.ID)
|
|
return nil
|
|
}
|
|
// Delete by match.
|
|
var in RecordInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
z := st.Zones[in.ZoneID]
|
|
if z == nil {
|
|
return errf(KindNotFound, "zone %d does not exist", in.ZoneID)
|
|
}
|
|
if in.Name == nil || in.Type == nil {
|
|
return errf(KindBadRequest, "deleting records by match needs a name and a type")
|
|
}
|
|
owner, err := dnsname.Owner(*in.Name, z.Name)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
typ, _, err := rr.NormalizeType(*in.Type)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
if err := c.require(recordAction(st, z, owner, typ, true)); err != nil {
|
|
return err
|
|
}
|
|
data := ""
|
|
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
|
|
_, p, err := parseRecord(z, owner, typ, *in.Data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = p.Data
|
|
}
|
|
n := 0
|
|
for id, r := range st.Records {
|
|
if r.ZoneID == z.ID && r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) {
|
|
delete(st.Records, id)
|
|
n++
|
|
}
|
|
}
|
|
if n == 0 && !in.MissingOK {
|
|
what := dnsname.Display(owner) + " " + typ
|
|
if data != "" {
|
|
what += " " + data
|
|
}
|
|
return errf(KindNotFound, "no record %s in zone %s", what, dnsname.Display(z.Name))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
|
var in RRSetInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
z := st.Zones[in.ZoneID]
|
|
if z == nil {
|
|
return errf(KindNotFound, "zone %d does not exist", in.ZoneID)
|
|
}
|
|
owner, err := dnsname.Owner(in.Name, z.Name)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
typ, _, err := rr.NormalizeType(in.Type)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
if err := c.require(recordAction(st, z, owner, typ, true)); err != nil {
|
|
return err
|
|
}
|
|
var desired []string
|
|
seen := map[string]bool{}
|
|
for _, d := range in.Data {
|
|
_, p, err := parseRecord(z, owner, typ, d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, w := range p.Warnings {
|
|
c.warn("%s %s: %s", dnsname.Display(owner), typ, w)
|
|
}
|
|
k := strings.ToLower(p.Data)
|
|
if !seen[k] {
|
|
seen[k] = true
|
|
desired = append(desired, p.Data)
|
|
}
|
|
}
|
|
var ttl *uint32
|
|
ttlKnown := false
|
|
if in.TTL.Set {
|
|
ttlKnown = true
|
|
if !in.TTL.Null {
|
|
if in.TTL.Value > maxTTL {
|
|
return errf(KindBadRequest, "TTL must be at most 2147483647")
|
|
}
|
|
ttl = model.Ptr(in.TTL.Value)
|
|
}
|
|
} else if t, ok := rrsetTTL(st, z.ID, owner, typ, 0); ok {
|
|
ttl, ttlKnown = t, true
|
|
}
|
|
keep := map[string]bool{}
|
|
for id, r := range st.Records {
|
|
if r.ZoneID != z.ID || r.Name != owner || r.Type != typ {
|
|
continue
|
|
}
|
|
k := strings.ToLower(r.Data)
|
|
if seen[k] && !keep[k] {
|
|
keep[k] = true
|
|
if ttlKnown && !sameTTL(r.TTL, ttl) {
|
|
r.TTL = copyTTL(ttl)
|
|
r.Touch(c.user(), c.now)
|
|
}
|
|
continue
|
|
}
|
|
delete(st.Records, id)
|
|
}
|
|
ids := in.NewIDs
|
|
for _, d := range desired {
|
|
if keep[strings.ToLower(d)] {
|
|
continue
|
|
}
|
|
if len(ids) == 0 {
|
|
return errf(KindBadRequest, "internal error: no IDs allocated for new records")
|
|
}
|
|
r := &model.Record{ID: ids[0], ZoneID: z.ID, Name: owner, Type: typ, Data: d, TTL: copyTTL(ttl)}
|
|
ids = ids[1:]
|
|
if st.Records[r.ID] != nil {
|
|
return errf(KindConflict, "record ID %d is already in use", r.ID)
|
|
}
|
|
if in.Comment != nil {
|
|
r.Comment = *in.Comment
|
|
}
|
|
r.Touch(c.user(), c.now)
|
|
st.Records[r.ID] = r
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sameTTL(a, b *uint32) bool {
|
|
if a == nil || b == nil {
|
|
return a == nil && b == nil
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
func copyTTL(t *uint32) *uint32 {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
v := *t
|
|
return &v
|
|
}
|
|
|
|
// ---- views
|
|
|
|
func viewAction(name string) auth.Action {
|
|
return auth.Action{Resource: auth.ResViews, Write: true, View: name}
|
|
}
|
|
|
|
func normalizeElements(list []string, field string) ([]string, error) {
|
|
out := []string{}
|
|
for _, s := range list {
|
|
if strings.TrimSpace(s) == "" {
|
|
continue
|
|
}
|
|
e, err := namedconf.ParseAMElement(s)
|
|
if err != nil {
|
|
return nil, errf(KindBadRequest, "%s element %q: %v", field, s, err)
|
|
}
|
|
out = append(out, e.String())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func setViewFields(v *model.View, in *ViewInput) error {
|
|
if in.Name != nil {
|
|
v.Name = strings.TrimSpace(*in.Name)
|
|
}
|
|
if in.MatchClients != nil {
|
|
l, err := normalizeElements(*in.MatchClients, "match-clients")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v.MatchClients = l
|
|
}
|
|
if in.MatchDestinations != nil {
|
|
l, err := normalizeElements(*in.MatchDestinations, "match-destinations")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v.MatchDestinations = l
|
|
}
|
|
if in.MatchRecursiveOnly.Set {
|
|
if in.MatchRecursiveOnly.Null {
|
|
v.MatchRecursiveOnly = nil
|
|
} else {
|
|
v.MatchRecursiveOnly = model.Ptr(in.MatchRecursiveOnly.Value)
|
|
}
|
|
}
|
|
if in.Comment != nil {
|
|
v.Comment = *in.Comment
|
|
}
|
|
if in.Position != nil {
|
|
v.Position = *in.Position
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|
var in ViewInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
if op.ID == 0 || st.Views[op.ID] != nil {
|
|
return errf(KindConflict, "view ID %d is already in use", op.ID)
|
|
}
|
|
if in.Name == nil || strings.TrimSpace(*in.Name) == "" {
|
|
return errf(KindBadRequest, "a view needs a name")
|
|
}
|
|
v := &model.View{ID: op.ID, Position: len(st.Views), MatchClients: []string{}, MatchDestinations: []string{}}
|
|
if err := setViewFields(v, &in); err != nil {
|
|
return err
|
|
}
|
|
if err := c.require(viewAction(v.Name)); err != nil {
|
|
return err
|
|
}
|
|
if st.ViewByName(v.Name) != nil {
|
|
return errf(KindConflict, "view %s already exists", v.Name)
|
|
}
|
|
v.Touch(c.user(), c.now)
|
|
st.Views[v.ID] = v
|
|
renumberViews(st)
|
|
return nil
|
|
}
|
|
|
|
// renumberViews makes positions 0..n-1 in the current order.
|
|
func renumberViews(st *model.State) {
|
|
for i, v := range st.OrderedViews() {
|
|
v.Position = i
|
|
}
|
|
}
|
|
|
|
func applyViewUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
|
v := st.Views[op.ID]
|
|
if v == nil {
|
|
return errf(KindNotFound, "view %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, v.Version, "view "+v.Name); err != nil {
|
|
return err
|
|
}
|
|
if err := c.require(viewAction(v.Name)); err != nil {
|
|
return err
|
|
}
|
|
var in ViewInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
oldName := v.Name
|
|
if err := setViewFields(v, &in); err != nil {
|
|
return err
|
|
}
|
|
if v.Name != oldName {
|
|
if other := st.ViewByName(v.Name); other != nil && other.ID != v.ID {
|
|
return errf(KindConflict, "view %s already exists", v.Name)
|
|
}
|
|
if err := c.require(viewAction(v.Name)); err != nil {
|
|
return err
|
|
}
|
|
// Keep in-view references working.
|
|
for _, z := range st.Zones {
|
|
if z.Type == model.ZoneInView && strings.EqualFold(z.InView, oldName) {
|
|
z.InView = v.Name
|
|
z.Touch(c.user(), c.now)
|
|
}
|
|
}
|
|
}
|
|
v.Touch(c.user(), c.now)
|
|
renumberViews(st)
|
|
return nil
|
|
}
|
|
|
|
func applyViewDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|
v := st.Views[op.ID]
|
|
if v == nil {
|
|
return errf(KindNotFound, "view %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, v.Version, "view "+v.Name); err != nil {
|
|
return err
|
|
}
|
|
if err := c.require(viewAction(v.Name)); err != nil {
|
|
return err
|
|
}
|
|
var in DeleteInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
zones := st.ZonesInView(v.ID)
|
|
if len(zones) > 0 && !in.Cascade {
|
|
var names []string
|
|
for _, z := range zones {
|
|
names = append(names, dnsname.Display(z.Name))
|
|
}
|
|
return errf(KindConflict, "view %s still has %d zone(s) (%s); move or delete them first, or delete the view with cascade", v.Name, len(zones), strings.Join(names, ", "))
|
|
}
|
|
for _, z := range zones {
|
|
if err := c.require(zoneAction(st, z, true)); err != nil {
|
|
return err
|
|
}
|
|
deleteZone(st, z.ID)
|
|
}
|
|
for oid, o := range st.Options {
|
|
if o.Scope == model.ScopeView && o.ScopeID == v.ID {
|
|
delete(st.Options, oid)
|
|
}
|
|
}
|
|
delete(st.Views, v.ID)
|
|
renumberViews(st)
|
|
return nil
|
|
}
|
|
|
|
func applyViewReorder(st *model.State, op model.Op, c *applyCtx) error {
|
|
var in ReorderInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
if len(in.IDs) != len(st.Views) {
|
|
return errf(KindConflict, "the new order lists %d views, but there are %d", len(in.IDs), len(st.Views))
|
|
}
|
|
seen := map[int64]bool{}
|
|
for _, id := range in.IDs {
|
|
v := st.Views[id]
|
|
if v == nil || seen[id] {
|
|
return errf(KindBadRequest, "the new order must list every view exactly once")
|
|
}
|
|
seen[id] = true
|
|
if err := c.require(viewAction(v.Name)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for i, id := range in.IDs {
|
|
v := st.Views[id]
|
|
if v.Position != i {
|
|
v.Position = i
|
|
v.Touch(c.user(), c.now)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- ACLs
|
|
|
|
func applyACLPut(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(auth.Action{Resource: auth.ResACLs, Write: true}); err != nil {
|
|
return err
|
|
}
|
|
var in ACLInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
var a *model.ACL
|
|
if op.Kind == OpACLCreate {
|
|
if op.ID == 0 || st.ACLs[op.ID] != nil {
|
|
return errf(KindConflict, "ACL ID %d is already in use", op.ID)
|
|
}
|
|
if in.Name == nil {
|
|
return errf(KindBadRequest, "an ACL needs a name")
|
|
}
|
|
a = &model.ACL{ID: op.ID, Elements: []string{}}
|
|
} else {
|
|
a = st.ACLs[op.ID]
|
|
if a == nil {
|
|
return errf(KindNotFound, "ACL %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, a.Version, "ACL "+a.Name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
oldName := a.Name
|
|
if in.Name != nil {
|
|
name := strings.TrimSpace(*in.Name)
|
|
if namedconf.BuiltinACLs[strings.ToLower(name)] {
|
|
return errf(KindBadRequest, "%q is a built-in ACL name", name)
|
|
}
|
|
if err := namedconf.CheckACLName(name); err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
if other := st.ACLByName(name); other != nil && other.ID != a.ID {
|
|
return errf(KindConflict, "ACL %s already exists", name)
|
|
}
|
|
a.Name = name
|
|
}
|
|
if in.Elements != nil {
|
|
l, err := normalizeElements(*in.Elements, "ACL")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.Elements = l
|
|
}
|
|
if in.Comment != nil {
|
|
a.Comment = *in.Comment
|
|
}
|
|
a.Touch(c.user(), c.now)
|
|
st.ACLs[a.ID] = a
|
|
if op.Kind == OpACLUpdate && oldName != a.Name {
|
|
renameRefs(st, refACL, oldName, a.Name, c)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyACLDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(auth.Action{Resource: auth.ResACLs, Write: true}); err != nil {
|
|
return err
|
|
}
|
|
a := st.ACLs[op.ID]
|
|
if a == nil {
|
|
return errf(KindNotFound, "ACL %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, a.Version, "ACL "+a.Name); err != nil {
|
|
return err
|
|
}
|
|
if users := FindRefs(st, refACL, a.Name); len(users) > 0 {
|
|
return errf(KindConflict, "ACL %s is still used by %s", a.Name, strings.Join(users, ", "))
|
|
}
|
|
delete(st.ACLs, a.ID)
|
|
return nil
|
|
}
|
|
|
|
// ---- TSIG keys
|
|
|
|
func applyTSIGPut(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil {
|
|
return err
|
|
}
|
|
var in TSIGInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
var k *model.TSIGKey
|
|
if op.Kind == OpTSIGCreate {
|
|
if op.ID == 0 || st.Keys[op.ID] != nil {
|
|
return errf(KindConflict, "key ID %d is already in use", op.ID)
|
|
}
|
|
if in.Name == nil || in.Secret == nil {
|
|
return errf(KindBadRequest, "a TSIG key needs a name and a secret")
|
|
}
|
|
k = &model.TSIGKey{ID: op.ID, Algorithm: "hmac-sha256"}
|
|
} else {
|
|
k = st.Keys[op.ID]
|
|
if k == nil {
|
|
return errf(KindNotFound, "TSIG key %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, k.Version, "TSIG key "+k.Name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
oldName := k.Name
|
|
if in.Name != nil {
|
|
name := model.NormalizeKeyName(*in.Name)
|
|
if err := dnsname.CheckZoneName(dnsname.Fqdn(name)); err != nil || name == "" || name == "." {
|
|
return errf(KindBadRequest, "invalid key name %q (key names are domain names, e.g. xfer-key or acme.example.com)", *in.Name)
|
|
}
|
|
if other := st.KeyByName(name); other != nil && other.ID != k.ID {
|
|
return errf(KindConflict, "TSIG key %s already exists", name)
|
|
}
|
|
k.Name = name
|
|
}
|
|
if in.Algorithm != nil {
|
|
alg := strings.ToLower(strings.TrimSpace(*in.Algorithm))
|
|
if _, ok := validate.TSIGAlgorithms[alg]; !ok {
|
|
return errf(KindBadRequest, "unsupported TSIG algorithm %q", alg)
|
|
}
|
|
k.Algorithm = alg
|
|
}
|
|
if in.Secret != nil {
|
|
k.Secret = strings.TrimSpace(*in.Secret)
|
|
}
|
|
if in.Comment != nil {
|
|
k.Comment = *in.Comment
|
|
}
|
|
k.Touch(c.user(), c.now)
|
|
st.Keys[k.ID] = k
|
|
if op.Kind == OpTSIGUpdate && oldName != k.Name {
|
|
renameRefs(st, refKey, oldName, k.Name, c)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyTSIGDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil {
|
|
return err
|
|
}
|
|
k := st.Keys[op.ID]
|
|
if k == nil {
|
|
return errf(KindNotFound, "TSIG key %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, k.Version, "TSIG key "+k.Name); err != nil {
|
|
return err
|
|
}
|
|
if users := FindRefs(st, refKey, k.Name); len(users) > 0 {
|
|
return errf(KindConflict, "TSIG key %s is still used by %s", k.Name, strings.Join(users, ", "))
|
|
}
|
|
delete(st.Keys, k.ID)
|
|
return nil
|
|
}
|
|
|
|
// ---- options
|
|
|
|
func optionAction(st *model.State, scope string, scopeID int64) (auth.Action, error) {
|
|
switch scope {
|
|
case model.ScopeOptions, model.ScopeTopLevel:
|
|
return auth.Action{Resource: auth.ResOptions, Write: true}, nil
|
|
case model.ScopeView:
|
|
v := st.Views[scopeID]
|
|
if v == nil {
|
|
return auth.Action{}, errf(KindNotFound, "view %d does not exist", scopeID)
|
|
}
|
|
return viewAction(v.Name), nil
|
|
case model.ScopeZone:
|
|
z := st.Zones[scopeID]
|
|
if z == nil {
|
|
return auth.Action{}, errf(KindNotFound, "zone %d does not exist", scopeID)
|
|
}
|
|
return zoneAction(st, z, true), nil
|
|
}
|
|
return auth.Action{}, errf(KindBadRequest, "unknown option scope %q (use options, view, zone or toplevel)", scope)
|
|
}
|
|
|
|
// catalogScope returns the catalog scope for an option scope.
|
|
func catalogScope(st *model.State, scope string, scopeID int64) string {
|
|
switch scope {
|
|
case model.ScopeOptions:
|
|
return catalog.ScopeOptions
|
|
case model.ScopeTopLevel:
|
|
return catalog.ScopeTop
|
|
case model.ScopeView:
|
|
return catalog.ScopeView
|
|
case model.ScopeZone:
|
|
if z := st.Zones[scopeID]; z != nil {
|
|
return catalog.ZoneScope(z.Type)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// NormalizeOption checks an option name for its scope and returns the
|
|
// canonical name and value.
|
|
func NormalizeOption(st *model.State, scope string, scopeID int64, name, value string) (string, string, error) {
|
|
cs := catalogScope(st, scope, scopeID)
|
|
n := strings.ToLower(strings.TrimSpace(name))
|
|
if canon, syn := catalog.Canonical(cs, n); syn {
|
|
n = canon
|
|
}
|
|
stmt, ok := catalog.Default().Lookup(cs, n)
|
|
if !ok {
|
|
hint := ""
|
|
if scope == model.ScopeZone {
|
|
if z := st.Zones[scopeID]; z != nil {
|
|
hint = " for " + z.Type + " zones"
|
|
}
|
|
}
|
|
return "", "", errf(KindBadRequest, "%q is not a valid option%s in this scope", name, hint)
|
|
}
|
|
if stmt.Managed {
|
|
return "", "", errf(KindBadRequest, "%q can't be set as an option; it is managed through %s", n, stmt.ManagedBy)
|
|
}
|
|
v := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), ";"))
|
|
if v == "" {
|
|
if n == "include" {
|
|
return "", "", errf(KindBadRequest, "include needs a file name")
|
|
}
|
|
return n, "", nil
|
|
}
|
|
// Quote plain strings for options that take a quoted string.
|
|
if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") {
|
|
v = namedconf.QuoteString(v)
|
|
}
|
|
parsed, err := namedconf.ParseOptionValue(n, v)
|
|
if err != nil {
|
|
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
|
|
}
|
|
return n, namedconf.Compact(parsed.Items[1:]), nil
|
|
}
|
|
|
|
func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error {
|
|
var in OptionInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
var o *model.Option
|
|
if op.Kind == OpOptionCreate {
|
|
if op.ID == 0 || st.Options[op.ID] != nil {
|
|
return errf(KindConflict, "option ID %d is already in use", op.ID)
|
|
}
|
|
if in.Scope == nil || in.Name == nil {
|
|
return errf(KindBadRequest, "an option needs a scope and a name")
|
|
}
|
|
o = &model.Option{ID: op.ID, Scope: *in.Scope}
|
|
if in.ScopeID != nil {
|
|
o.ScopeID = *in.ScopeID
|
|
}
|
|
if o.Scope == model.ScopeOptions || o.Scope == model.ScopeTopLevel {
|
|
o.ScopeID = 0
|
|
}
|
|
o.Position = maxPosition(st, o.Scope, o.ScopeID) + 1
|
|
} else {
|
|
o = st.Options[op.ID]
|
|
if o == nil {
|
|
return errf(KindNotFound, "option %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, o.Version, "option "+o.Name); err != nil {
|
|
return err
|
|
}
|
|
if in.Scope != nil && *in.Scope != o.Scope || in.ScopeID != nil && *in.ScopeID != o.ScopeID {
|
|
return errf(KindBadRequest, "options can't be moved to another scope; delete and re-create them")
|
|
}
|
|
}
|
|
act, err := optionAction(st, o.Scope, o.ScopeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.require(act); err != nil {
|
|
return err
|
|
}
|
|
name, value := o.Name, o.Value
|
|
if in.Name != nil {
|
|
name = *in.Name
|
|
}
|
|
if in.Value != nil {
|
|
value = *in.Value
|
|
}
|
|
n, v, err := NormalizeOption(st, o.Scope, o.ScopeID, name, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o.Name, o.Value = n, v
|
|
if in.Comment != nil {
|
|
o.Comment = *in.Comment
|
|
}
|
|
if in.Position != nil {
|
|
o.Position = *in.Position
|
|
}
|
|
o.Touch(c.user(), c.now)
|
|
st.Options[o.ID] = o
|
|
return nil
|
|
}
|
|
|
|
func maxPosition(st *model.State, scope string, scopeID int64) int {
|
|
m := 0
|
|
for _, o := range st.Options {
|
|
if o.Scope == scope && o.ScopeID == scopeID && o.Position > m {
|
|
m = o.Position
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func applyOptionDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|
o := st.Options[op.ID]
|
|
if o == nil {
|
|
return errf(KindNotFound, "option %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, o.Version, "option "+o.Name); err != nil {
|
|
return err
|
|
}
|
|
act, err := optionAction(st, o.Scope, o.ScopeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.require(act); err != nil {
|
|
return err
|
|
}
|
|
delete(st.Options, o.ID)
|
|
return nil
|
|
}
|
|
|
|
// ---- settings
|
|
|
|
func applySettings(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(auth.Action{Resource: auth.ResOptions, Write: true}); err != nil {
|
|
return err
|
|
}
|
|
var in SettingsInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
s := &st.Settings
|
|
if in.DefaultTTL != nil {
|
|
s.DefaultTTL = *in.DefaultTTL
|
|
}
|
|
if in.Nameservers != nil {
|
|
var ns []string
|
|
for _, n := range *in.Nameservers {
|
|
if strings.TrimSpace(n) == "" {
|
|
continue
|
|
}
|
|
a, err := dnsname.ToASCII(strings.TrimSpace(n))
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
ns = append(ns, dnsname.Fqdn(a))
|
|
}
|
|
s.Nameservers = ns
|
|
}
|
|
if in.RName != nil {
|
|
if strings.TrimSpace(*in.RName) == "" {
|
|
s.RName = ""
|
|
} else {
|
|
mb, err := dnsname.MailboxFromEmail(*in.RName)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
s.RName = mb
|
|
}
|
|
}
|
|
if in.Refresh != nil {
|
|
s.Refresh = *in.Refresh
|
|
}
|
|
if in.Retry != nil {
|
|
s.Retry = *in.Retry
|
|
}
|
|
if in.Expire != nil {
|
|
s.Expire = *in.Expire
|
|
}
|
|
if in.NegativeTTL != nil {
|
|
s.NegativeTTL = *in.NegativeTTL
|
|
}
|
|
if in.SerialPolicy != nil {
|
|
s.SerialPolicy = strings.ToLower(*in.SerialPolicy)
|
|
}
|
|
s.Version++
|
|
return nil
|
|
}
|