Add DNSSEC signing, key backups and Porkbun support

Zones get a DNSSEC setting: named signs them with BIND's default (or a
custom) dnssec-policy and inline signing, with keys in a per-view key
directory. Jarl shows the keys, the DS record and when it is safe to
publish it, checks the parent's DS records (check-ds, which also tells
named), and refuses steps that would break a signed domain: switching
straight to unsigned, finishing a turn-off too early, deleting or moving
a signed zone, TTLs above max-zone-ttl. Turning off goes through the
insecure policy; wipe starts over once the parent has no DS.

Backups store the DNSSEC keys and restore puts them back for named; the
backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's
serial above named's whenever it writes an older one (after a restore).
The serials check compares the signed serial.

With a Porkbun API key in jarl.yaml, the registrar's name servers, glue
records and DS records can be shown and changed, each change checked
first (dry run, force to override).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 21:19:04 +00:00
1 parent 293e6ddd33
commit 5630e7a848
72 files changed
+5760 -153

No files matched your search

+587
View File
@@ -0,0 +1,587 @@
package server
// The registrar (docs/dnssec.md#porkbun): a domain's name servers, glue
// records and DNSSEC DS records at Porkbun, next to what the zone in Jarl
// says.
//
// GET /zones/{zone}/registrar
// POST /zones/{zone}/registrar/glue create or update glue records
// POST /zones/{zone}/registrar/ds publish the zone's DS record
// DELETE /zones/{zone}/registrar/ds/{keytag} remove a DS record
// PUT /zones/{zone}/registrar/ns set the domain's name servers
//
// All of them need an administrator: the API key can move the domain
// anywhere. Every change first checks that it won't break the domain
// (errors refuse it unless forced), and a dry run returns those checks
// without changing anything.
import (
"context"
"fmt"
"net/http"
"net/netip"
"sort"
"strconv"
"strings"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/dnsname"
"jarl/internal/dnssec"
"jarl/internal/model"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
)
// registrarFor names the registrar Jarl can manage z's delegation at:
// "porkbun" when a key is configured and allows the zone's name.
func (s *Server) registrarFor(z *model.Zone) string {
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
return ""
}
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
return ""
}
return "porkbun"
}
func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State, *model.Zone, string, error) {
if err := requireAdmin(p); err != nil {
return nil, nil, "", err
}
st, err := s.state(r)
if err != nil {
return nil, nil, "", err
}
z, err := s.zoneByPath(r, st, p)
if err != nil {
return nil, nil, "", err
}
if s.registrarFor(z) == "" {
if s.cfg.Porkbun == nil {
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
}
return nil, nil, "", badRequest("Jarl may not manage %s at Porkbun (only primary zones listed in porkbun.domains in jarl.yaml)", dnsname.Display(z.Name))
}
return st, z, dnsname.Display(z.Name), nil
}
func lowerNoDot(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
// zoneNS returns the zone's apex NS records, as lowercase names without
// trailing dots.
func zoneNS(st *model.State, z *model.Zone) []string {
var out []string
for _, rec := range st.ZoneRecords(z.ID) {
if rec.Type == "NS" && rec.Name == z.Name {
out = append(out, lowerNoDot(rec.Data))
}
}
sort.Strings(out)
return out
}
func sameSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
x := append([]string{}, a...)
y := append([]string{}, b...)
sort.Strings(x)
sort.Strings(y)
return strings.Join(x, " ") == strings.Join(y, " ")
}
// registrarState asks the registrar about the domain and compares that
// with the zone.
func (s *Server) registrarState(ctx context.Context, st *model.State, z *model.Zone, domain string, sig *dnssec.Status) *api.RegistrarResponse {
resp := &api.RegistrarResponse{Registrar: "porkbun", Domain: domain, NS: []string{}, JarlNS: zoneNS(st, z), Glue: []api.GlueStatus{}, DS: []api.RegistrarDS{}, Findings: []nscheck.Finding{}}
if resp.JarlNS == nil {
resp.JarlNS = []string{}
}
pb := s.cfg.Porkbun
ns, err := pb.NS(ctx, domain)
if err != nil {
resp.Error = err.Error()
return resp
}
glue, err := pb.Glue(ctx, domain)
if err != nil {
resp.Error = err.Error()
return resp
}
ds, err := pb.DS(ctx, domain)
if err != nil {
resp.Error = err.Error()
return resp
}
resp.NS = ns
resp.NSMatch = sameSet(ns, resp.JarlNS)
add := func(level, format string, args ...any) {
resp.Findings = append(resp.Findings, nscheck.Finding{Level: level, Message: fmt.Sprintf(format, args...)})
}
switch {
case resp.NSMatch:
add("ok", "Porkbun delegates %s to the zone's name servers: %s.", domain, strings.Join(ns, ", "))
case len(resp.JarlNS) == 0:
add("warning", "The zone has no NS records.")
default:
add("warning", "Porkbun delegates %s to %s, but the zone's NS records are %s.", domain, strings.Join(ns, ", "), strings.Join(resp.JarlNS, ", "))
}
// Glue: every host at the registry, and every name server inside the
// domain (which resolvers can only find through its glue).
byHost := map[string]*api.GlueStatus{}
var order []string
get := func(host string) *api.GlueStatus {
if g := byHost[host]; g != nil {
return g
}
g := &api.GlueStatus{Host: host, Registry: []string{}, Jarl: []string{}}
byHost[host] = g
order = append(order, host)
return g
}
for _, g := range glue {
get(g.Host).Registry = canonAddrs(g.Addresses())
}
for _, n := range resp.JarlNS {
if n == domain || !dnsname.IsSubdomain(dnsname.Fqdn(n), z.Name) {
continue
}
g := get(n)
g.Needed = true
g.Jarl = canonAddrs(jarlAddresses(st, z.ViewID, dnsname.Fqdn(n)))
switch {
case len(g.Registry) == 0:
g.Action = "create"
case !sameSet(g.Registry, g.Jarl) && len(g.Jarl) > 0:
g.Action = "update"
}
}
sort.Strings(order)
for _, h := range order {
g := byHost[h]
for _, n := range ns {
if n == h {
g.InUse = true
}
}
resp.Glue = append(resp.Glue, *g)
switch {
case g.Needed && len(g.Jarl) == 0:
add("error", "%s is one of the zone's name servers, inside the domain, but the zone has no A or AAAA record for it; add one, then sync the glue.", h)
case g.Action == "create":
add("warning", "%s has no glue record at Porkbun, so resolvers can't find it once the domain is delegated to it. Sync the glue (%s).", h, strings.Join(g.Jarl, ", "))
case g.Action == "update":
add("warning", "The glue for %s at Porkbun is %s, but the zone says %s. Sync the glue.", h, strings.Join(g.Registry, ", "), strings.Join(g.Jarl, ", "))
case g.Needed:
add("ok", "The glue for %s matches the zone (%s).", h, strings.Join(g.Jarl, ", "))
case !g.InUse:
add("info", "Porkbun has a glue record for %s (%s), which the zone's NS records don't use.", h, strings.Join(g.Registry, ", "))
}
}
// DS records.
live := publishedDS(sig)
var good, stale []string
for _, d := range ds {
rd := api.RegistrarDS{DS: d}
for _, k := range live {
if k.KeyTag == d.KeyTag && k.Algorithm == d.Algorithm && k.DigestType == d.DigestType && strings.EqualFold(k.Digest, d.Digest) {
rd.Matches = true
}
}
resp.DS = append(resp.DS, rd)
if rd.Matches {
good = append(good, strconv.Itoa(int(d.KeyTag)))
} else {
stale = append(stale, strconv.Itoa(int(d.KeyTag)))
}
}
signed := sig != nil && sig.Policy != "" && sig.Policy != model.DNSSECInsecure
switch {
case len(ds) == 0 && signed && sig.Phase.CanPublishDS:
add("info", "The zone is signed and ready: publish its DS record at Porkbun to turn validation on.")
case len(ds) == 0 && signed:
add("info", "The zone is signed; its DS record can be published once named says it's ready (DNSSEC tab).")
case len(ds) > 0 && (sig == nil || sig.Policy == ""):
add("error", "Porkbun has a DS record for %s (key %s), but the zone isn't signed: validating resolvers reject the domain. Remove the DS record.", domain, strings.Join(stale, ", "))
case len(ds) > 0 && len(good) == 0:
add("error", "Porkbun's DS record (key %s) matches none of the zone's keys: validating resolvers reject the domain. Replace it with the zone's DS record.", strings.Join(stale, ", "))
case len(ds) > 0 && sig.Policy == model.DNSSECInsecure:
add("warning", "DNSSEC is being turned off: remove the DS record (key %s) at Porkbun, then run the DS check.", strings.Join(good, ", "))
case len(good) > 0:
add("ok", "Porkbun's DS record matches the zone's key %s.", strings.Join(good, ", "))
if len(stale) > 0 {
add("warning", "Porkbun also has a DS record for key %s, which the zone doesn't have any more; remove it.", strings.Join(stale, ", "))
}
}
return resp
}
// publishedDS returns the DS records of the key-signing keys named
// publishes for the zone.
func publishedDS(sig *dnssec.Status) []dnssec.DS {
if sig == nil {
return nil
}
var out []dnssec.DS
for _, k := range sig.Keys {
if k.DS != nil && k.DNSKEYState != dnssec.Hidden {
out = append(out, *k.DS)
}
}
return out
}
func canonAddrs(addrs []string) []string {
out := []string{}
seen := map[string]bool{}
for _, a := range addrs {
if v, err := netip.ParseAddr(a); err == nil && !seen[v.String()] {
seen[v.String()] = true
out = append(out, v.String())
}
}
sort.Strings(out)
return out
}
func (s *Server) zoneRegistrar(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, z, domain, err := s.registrarZone(r, p)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
writeJSON(w, http.StatusOK, s.registrarState(ctx, st, z, domain, s.eng.DNSSEC(ctx, st, z)))
return nil
}
// changeCtx holds what a registrar change needs.
type changeCtx struct {
st *model.State
z *model.Zone
domain string
in api.RegistrarChange
sig *dnssec.Status
cur *api.RegistrarResponse
out api.RegistrarChangeResponse
}
func (c *changeCtx) check(level, format string, args ...any) {
c.out.Checks = append(c.out.Checks, nscheck.Finding{Level: level, Message: fmt.Sprintf(format, args...)})
}
// blocked returns the error that refuses the change when a check failed
// (unless forced).
func (c *changeCtx) blocked() error {
if c.in.Force || c.in.DryRun {
return nil
}
var errs []string
for _, f := range c.out.Checks {
if f.Level == "error" {
errs = append(errs, f.Message)
}
}
if len(errs) == 0 {
return nil
}
return conflict("not changed, because it could break the domain: %s (force overrides this check)", strings.Join(errs, " "))
}
func (s *Server) registrarChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, body bool, run func(ctx context.Context, c *changeCtx) error) error {
st, z, domain, err := s.registrarZone(r, p)
if err != nil {
return err
}
c := &changeCtx{st: st, z: z, domain: domain, out: api.RegistrarChangeResponse{Done: []string{}, Checks: []nscheck.Finding{}, Warnings: []string{}}}
if body {
if err := decodeOptional(r, &c.in); err != nil {
return err
}
}
if queryBool(r, "dry_run") {
c.in.DryRun = true
}
if queryBool(r, "force") {
c.in.Force = true
}
c.out.DryRun = c.in.DryRun
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
c.sig = s.eng.DNSSEC(ctx, st, z)
c.cur = s.registrarState(ctx, st, z, domain, c.sig)
if c.cur.Error != "" {
return &httpError{http.StatusBadGateway, "registrar_error", c.cur.Error}
}
if err := run(ctx, c); err != nil {
return err
}
writeJSON(w, http.StatusOK, c.out)
return nil
}
func (s *Server) audit(ctx context.Context, p *auth.Principal, c *changeCtx, action, msg string) {
s.eng.Audit(ctx, p, action, "zone", c.z.ID, dnsname.Display(c.z.Name), "success", msg)
}
// serving checks that the name servers answer authoritatively for the
// zone with the serial this server serves (for a signed zone, the signed
// serial: so they serve the signed zone).
func (s *Server) serving(ctx context.Context, c *changeCtx, targets []nscheck.Target, why string) {
ref, _, _ := s.localSerial(ctx, c.st, c.z)
for _, res := range s.checker().Serials(ctx, c.z.Name, ref, targets) {
where := dnsname.Display(res.Name)
if res.Address != "" {
where += " (" + res.Address + ")"
}
switch res.Status {
case "self", "in_sync":
case "answered":
c.check("warning", "%s answers for the zone, but its serial couldn't be compared with this server's.", where)
case "behind", "ahead":
c.check("error", "%s serves serial %d, not this server's %d; %s.", where, *res.Serial, *ref, why)
default:
msg := res.Status
if res.Error != "" {
msg += ": " + res.Error
}
c.check("error", "%s doesn't serve the zone (%s); %s.", where, msg, why)
}
}
}
// targetsFor returns check targets for name servers: in-domain ones at
// the given addresses, others looked up.
func targetsFor(names []string, addrs map[string][]string) []nscheck.Target {
var out []nscheck.Target
for _, n := range names {
out = append(out, nscheck.Target{Name: dnsname.Fqdn(n), Addresses: addrs[n], Source: "name server"})
}
return out
}
func (s *Server) registrarSyncGlue(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
var todo []api.GlueStatus
for _, g := range c.cur.Glue {
if !g.Needed {
continue
}
if len(g.Jarl) == 0 {
c.check("error", "%s has no A or AAAA record in the zone, so there is no address to give its glue.", g.Host)
continue
}
if g.Action == "" {
continue
}
todo = append(todo, g)
if g.Action == "update" && g.InUse {
// Resolvers go to the new addresses at once.
s.serving(ctx, c, []nscheck.Target{{Name: dnsname.Fqdn(g.Host), Addresses: g.Jarl, Source: "glue"}}, "the domain is delegated to "+g.Host+", so resolvers would go there")
}
}
if len(todo) == 0 && len(c.out.Checks) == 0 {
c.check("ok", "The glue records at Porkbun already match the zone.")
}
if err := c.blocked(); err != nil {
return err
}
for _, g := range todo {
what := fmt.Sprintf("%s glue for %s: %s", map[string]string{"create": "create", "update": "update"}[g.Action], g.Host, strings.Join(g.Jarl, ", "))
if !c.in.DryRun {
if err := s.cfg.Porkbun.SetGlue(ctx, c.domain, g.Host, g.Jarl, g.Action == "update"); err != nil {
return &httpError{http.StatusBadGateway, "registrar_error", fmt.Sprintf("%v (done before that: %s)", err, strings.Join(c.out.Done, "; "))}
}
s.audit(ctx, p, c, "registrar.glue", what)
}
c.out.Done = append(c.out.Done, what)
}
return nil
})
}
func (s *Server) registrarPublishDS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
sig := c.sig
if sig.Policy == "" || sig.Policy == model.DNSSECInsecure {
return conflict("the zone isn't signed (or DNSSEC is being turned off), so it has no DS record to publish")
}
var keys []*dnssec.Key
for _, k := range sig.Keys {
if k.DS != nil && k.Goal == dnssec.Omnipresent && (c.in.KeyTag == 0 || k.Tag == c.in.KeyTag) {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return conflict("no key to publish a DS record for (named hasn't made the zone's key yet, or there is no key %d)", c.in.KeyTag)
}
if !sig.Phase.CanPublishDS {
c.check("error", "Not yet: %s", sig.Phase.Next)
}
// The DS turns validation on: whatever the domain is delegated
// to must serve this signed zone.
if !c.cur.NSMatch {
c.check("error", "Porkbun delegates the domain to %s, not to the zone's name servers (%s): validating resolvers would reject the answers of servers that don't serve this signed zone. Set the name servers first.", strings.Join(c.cur.NS, ", "), strings.Join(c.cur.JarlNS, ", "))
} else {
glue := map[string][]string{}
for _, g := range c.cur.Glue {
glue[g.Host] = g.Registry
}
s.serving(ctx, c, targetsFor(c.cur.NS, glue), "validating resolvers would reject its answers once the DS is published")
}
if err := c.blocked(); err != nil {
return err
}
for _, k := range keys {
already := false
for _, d := range c.cur.DS {
if d.Matches && d.KeyTag == k.Tag {
already = true
}
}
if already {
c.out.Warnings = append(c.out.Warnings, fmt.Sprintf("Porkbun already has the DS record for key %d.", k.Tag))
continue
}
what := fmt.Sprintf("publish the DS record for key %d (algorithm %d, digest type %d)", k.Tag, k.DS.Algorithm, k.DS.DigestType)
if !c.in.DryRun {
warns, err := s.cfg.Porkbun.AddDS(ctx, c.domain, porkbun.DS{KeyTag: k.DS.KeyTag, Algorithm: k.DS.Algorithm, DigestType: k.DS.DigestType, Digest: k.DS.Digest})
if err != nil {
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
}
c.out.Warnings = append(c.out.Warnings, warns...)
s.audit(ctx, p, c, "registrar.ds.add", what)
}
c.out.Done = append(c.out.Done, what)
}
if len(c.out.Done) > 0 && !c.in.DryRun {
c.out.Warnings = append(c.out.Warnings, "The registry publishes it in the parent zone within minutes to hours; then run the DS check (jarl zone dnssec check-ds) so named knows.")
}
return nil
})
}
func (s *Server) registrarRemoveDS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
tag, err := strconv.ParseUint(r.PathValue("keytag"), 10, 16)
if err != nil {
return badRequest("invalid key tag %q", r.PathValue("keytag"))
}
return s.registrarChange(w, r, p, false, func(ctx context.Context, c *changeCtx) error {
var found *api.RegistrarDS
for i := range c.cur.DS {
if c.cur.DS[i].KeyTag == uint16(tag) {
found = &c.cur.DS[i]
}
}
if found == nil {
return notFound("Porkbun has no DS record for key %d", tag)
}
if found.Matches && c.sig.Policy != "" && c.sig.Policy != model.DNSSECInsecure {
c.check("warning", "This DS record points at the zone's key: without it, resolvers stop validating the domain (the zone stays signed). To turn DNSSEC off, start with 'Turn signing off' so named removes its signatures safely afterwards.")
}
c.check("info", "Porkbun notes that most registries delete every DS record with the same key data.")
what := fmt.Sprintf("remove the DS record for key %d", tag)
if !c.in.DryRun {
if err := s.cfg.Porkbun.DeleteDS(ctx, c.domain, uint16(tag)); err != nil {
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
}
s.audit(ctx, p, c, "registrar.ds.delete", what)
c.out.Warnings = append(c.out.Warnings, "Resolvers may keep the DS cached for its TTL (often a day). If DNSSEC is being turned off, run the DS check once the parent zone no longer has it.")
}
c.out.Done = append(c.out.Done, what)
return nil
})
}
func (s *Server) registrarSetNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
target := c.cur.JarlNS
if len(c.in.NS) > 0 {
target = nil
for _, n := range c.in.NS {
n = lowerNoDot(n)
if !dnsname.IsHostname(dnsname.Fqdn(n), false) {
return badRequest("%q isn't a host name", n)
}
target = append(target, n)
}
}
if len(target) == 0 {
return badRequest("no name servers to set: the zone has no NS records")
}
if sameSet(target, c.cur.NS) {
c.check("ok", "Porkbun already delegates the domain to %s.", strings.Join(target, ", "))
return nil
}
if len(target) < 2 {
c.check("warning", "Only one name server: when it can't be reached, the domain can't be resolved at all. Registries and RFC 1034 expect at least two (a secondary on another network).")
}
if !sameSet(target, c.cur.JarlNS) {
c.check("warning", "These aren't the zone's NS records (%s); resolvers prefer the zone's own list once they reach it.", strings.Join(c.cur.JarlNS, ", "))
}
// In-domain servers need glue at the registry.
addrs := map[string][]string{}
for _, n := range target {
if !dnsname.IsSubdomain(dnsname.Fqdn(n), c.z.Name) {
continue
}
var g *api.GlueStatus
for i := range c.cur.Glue {
if c.cur.Glue[i].Host == n {
g = &c.cur.Glue[i]
}
}
switch {
case g == nil || len(g.Registry) == 0:
c.check("error", "%s is inside the domain but has no glue record at Porkbun, so resolvers couldn't find it. Sync the glue first.", n)
case len(g.Jarl) > 0 && !sameSet(g.Registry, g.Jarl):
c.check("error", "The glue for %s at Porkbun (%s) differs from the zone (%s). Sync the glue first.", n, strings.Join(g.Registry, ", "), strings.Join(g.Jarl, ", "))
default:
addrs[n] = g.Registry
}
if addrs[n] == nil {
// Check the addresses the glue will have.
addrs[n] = canonAddrs(jarlAddresses(c.st, c.z.ViewID, dnsname.Fqdn(n)))
}
}
// Every new server must serve the zone...
s.serving(ctx, c, targetsFor(target, addrs), "resolvers would be sent there")
// ...and match the DS records at the registry, or the domain goes
// dark for validating resolvers.
if len(c.cur.DS) > 0 {
matches := false
for _, d := range c.cur.DS {
matches = matches || d.Matches
}
if !matches {
c.check("error", "Porkbun has DS records for the domain (key %s), and the zone's keys don't match them: validating resolvers would reject every answer from the new name servers. Remove the DS records first (or publish the zone's own).", dsTags(c.cur.DS))
}
}
if err := c.blocked(); err != nil {
return err
}
what := "set the name servers to " + strings.Join(target, ", ")
if !c.in.DryRun {
if err := s.cfg.Porkbun.SetNS(ctx, c.domain, target); err != nil {
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
}
s.audit(ctx, p, c, "registrar.ns", what+" (was "+strings.Join(c.cur.NS, ", ")+")")
c.out.Warnings = append(c.out.Warnings, "Resolvers may keep using the old name servers for the parent's NS TTL (often a day or two); keep them serving the zone until then.")
}
c.out.Done = append(c.out.Done, what)
return nil
})
}
func dsTags(ds []api.RegistrarDS) string {
var s []string
for _, d := range ds {
s = append(s, strconv.Itoa(int(d.KeyTag)))
}
return strings.Join(s, ", ")
}