Files
adamandClaude Opus 5.5 113a6913a2 Give the DS command for a parent zone in Jarl, and allow port 443
The DNSSEC tab and 'jarl zone dnssec status' give the jarl record add
command (and an Add to parent button) when the parent zone is in Jarl.
jarl.service grants CAP_NET_BIND_SERVICE so the web UI can listen on 443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:42:16 +00:00

210 lines
6.9 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, 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)
}
}
}
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
}