Files
adamandClaude Opus 5.5 82c282cd32 Registrar: only for zones that are a registered domain
With porkbun.domains empty the Registrar tab was offered for every zone
and always failed for subdomain zones, reverse zones and private names.
dnsname.RegisteredDomain (public suffix list) now decides; the others
get a message naming what delegates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:36:39 +01:00

199 lines
8.6 KiB
Go

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()
}
// With no porkbun.domains, only zones that are registered domains get the
// registrar: not subdomains or reverse zones, which their parent delegates.
func TestRegistrarOnlyRegisteredDomains(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"}})
for _, z := range []string{"adbraun.lol", "lab.adbraun.lol", "10.0.10.0/24", "home.lan"} {
e.do("POST", "/zones", e.token, map[string]any{"name": z, "nameservers": []string{"ns1.example.net."}}, 201)
}
for zone, want := range map[string]string{"adbraun.lol": "porkbun", "lab.adbraun.lol": "", "10.0.10.in-addr.arpa": "", "home.lan": ""} {
got, _ := e.do("GET", "/zones/"+zone, e.token, nil, 200)["registrar"].(string)
if got != want {
t.Errorf("%s: registrar %q, want %q", zone, got, want)
}
}
for zone, msg := range map[string]string{"lab.adbraun.lol": "subdomain of adbraun.lol", "10.0.10.in-addr.arpa": "reverse zone", "home.lan": "isn't a domain registered"} {
out := e.do("GET", "/zones/"+zone+"/registrar", e.token, nil, 400)
errObj, _ := out["error"].(map[string]any)
if m, _ := errObj["message"].(string); !strings.Contains(m, msg) {
t.Errorf("%s: %v", zone, out)
}
}
e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
}