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

+1 -1
View File
@@ -158,7 +158,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
for _, z := range st.SortedZones() {
vn := st.ViewName(z)
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
out = append(out, acme.Zone{Name: z.Name, View: vn})
out = append(out, acme.Zone{Name: z.Name, View: vn, Link: z.LinkGroup})
}
}
return out
+33 -1
View File
@@ -157,6 +157,37 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request, p *auth.Prin
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
}
// zoneLink: POST /zones/{zone}/link {"with": ["external"]} links the zone
// with the zones of the same name in those views (default: every view
// that has one). zoneUnlink: POST /zones/{zone}/unlink.
func (s *Server) zoneLink(link bool) handler {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
z, err := s.zoneByPath(r, st, p)
if err != nil {
return err
}
in := engine.LinkInput{ZoneID: z.ID}
op := engine.OpZoneUnlink
if link {
op = engine.OpZoneLink
var body struct {
With []string `json:"with"`
}
if r.ContentLength != 0 {
if err := decodeJSON(r, &body); err != nil {
return err
}
}
in.With = body.With
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
}
}
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
@@ -462,11 +493,12 @@ func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Princi
TTL model.Opt[uint32] `json:"ttl,omitzero"`
Data []string `json:"data"`
Comment *string `json:"comment,omitempty"`
Local *bool `json:"local,omitempty"`
}
if err := decodeJSON(r, &in); err != nil {
return err
}
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment})
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment, Local: in.Local})
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
z := st.Zones[zid]
+9
View File
@@ -22,6 +22,9 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
}
out.Registrar = s.registrarFor(z)
for _, o := range st.LinkedWith(z) {
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
}
return out
}
@@ -30,6 +33,12 @@ func presentRecord(st *model.State, r *model.Record) api.Record {
out := api.Record{Record: *r}
if z != nil {
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
if z.LinkGroup != 0 {
out.Linked = "local"
if r.SharedID != 0 {
out.Linked = "shared"
}
}
if rr.IsTextType(r.Type) {
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
if t, ok := rr.TextValue(p.RR); ok {
+2
View File
@@ -111,6 +111,8 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))