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>
853 lines
23 KiB
Go
853 lines
23 KiB
Go
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
|
|
}
|