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. // A new name and type is shared, unless the principal may not change it // in every linked view (an ACME token for the external view only): then // it is local to z's view, which is all they may change. func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) { if want != nil { return *want, nil } if x.st.HasLocal(z.ID, name, typ) { return true, nil } if len(x.st.SharedRecords(z.ID, name, typ)) > 0 { return false, nil } var not []string for _, o := range x.st.LinkedWith(z) { if !x.p.Can(recordAction(x.st, o, name, typ, true)) { not = append(not, x.st.ViewName(o)) } } if len(not) > 0 { x.warnings = append(x.warnings, fmt.Sprintf("%s %s is local to view %s: you may not change it in %s", dnsname.Display(name), typ, x.st.ViewName(z), strings.Join(not, ", "))) return true, nil } return false, 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 }