Files
adamandClaude Opus 5.5 057433c198 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>
2026-10-12 15:53:58 +01:00

110 lines
3.1 KiB
Go

// Package acme turns "publish (or remove) this DNS-01 challenge value"
// into record changes. The jarl CLI (jarl acme) and jarld's httpreq
// endpoint for lego both use it, so they behave the same way.
package acme
import (
"encoding/json"
"strings"
"jarl/internal/api"
"jarl/internal/dnsname"
)
// DefaultTTL is the TTL of challenge records.
const DefaultTTL = 60
// Comment is stored with every challenge record.
const Comment = "ACME DNS-01 challenge"
// Zone is a primary zone that may receive challenge records.
type Zone struct {
Name string // as Jarl stores it, e.g. "adbraun.lol."
View string // "" when views are not used
// Link is the zone's link group (0: not linked). A record written
// into one zone of a link reaches the others (docs/views.md#linked-zones),
// so only one of them is written to.
Link int64
}
// Zones returns the zones that hold name: the longest matching zone, in
// every view that has it, or only in view when view is set. The record is
// written into every view so that the CA (usually in an external view) and
// internal resolvers see the same thing.
func Zones(name string, zones []Zone, view string) []Zone {
name = dnsname.Fqdn(name)
best := ""
var out []Zone
for _, z := range zones {
if view != "" && !strings.EqualFold(z.View, view) {
continue
}
zn := dnsname.Fqdn(z.Name)
if !dnsname.IsSubdomain(name, zn) {
continue
}
switch {
case len(zn) > len(best):
best, out = zn, []Zone{z}
case zn == best:
out = append(out, z)
}
}
// One zone per link: Jarl writes the shared record into the others.
var dedup []Zone
links := map[int64]bool{}
for _, z := range out {
if z.Link != 0 {
if links[z.Link] {
continue
}
links[z.Link] = true
}
dedup = append(dedup, z)
}
return dedup
}
// Requests returns the changes that set (or, with set false, remove) the
// TXT values at name in each zone. Setting first deletes the same value, so
// a retried hook succeeds instead of failing on a duplicate record, and
// removing a value that isn't there succeeds too. Removing with no values
// removes every TXT record at name.
func Requests(name string, zones []Zone, values []string, set bool, ttl uint32) []api.ChangeRequest {
name = dnsname.Fqdn(name)
var reqs []api.ChangeRequest
add := func(op string, z Zone, extra map[string]any) {
m := map[string]any{"zone": z.Name, "name": name, "type": "TXT"}
if z.View != "" {
m["view"] = z.View
}
for k, v := range extra {
m[k] = v
}
b, _ := json.Marshal(m)
reqs = append(reqs, api.ChangeRequest{Op: op, Data: b})
}
for _, z := range zones {
if !set && len(values) == 0 {
add("record.delete", z, map[string]any{"missing_ok": true})
continue
}
for _, v := range values {
add("record.delete", z, map[string]any{"data": v, "missing_ok": true})
if set {
add("record.create", z, map[string]any{"data": v, "ttl": ttl, "comment": Comment})
}
}
}
return reqs
}
// Description is the changeset description for a change at name.
func Description(name string, set bool) string {
verb := "remove"
if set {
verb = "set"
}
return "ACME: " + verb + " challenge for " + dnsname.Display(dnsname.Fqdn(name))
}