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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+852
View File
@@ -0,0 +1,852 @@
package engine
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"strings"
"time"
"github.com/miekg/dns"
"jarl/internal/auth"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
"jarl/internal/validate"
)
// IDAllocator reserves object IDs.
type IDAllocator func(ctx context.Context, n int) ([]int64, error)
type expander struct {
ctx context.Context
st *model.State // working state: base plus the changeset's ops so far
alloc IDAllocator
p *auth.Principal
now time.Time
warnings []string
}
func (x *expander) ids(n int) ([]int64, error) {
ids, err := x.alloc(x.ctx, n)
if err != nil {
return nil, fmt.Errorf("allocating IDs: %w", err)
}
return ids, nil
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func newOp(kind string, id int64, base int, data any, summary string) model.Op {
op := model.Op{Kind: kind, ID: id, BaseVersion: base, Summary: summary}
if data != nil {
op.Data = mustJSON(data)
}
return op
}
// resolveZone finds the zone a record request refers to.
func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, error) {
if zoneID != 0 {
z := x.st.Zones[zoneID]
if z == nil {
return nil, errf(KindNotFound, "zone %d does not exist", zoneID)
}
return z, nil
}
if zone == "" {
return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)")
}
return FindZoneRef(x.st, zone, view)
}
// FindZoneRef finds a zone by name and optional view name.
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
name, err := ParseZoneName(zone)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
if view != "" {
v := st.ViewByName(view)
if v == nil {
return nil, errf(KindNotFound, "view %q does not exist", view)
}
z := st.FindZone(name, v.ID)
if z == nil {
return nil, errf(KindNotFound, "zone %s does not exist in view %s", dnsname.Display(name), v.Name)
}
return z, nil
}
zs := st.ZonesNamed(name)
switch len(zs) {
case 0:
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
case 1:
return zs[0], nil
}
var views []string
for _, z := range zs {
views = append(views, st.ViewName(z))
}
return nil, errf(KindBadRequest, "zone %s exists in several views (%s); say which view", dnsname.Display(name), strings.Join(views, ", "))
}
func zoneDesc(st *model.State, z *model.Zone) string {
s := dnsname.Display(z.Name)
if v := st.ViewName(z); v != "" {
s += " [" + v + "]"
}
return s
}
func ttlText(o model.Opt[uint32]) string {
if o.Set && !o.Null {
return fmt.Sprintf(" %d", o.Value)
}
return ""
}
// InitialSerial returns the first serial for a new zone.
func InitialSerial(policy string, now time.Time) uint32 {
switch policy {
case model.SerialIncrement:
return 1
case model.SerialUnixtime:
return uint32(now.Unix())
}
return dateSerial(now)
}
func dateSerial(now time.Time) uint32 {
y, m, d := now.UTC().Date()
return uint32(y*1000000 + int(m)*10000 + d*100)
}
func (x *expander) expand(req Request) ([]model.Op, error) {
switch req.Op {
case OpZoneCreate:
return x.zoneCreate(req)
case OpZoneUpdate, OpZoneDelete:
z := x.st.Zones[req.ID]
if z == nil {
return nil, errf(KindNotFound, "zone %d does not exist", req.ID)
}
verb := "update"
if req.Op == OpZoneDelete {
verb = "delete"
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
verb += fmt.Sprintf(" (with its %d records)", n)
}
}
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
case OpRecordCreate:
return x.recordCreate(req)
case OpRecordUpdate:
return x.recordUpdate(req)
case OpRecordDelete:
return x.recordDelete(req)
case OpRRSetReplace:
return x.rrsetReplace(req)
case OpViewCreate:
return x.viewCreate(req)
case OpViewUpdate, OpViewDelete:
v := x.st.Views[req.ID]
if v == nil {
return nil, errf(KindNotFound, "view %d does not exist", req.ID)
}
return []model.Op{x.versioned(req, v.Version, strings.TrimPrefix(req.Op, "view.")+" view "+v.Name)}, nil
case OpViewReorder:
var in ReorderInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
var names []string
for _, id := range in.IDs {
if v := x.st.Views[id]; v != nil {
names = append(names, v.Name)
}
}
return []model.Op{newOp(OpViewReorder, 0, 0, in, "reorder views: "+strings.Join(names, ", "))}, nil
case OpACLCreate:
return x.simpleCreate(req, OpACLCreate, func(data json.RawMessage) (string, error) {
var in ACLInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil {
return "", errf(KindBadRequest, "an ACL needs a name")
}
return "create ACL " + *in.Name, nil
})
case OpACLUpdate, OpACLDelete:
a := x.st.ACLs[req.ID]
if a == nil {
return nil, errf(KindNotFound, "ACL %d does not exist", req.ID)
}
return []model.Op{x.versioned(req, a.Version, strings.TrimPrefix(req.Op, "acl.")+" ACL "+a.Name)}, nil
case OpTSIGCreate:
return x.tsigCreate(req)
case OpTSIGUpdate, OpTSIGDelete:
k := x.st.Keys[req.ID]
if k == nil {
return nil, errf(KindNotFound, "TSIG key %d does not exist", req.ID)
}
return []model.Op{x.versioned(req, k.Version, strings.TrimPrefix(req.Op, "tsig.")+" TSIG key "+k.Name)}, nil
case OpOptionCreate:
return x.optionCreate(req)
case OpOptionUpdate, OpOptionDelete:
o := x.st.Options[req.ID]
if o == nil {
return nil, errf(KindNotFound, "option %d does not exist", req.ID)
}
summary := strings.TrimPrefix(req.Op, "option.") + " " + optionLabel(x.st, o)
if req.Op == OpOptionUpdate {
var in OptionInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Value != nil {
summary += ": " + o.Value + " → " + *in.Value
}
}
return []model.Op{x.versioned(req, o.Version, summary)}, nil
case OpSettingsUpdate:
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
}
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
}
func newOpRaw(kind string, id int64, base int, data json.RawMessage, summary string) model.Op {
return model.Op{Kind: kind, ID: id, BaseVersion: base, Data: data, Summary: summary}
}
// versioned builds an update/delete operation, recording the object's
// version for conflict detection.
func (x *expander) versioned(req Request, current int, summary string) model.Op {
base := req.BaseVersion
if base == 0 {
base = current
}
return newOpRaw(req.Op, req.ID, base, req.Data, summary)
}
func (x *expander) simpleCreate(req Request, kind string, describe func(json.RawMessage) (string, error)) ([]model.Op, error) {
summary, err := describe(req.Data)
if err != nil {
return nil, err
}
ids, err := x.ids(1)
if err != nil {
return nil, err
}
return []model.Op{newOpRaw(kind, ids[0], 0, req.Data, summary)}, nil
}
func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
var in ZoneInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Name == nil || strings.TrimSpace(*in.Name) == "" {
return nil, errf(KindBadRequest, "a zone needs a name")
}
name, err := ParseZoneName(*in.Name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
in.Name = &name
if in.Type == nil {
in.Type = model.Ptr(model.ZonePrimary)
}
zt, err := normalizeZoneType(*in.Type)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
in.Type = &zt
if in.View != nil {
if *in.View != "" {
v := x.st.ViewByName(*in.View)
if v == nil {
return nil, errf(KindNotFound, "view %q does not exist", *in.View)
}
in.ViewID = &v.ID
}
in.View = nil
}
nameservers := in.Nameservers
records := in.Records
cloneFrom := in.CloneFrom
zoneOpts := in.Options
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
set := x.st.Settings
var src *model.Zone
if cloneFrom != 0 {
src = x.st.Zones[cloneFrom]
if src == nil {
return nil, errf(KindNotFound, "zone %d (clone_from) does not exist", cloneFrom)
}
if src.Name != name {
return nil, errf(KindBadRequest, "clone_from must be a zone with the same name in another view (it is %s)", dnsname.Display(src.Name))
}
if !x.p.Can(zoneAction(x.st, src, false)) || !x.p.Can(recordAction(x.st, src, "", "", false)) {
return nil, errf(KindForbidden, "permission denied: you can't read zone %s", zoneDesc(x.st, src))
}
}
if zt == model.ZonePrimary {
if len(nameservers) == 0 && src == nil {
nameservers = set.Nameservers
}
if len(nameservers) == 0 && src == nil {
return nil, errf(KindBadRequest, "a primary zone needs at least one name server: give nameservers, or set default name servers in the settings")
}
if in.SOA == nil {
in.SOA = &SOAInput{}
}
s := in.SOA
if src != nil {
// Copy the source zone's SOA and TTL unless overridden.
if s.MName == nil {
s.MName = model.Ptr(src.SOA.MName)
}
if s.RName == nil {
s.RName = model.Ptr(src.SOA.RName)
}
if s.Refresh == nil {
s.Refresh = model.Ptr(src.SOA.Refresh)
}
if s.Retry == nil {
s.Retry = model.Ptr(src.SOA.Retry)
}
if s.Expire == nil {
s.Expire = model.Ptr(src.SOA.Expire)
}
if s.Minimum == nil {
s.Minimum = model.Ptr(src.SOA.Minimum)
}
if in.DefaultTTL == nil {
in.DefaultTTL = model.Ptr(src.DefaultTTL)
}
}
if s.MName == nil {
first, err := dnsname.Owner(nameservers[0], name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
s.MName = &first
}
if s.RName == nil {
if set.RName != "" {
s.RName = model.Ptr(set.RName)
} else {
s.RName = model.Ptr("hostmaster." + name)
}
}
if s.Refresh == nil {
s.Refresh = model.Ptr(set.Refresh)
}
if s.Retry == nil {
s.Retry = model.Ptr(set.Retry)
}
if s.Expire == nil {
s.Expire = model.Ptr(set.Expire)
}
if s.Minimum == nil {
s.Minimum = model.Ptr(set.NegativeTTL)
}
if in.DefaultTTL == nil {
in.DefaultTTL = model.Ptr(set.DefaultTTL)
}
if s.Serial == nil {
policy := set.SerialPolicy
if in.SerialPolicy != nil && *in.SerialPolicy != "" {
policy = *in.SerialPolicy
}
serial := InitialSerial(policy, x.now)
if src != nil && serialGreater(src.SOA.Serial, serial) {
serial = src.SOA.Serial
}
s.Serial = &serial
}
} else if len(nameservers) > 0 || len(records) > 0 || src != nil {
return nil, errf(KindBadRequest, "nameservers, records and clone_from only apply to primary zones")
}
type recSpec struct {
in RecordInput
summary string
}
var recs []recSpec
seen := map[string]bool{}
addRec := func(ri RecordInput) error {
tmp := &model.Zone{Name: name, Type: zt}
owner, p, err := parseRecord(tmp, deref(ri.Name), deref(ri.Type), deref(ri.Data))
if err != nil {
return err
}
k := owner + "|" + p.Type + "|" + strings.ToLower(p.Data)
if seen[k] {
return nil
}
seen[k] = true
ri.Name, ri.Type, ri.Data = &owner, &p.Type, &p.Data
recs = append(recs, recSpec{ri, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(ri.TTL), p.Type, p.Data)})
return nil
}
for _, ns := range nameservers {
target, err := dnsname.Owner(ns, name)
if err != nil {
return nil, errf(KindBadRequest, "name server %q: %v", ns, err)
}
if err := addRec(RecordInput{Name: model.Ptr("@"), Type: model.Ptr("NS"), Data: model.Ptr(target)}); err != nil {
return nil, err
}
}
if src != nil {
for _, r := range x.st.ZoneRecords(src.ID) {
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
if r.TTL != nil {
ri.TTL = model.Some(*r.TTL)
} else {
ri.TTL = model.Opt[uint32]{Set: true, Null: true}
}
if err := addRec(ri); err != nil {
return nil, err
}
}
}
for _, ri := range records {
ri.ZoneID, ri.Zone, ri.View = 0, "", ""
if err := addRec(ri); err != nil {
return nil, err
}
}
ids, err := x.ids(1 + len(recs) + len(zoneOpts))
if err != nil {
return nil, err
}
summary := fmt.Sprintf("create %s zone %s", zt, dnsname.Display(name))
if in.ViewID != nil && *in.ViewID != 0 {
if v := x.st.Views[*in.ViewID]; v != nil {
summary += " in view " + v.Name
}
}
if src != nil {
summary += " (copy of " + zoneDesc(x.st, src) + ")"
}
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
for i, zo := range zoneOpts {
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
if zo.Comment != "" {
oi.Comment = model.Ptr(zo.Comment)
}
ops = append(ops, newOp(OpOptionCreate, ids[1+len(recs)+i], 0, oi, fmt.Sprintf("set zone %s option %s %s", dnsname.Display(name), zo.Name, zo.Value)))
}
for i, r := range recs {
r.in.ZoneID = ids[0]
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
}
return ops, nil
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// reverseZoneFor finds the primary reverse zone for addr in a view.
func reverseZoneFor(st *model.State, viewID int64, addr netip.Addr) (*model.Zone, string) {
name := dnsname.ReverseName(addr)
var best *model.Zone
for _, z := range st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && dnsname.IsSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
return best, name
}
func addrOf(p *rr.Parsed) (netip.Addr, bool) {
switch v := p.RR.(type) {
case *dns.A:
a, ok := netip.AddrFromSlice(v.A.To4())
return a, ok
case *dns.AAAA:
a, ok := netip.AddrFromSlice(v.AAAA)
return a, ok
}
return netip.Addr{}, false
}
// ptrOps returns operations creating the PTR record for an address record.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) {
addr, ok := addrOf(p)
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
return nil, nil
}
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) {
return nil, nil // already there
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
}
}
ids, err := x.ids(1)
if err != nil {
return nil, err
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
}
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
}
func (x *expander) recordCreate(req Request) ([]model.Op, error) {
var in RecordInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
if in.Name == nil || in.Type == nil || in.Data == nil {
return nil, 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 nil, err
}
ptr := in.PTR
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
ids, err := x.ids(1)
if err != nil {
return nil, err
}
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
if ptr {
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
r := x.st.Records[req.ID]
if r == nil {
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
}
z := x.st.Zones[r.ZoneID]
var in RecordInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
ptr := in.PTR
in.PTR = false
in.Zone, in.View = "", ""
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
}
owner, p, err := parseRecord(z, name, typ, data)
if err != nil {
return nil, err
}
if in.Name != nil {
in.Name = &owner
}
if in.Type != nil {
in.Type = &p.Type
}
if in.Data != nil {
in.Data = &p.Data
}
var changes []string
if owner != r.Name {
changes = append(changes, "name "+r.Name+" → "+owner)
}
if p.Type != r.Type {
changes = append(changes, "type "+r.Type+" → "+p.Type)
}
if p.Data != r.Data {
changes = append(changes, "data "+r.Data+" → "+p.Data)
}
if in.TTL.Set {
old := "default"
if r.TTL != nil {
old = fmt.Sprint(*r.TTL)
}
nw := "default"
if !in.TTL.Null {
nw = fmt.Sprint(in.TTL.Value)
}
if old != nw {
changes = append(changes, "ttl "+old+" → "+nw)
}
}
if in.Comment != nil && *in.Comment != r.Comment {
changes = append(changes, "comment")
}
summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data)
if len(changes) > 0 {
summary += ": " + strings.Join(changes, ", ")
}
base := req.BaseVersion
if base == 0 {
base = r.Version
}
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
if ptr && (p.Data != r.Data || owner != r.Name) {
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
func (x *expander) recordDelete(req Request) ([]model.Op, error) {
var in RecordInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if req.ID != 0 {
r := x.st.Records[req.ID]
if r == nil {
if in.MissingOK {
return nil, nil
}
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
}
base := req.BaseVersion
if base == 0 {
base = r.Version
}
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
if in.PTR {
z := x.st.Zones[r.ZoneID]
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
return ops, nil
}
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
if in.Name == nil || in.Type == nil {
return nil, errf(KindBadRequest, "deleting by match needs a name and a type (or give the record id)")
}
owner, err := dnsname.Owner(*in.Name, z.Name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
typ, _, err := rr.NormalizeType(*in.Type)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
what := owner + " " + typ
var parsed *rr.Parsed
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
_, p, err := parseRecord(z, owner, typ, *in.Data)
if err != nil {
return nil, err
}
in.Data = &p.Data
what += " " + p.Data
parsed = p
} else {
in.Data = nil
what += " (all)"
}
if in.MissingOK {
what += " (if present)"
}
ptr := in.PTR
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type = &owner, &typ
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
if ptr && parsed != nil {
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
}
return ops, nil
}
func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
var in RRSetInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
if err != nil {
return nil, err
}
owner, err := dnsname.Owner(in.Name, z.Name)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
typ, _, err := rr.NormalizeType(in.Type)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
}
var canon []string
for _, d := range in.Data {
_, p, err := parseRecord(z, owner, typ, d)
if err != nil {
return nil, err
}
canon = append(canon, p.Data)
}
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
if len(canon) > 0 {
in.NewIDs, err = x.ids(len(canon))
if err != nil {
return nil, err
}
}
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
}
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
}
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
var in ViewInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Name == nil || strings.TrimSpace(*in.Name) == "" {
return nil, errf(KindBadRequest, "a view needs a name")
}
adopt := in.AdoptZones
in.AdoptZones = false
ids, err := x.ids(1)
if err != nil {
return nil, err
}
ops := []model.Op{newOp(OpViewCreate, ids[0], 0, in, "create view "+*in.Name)}
if adopt {
for _, z := range x.st.ZonesInView(0) {
ops = append(ops, newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{ViewID: model.Ptr(ids[0])}, "move zone "+dnsname.Display(z.Name)+" into view "+*in.Name))
}
}
return ops, nil
}
func (x *expander) tsigCreate(req Request) ([]model.Op, error) {
var in TSIGInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Name == nil {
return nil, errf(KindBadRequest, "a TSIG key needs a name")
}
alg := "hmac-sha256"
if in.Algorithm != nil {
alg = strings.ToLower(*in.Algorithm)
}
in.Algorithm = &alg
if in.Secret == nil || *in.Secret == "" {
n := validate.TSIGAlgorithms[alg]
if n == 0 {
return nil, errf(KindBadRequest, "unsupported TSIG algorithm %q", alg)
}
in.Secret = model.Ptr(auth.GenerateTSIGSecret(n))
}
ids, err := x.ids(1)
if err != nil {
return nil, err
}
return []model.Op{newOp(OpTSIGCreate, ids[0], 0, in, "create TSIG key "+model.NormalizeKeyName(*in.Name)+" ("+alg+")")}, nil
}
func (x *expander) optionCreate(req Request) ([]model.Op, error) {
var in OptionInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.Scope == nil || in.Name == nil {
return nil, errf(KindBadRequest, "an option needs a scope and a name")
}
scopeID := int64(0)
if in.ScopeID != nil {
scopeID = *in.ScopeID
}
val := ""
if in.Value != nil {
val = *in.Value
}
n, v, err := NormalizeOption(x.st, *in.Scope, scopeID, *in.Name, val)
if err != nil {
return nil, err
}
in.Name, in.Value = &n, &v
ids, err := x.ids(1)
if err != nil {
return nil, err
}
o := &model.Option{Scope: *in.Scope, ScopeID: scopeID, Name: n}
return []model.Op{newOp(OpOptionCreate, ids[0], 0, in, "set "+optionLabel(x.st, o)+" "+v)}, nil
}
// RedactOps hides TSIG secrets in operation data.
func RedactOps(ops []model.Op) []model.Op {
out := make([]model.Op, len(ops))
for i, op := range ops {
out[i] = op
if op.Kind != OpTSIGCreate && op.Kind != OpTSIGUpdate || len(op.Data) == 0 {
continue
}
var m map[string]any
if json.Unmarshal(op.Data, &m) == nil {
if _, ok := m["secret"]; ok {
m["secret"] = "(hidden)"
out[i].Data = mustJSON(m)
}
}
}
return out
}