Files
jarl/internal/engine/ops.go
T
adamandClaude Opus 5.5 295c549479 Disable records instead of deleting them
A disabled record stays in Jarl with its ID, TTL and comment but is
left out of the zone; the zone file lists it as a comment. Web UI
toggle, jarl record disable|enable, record.disable|enable operations
and API endpoints. Database schema 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:33:02 +00:00

1270 lines
33 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.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
}
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
}