Files
jarl/internal/engine/ops.go
T
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:14 +01:00

1335 lines
35 KiB
Go

package engine
import (
"bytes"
"encoding/json"
"fmt"
"net/netip"
"strings"
"time"
"jarl/internal/auth"
"jarl/internal/catalog"
"jarl/internal/dnsname"
"jarl/internal/iprange"
"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)
case OpBlockStepCreate, OpBlockStepUpdate:
err = applyBlockStepPut(st, op, c)
case OpBlockStepDelete:
err = applyBlockStepDelete(st, op, c)
case OpClientGroupCreate, OpClientGroupUpdate:
err = applyClientGroupPut(st, op, c)
case OpClientGroupDelete:
err = applyClientGroupDelete(st, op, c)
case OpBlockingPause:
err = applyBlockingPause(st, op, c)
default:
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
}
if e, ok := AsError(err); ok && e.OpSeq == 0 {
e.OpSeq, e.OpSummary = op.Seq, op.Summary
}
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.Disabled != nil {
z.Disabled = *in.Disabled
}
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(KindConflict, "the record (ID %d) no longer exists: it was deleted after this change was staged; remove this operation from the changeset", 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 {
// Deleted since this was staged (by someone else, or the
// CLI): what the operation wanted is done.
c.warn("%s: the record was already deleted outside this changeset, so there was nothing to delete", strings.TrimPrefix(op.Summary, "delete "))
return nil
}
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)
}
// Lists may be given without their braces: "10.0.0.53; key xfer".
v = stmt.WrapList(v)
parsed, err := namedconf.ParseOptionValue(n, v)
if err != nil {
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
}
namedconf.JoinRanges(parsed)
if err := checkOptionRanges(parsed, strings.Contains(stmt.Grammar, "<address_match_element>")); err != nil {
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
}
return n, namedconf.Compact(parsed.Items[1:]), nil
}
// checkOptionRanges checks the address ranges in an option's blocks: they
// must be valid, and only options with address match lists take them.
func checkOptionRanges(st *namedconf.Stmt, aml bool) error {
for _, it := range st.Items {
if it.Block == nil {
continue
}
for _, s := range it.Block.Stmts {
items := s.Items
if len(items) == 2 && items[0].Bang {
items = items[1:]
}
if len(items) == 1 && items[0].Value != nil && !items[0].Value.Quoted {
if _, ok, err := iprange.Parse(items[0].Value.Text); ok {
if err != nil {
return err
}
if !aml {
return fmt.Errorf("%s: address ranges only work in address match lists (such as allow-query or an ACL), not here", items[0].Value.Text)
}
continue
}
}
if err := checkOptionRanges(s, aml); err != nil {
return err
}
}
}
return 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
}