Files
adamandClaude Opus 5.5 fef916b7ab Encrypted DNS: several host names, views per listen address, what named uses a certificate for
The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports.

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

1423 lines
38 KiB
Go

package engine
import (
"bytes"
"encoding/json"
"fmt"
"net/netip"
"slices"
"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
}
if in.LinkGroup != nil {
z.LinkGroup = *in.LinkGroup
}
if in.AutoPTR != nil {
z.AutoPTR = *in.AutoPTR
}
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
}
if in.SharedID != nil {
r.SharedID = *in.SharedID
}
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
}
if in.SharedID != nil {
r.SharedID = *in.SharedID
}
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
shared := r.SharedID
if in.Shared != nil {
shared = 0
if *in.Shared {
shared = r.SharedID
if shared == 0 {
shared = r.ID
}
}
}
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled || shared != r.SharedID {
if ttlKnown {
r.TTL = copyTTL(ttl)
}
r.Disabled, r.SharedID = false, shared
r.Touch(c.user(), c.now)
}
continue
}
if r.Disabled {
// Kept, but shared or local like the rest of the set.
if in.Shared != nil && (*in.Shared) != (r.SharedID != 0) {
r.SharedID = 0
if *in.Shared {
r.SharedID = r.ID
}
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
}
if in.Shared != nil && *in.Shared {
r.SharedID = r.ID
}
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
}
if in.Exposure != nil {
e, err := normalizeExposure(*in.Exposure)
if err != nil {
return err
}
v.Exposure = e
}
if in.Acknowledged != nil {
v.Acknowledged = cleanAcks(*in.Acknowledged)
}
if in.ReverseSuggestions != nil {
v.ReverseSuggestionsOff = !*in.ReverseSuggestions
}
if in.DismissedReverse != nil {
v.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
return nil
}
// normalizeExposure checks an exposure override.
func normalizeExposure(s string) (string, error) {
switch e := strings.ToLower(strings.TrimSpace(s)); e {
case "", "auto":
return model.ExposureAuto, nil
case model.ExposurePublic, model.ExposurePrivate:
return e, nil
}
return "", errf(KindBadRequest, "exposure must be auto, public or private, not %q", s)
}
// cleanAcks drops empty acknowledgements (nil for none).
func cleanAcks(acks []model.Acknowledgement) []model.Acknowledgement {
var out []model.Acknowledgement
for _, a := range acks {
if a.Code != "" {
out = append(out, a)
}
}
return out
}
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)
}
if in.Exposure != nil {
e, err := normalizeExposure(*in.Exposure)
if err != nil {
return err
}
s.Exposure = e
}
if in.Acknowledged != nil {
s.Acknowledged = cleanAcks(*in.Acknowledged)
}
if in.DismissedReverse != nil {
s.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
if in.EncryptedDNSHosts != nil {
s.EncryptedDNSHosts = slices.Clone(*in.EncryptedDNSHosts)
}
s.Version++
return nil
}