The DNSSEC tab said "give this to your registrar" for every zone whose parent wasn't in Jarl in the same view. The DNSSEC response now has ds_at/ds_place/ds_note: the parent zone in Jarl (any view, its own first, then a public one), the registrar for a registered domain, the parent zone wherever it is hosted for a subdomain, the ISP or RIR for a reverse zone, and nowhere for a private name. The page and the CLI use them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
8.4 KiB
Go
249 lines
8.4 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)}
|
|
public := map[int64]bool{}
|
|
for _, e := range s.eng.Exposures(st) {
|
|
public[e.ViewID] = e.Public
|
|
}
|
|
name := dnsname.Display(z.Name)
|
|
reg, regOK := dnsname.RegisteredDomain(z.Name)
|
|
switch pz := parentInJarl(st, z, public); {
|
|
case pz != nil:
|
|
resp.DSAt = "parent_zone"
|
|
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
|
|
for _, rec := range st.ServedRecords(pz.ID) {
|
|
if rec.Name == z.Name && rec.Type == "DS" {
|
|
resp.ParentDS = append(resp.ParentDS, rec.Data)
|
|
}
|
|
}
|
|
resp.DSPlace = "in the parent zone " + dnsname.Display(pz.Name)
|
|
if pz.ViewID != z.ViewID {
|
|
resp.DSPlace += " (view " + st.ViewName(pz) + ")"
|
|
}
|
|
case dnsname.IsReverse(z.Name):
|
|
resp.DSAt = "reverse"
|
|
resp.DSPlace = "with whoever delegates " + name + " to you (your ISP, or your RIR for an address block of your own; many ISPs don't take DS records)"
|
|
case !regOK:
|
|
resp.DSAt = "none"
|
|
resp.DSNote = "No zone on the internet delegates " + name + ", so it can't get a DS record and resolvers never validate it. Signing only matters for a zone on the internet."
|
|
case reg == name:
|
|
resp.DSAt, resp.DSPlace = "registrar", "at your registrar"
|
|
default:
|
|
resp.DSAt = "parent"
|
|
resp.DSPlace = "in its parent zone (most likely " + reg + "), wherever that zone's DNS is hosted (at Porkbun: " + reg + "'s DNS records, as a DS record named " + strings.TrimSuffix(name, "."+reg) + ")"
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// parentInJarl returns the primary zone in Jarl that z is delegated from:
|
|
// in z's own view, else in another view, a public one first (the one the
|
|
// internet asks).
|
|
func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone {
|
|
in := func(viewID int64) *model.Zone {
|
|
var best *model.Zone
|
|
for _, o := range st.ZonesInView(viewID) {
|
|
if 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
|
|
}
|
|
if pz := in(z.ViewID); pz != nil {
|
|
return pz
|
|
}
|
|
var other *model.Zone
|
|
for _, v := range st.OrderedViews() {
|
|
if v.ID == z.ViewID {
|
|
continue
|
|
}
|
|
if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) {
|
|
other = pz
|
|
}
|
|
}
|
|
return other
|
|
}
|
|
|
|
// 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
|
|
}
|