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,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
)
|
||||
|
||||
func TestDNSSECEndpoints(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
// Nothing answers DNS here, so the parent can't be checked.
|
||||
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Resolver: "127.0.0.1:1"}
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
e.do("GET", "/zones/example.com/dnssec?view=", e.token, nil, 200)
|
||||
|
||||
e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "default"}, 200)
|
||||
got := e.do("GET", "/zones/example.com/dnssec", e.token, nil, 200)
|
||||
if got["policy"] != "default" || got["phase"].(map[string]any)["code"] != dnssec.PhaseStarting || !strings.HasSuffix(got["key_dir"].(string), "zones/dnssec-keys/_default") {
|
||||
t.Errorf("status: %v", got)
|
||||
}
|
||||
// Switching off at once is refused; insecure is the way.
|
||||
out := e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "off"}, 422)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "switching DNSSEC off at once") {
|
||||
t.Errorf("switching off: %v", out)
|
||||
}
|
||||
cd := e.do("POST", "/zones/example.com/dnssec/check-ds", e.token, nil, 200)
|
||||
if cd["parent"].(map[string]any)["error"] == "" || len(cd["marked"].([]any)) != 0 {
|
||||
t.Errorf("check-ds without a resolver: %v", cd)
|
||||
}
|
||||
// Wipe: refused while the parent can't be checked, unless forced.
|
||||
out = e.do("POST", "/zones/example.com/dnssec/wipe", e.token, nil, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "wipe with force") {
|
||||
t.Errorf("wipe: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true, "dry_run": true}, 200)
|
||||
if e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"] != "default" {
|
||||
t.Error("a dry run changed the zone")
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 200)
|
||||
if p, ok := e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"]; ok && p != "" {
|
||||
t.Errorf("after wipe: %v", p)
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 409) // nothing left
|
||||
|
||||
// Secondary zones aren't signed by Jarl.
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "copy.example", "type": "secondary", "options": []map[string]any{{"name": "primaries", "value": "{ 192.0.2.1; }"}}}, 201)
|
||||
e.do("GET", "/zones/copy.example/dnssec", e.token, nil, 400)
|
||||
e.do("PATCH", "/zones/copy.example", e.token, map[string]any{"dnssec_policy": "default"}, 422)
|
||||
}
|
||||
|
||||
func TestDSChanges(t *testing.T) {
|
||||
ds := dnssec.DS{KeyTag: 1, Algorithm: 13, DigestType: 2, Digest: "AA"}
|
||||
key := func(goal, dsState string) *dnssec.Key {
|
||||
d := ds
|
||||
return &dnssec.Key{Tag: 1, Role: "CSK", DS: &d, Goal: goal, DSState: dsState}
|
||||
}
|
||||
with := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated", DS: []dnssec.DS{ds}}}, DS: []dnssec.DS{ds}, Consistent: true}
|
||||
half := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated"}}, DS: []dnssec.DS{ds}}
|
||||
without := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated"}}, Consistent: true}
|
||||
signed := &model.Zone{DNSSECPolicy: "default"}
|
||||
off := &model.Zone{DNSSECPolicy: "insecure"}
|
||||
|
||||
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, with); len(p) != 1 || len(w) != 0 {
|
||||
t.Errorf("DS added: %v %v", p, w)
|
||||
}
|
||||
if p, _ := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, half); len(p) != 0 {
|
||||
t.Error("published while not every parent server has it")
|
||||
}
|
||||
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, without); len(p)+len(w) != 0 {
|
||||
t.Error("a signed zone without a DS yet is just waiting")
|
||||
}
|
||||
if p, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, without); len(p) != 0 || len(w) != 1 {
|
||||
t.Errorf("DS removed: %v %v", p, w)
|
||||
}
|
||||
if _, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, half); len(w) != 0 {
|
||||
t.Error("withdrawn while a parent server still has it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
"jarl/internal/nscheck"
|
||||
@@ -61,6 +62,11 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
if err != nil {
|
||||
resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error())
|
||||
}
|
||||
if z.Signed() && resp.SerialSource == "jarl" {
|
||||
// named gives the signed zone its own serial, ahead of Jarl's.
|
||||
resp.Notes = append(resp.Notes, "The zone is signed and named wasn't asked: other servers serve the signed zone's serial, which named sets and which is usually ahead of Jarl's.")
|
||||
resp.Serial = nil
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
if z.Type == model.ZonePrimary {
|
||||
resp.Notes = append(resp.Notes, "The zone has no NS records or also-notify targets to check.")
|
||||
@@ -116,7 +122,7 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
ref, _, _ := s.localSerial(ctx, st, z)
|
||||
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref), View: st.ViewName(z), Notes: []string{}, CheckedAt: time.Now()}
|
||||
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref, s.signing(ctx, st, z)), View: st.ViewName(z), Notes: []string{}, CheckedAt: time.Now()}
|
||||
if resp.View != "" {
|
||||
resp.Notes = append(resp.Notes, fmt.Sprintf("Compared with the zone in view %s. Resolvers on the internet get the view that matches their address; make sure that's this one.", resp.View))
|
||||
}
|
||||
@@ -124,6 +130,18 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
return nil
|
||||
}
|
||||
|
||||
// signing describes how Jarl signs z, for comparing with the parent's DS
|
||||
// records: the DS of every key-signing key named still publishes.
|
||||
func (s *Server) signing(ctx context.Context, st *model.State, z *model.Zone) *nscheck.Signing {
|
||||
sig := &nscheck.Signing{Policy: z.DNSSECPolicy}
|
||||
for _, k := range s.eng.DNSSEC(ctx, st, z).Keys {
|
||||
if k.DS != nil && k.DNSKEYState != dnssec.Hidden {
|
||||
sig.DS = append(sig.DS, *k.DS)
|
||||
}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// localSerial is the serial this server serves for z: named's, or for a
|
||||
// primary zone Jarl's when named isn't asked.
|
||||
func (s *Server) localSerial(ctx context.Context, st *model.State, z *model.Zone) (*uint32, string, error) {
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]in
|
||||
if z.Type == model.ZonePrimary {
|
||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||
}
|
||||
out.Registrar = s.registrarFor(z)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
)
|
||||
|
||||
// soaServers answers SOA queries for zone with serial on each address
|
||||
// (all on one port, which it returns).
|
||||
func soaServers(t *testing.T, zone string, serial uint32, addrs ...string) string {
|
||||
t.Helper()
|
||||
port := "0"
|
||||
for _, a := range addrs {
|
||||
pc, err := net.ListenPacket("udp", net.JoinHostPort(a, port))
|
||||
if err != nil {
|
||||
t.Skipf("can't listen on %s: %v", a, err)
|
||||
}
|
||||
_, port, _ = net.SplitHostPort(pc.LocalAddr().String())
|
||||
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(req)
|
||||
m.Authoritative = true
|
||||
if req.Question[0].Qtype == dns.TypeSOA {
|
||||
rr, _ := dns.NewRR(zone + " 300 IN SOA ns1." + zone + " h." + zone + " " + strconv.FormatUint(uint64(serial), 10) + " 3600 900 1209600 300")
|
||||
m.Answer = []dns.RR{rr}
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
})}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func TestRegistrar(t *testing.T) {
|
||||
fake := porkbun.NewFake("pk1_a", "sk1_b", "adbraun.lol")
|
||||
pts := httptest.NewServer(fake)
|
||||
t.Cleanup(pts.Close)
|
||||
e := newAPIEnvConfig(t, Config{Version: "test", Porkbun: &porkbun.Client{BaseURL: pts.URL + "/api/json/v3", APIKey: "pk1_a", Secret: "sk1_b"},
|
||||
PorkbunAllows: func(d string) bool { return d == "adbraun.lol" }})
|
||||
e.do("POST", "/zones", e.token, map[string]any{
|
||||
"name": "adbraun.lol", "nameservers": []string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
|
||||
"records": []map[string]any{
|
||||
{"name": "ns1", "type": "A", "data": "127.0.0.41"}, {"name": "ns1", "type": "AAAA", "data": "::1"},
|
||||
{"name": "ns2", "type": "A", "data": "127.0.0.42"},
|
||||
},
|
||||
}, 201)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "other.lol"}, 201)
|
||||
e.do("GET", "/zones/other.lol/registrar", e.token, nil, 400)
|
||||
|
||||
st := e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
if st["ns_match"] != false || len(st["glue"].([]any)) != 2 {
|
||||
t.Fatalf("status: %v", st)
|
||||
}
|
||||
for _, g := range st["glue"].([]any) {
|
||||
if g.(map[string]any)["action"] != "create" {
|
||||
t.Errorf("glue: %v", g)
|
||||
}
|
||||
}
|
||||
|
||||
// Glue: a dry run changes nothing; then both hosts, IPv4 and IPv6.
|
||||
out := e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, map[string]any{"dry_run": true}, 200)
|
||||
if len(out["done"].([]any)) != 2 || len(fake.Domains["adbraun.lol"].Glue) != 0 {
|
||||
t.Errorf("dry run: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 200)
|
||||
g := fake.Domains["adbraun.lol"].Glue
|
||||
sort.Strings(g["ns1.adbraun.lol"])
|
||||
if strings.Join(g["ns1.adbraun.lol"], " ") != "127.0.0.41 ::1" || strings.Join(g["ns2.adbraun.lol"], " ") != "127.0.0.42" {
|
||||
t.Errorf("glue at Porkbun: %v", g)
|
||||
}
|
||||
|
||||
// NS: refused while the servers don't answer...
|
||||
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(netip.Addr) bool { return false }}
|
||||
out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "doesn't serve the zone") {
|
||||
t.Errorf("set-ns without servers: %v", out)
|
||||
}
|
||||
chk := e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"dry_run": true}, 200)
|
||||
if len(chk["checks"].([]any)) == 0 {
|
||||
t.Errorf("dry run checks: %v", chk)
|
||||
}
|
||||
// ...and allowed once they serve the zone.
|
||||
serial := uint32(e.do("GET", "/zones/adbraun.lol", e.token, nil, 200)["soa"].(map[string]any)["serial"].(float64))
|
||||
port := soaServers(t, "adbraun.lol.", serial, "127.0.0.41", "127.0.0.42", "::1")
|
||||
e.srv.nsChecker.Port = port
|
||||
e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 200)
|
||||
if ns := fake.Domains["adbraun.lol"].NS; strings.Join(ns, " ") != "ns1.adbraun.lol ns2.adbraun.lol" {
|
||||
t.Errorf("NS at Porkbun: %v", ns)
|
||||
}
|
||||
if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); st["ns_match"] != true {
|
||||
t.Errorf("after set-ns: %v", st)
|
||||
}
|
||||
|
||||
// A DS record for an unsigned zone: reported, removable; and setting
|
||||
// NS while a foreign DS is there is refused.
|
||||
fake.Domains["adbraun.lol"].DS[4242] = porkbun.DS{KeyTag: 4242, Algorithm: 13, DigestType: 2, Digest: "AB"}
|
||||
st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
found := false
|
||||
for _, f := range st["findings"].([]any) {
|
||||
if f.(map[string]any)["level"] == "error" && strings.Contains(f.(map[string]any)["message"].(string), "isn't signed") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("DS on an unsigned zone not reported: %v", st["findings"])
|
||||
}
|
||||
out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"ns": []string{"ns1.adbraun.lol"}}, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "DS records") {
|
||||
t.Errorf("set-ns with a foreign DS: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // not signed
|
||||
e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 200)
|
||||
if len(fake.Domains["adbraun.lol"].DS) != 0 {
|
||||
t.Error("DS not removed")
|
||||
}
|
||||
e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 404)
|
||||
|
||||
// A signed zone whose key named has published long enough: its DS
|
||||
// goes to Porkbun.
|
||||
e.do("PATCH", "/zones/adbraun.lol", e.token, map[string]any{"dnssec_policy": "default"}, 200)
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // no key yet
|
||||
tag := writeKey(t, filepath.Join(e.srv.eng.Config().Paths.KeysDir(), "_default"), "adbraun.lol.")
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200)
|
||||
if d, ok := fake.Domains["adbraun.lol"].DS[tag]; !ok || d.Algorithm != 13 || d.DigestType != 2 || len(d.Digest) != 64 {
|
||||
t.Errorf("DS at Porkbun: %+v", fake.Domains["adbraun.lol"].DS)
|
||||
}
|
||||
st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
if !st["ds"].([]any)[0].(map[string]any)["matches"].(bool) {
|
||||
t.Errorf("DS doesn't match: %v", st["ds"])
|
||||
}
|
||||
out = e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200)
|
||||
if len(out["done"].([]any)) != 0 {
|
||||
t.Errorf("published twice: %v", out)
|
||||
}
|
||||
|
||||
// Registrar errors are passed on.
|
||||
fake.Domains["adbraun.lol"].APIAccess = false
|
||||
if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); !strings.Contains(st["error"].(string), "API_ACCESS_DISABLED") {
|
||||
t.Errorf("error: %v", st)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 502)
|
||||
}
|
||||
|
||||
// writeKey writes the .key and .state files named would have for a key
|
||||
// of zone that is ready for its DS record, and returns its tag.
|
||||
func writeKey(t *testing.T, dir, zone string) uint16 {
|
||||
t.Helper()
|
||||
k := &dns.DNSKEY{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET, Ttl: 3600}, Flags: 257, Protocol: 3, Algorithm: dns.ECDSAP256SHA256}
|
||||
if _, err := k.Generate(256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := filepath.Join(dir, fmt.Sprintf("K%s+013+%05d", zone, k.KeyTag()))
|
||||
os.MkdirAll(dir, 0o755)
|
||||
os.WriteFile(base+".key", []byte(k.String()+"\n"), 0o644)
|
||||
os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644)
|
||||
return k.KeyTag()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -156,6 +157,21 @@ func readBody(r *http.Request) (json.RawMessage, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// decodeOptional reads a JSON body into v like decodeJSON, but an empty
|
||||
// body leaves v as it is.
|
||||
func decodeOptional(r *http.Request, v any) error {
|
||||
b, err := readBody(r)
|
||||
if err != nil || b == nil {
|
||||
return err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
v := r.PathValue(name)
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
|
||||
@@ -67,6 +67,14 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}", s.authed(s.deleteZone))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/serials", s.authed(s.zoneSerials))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/delegation", s.authed(s.zoneDelegation))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/dnssec", s.authed(s.zoneDNSSEC))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/dnssec/check-ds", s.authed(s.zoneCheckDS))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/dnssec/wipe", s.authed(s.zoneDNSSECWipe))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/registrar", s.authed(s.zoneRegistrar))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/registrar/glue", s.authed(s.registrarSyncGlue))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/registrar/ds", s.authed(s.registrarPublishDS))
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/stats"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
@@ -47,6 +48,10 @@ type Config struct {
|
||||
// reports on (backup.dir and backup.warn_after).
|
||||
BackupDir string
|
||||
BackupWarnAfter time.Duration
|
||||
// Porkbun manages domains' delegation at the registrar Porkbun (nil
|
||||
// without an API key); PorkbunAllows says which domains it may touch.
|
||||
Porkbun *porkbun.Client
|
||||
PorkbunAllows func(domain string) bool
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
|
||||
Reference in new issue
Block a user