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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-18 15:36:39 +01:00
1 parent 65086e314a
commit 82c282cd32
7 files changed
+106 -2

No files matched your search

+8
View File
@@ -33,6 +33,14 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### 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 - 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 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 records in the other views too; deleting or changing the address in
+1 -1
View File
@@ -163,7 +163,7 @@ check` says so).
|-----|---------|---------| |-----|---------|---------|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. | | `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). | | `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 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 file: it isn't stored in the database or backups, and there is no API to
+12
View File
@@ -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 when everyone can read it. Only administrators can use the registrar
features. 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`: **Using it**: the zone's **Registrar** tab, or `jarl zone registrar`:
| | | | | |
+23
View File
@@ -11,6 +11,7 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
"golang.org/x/net/idna" "golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
) )
// Root is the root domain name. // Root is the root domain name.
@@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string {
return local + "@" + strings.Join(labels[1:], ".") 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. // IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
func IsReverse(name string) bool { func IsReverse(name string) bool {
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.") return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
+20
View File
@@ -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)
}
}
}
+16 -1
View File
@@ -35,11 +35,16 @@ import (
) )
// registrarFor names the registrar Jarl can manage z's delegation at: // 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 { func (s *Server) registrarFor(z *model.Zone) string {
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary { if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
return "" 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)) { if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
return "" return ""
} }
@@ -59,6 +64,16 @@ func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State
return nil, nil, "", err return nil, nil, "", err
} }
if s.registrarFor(z) == "" { 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 { 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("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
} }
+26
View File
@@ -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) 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() 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)
}