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>
147 lines
3.9 KiB
Go
147 lines
3.9 KiB
Go
package validate
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// checkLinked checks linked zones (docs/views.md#linked-zones): what can
|
|
// be linked, and that their shared records are in step. Jarl keeps them
|
|
// in step itself; they can only drift when a change was staged before the
|
|
// zones were linked (or by editing the database).
|
|
func (c *checker) checkLinked() {
|
|
groups := map[int64][]*model.Zone{}
|
|
for _, z := range c.s.SortedZones() {
|
|
if z.LinkGroup != 0 {
|
|
groups[z.LinkGroup] = append(groups[z.LinkGroup], z)
|
|
}
|
|
}
|
|
for _, g := range sortedGroupIDs(groups) {
|
|
zs := c.s.LinkMembers(g)
|
|
label := func(z *model.Zone) string {
|
|
if v := c.s.ViewName(z); v != "" {
|
|
return dnsname.Display(z.Name) + " [" + v + "]"
|
|
}
|
|
return dnsname.Display(z.Name)
|
|
}
|
|
bad := false
|
|
for _, z := range zs {
|
|
switch {
|
|
case z.Type != model.ZonePrimary:
|
|
c.errf("linked_zone_type", "zone", z.ID, "zone %s is linked, but only primary zones can be: unlink it", label(z))
|
|
bad = true
|
|
case z.Name != zs[0].Name:
|
|
c.errf("linked_zone_name", "zone", z.ID, "zone %s is linked with %s; only zones of the same name can be linked", label(z), label(zs[0]))
|
|
bad = true
|
|
}
|
|
}
|
|
if len(zs) == 1 {
|
|
c.infof("linked_zone_alone", "zone", zs[0].ID, "zone %s is linked with no other zone; unlink it (jarl zone unlink)", label(zs[0]))
|
|
continue
|
|
}
|
|
if bad {
|
|
continue
|
|
}
|
|
// Shared and local records of one name and type can't mix in a zone.
|
|
type key struct{ name, typ string }
|
|
keys := map[key]bool{}
|
|
var order []key
|
|
for _, z := range zs {
|
|
for _, r := range c.s.ZoneRecords(z.ID) {
|
|
k := key{r.Name, r.Type}
|
|
if !keys[k] {
|
|
keys[k] = true
|
|
order = append(order, k)
|
|
}
|
|
}
|
|
}
|
|
for _, k := range order {
|
|
var truth []*model.Record
|
|
var truthZone *model.Zone
|
|
for _, z := range zs {
|
|
rs := c.s.RRset(z.ID, k.name, k.typ)
|
|
local, shared := 0, 0
|
|
for _, r := range rs {
|
|
if r.SharedID == 0 {
|
|
local++
|
|
} else {
|
|
shared++
|
|
}
|
|
}
|
|
if local > 0 && shared > 0 {
|
|
c.errf("linked_mixed", "zone", z.ID, "%s %s in zone %s has both shared and local records; make the set one or the other", dnsname.Display(k.name), k.typ, label(z)).Zone = z.Name
|
|
continue
|
|
}
|
|
if local > 0 {
|
|
continue // an override
|
|
}
|
|
got := c.s.SharedRecords(z.ID, k.name, k.typ)
|
|
if truthZone == nil {
|
|
truth, truthZone = got, z
|
|
continue
|
|
}
|
|
if diff := sharedDiff(truth, got); diff != "" {
|
|
p := c.errf("linked_out_of_sync", "zone", z.ID, "linked zones %s and %s differ at %s %s (%s). Jarl keeps them in step, so a change was probably staged before they were linked: link them again (jarl zone link) to work out what is shared", label(truthZone), label(z), dnsname.Display(k.name), k.typ, diff)
|
|
p.Zone = z.Name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func sortedGroupIDs(m map[int64][]*model.Zone) []int64 {
|
|
var out []int64
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
for i := range out {
|
|
for j := i + 1; j < len(out); j++ {
|
|
if out[j] < out[i] {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sharedDiff says how two zones' copies of a shared set differ ("" when
|
|
// they don't).
|
|
func sharedDiff(a, b []*model.Record) string {
|
|
idx := map[int64]*model.Record{}
|
|
for _, r := range b {
|
|
idx[r.SharedID] = r
|
|
}
|
|
var out []string
|
|
for _, r := range a {
|
|
o := idx[r.SharedID]
|
|
delete(idx, r.SharedID)
|
|
switch {
|
|
case o == nil:
|
|
out = append(out, "missing "+r.Data)
|
|
case o.Data != r.Data:
|
|
out = append(out, r.Data+" vs "+o.Data)
|
|
case !sameTTLPtr(o.TTL, r.TTL):
|
|
out = append(out, "TTL of "+r.Data)
|
|
case o.Disabled != r.Disabled:
|
|
out = append(out, "disabled "+r.Data)
|
|
}
|
|
}
|
|
for _, r := range idx {
|
|
out = append(out, "extra "+r.Data)
|
|
}
|
|
if len(out) > 3 {
|
|
out = append(out[:3], fmt.Sprintf("and %d more", len(out)-3))
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
func sameTTLPtr(a, b *uint32) bool {
|
|
if a == nil || b == nil {
|
|
return a == nil && b == nil
|
|
}
|
|
return *a == *b
|
|
}
|