From 82c282cd325b281bb0aa5e0039c4773e776030cd Mon Sep 17 00:00:00 2001 From: adam Date: Sun, 18 Oct 2026 15:36:39 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 8 ++++++++ docs/configuration.md | 2 +- docs/dnssec.md | 12 ++++++++++++ internal/dnsname/dnsname.go | 23 +++++++++++++++++++++++ internal/dnsname/dnsname_test.go | 20 ++++++++++++++++++++ internal/server/h_registrar.go | 17 ++++++++++++++++- internal/server/registrar_test.go | 26 ++++++++++++++++++++++++++ 7 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e06f5f..fd6c6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ All notable changes to Jarl are documented here. The format follows ### Fixed +- The Registrar tab (and `jarl zone registrar`, and the DNSSEC page's + registrar button) was offered for every zone when `porkbun.domains` is + empty, and always failed for subdomain zones (`lab.adbraun.lol`), + reverse zones and private names (`home.lan`): Porkbun only knows + registered domains. Only zones that are a registered domain (by the + public suffix list) get it now; the others say what delegates them + instead. + - Auto-PTR in linked zones changed PTR records in every linked view, not just where the address record is. A record local to one view got PTR records in the other views too; deleting or changing the address in diff --git a/docs/configuration.md b/docs/configuration.md index 96e6c6c..dc5cd69 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -163,7 +163,7 @@ check` says so). |-----|---------|---------| | `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. | | `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). | -| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). | +| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). Subdomain and reverse zones never get the registrar features ([dnssec.md](dnssec.md#porkbun)). | The key can move your domain anywhere, so it is only ever read from this file: it isn't stored in the database or backups, and there is no API to diff --git a/docs/dnssec.md b/docs/dnssec.md index 3776c1e..395b909 100644 --- a/docs/dnssec.md +++ b/docs/dnssec.md @@ -204,6 +204,18 @@ root:jarl`, `chmod 640`, as the package installs it); jarld warns at start when everyone can read it. Only administrators can use the registrar features. +Only a zone that is a registered domain gets the registrar features +(`adbraun.lol`, `example.co.uk`, judged by the public suffix list). Their +parent zone delegates the others, not a registrar: + +* a subdomain zone such as `lab.adbraun.lol`: the NS and DS records in + `adbraun.lol`; +* a reverse zone: whoever holds the addresses (your ISP, or the RIR); +* a zone under a suffix that isn't public (`home.lan`, `home.arpa`): + nobody. + +The zone's **Name servers** tab (Check delegation) checks all of them. + **Using it**: the zone's **Registrar** tab, or `jarl zone registrar`: | | | diff --git a/internal/dnsname/dnsname.go b/internal/dnsname/dnsname.go index 55b9605..df0305b 100644 --- a/internal/dnsname/dnsname.go +++ b/internal/dnsname/dnsname.go @@ -11,6 +11,7 @@ import ( "github.com/miekg/dns" "golang.org/x/net/idna" + "golang.org/x/net/publicsuffix" ) // Root is the root domain name. @@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string { return local + "@" + strings.Join(labels[1:], ".") } +// RegisteredDomain returns the domain registered at a registrar that name +// is in, without a trailing dot: adbraun.lol for lab.adbraun.lol, from +// the public suffix list. ok is false when no registrar is involved: +// reverse zones and the rest of .arpa (delegated by whoever holds the +// addresses), names under a suffix the list doesn't know (home.lan, +// internal) and public suffixes themselves (co.uk). +func RegisteredDomain(name string) (string, bool) { + n := strings.ToLower(strings.TrimSuffix(name, ".")) + if n == "" || n == "arpa" || strings.HasSuffix(n, ".arpa") { + return "", false + } + suffix, icann := publicsuffix.PublicSuffix(n) + if !icann || suffix == n { + return "", false + } + d, err := publicsuffix.EffectiveTLDPlusOne(n) + if err != nil { + return "", false + } + return d, true +} + // IsReverse reports whether name is in in-addr.arpa or ip6.arpa. func IsReverse(name string) bool { return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.") diff --git a/internal/dnsname/dnsname_test.go b/internal/dnsname/dnsname_test.go index 5303635..40d3ca6 100644 --- a/internal/dnsname/dnsname_test.go +++ b/internal/dnsname/dnsname_test.go @@ -249,3 +249,23 @@ func TestPrefixFromReverse(t *testing.T) { } } } + +func TestRegisteredDomain(t *testing.T) { + for name, want := range map[string]string{ + "adbraun.lol.": "adbraun.lol", + "lab.adbraun.lol.": "adbraun.lol", + "Example.CO.UK": "example.co.uk", + "co.uk.": "", + "lol.": "", + "10.0.10.in-addr.arpa.": "", + "d.f.ip6.arpa.": "", + "home.arpa.": "", + "nas.home.lan.": "", + "": "", + } { + got, ok := RegisteredDomain(name) + if got != want || ok != (want != "") { + t.Errorf("RegisteredDomain(%q) = %q, %v; want %q", name, got, ok, want) + } + } +} diff --git a/internal/server/h_registrar.go b/internal/server/h_registrar.go index 750ff88..e55c1c9 100644 --- a/internal/server/h_registrar.go +++ b/internal/server/h_registrar.go @@ -35,11 +35,16 @@ import ( ) // registrarFor names the registrar Jarl can manage z's delegation at: -// "porkbun" when a key is configured and allows the zone's name. +// "porkbun" when a key is configured and allows the zone's name, and the +// zone is a registered domain (not a subdomain or a reverse zone, which +// their parent zone delegates). func (s *Server) registrarFor(z *model.Zone) string { if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary { return "" } + if d, ok := dnsname.RegisteredDomain(z.Name); !ok || d != dnsname.Display(z.Name) { + return "" + } if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) { return "" } @@ -59,6 +64,16 @@ func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State return nil, nil, "", err } if s.registrarFor(z) == "" { + d, ok := dnsname.RegisteredDomain(z.Name) + switch { + case s.cfg.Porkbun == nil: + case dnsname.IsReverse(z.Name): + return nil, nil, "", badRequest("%s is a reverse zone: whoever holds the addresses (your ISP, or the RIR) delegates it, not a registrar. Check its delegation on the zone's Name servers tab (jarl zone delegation ZONE)", dnsname.Display(z.Name)) + case !ok: + return nil, nil, "", badRequest("%s isn't a domain registered at a registrar (its suffix isn't a public one), so there is nothing to manage at Porkbun", dnsname.Display(z.Name)) + case d != dnsname.Display(z.Name): + return nil, nil, "", badRequest("%s is a subdomain of %s: the NS (and DS) records in the zone %s delegate it, not the registrar. Manage %s at Porkbun instead", dnsname.Display(z.Name), d, d, d) + } 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)") } diff --git a/internal/server/registrar_test.go b/internal/server/registrar_test.go index 383ee22..e9b2420 100644 --- a/internal/server/registrar_test.go +++ b/internal/server/registrar_test.go @@ -170,3 +170,29 @@ func writeKey(t *testing.T, dir, zone string) uint16 { 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) +}