Add DNSSEC signing, key backups and Porkbun support
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>
This commit is contained in:
1 parent
293e6ddd33
commit
5630e7a848
72 files changed
+5760
-153
No files matched your search
@@ -0,0 +1,587 @@
|
||||
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, ", ")
|
||||
}
|
||||
Reference in new issue
Block a user