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>
205 lines
6.7 KiB
Go
205 lines
6.7 KiB
Go
package server
|
|
|
|
// DNSSEC signing (docs/dnssec.md): GET /zones/{zone}/dnssec,
|
|
// POST /zones/{zone}/dnssec/check-ds and POST /zones/{zone}/dnssec/wipe.
|
|
// Signing itself is switched with the zone's dnssec_policy field
|
|
// (PATCH /zones/{zone}), which goes through a changeset like any change.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/auth"
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/dnssec"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/model"
|
|
"jarl/internal/nscheck"
|
|
)
|
|
|
|
func (s *Server) zoneDNSSEC(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
|
|
}
|
|
if z.Type != model.ZonePrimary {
|
|
return badRequest("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
|
|
}
|
|
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
|
|
if pz := parentInJarl(st, z); pz != nil {
|
|
resp.ParentZone = pz.Name
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// parentInJarl returns the primary zone in z's view that z is delegated
|
|
// from, if Jarl has it.
|
|
func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
|
|
var best *model.Zone
|
|
for _, o := range st.ZonesInView(z.ViewID) {
|
|
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
|
|
continue
|
|
}
|
|
if best == nil || len(o.Name) > len(best.Name) {
|
|
best = o
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
|
|
func (s *Server) dsChecker(r *http.Request) (*nscheck.Checker, error) {
|
|
c := s.checker()
|
|
if v := r.URL.Query().Get("resolver"); v != "" {
|
|
a, err := netip.ParseAddr(v)
|
|
if err != nil {
|
|
return nil, badRequest("resolver must be an IP address, e.g. 1.1.1.1 or 9.9.9.9")
|
|
}
|
|
c.Resolver = net.JoinHostPort(a.String(), "53")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (s *Server) zoneCheckDS(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
|
|
}
|
|
if err := require(p, auth.Action{Resource: auth.ResZones, Write: true, Zone: z.Name, View: st.ViewName(z)}); err != nil {
|
|
return err
|
|
}
|
|
if z.Type != model.ZonePrimary {
|
|
return badRequest("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
|
|
}
|
|
c, err := s.dsChecker(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
resp := api.CheckDSResponse{Parent: c.ParentDS(ctx, z.Name), Marked: []string{}, Notes: []string{}}
|
|
status := s.eng.DNSSEC(ctx, st, z)
|
|
if resp.Parent.Error == "" {
|
|
pub, wd := dsChanges(z, status.Keys, resp.Parent)
|
|
if len(pub)+len(wd) > 0 {
|
|
marked, err := s.eng.MarkDS(ctx, st, z, p, pub, wd)
|
|
resp.Marked = append(resp.Marked, marked...)
|
|
if err != nil {
|
|
resp.Notes = append(resp.Notes, "telling named failed: "+err.Error())
|
|
}
|
|
}
|
|
if !resp.Parent.Consistent {
|
|
resp.Notes = append(resp.Notes, "The parent's servers don't all have the same DS records yet; named is only told once they agree. Check again in a while.")
|
|
}
|
|
if len(resp.Marked) == 0 {
|
|
resp.Notes = append(resp.Notes, "Nothing new: named already knows what the parent has.")
|
|
}
|
|
status = s.eng.DNSSEC(ctx, st, z)
|
|
} else {
|
|
resp.Notes = append(resp.Notes, "The parent zone couldn't be checked, so named wasn't told anything.")
|
|
}
|
|
resp.Status = status
|
|
writeJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// dsChanges works out what named should be told after a DS check: keys
|
|
// whose DS every parent server now has (published), and keys whose DS no
|
|
// parent server has any more, when they're on their way out (withdrawn).
|
|
// A signed zone without a DS yet is simply waiting for one: nothing is
|
|
// withdrawn for it.
|
|
func dsChanges(z *model.Zone, keys []*dnssec.Key, pr *nscheck.ParentDSReport) (published, withdrawn []*dnssec.Key) {
|
|
for _, k := range keys {
|
|
if !k.SigningKey() || k.DS == nil {
|
|
continue
|
|
}
|
|
leaving := z.DNSSECPolicy == model.DNSSECInsecure || k.Goal == dnssec.Hidden
|
|
switch {
|
|
case !leaving && k.DSState != dnssec.Omnipresent && k.DSPublished == nil && pr.AllHaveDS(k.Tag, k.DS.Digest):
|
|
published = append(published, k)
|
|
case leaving && k.DSState != dnssec.Hidden && k.DSRemoved == nil && !pr.HasDS(k.Tag, k.DS.Digest):
|
|
withdrawn = append(withdrawn, k)
|
|
}
|
|
}
|
|
return published, withdrawn
|
|
}
|
|
|
|
func (s *Server) zoneDNSSECWipe(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireAdmin(p); err != nil {
|
|
return err
|
|
}
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
z, err := s.zoneByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in api.DNSSECWipe
|
|
if err := decodeOptional(r, &in); err != nil {
|
|
return err
|
|
}
|
|
if queryBool(r, "dry_run") {
|
|
in.DryRun = true
|
|
}
|
|
c, err := s.dsChecker(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
// The one thing that must not happen: the zone goes unsigned while
|
|
// resolvers can still get a DS record for it.
|
|
pr := c.ParentDS(ctx, z.Name)
|
|
if len(pr.DS) > 0 {
|
|
var tags []string
|
|
for _, d := range pr.DS {
|
|
tags = append(tags, fmt.Sprint(d.KeyTag))
|
|
}
|
|
return conflict("the parent zone %s still has a DS record for %s (key %s): wiping now would make validating resolvers reject the whole domain. Remove the DS at your registrar first and wait for its TTL to pass (or turn DNSSEC off step by step: jarl zone dnssec disable)", dnsname.Display(pr.Parent), dnsname.Display(z.Name), strings.Join(tags, ", "))
|
|
}
|
|
if pr.Error != "" && !in.Force {
|
|
return conflict("couldn't check that the parent zone has no DS record for %s: %s. If the zone isn't on the internet (or you're sure its parent has no DS for it), wipe with force", dnsname.Display(z.Name), pr.Error)
|
|
}
|
|
if !in.Force {
|
|
for _, k := range s.eng.DNSSEC(ctx, st, z).Keys {
|
|
if k.SigningKey() && k.DSPublished != nil && k.DSState != dnssec.Hidden {
|
|
return conflict("the parent's DS record for key %d is gone, but resolvers may still have it cached (named counts until its TTL has passed); wait until 'jarl zone dnssec status' no longer shows it, or wipe with force", k.Tag)
|
|
}
|
|
}
|
|
}
|
|
res, err := s.eng.WipeDNSSEC(r.Context(), z.ID, p, via(r, p), engine.ApplyOptions{DryRun: in.DryRun})
|
|
if err != nil {
|
|
if res != nil && res.Result != nil {
|
|
s.writeErr(w, r, err, res.Result)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
out := api.DNSSECWipeResponse{Removed: res.Removed, DryRun: res.DryRun}
|
|
if pr.Error == "" {
|
|
out.Parent = pr
|
|
}
|
|
if res.Result != nil {
|
|
out.ChangesetID = res.Result.ChangesetID
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|