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, ", ") }