jarld downloads blocklists (Adblock, hosts or plain domains), checks them daily and reloads named only when a list changed; your own block and allow rules take precedence, and the most specific rule wins. Lists and rules apply per view (default: every view that offers recursion). Web UI Blocking page with name checks, jarl blocklist and jarl block, the /blocking API, blocked counts in the statistics. Database schema 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1281 lines
34 KiB
Go
1281 lines
34 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)
|
|
case OpBlocklistCreate, OpBlocklistUpdate:
|
|
err = applyBlocklistPut(st, op, c)
|
|
case OpBlocklistDelete:
|
|
err = applyBlocklistDelete(st, op, c)
|
|
case OpBlockRuleCreate, OpBlockRuleUpdate:
|
|
err = applyBlockRulePut(st, op, c)
|
|
case OpBlockRuleDelete:
|
|
err = applyBlockRuleDelete(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.DNSSECPolicy != nil {
|
|
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
|
|
switch strings.ToLower(p) {
|
|
case "none", "off":
|
|
p = ""
|
|
case model.DNSSECDefault, model.DNSSECInsecure:
|
|
p = strings.ToLower(p)
|
|
}
|
|
z.DNSSECPolicy = p
|
|
}
|
|
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 && !r.Disabled {
|
|
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
|
|
}
|
|
if in.Disabled != nil {
|
|
r.Disabled = *in.Disabled
|
|
}
|
|
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
|
|
}
|
|
if in.Disabled != nil {
|
|
r.Disabled = *in.Disabled
|
|
}
|
|
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
|
|
}
|
|
// The RRset becomes exactly the given data. A disabled record with
|
|
// one of the values is enabled again; other disabled records are left
|
|
// alone.
|
|
keep := map[string]bool{}
|
|
// (In ID order, so which of two identical records is kept doesn't
|
|
// depend on map order.)
|
|
for _, id := range sortedKeys(st.Records) {
|
|
r := st.Records[id]
|
|
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.Disabled {
|
|
if ttlKnown {
|
|
r.TTL = copyTTL(ttl)
|
|
}
|
|
r.Disabled = false
|
|
r.Touch(c.user(), c.now)
|
|
}
|
|
continue
|
|
}
|
|
if r.Disabled {
|
|
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
|
|
}
|
|
if users := blockingUsers(st, v.ID); len(users) > 0 {
|
|
return errf(KindConflict, "view %s is used by %s; change them to other views first", v.Name, strings.Join(users, ", "))
|
|
}
|
|
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 (use letters, digits, hyphens and dots, e.g. xfer-key or ns2-xfer.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
|
|
}
|