Files
jarl/internal/engine/ops.go
T
abrown38andClaude Opus 5.5 bd6b8778e4 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
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>
2026-10-07 15:03:13 +00:00

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
}