Auto-PTR (and --ptr) changed PTR records in every linked view, whatever view the address record was in. ptrSync now compares each touched A/AAAA set before and after, per view, and changes only that view's reverse zone. PTR records in linked reverse zones are local (fill too); a shared PTR set is made local in each view before one view changes it. Deleting a linked zone's local records no longer brings the shared ones back into the view: the name and type are gone from that view and the other views keep theirs, local to each. Share still restores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
497 lines
16 KiB
Go
497 lines
16 KiB
Go
package engine
|
|
|
|
// Linked zones (docs/views.md#linked-zones). Zones of the same name in
|
|
// different views can be linked: their shared records are kept in step,
|
|
// and each zone can override a name and type with records local to its
|
|
// view. Every zone keeps its own copy of each shared record (with the
|
|
// same SharedID), so the rest of Jarl sees ordinary zones.
|
|
//
|
|
// Keeping them in step happens when requests are expanded: the request's
|
|
// operations are applied to a scratch copy, and linkSync adds explicit
|
|
// operations for the other zones of the link. So a change to a shared
|
|
// record is one changeset with one operation per zone: the diff, the
|
|
// permission checks, history and undo all see every zone.
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
type rrKey struct {
|
|
zone int64
|
|
name, typ string
|
|
}
|
|
|
|
// linkSync returns ops followed by the operations that bring the other
|
|
// zones of each affected link in step.
|
|
func (x *expander) linkSync(ops []model.Op) ([]model.Op, error) {
|
|
if !x.st.AnyLinked() {
|
|
return ops, nil
|
|
}
|
|
work := x.st.Clone()
|
|
c := &applyCtx{p: x.p, now: x.now}
|
|
for _, op := range ops {
|
|
if err := applyOp(work, op, c); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
keys := affectedKeys(x.st, work, ops)
|
|
var extra []model.Op
|
|
for _, k := range keys {
|
|
z := work.Zones[k.zone]
|
|
if z == nil || z.LinkGroup == 0 {
|
|
continue
|
|
}
|
|
more, err := x.syncKey(work, z, k.name, k.typ)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, op := range more {
|
|
if err := applyOp(work, op, c); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
extra = append(extra, more...)
|
|
}
|
|
return append(ops, extra...), nil
|
|
}
|
|
|
|
// affectedKeys lists the (zone, name, type) sets that ops touch, before
|
|
// and after.
|
|
func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
|
|
var out []rrKey
|
|
seen := map[rrKey]bool{}
|
|
add := func(zone int64, name, typ string) {
|
|
k := rrKey{zone, name, typ}
|
|
if zone != 0 && name != "" && !seen[k] {
|
|
seen[k] = true
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
fromRecord := func(id int64) {
|
|
for _, st := range []*model.State{pre, post} {
|
|
if r := st.Records[id]; r != nil {
|
|
add(r.ZoneID, r.Name, r.Type)
|
|
}
|
|
}
|
|
}
|
|
for _, op := range ops {
|
|
switch op.Kind {
|
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete:
|
|
if op.ID != 0 {
|
|
fromRecord(op.ID)
|
|
}
|
|
if op.Kind == OpRecordDelete && op.ID == 0 {
|
|
var in RecordInput
|
|
if decode(op.Data, &in) == nil && in.Name != nil && in.Type != nil {
|
|
add(in.ZoneID, *in.Name, *in.Type)
|
|
}
|
|
}
|
|
case OpRRSetReplace:
|
|
var in RRSetInput
|
|
if decode(op.Data, &in) == nil {
|
|
add(in.ZoneID, in.Name, in.Type)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// syncKey brings the other zones of z's link in step with z for one name
|
|
// and type, after z was changed:
|
|
//
|
|
// - z overrides it (local records): z's shared copies go; the others
|
|
// keep theirs.
|
|
// - z's override was just removed: z goes without the name and type,
|
|
// and the other zones' shared copies become local to each.
|
|
// - otherwise z's shared records are the truth: every zone that doesn't
|
|
// override the name and type gets the same (created, updated or
|
|
// deleted to match).
|
|
func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]model.Op, error) {
|
|
var ops []model.Op
|
|
where := func(o *model.Zone) string { return zoneDesc(st, o) }
|
|
if st.HasLocal(z.ID, name, typ) {
|
|
for _, r := range st.SharedRecords(z.ID, name, typ) {
|
|
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil,
|
|
fmt.Sprintf("linked: %s %s is local in %s now, so its shared %s goes there", name, typ, where(z), r.Data)))
|
|
}
|
|
return ops, nil
|
|
}
|
|
truth := st.SharedRecords(z.ID, name, typ)
|
|
members := st.LinkedWith(z)
|
|
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) && x.sharing[rrKey{z.ID, name, typ}] {
|
|
// Another view's version was shared (dropOverrides): z gets it.
|
|
for _, o := range members {
|
|
if src := st.SharedRecords(o.ID, name, typ); len(src) > 0 && !st.HasLocal(o.ID, name, typ) {
|
|
for _, r := range src {
|
|
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ops = append(ops, op)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return ops, nil
|
|
}
|
|
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
|
// z's local records are gone, and with them the name and type from
|
|
// z: the other views keep theirs, each its own now (a shared set
|
|
// would be in z too). Share sets one version for all again.
|
|
for _, o := range members {
|
|
if st.HasLocal(o.ID, name, typ) {
|
|
continue
|
|
}
|
|
var more []model.Op
|
|
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
|
more = append(more, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
|
|
fmt.Sprintf("linked: %s %s %s stays in %s, local to it now (%s has none)", name, typ, r.Data, where(o), where(z))))
|
|
}
|
|
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
|
return nil, errf(KindForbidden, "deleting %s %s from %s makes it local in %s, which you may not change; ask someone who may to delete it, or share %s's version (jarl record update ID --shared)",
|
|
dnsname.Display(name), typ, where(z), where(o), where(o))
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
return ops, nil
|
|
}
|
|
for _, o := range members {
|
|
if st.HasLocal(o.ID, name, typ) {
|
|
continue // o overrides this name and type
|
|
}
|
|
copies := map[int64]*model.Record{}
|
|
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
|
copies[r.SharedID] = r
|
|
}
|
|
var more []model.Op
|
|
for _, t := range truth {
|
|
cp := copies[t.SharedID]
|
|
delete(copies, t.SharedID)
|
|
if cp == nil {
|
|
op, err := x.copyRecord(st, o, t, fmt.Sprintf("linked: add %s %s %s in %s", name, typ, t.Data, where(o)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
more = append(more, op)
|
|
continue
|
|
}
|
|
in, changes := recordDiff(cp, t)
|
|
if len(changes) > 0 {
|
|
more = append(more, newOp(OpRecordUpdate, cp.ID, cp.Version, in,
|
|
fmt.Sprintf("linked: change %s %s %s in %s: %s", name, typ, cp.Data, where(o), strings.Join(changes, ", "))))
|
|
}
|
|
}
|
|
for _, id := range sortedKeys(copies) {
|
|
cp := copies[id]
|
|
more = append(more, newOp(OpRecordDelete, cp.ID, cp.Version, nil, fmt.Sprintf("linked: delete %s %s %s in %s", name, typ, cp.Data, where(o))))
|
|
}
|
|
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
|
return nil, errf(KindForbidden, "%s %s is shared with %s, which you may not change; to change it in %s only, make it local to that view (local: true, or --local)",
|
|
dnsname.Display(name), typ, where(o), where(z))
|
|
}
|
|
ops = append(ops, more...)
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
// copyRecord returns an operation that creates a copy of shared record r
|
|
// in zone z.
|
|
func (x *expander) copyRecord(st *model.State, z *model.Zone, r *model.Record, summary string) (model.Op, error) {
|
|
if !x.p.Can(recordAction(st, z, r.Name, r.Type, true)) {
|
|
return model.Op{}, errf(KindForbidden, "%s %s is shared with %s, which you may not change", dnsname.Display(r.Name), r.Type, zoneDesc(st, z))
|
|
}
|
|
ids, err := x.ids(1)
|
|
if err != nil {
|
|
return model.Op{}, err
|
|
}
|
|
return newOp(OpRecordCreate, ids[0], 0, recordCopyInput(z.ID, r), summary), nil
|
|
}
|
|
|
|
func recordCopyInput(zoneID int64, r *model.Record) RecordInput {
|
|
in := RecordInput{ZoneID: zoneID, Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data),
|
|
Comment: model.Ptr(r.Comment), Disabled: model.Ptr(r.Disabled), SharedID: model.Ptr(r.SharedID)}
|
|
if r.TTL != nil {
|
|
in.TTL = model.Some(*r.TTL)
|
|
} else {
|
|
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
|
}
|
|
return in
|
|
}
|
|
|
|
// recordDiff returns the update that makes cp like t, and what changes.
|
|
func recordDiff(cp, t *model.Record) (RecordInput, []string) {
|
|
var in RecordInput
|
|
var changes []string
|
|
if cp.Data != t.Data {
|
|
in.Data = model.Ptr(t.Data)
|
|
changes = append(changes, "data "+t.Data)
|
|
}
|
|
if !sameTTL(cp.TTL, t.TTL) {
|
|
if t.TTL != nil {
|
|
in.TTL = model.Some(*t.TTL)
|
|
changes = append(changes, fmt.Sprintf("ttl %d", *t.TTL))
|
|
} else {
|
|
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
|
changes = append(changes, "ttl default")
|
|
}
|
|
}
|
|
if cp.Comment != t.Comment {
|
|
in.Comment = model.Ptr(t.Comment)
|
|
changes = append(changes, "comment")
|
|
}
|
|
if cp.Disabled != t.Disabled {
|
|
in.Disabled = model.Ptr(t.Disabled)
|
|
changes = append(changes, enableWord(t.Disabled)+"d")
|
|
}
|
|
return in, changes
|
|
}
|
|
|
|
// linkTarget resolves the zone a link request names.
|
|
func (x *expander) linkTarget(in LinkInput) (*model.Zone, error) {
|
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if z.Type != model.ZonePrimary {
|
|
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, z), z.Type)
|
|
}
|
|
return z, nil
|
|
}
|
|
|
|
// zoneLink links zones of the same name in several views. It never
|
|
// changes what named answers: a name and type with the same records in
|
|
// every zone becomes shared; anything that differs stays local to each
|
|
// view (and can be shared later, from the view whose version is right).
|
|
func (x *expander) zoneLink(req Request) ([]model.Op, error) {
|
|
var in LinkInput
|
|
if err := decode(req.Data, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
z, err := x.linkTarget(in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
members := []*model.Zone{z}
|
|
add := func(o *model.Zone) {
|
|
if !slices.ContainsFunc(members, func(m *model.Zone) bool { return m.ID == o.ID }) {
|
|
members = append(members, o)
|
|
}
|
|
}
|
|
if len(in.With) == 0 {
|
|
for _, o := range x.st.ZonesNamed(z.Name) {
|
|
if o.Type == model.ZonePrimary && o.ID != z.ID {
|
|
add(o)
|
|
}
|
|
}
|
|
}
|
|
for _, vn := range in.With {
|
|
v := x.st.ViewByName(vn)
|
|
if v == nil {
|
|
return nil, errf(KindNotFound, "view %q does not exist", vn)
|
|
}
|
|
o := x.st.FindZone(z.Name, v.ID)
|
|
if o == nil {
|
|
return nil, errf(KindNotFound, "view %s has no zone %s (create it linked: jarl zone create %s --view %s --link-with %s)", v.Name, dnsname.Display(z.Name), dnsname.Display(z.Name), v.Name, x.st.ViewName(z))
|
|
}
|
|
if o.Type != model.ZonePrimary {
|
|
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, o), o.Type)
|
|
}
|
|
add(o)
|
|
}
|
|
// Zones already linked with any of them join too.
|
|
for _, m := range slices.Clone(members) {
|
|
for _, o := range x.st.LinkMembers(m.LinkGroup) {
|
|
add(o)
|
|
}
|
|
}
|
|
if len(members) < 2 {
|
|
return nil, errf(KindBadRequest, "zone %s exists in no other view to link it with", dnsname.Display(z.Name))
|
|
}
|
|
x.st.SortByView(members)
|
|
group := members[0].ID
|
|
for _, m := range members {
|
|
if m.LinkGroup != 0 {
|
|
group = m.LinkGroup
|
|
break
|
|
}
|
|
}
|
|
|
|
// What is the same everywhere?
|
|
type key struct{ name, typ string }
|
|
sig := func(rs []*model.Record) string {
|
|
var parts []string
|
|
for _, r := range rs {
|
|
ttl := "-"
|
|
if r.TTL != nil {
|
|
ttl = fmt.Sprint(*r.TTL)
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s|%s|%v", strings.ToLower(r.Data), ttl, r.Disabled))
|
|
}
|
|
slices.Sort(parts)
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
keys := map[key]bool{}
|
|
var order []key
|
|
for _, m := range members {
|
|
for _, r := range x.st.ZoneRecords(m.ID) {
|
|
k := key{r.Name, r.Type}
|
|
if !keys[k] {
|
|
keys[k] = true
|
|
order = append(order, k)
|
|
}
|
|
}
|
|
}
|
|
var ops []model.Op
|
|
var local []string
|
|
shared := 0
|
|
for _, k := range order {
|
|
first := x.st.RRset(members[0].ID, k.name, k.typ)
|
|
same := len(first) > 0
|
|
for _, m := range members[1:] {
|
|
if rs := x.st.RRset(m.ID, k.name, k.typ); len(rs) == 0 || sig(rs) != sig(first) {
|
|
same = false
|
|
break
|
|
}
|
|
}
|
|
if same {
|
|
shared++
|
|
} else {
|
|
var in []string
|
|
for _, m := range members {
|
|
if len(x.st.RRset(m.ID, k.name, k.typ)) > 0 {
|
|
in = append(in, x.st.ViewName(m))
|
|
}
|
|
}
|
|
local = append(local, fmt.Sprintf("%s %s (%s)", dnsname.Display(k.name), k.typ, strings.Join(in, ", ")))
|
|
}
|
|
// Pair the copies: record i of the first zone's set (by data) with
|
|
// the same data in the others.
|
|
byData := func(rs []*model.Record) []*model.Record {
|
|
out := slices.Clone(rs)
|
|
slices.SortStableFunc(out, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
|
return out
|
|
}
|
|
ref := byData(first)
|
|
for _, m := range members {
|
|
rs := byData(x.st.RRset(m.ID, k.name, k.typ))
|
|
for i, r := range rs {
|
|
want := int64(0)
|
|
var comment *string
|
|
if same {
|
|
want = ref[i].ID
|
|
if r.Comment != ref[i].Comment && ref[i].Comment != "" {
|
|
comment = model.Ptr(ref[i].Comment)
|
|
}
|
|
}
|
|
if r.SharedID == want && comment == nil {
|
|
continue
|
|
}
|
|
in := RecordInput{SharedID: model.Ptr(want), Comment: comment}
|
|
what := "local to view " + x.st.ViewName(m)
|
|
if same {
|
|
what = "shared"
|
|
}
|
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s %s: %s", r.Name, r.Type, r.Data, what)))
|
|
}
|
|
}
|
|
}
|
|
var views []string
|
|
for _, m := range members {
|
|
views = append(views, x.st.ViewName(m))
|
|
}
|
|
var zoneOps []model.Op
|
|
for _, m := range members {
|
|
if m.LinkGroup != group {
|
|
zoneOps = append(zoneOps, newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(group)},
|
|
fmt.Sprintf("link zone %s in views %s", zoneDesc(x.st, m), strings.Join(views, ", "))))
|
|
}
|
|
}
|
|
if len(local) > 0 {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("%d names differ between the views and stay local to each: %s. Share one from the view whose version is right (jarl record update ID --shared, or Share in the web UI).", len(local), strings.Join(local, "; ")))
|
|
}
|
|
x.warnings = append(x.warnings, fmt.Sprintf("%d names and types are shared between views %s", shared, strings.Join(views, ", ")))
|
|
return append(zoneOps, ops...), nil
|
|
}
|
|
|
|
// zoneUnlink takes a zone out of its link: its records stay as they are,
|
|
// all local. A link left with one zone is dissolved.
|
|
func (x *expander) zoneUnlink(req Request) ([]model.Op, error) {
|
|
var in LinkInput
|
|
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 z.LinkGroup == 0 {
|
|
return nil, errf(KindConflict, "zone %s isn't linked", zoneDesc(x.st, z))
|
|
}
|
|
out := []*model.Zone{z}
|
|
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
|
out = append(out, rest[0])
|
|
}
|
|
var ops []model.Op
|
|
for _, m := range out {
|
|
ops = append(ops, x.unlinkOps(m, "unlink zone "+zoneDesc(x.st, m))...)
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
// unlinkOps returns the operations that take zone m out of its link.
|
|
func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
|
|
ops := []model.Op{newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(int64(0))}, summary)}
|
|
for _, r := range x.st.ZoneRecords(m.ID) {
|
|
if r.SharedID != 0 {
|
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))}, fmt.Sprintf("%s %s %s: no longer shared", r.Name, r.Type, r.Data)))
|
|
}
|
|
}
|
|
return ops
|
|
}
|
|
|
|
// localFor works out whether a new record (or set) in zone z is local:
|
|
// as asked, or like the records of the same name and type already there.
|
|
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
|
|
has := x.st.HasLocal(z.ID, name, typ)
|
|
if want == nil {
|
|
return has, nil
|
|
}
|
|
return *want, nil
|
|
}
|
|
|
|
// shareAllOps returns the operations that start a link with zone z: it
|
|
// joins group and all its records become shared.
|
|
func (x *expander) shareAllOps(z *model.Zone, group int64) []model.Op {
|
|
ops := []model.Op{newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{LinkGroup: model.Ptr(group)}, "link zone "+zoneDesc(x.st, z))}
|
|
for _, r := range x.st.ZoneRecords(z.ID) {
|
|
if r.SharedID == 0 {
|
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(r.ID)}, fmt.Sprintf("%s %s %s: shared", r.Name, r.Type, r.Data)))
|
|
}
|
|
}
|
|
return ops
|
|
}
|
|
|
|
// dropOverrides returns the operations that delete the other linked
|
|
// zones' local records of a name and type, so the shared version reaches
|
|
// them (sharing a version makes it every view's).
|
|
func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
|
var ops []model.Op
|
|
for _, o := range x.st.LinkedWith(z) {
|
|
for _, r := range x.st.RRset(o.ID, name, typ) {
|
|
if r.SharedID == 0 {
|
|
if x.sharing == nil {
|
|
x.sharing = map[rrKey]bool{}
|
|
}
|
|
x.sharing[rrKey{o.ID, name, typ}] = true
|
|
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
|
|
}
|
|
}
|
|
}
|
|
return ops
|
|
}
|