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>
110 lines
3.1 KiB
Go
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))
|
|
}
|