Linked zones: shared records kept in step across views

Zones of the same name in different views can be linked. Shared records
are kept in step: a change in one view reaches the others in the same
changeset, as explicit operations, so the diff, permission checks,
history and undo cover every view. Records local to a view override the
shared records of that name and type there; removing the override brings
the shared ones back, and sharing a view's version makes it every view's.
Each zone keeps its own options, serial and file.

Linking existing zones changes no answer: what is the same everywhere
becomes shared, the rest stays local. Unlinking keeps every record, all
local. ACME challenges are written once per link, and --view all changes
a link once. jarl zone link/unlink, zone create --link-with, record
--local/--shared, LINK column, web UI controls, POST /zones/{zone}/link
and /unlink, validation checks linked_*, store migration 11, docs and
e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 15:53:58 +01:00
1 parent 6313fa51f8
commit 057433c198
31 files changed
+1729 -55

No files matched your search

+2 -2
View File
@@ -66,7 +66,7 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Zones[k]
s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)}
if n.Type == model.ZonePrimary {
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
}
@@ -94,7 +94,7 @@ func DiffOps(from, to *model.State) []model.Op {
}
for _, k := range sortedKeys(to.Records) {
n := to.Records[k]
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled, SharedID: model.Ptr(n.SharedID)}
if n.TTL != nil {
in.TTL = model.Some(*n.TTL)
} else {
+157 -9
View File
@@ -131,7 +131,24 @@ func dateSerial(now time.Time) uint32 {
}
func (x *expander) expand(req Request) ([]model.Op, error) {
ops, err := x.expandOne(req)
if err != nil {
return nil, err
}
switch req.Op {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
// Changes to linked zones reach the other zones of the link.
return x.linkSync(ops)
}
return ops, nil
}
func (x *expander) expandOne(req Request) ([]model.Op, error) {
switch req.Op {
case OpZoneLink:
return x.zoneLink(req)
case OpZoneUnlink:
return x.zoneUnlink(req)
case OpZoneCreate:
return x.zoneCreate(req)
case OpZoneUpdate, OpZoneDelete:
@@ -152,7 +169,24 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
}
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
}
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
ops := []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}
if req.Op == OpZoneDelete {
if rest := x.st.LinkedWith(z); len(rest) == 1 {
ops = append(ops, x.unlinkOps(rest[0], "unlink zone "+zoneDesc(x.st, rest[0])+": the zone it was linked with is deleted")...)
}
} else {
var in ZoneInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.LinkGroup != nil && *in.LinkGroup != z.LinkGroup {
return nil, errf(KindBadRequest, "link zones with zone.link and zone.unlink (jarl zone link / unlink)")
}
if in.LinkWith != "" {
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
}
}
return ops, nil
case OpRecordCreate:
return x.recordCreate(req)
case OpRecordUpdate:
@@ -355,7 +389,25 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
records := in.Records
cloneFrom := in.CloneFrom
zoneOpts := in.Options
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
linkWith := in.LinkWith
in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil
if linkWith != "" {
if cloneFrom != 0 {
return nil, errf(KindBadRequest, "give clone_from or link_with, not both")
}
v := x.st.ViewByName(linkWith)
if v == nil {
return nil, errf(KindNotFound, "view %q does not exist", linkWith)
}
o := x.st.FindZone(name, v.ID)
if o == nil {
return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name))
}
if o.Type != model.ZonePrimary || zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "only primary zones can be linked")
}
cloneFrom = o.ID
}
set := x.st.Settings
var src *model.Zone
@@ -480,9 +532,30 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
return nil, err
}
}
var linkOps []model.Op
if src != nil && linkWith != "" {
// The new zone joins src's link, or starts one with src: then
// all of src's records become shared.
group := src.LinkGroup
if group == 0 {
group = src.ID
linkOps = x.shareAllOps(src, group)
}
in.LinkGroup = &group
}
if src != nil {
for _, r := range x.st.ZoneRecords(src.ID) {
if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 {
continue // local to src's view
}
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
if linkWith != "" {
id := r.SharedID
if id == 0 {
id = r.ID
}
ri.SharedID = &id
}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
@@ -512,10 +585,12 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
summary += " in view " + v.Name
}
}
if src != nil {
if src != nil && linkWith != "" {
summary += " (linked with " + zoneDesc(x.st, src) + ")"
} else if src != nil {
summary += " (copy of " + zoneDesc(x.st, src) + ")"
}
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
ops := append(linkOps, 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 != "" {
@@ -666,7 +741,27 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
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))}
summary := fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data)
in.SharedID = nil
if z.LinkGroup != 0 {
local, err := x.localFor(z, owner, p.Type, in.Local)
if err != nil {
return nil, err
}
if local {
in.SharedID = model.Ptr(int64(0))
summary += " (local to view " + x.st.ViewName(z) + ")"
} else {
if x.st.HasLocal(z.ID, owner, p.Type) {
return nil, errf(KindConflict, "%s %s is local in view %s; share the set first (jarl record update ID --shared) or add this one as local", dnsname.Display(owner), p.Type, x.st.ViewName(z))
}
in.SharedID = &ids[0]
}
} else if in.Local != nil && *in.Local {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
}
in.Local = nil
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
if ptr {
more, err := x.ptrCreate(z, owner, p)
if err != nil {
@@ -690,6 +785,35 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
ptr := in.PTR
in.PTR = false
in.Zone, in.View = "", ""
if in.SharedID != nil {
return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared")
}
var pre []model.Op
if in.Local != nil {
if z.LinkGroup == 0 {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
}
if want := *in.Local; want != (r.SharedID == 0) {
// The whole set of this name and type changes sides.
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
id := int64(0)
what := fmt.Sprintf("make %s %s local to view %s", o.Name, o.Type, x.st.ViewName(z))
if !want {
id = o.ID
what = fmt.Sprintf("share %s %s %s with the linked views", o.Name, o.Type, o.Data)
}
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
}
if !want {
// Sharing a version makes it every view's.
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
}
}
in.Local = nil
if string(mustJSON(in)) == "{}" {
return pre, nil
}
}
name, typ, data := r.Name, r.Type, r.Data
if in.Name != nil {
name = *in.Name
@@ -751,11 +875,17 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
case len(changes) > 0:
summary += ": " + strings.Join(changes, ", ")
}
if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) {
return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z))
}
base := req.BaseVersion
if base == 0 {
base = r.Version
}
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
if len(pre) > 0 {
base = 0 // the record was just updated by pre
}
ops := append(pre, newOp(OpRecordUpdate, r.ID, base, in, summary))
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
@@ -953,17 +1083,35 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
canon = append(canon, p.Data)
}
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
where := ""
var pre []model.Op
if z.LinkGroup != 0 {
local, err := x.localFor(z, owner, typ, in.Local)
if err != nil {
return nil, err
}
in.Shared = model.Ptr(!local)
if local {
where = " (local to view " + x.st.ViewName(z) + ")"
} else if in.Local != nil {
// Explicitly shared: this version becomes every view's.
pre = x.dropOverrides(z, owner, typ)
}
} else if in.Local != nil && *in.Local {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
}
in.Local = nil
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, ", "))
summary := fmt.Sprintf("set %s%s IN %s to [%s]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where)
if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
}
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
}
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
+35
View File
@@ -55,6 +55,10 @@ const (
// back, become view.update or settings.update operations.
OpExposureAck = "exposure.ack"
OpExposureUnack = "exposure.unack"
// Linked zones (docs/views.md#linked-zones): requests that expand
// into zone and record updates.
OpZoneLink = "zone.link"
OpZoneUnlink = "zone.unlink"
)
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
@@ -131,6 +135,13 @@ type ZoneInput struct {
Comment *string `json:"comment,omitempty"`
// Disabled keeps the zone in Jarl but out of named's configuration.
Disabled *bool `json:"disabled,omitempty"`
// LinkGroup is the zone's link group (0: not linked). Set by zone.link
// and zone.unlink; requests use those.
LinkGroup *int64 `json:"link_group,omitempty"`
// LinkWith (create only) links the new zone with the zone of the same
// name in this view, copying its shared records (or all of its
// records, when it isn't linked yet).
LinkWith string `json:"link_with,omitempty"`
// Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to
@@ -167,6 +178,25 @@ type RecordInput struct {
PTR bool `json:"ptr,omitempty"`
// MissingOK makes a delete-by-match succeed when nothing matches.
MissingOK bool `json:"missing_ok,omitempty"`
// Local (requests, linked zones): true keeps the record in this view
// only (an override: the whole name and type becomes local here);
// false shares it with the linked zones. Unset: like the records of
// the same name and type, shared when there are none.
Local *bool `json:"local,omitempty"`
// SharedID (stored operations) ties the copies of a shared record
// together; 0 makes it local.
SharedID *int64 `json:"shared_id,omitempty"`
}
// LinkInput links zones of the same name in several views, or (unlink)
// takes one out of its link.
type LinkInput struct {
ZoneID int64 `json:"zone_id,omitempty"`
Zone string `json:"zone,omitempty"`
View string `json:"view,omitempty"`
// With are the views whose zone of the same name to link with (link
// only; default: every view that has one).
With []string `json:"with,omitempty"`
}
// RRSetInput replaces all records of one name and type.
@@ -181,6 +211,11 @@ type RRSetInput struct {
Comment *string `json:"comment,omitempty"`
// NewIDs are allocated for records that need to be created.
NewIDs []int64 `json:"new_ids,omitempty"`
// Local (requests): like RecordInput.Local, for the whole set.
Local *bool `json:"local,omitempty"`
// Shared (stored operations, linked zones): the records are shared
// (true) or local (false).
Shared *bool `json:"shared,omitempty"`
}
// ViewInput creates or updates a view.
+476
View File
@@ -0,0 +1,476 @@
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 gets the shared records back from
// a zone that has them.
// - 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) {
// The override was removed: back to the shared records.
for _, o := range members {
if st.HasLocal(o.ID, name, typ) {
continue
}
src := st.SharedRecords(o.ID, name, typ)
if len(src) == 0 {
continue
}
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
}
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 {
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
}
+287
View File
@@ -0,0 +1,287 @@
package engine
import (
"slices"
"strings"
"testing"
"jarl/internal/auth"
"jarl/internal/model"
"jarl/internal/validate"
)
// linkedEnv has views internal and external and zone example.com in
// internal with a few records.
func linkedEnv(t *testing.T) *testEnv {
env := newEnv(t)
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
env.submit(env.admin,
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
env.submit(env.admin,
recIn("internal", "www", "A", "192.0.2.10"),
recIn("internal", "mail", "A", "192.0.2.25"),
recIn("internal", "@", "MX", "10 mail"))
return env
}
func recIn(view, name, typ, data string) Request {
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: view, Name: &name, Type: &typ, Data: &data})
}
// view returns a view's records of example.com as "name type data S|L",
// S for shared, L for local.
func (env *testEnv) view(name string) []string {
env.t.Helper()
s := env.state()
v := s.ViewByName(name)
z := s.FindZone("example.com.", v.ID)
var out []string
for _, r := range s.ZoneRecords(z.ID) {
if r.Type == "NS" {
continue
}
mark := "L"
if r.SharedID != 0 {
mark = "S"
}
if r.Disabled {
mark += " disabled"
}
out = append(out, strings.TrimSuffix(r.Name, ".example.com.")+" "+r.Type+" "+r.Data+" "+mark)
}
return out
}
func (env *testEnv) recordID(view, name, typ string) int64 {
env.t.Helper()
s := env.state()
z := s.FindZone("example.com.", s.ViewByName(view).ID)
for _, r := range s.ZoneRecords(z.ID) {
if r.Name == name+".example.com." && r.Type == typ {
return r.ID
}
}
env.t.Fatalf("no %s %s in %s", name, typ, view)
return 0
}
func want(t *testing.T, what string, got []string, w ...string) {
t.Helper()
if !slices.Equal(got, w) {
t.Errorf("%s:\n got %q\n want %q", what, got, w)
}
}
func TestLinkedZones(t *testing.T) {
env := linkedEnv(t)
// A zone created linked copies everything; both are linked and shared.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
all := []string{"mail A 192.0.2.25 S", "www A 192.0.2.10 S", "example.com. MX 10 mail.example.com. S"}
slices.Sort(all)
got := env.view("external")
slices.Sort(got)
want(t, "linked copy", got, all...)
s := env.state()
if zs := s.ZonesNamed("example.com."); zs[0].LinkGroup == 0 || zs[0].LinkGroup != zs[1].LinkGroup {
t.Fatalf("not linked: %+v %+v", zs[0], zs[1])
}
// Adding, changing, disabling and deleting a shared record reaches the other view.
env.submit(env.admin, recIn("external", "ftp", "CNAME", "www"))
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S") {
t.Errorf("shared add didn't reach internal: %q", env.view("internal"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("192.0.2.26")}))
if !slices.Contains(env.view("external"), "mail A 192.0.2.26 S") {
t.Errorf("shared change didn't reach external: %q", env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordDisable, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S disabled") {
t.Errorf("disable didn't reach internal: %q", env.view("internal"))
}
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
for _, v := range []string{"internal", "external"} {
for _, r := range env.view(v) {
if strings.HasPrefix(r, "ftp ") {
t.Errorf("delete by match left %s in %s", r, v)
}
}
}
// A local override: www in internal points elsewhere; external keeps
// the shared one.
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.10"), Local: model.Ptr(true)}))
if !slices.Contains(env.view("internal"), "www A 10.0.0.10 L") || slices.Contains(env.view("internal"), "www A 192.0.2.10 S") {
t.Errorf("override: internal %q", env.view("internal"))
}
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
t.Errorf("override changed external: %q", env.view("external"))
}
_ = res
// A new record of an overridden set joins it as local.
env.submit(env.admin, recIn("internal", "www", "A", "10.0.0.11"))
if !slices.Contains(env.view("internal"), "www A 10.0.0.11 L") || len(env.view("external")) != 3 {
t.Errorf("join override: internal %q external %q", env.view("internal"), env.view("external"))
}
// Changing the shared www in external doesn't touch internal's override.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"192.0.2.11"}}))
if !slices.Contains(env.view("external"), "www A 192.0.2.11 S") || slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external"))
}
// Removing the override brings the shared records back.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
t.Errorf("override removed: internal %q", env.view("internal"))
}
// Make a shared set local, then share another view's version.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(true)}))
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.25")}))
if !slices.Contains(env.view("external"), "mail A 198.51.100.25 L") || !slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
t.Errorf("local edit: internal %q external %q", env.view("internal"), env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(false)}))
if !slices.Contains(env.view("internal"), "mail A 198.51.100.25 S") || slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
t.Errorf("share external's version: internal %q", env.view("internal"))
}
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
t.Errorf("validation: %+v", probs)
}
// Undoing a changeset undoes it in both views.
r := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.99")}))
cs, err := env.e.Revert(env.ctx, r.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err != nil {
t.Fatal(err)
}
for _, v := range []string{"internal", "external"} {
if !slices.Contains(env.view(v), "mail A 198.51.100.25 S") {
t.Errorf("revert in %s: %q", v, env.view(v))
}
}
// Unlinking keeps every record, all local.
env.submit(env.admin, NewRequest(OpZoneUnlink, 0, LinkInput{Zone: "example.com", View: "external"}))
for _, z := range env.state().ZonesNamed("example.com.") {
if z.LinkGroup != 0 {
t.Errorf("still linked: %s", zoneDesc(env.state(), z))
}
}
for _, v := range []string{"internal", "external"} {
for _, r := range env.view(v) {
if strings.HasSuffix(r, " S") {
t.Errorf("still shared after unlink in %s: %s", v, r)
}
}
}
env.submit(env.admin, recIn("internal", "solo", "A", "10.0.0.5"))
if slices.Contains(env.view("external"), "solo A 10.0.0.5 L") {
t.Error("an unlinked zone was changed")
}
// Linking again works out what is shared: same in both, or not.
env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "internal"}))
in, ex := env.view("internal"), env.view("external")
if !slices.Contains(in, "mail A 198.51.100.25 S") || !slices.Contains(ex, "mail A 198.51.100.25 S") {
t.Errorf("relink: same records not shared: internal %q external %q", in, ex)
}
if !slices.Contains(in, "solo A 10.0.0.5 L") {
t.Errorf("relink: internal-only record should be local: %q", in)
}
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
t.Errorf("validation after relink: %+v", probs)
}
// Deleting one zone of two dissolves the link.
z := env.state().FindZone("example.com.", env.state().ViewByName("external").ID)
env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
if zs := env.state().ZonesNamed("example.com."); len(zs) != 1 || zs[0].LinkGroup != 0 {
t.Errorf("after deleting external: %+v", zs)
}
for _, r := range env.view("internal") {
if strings.HasSuffix(r, " S") {
t.Errorf("shared record left in an unlinked zone: %s", r)
}
}
}
func hasCode(probs []validate.Problem, code string) bool {
for _, p := range probs {
if p.Code == code {
return true
}
}
return false
}
func TestLinkedPermissions(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
u := &model.User{Username: "lan", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Views: []string{"internal"}}, {Resource: auth.ResZones, Access: "read"}}}
if err := env.st.CreateUser(env.ctx, u); err != nil {
t.Fatal(err)
}
lan := &auth.Principal{User: u, Method: "token"}
// A shared change would reach external, which this user may not change.
e := env.submitErr(lan, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Data: model.Ptr("10.9.9.9")}))
if e.Kind != KindForbidden || !strings.Contains(e.Message, "shared with example.com [external]") || !strings.Contains(e.Message, "local") {
t.Errorf("shared change: %v", e)
}
// A local override is theirs to make.
env.submit(lan, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.9.9.9"), Local: model.Ptr(true)}))
if !slices.Contains(env.view("internal"), "www A 10.9.9.9 L") || !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
t.Errorf("override by a view-restricted user: internal %q external %q", env.view("internal"), env.view("external"))
}
// Records of a zone that isn't linked can't be "local".
e = env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.org"), View: model.Ptr("internal"), LinkWith: "external"}))
if e.Kind != KindNotFound {
t.Errorf("link_with a view without the zone: %v", e)
}
}
func TestLinkExisting(t *testing.T) {
env := linkedEnv(t)
// external was made by hand: the same apart from www.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external")}))
env.submit(env.admin,
recIn("external", "www", "A", "203.0.113.10"),
recIn("external", "mail", "A", "192.0.2.25"),
recIn("external", "@", "MX", "10 mail"))
before := []string{}
for _, v := range []string{"internal", "external"} {
before = append(before, strings.Join(env.view(v), ";"))
}
res := env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "external", With: []string{"internal"}}))
in, ex := env.view("internal"), env.view("external")
if !slices.Contains(in, "www A 192.0.2.10 L") || !slices.Contains(ex, "www A 203.0.113.10 L") {
t.Errorf("different www should stay local: internal %q external %q", in, ex)
}
if !slices.Contains(in, "mail A 192.0.2.25 S") || !slices.Contains(ex, "mail A 192.0.2.25 S") {
t.Errorf("same mail should be shared: internal %q external %q", in, ex)
}
if !strings.Contains(strings.Join(res.Warnings, " "), "www.example.com A (internal, external)") {
t.Errorf("warnings: %q", res.Warnings)
}
// Linking never changes what named answers: the same data as before.
for i, v := range []string{"internal", "external"} {
var data []string
for _, r := range env.view(v) {
data = append(data, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
}
var was []string
for _, r := range strings.Split(before[i], ";") {
was = append(was, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
}
want(t, "answers in "+v, data, was...)
}
// Sharing internal's www replaces external's.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
t.Errorf("shared from internal: external %q", env.view("external"))
}
}
+32 -2
View File
@@ -265,6 +265,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.Comment != nil {
z.Comment = *in.Comment
}
if in.LinkGroup != nil {
z.LinkGroup = *in.LinkGroup
}
return nil
}
@@ -447,6 +450,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
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)
}
@@ -526,6 +532,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
if in.Disabled != nil {
r.Disabled = *in.Disabled
}
if in.SharedID != nil {
r.SharedID = *in.SharedID
}
r.Touch(c.user(), c.now)
return nil
}
@@ -662,16 +671,34 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
k := strings.ToLower(r.Data)
if seen[k] && !keep[k] {
keep[k] = true
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled {
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 = false
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)
@@ -692,6 +719,9 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
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
}