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 }