diff --git a/CHANGELOG.md b/CHANGELOG.md index fd6c6a5..67e1c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ All notable changes to Jarl are documented here. The format follows ### Fixed +- The DNSSEC tab told you to give the DS record to your registrar for + every zone whose parent isn't in Jarl in the same view. Now it (and + `jarl zone dnssec status`, and the API's new `ds_at`, `ds_place` and + `ds_note`) says where it really goes: the parent zone in Jarl (in any + view now, the zone's own first), the registrar for a registered + domain, the parent zone wherever it is hosted for a subdomain, your + ISP or RIR for a reverse zone, and nowhere for a private name such as + `home.lan` (signing it has no effect). + - 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`), diff --git a/cmd/jarl/dnssec.go b/cmd/jarl/dnssec.go index d090b9c..89c7964 100644 --- a/cmd/jarl/dnssec.go +++ b/cmd/jarl/dnssec.go @@ -23,8 +23,8 @@ keeps writing the unsigned zone, and named keeps a signed copy next to it and its keys in Jarl's key directory. See docs/dnssec.md. Turning signing on is safe at any time; validation only starts once you add -the zone's DS record at the parent zone (your registrar), and 'status' says -when that is safe. Turning it off goes through BIND's insecure policy, which +the zone's DS record at the parent zone (your registrar for a domain, the +parent zone for a subdomain), and 'status' says when that is safe and where. Turning it off goes through BIND's insecure policy, which keeps the zone signed until the DS is gone from the parent.`, } var view string @@ -71,7 +71,7 @@ DS after that.`, return err } if !flagQuiet { - fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view)) + fmt.Printf("\nnamed is signing %s now. Add its DS record to the parent zone only when\n'jarl zone dnssec status %s' says the zone is ready; it also says where it goes.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view)) } return nil }, @@ -87,8 +87,8 @@ DS after that.`, parent zone still has its DS record: 1. The first run switches the zone to BIND's insecure policy. named keeps it - signed and publishes a CDS "delete" record. Remove the DS record at your - registrar, then run 'jarl zone dnssec check-ds' so named knows. named + signed and publishes a CDS "delete" record. Remove the DS record at the + parent ('status' says where), then run 'jarl zone dnssec check-ds' so named knows. named waits until resolvers have forgotten the DS (the parent's DS TTL, often a day) and then removes the keys and signatures. 2. Run it again when 'status' says turning off has finished: DNSSEC is then @@ -137,7 +137,7 @@ A zone whose DS never reached the parent can be wiped at once instead if next == "insecure" && st.ParentZone != "" { fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view)) } else if next == "insecure" { - fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view)) + fmt.Printf("\nNext: remove the DS record for %s %s, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), dsPlace(st), args[0]+viewFlag(view), args[0]+viewFlag(view)) } else { fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').") } @@ -153,7 +153,7 @@ A zone whose DS never reached the parent can be wiped at once instead Short: "Check the parent zone's DS records and tell named", Long: `Asks every server of the parent zone for the zone's DS records, and tells named (rndc dnssec -checkds) when its key's DS has appeared there (after you -added it at the registrar) or is gone (while turning DNSSEC off). named +added it there) or is gone (while turning DNSSEC off). named moves to the next step only once it knows. The parent is found through jarld's resolver, or --resolver. Exits with 2 @@ -345,9 +345,12 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) { } } if len(ds) > 0 && r.Policy != "insecure" { - where := "at your registrar" + if r.DSAt == "none" { + fmt.Fprintf(w, "\n%s\n", r.DSNote) + } + where := dsPlace(r) if r.ParentZone != "" { - where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there" + where += ", which is in Jarl: add it as a DS record there" } fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where) if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil { @@ -368,6 +371,14 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) { } } +// dsPlace says where the zone's DS record goes ("at your registrar"). +func dsPlace(r *api.DNSSECResponse) string { + if r.DSPlace != "" { + return r.DSPlace + } + return "at the parent zone" +} + // parentDSCommand is the jarl command that adds (or deletes) the zone's DS // record in its parent zone in Jarl. func parentDSCommand(r *api.DNSSECResponse, verb, data string) string { diff --git a/docs/api.md b/docs/api.md index 71e337e..6552a1a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -294,11 +294,20 @@ view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)). }, "named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."}, "policies": ["default"], - "parent_zone": "", // set when the parent zone is a primary zone in Jarl + "ds_at": "registrar", // parent_zone, registrar, parent, reverse or none + "ds_place": "at your registrar", // completes "add the DS record ..." + "ds_note": "", // for ds_at none: why it can't have one + "parent_zone": "", // set when the parent zone is a primary zone in Jarl (any view, its own first) "registrar": "porkbun" // when the DS can be published through Jarl } ``` +`ds_at` says where the DS record goes ([dnssec.md](dnssec.md#should-you-sign)): +`parent_zone` (a zone in Jarl: `parent_zone`, `parent_zone_id`, +`parent_view`), `registrar` (the zone is a registered domain), `parent` (a +subdomain whose parent zone is hosted elsewhere), `reverse` (whoever +delegates the addresses) or `none` (nothing on the internet delegates it). + `phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`, `ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`, `removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)). diff --git a/docs/dnssec.md b/docs/dnssec.md index 395b909..a9bf453 100644 --- a/docs/dnssec.md +++ b/docs/dnssec.md @@ -30,6 +30,17 @@ Signing an internal-only zone (one no registrar delegates, or a split-DNS view only your own network reaches) adds nothing. There is no parent to publish a DS, and BIND doesn't validate the zones it serves itself. +**Where the DS record goes** depends on the zone. The DNSSEC tab and +`jarl zone dnssec status` say which: + +| Zone | The DS record goes | +|---|---| +| a registered domain (`adbraun.lol`) | at your registrar ([Porkbun](#porkbun) can do it for you) | +| a subdomain whose parent zone is in Jarl (`lab.adbraun.lol`) | in the parent zone, as a DS record named `lab`: one button, or the `jarl record add` command shown. The parent in the zone's own view counts first, else one in another view (a public one first). | +| a subdomain whose parent zone is hosted elsewhere | in that parent zone, wherever it is hosted (at Porkbun: the domain's DNS records) | +| a reverse zone | with whoever delegates it to you: your ISP, or your RIR for an address block of your own. Many ISPs don't take DS records. | +| a private name (`home.lan`) | nowhere: nothing on the internet delegates it, so signing it has no effect | + **Split DNS.** In Jarl a zone belongs to a view, so you sign the external view's `adbraun.lol` and leave the internal view's copy unsigned. That is the usual setup, with one catch. Once the DS is at the parent, the world diff --git a/internal/api/types.go b/internal/api/types.go index b5f2ecb..5962386 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -465,8 +465,20 @@ type DNSSECResponse struct { View string `json:"view,omitempty"` Type string `json:"type"` *dnssec.Status + // DSAt says where the zone's DS record goes: "parent_zone" (a primary + // zone in Jarl, below), "registrar" (the zone is a registered domain), + // "parent" (a subdomain whose parent zone is hosted elsewhere), + // "reverse" (whoever delegates the addresses) or "none" (nothing on + // the internet delegates the zone, so it can't have one). + DSAt string `json:"ds_at"` + // DSPlace completes "add the DS record …" ("at your registrar"); "" + // for "none". + DSPlace string `json:"ds_place,omitempty"` + // DSNote explains DSAt "none". + DSNote string `json:"ds_note,omitempty"` // ParentZone is set when the parent zone is a primary zone in Jarl - // (in the same view): the DS record then goes there, as a record. + // (in the zone's own view, else preferably a public one): the DS + // record then goes there, as a record. ParentZone string `json:"parent_zone,omitempty"` ParentZoneID int64 `json:"parent_zone_id,omitempty"` ParentView string `json:"parent_view,omitempty"` diff --git a/internal/server/dsat_test.go b/internal/server/dsat_test.go new file mode 100644 index 0000000..bfb824c --- /dev/null +++ b/internal/server/dsat_test.go @@ -0,0 +1,37 @@ +package server + +import ( + "strings" + "testing" +) + +// The DNSSEC page says where each zone's DS record goes. +func TestDNSSECWhereDSGoes(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201) + e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201) + zone := func(name, view string) { + e.do("POST", "/zones", e.token, map[string]any{"name": name, "view": view, "nameservers": []string{"ns1.example.net."}}, 201) + } + zone("adbraun.lol", "external") + zone("lab.adbraun.lol", "external") + zone("dev.adbraun.lol", "internal") // its parent only in external + zone("sub.example.com", "external") + zone("10.0.10.0/24", "internal") + zone("home.lan", "internal") + for _, c := range []struct{ zone, at, place, parent string }{ + {"adbraun.lol?view=external", "registrar", "at your registrar", ""}, + {"lab.adbraun.lol?view=external", "parent_zone", "in the parent zone adbraun.lol", "adbraun.lol."}, + {"dev.adbraun.lol?view=internal", "parent_zone", "in the parent zone adbraun.lol (view external)", "adbraun.lol."}, + {"sub.example.com?view=external", "parent", "in its parent zone (most likely example.com)", ""}, + {"10.0.10.in-addr.arpa?view=internal", "reverse", "your ISP", ""}, + {"home.lan?view=internal", "none", "", ""}, + } { + r := e.do("GET", "/zones/"+strings.Replace(c.zone, "?", "/dnssec?", 1), e.token, nil, 200) + place, _ := r["ds_place"].(string) + parent, _ := r["parent_zone"].(string) + if r["ds_at"] != c.at || !strings.Contains(place, c.place) || (c.place == "" && place != "") || parent != c.parent { + t.Errorf("%s: ds_at %v, ds_place %q, parent_zone %q", c.zone, r["ds_at"], place, parent) + } + } +} diff --git a/internal/server/h_dnssec.go b/internal/server/h_dnssec.go index 5c15b46..66b1bba 100644 --- a/internal/server/h_dnssec.go +++ b/internal/server/h_dnssec.go @@ -36,31 +36,70 @@ func (s *Server) zoneDNSSEC(w http.ResponseWriter, r *http.Request, p *auth.Prin 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 { + public := map[int64]bool{} + for _, e := range s.eng.Exposures(st) { + public[e.ViewID] = e.Public + } + name := dnsname.Display(z.Name) + reg, regOK := dnsname.RegisteredDomain(z.Name) + switch pz := parentInJarl(st, z, public); { + case pz != nil: + resp.DSAt = "parent_zone" resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz) for _, rec := range st.ServedRecords(pz.ID) { if rec.Name == z.Name && rec.Type == "DS" { resp.ParentDS = append(resp.ParentDS, rec.Data) } } + resp.DSPlace = "in the parent zone " + dnsname.Display(pz.Name) + if pz.ViewID != z.ViewID { + resp.DSPlace += " (view " + st.ViewName(pz) + ")" + } + case dnsname.IsReverse(z.Name): + resp.DSAt = "reverse" + resp.DSPlace = "with whoever delegates " + name + " to you (your ISP, or your RIR for an address block of your own; many ISPs don't take DS records)" + case !regOK: + resp.DSAt = "none" + resp.DSNote = "No zone on the internet delegates " + name + ", so it can't get a DS record and resolvers never validate it. Signing only matters for a zone on the internet." + case reg == name: + resp.DSAt, resp.DSPlace = "registrar", "at your registrar" + default: + resp.DSAt = "parent" + resp.DSPlace = "in its parent zone (most likely " + reg + "), wherever that zone's DNS is hosted (at Porkbun: " + reg + "'s DNS records, as a DS record named " + strings.TrimSuffix(name, "."+reg) + ")" } 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) { +// parentInJarl returns the primary zone in Jarl that z is delegated from: +// in z's own view, else in another view, a public one first (the one the +// internet asks). +func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone { + in := func(viewID int64) *model.Zone { + var best *model.Zone + for _, o := range st.ZonesInView(viewID) { + if 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 + } + if pz := in(z.ViewID); pz != nil { + return pz + } + var other *model.Zone + for _, v := range st.OrderedViews() { + if v.ID == z.ViewID { continue } - if best == nil || len(o.Name) > len(best.Name) { - best = o + if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) { + other = pz } } - return best + return other } // dsChecker returns the checker for parent DS lookups, with ?resolver=IP. diff --git a/internal/web/static/assets/pages/dnssec.js b/internal/web/static/assets/pages/dnssec.js index ea3d87c..60695b7 100644 --- a/internal/web/static/assets/pages/dnssec.js +++ b/internal/web/static/assets/pages/dnssec.js @@ -44,7 +44,8 @@ export async function dnssecTab(el, zone) { s.named_error ? h("div", { class: "msg warn small" }, "named: " + s.named_error) : null, s.key_error ? h("div", { class: "msg err small" }, "The keys can't be read: " + s.key_error) : null, !s.policy && !s.keys.length ? h("p", { class: "muted small" }, - "Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record in the parent zone (at your registrar), and resolvers on your own network don't validate zones this server answers itself.") : null, + "Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record " + (s.ds_place || "in the parent zone") + ", and resolvers on your own network don't validate zones this server answers itself.") : null, + s.ds_at === "none" ? h("div", { class: "msg warn small" }, s.ds_note) : null, out)]; const live = s.keys.filter((k) => k.ds && k.goal === "omnipresent"); @@ -98,8 +99,12 @@ async function addToParent(zone, s, k) { function dsPanel(zone, s, keys) { const ph = s.phase; const where = s.parent_zone - ? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."] - : "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields."; + ? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)), + s.parent_view && s.parent_view !== zone.view ? " (view " + s.parent_view + ")" : "", + " is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."] + : s.ds_at === "registrar" + ? "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields." + : s.ds_at === "none" ? s.ds_note : "Add this " + s.ds_place + "."; return h("div", { class: "panel" }, h("h2", null, "DS record for the parent zone"), ph.can_publish_ds @@ -153,7 +158,7 @@ async function enableDialog(zone, s, others) { "The server's clock must be right (NTP): signatures are valid for a fixed time window.", "TTLs in the zone must be at most a day; Jarl refuses longer ones.", "Secondaries get the signed zone by zone transfer; they need nothing else.", - "With the default policy the key is never replaced, so the DS at your registrar never has to change. Keep backups: the daily backup includes the keys.", + "With the default policy the key is never replaced, so the DS record at the parent never has to change. Keep backups: the daily backup includes the keys.", ]), split, s.policies.length > 1 ? h("div", { class: "form-grid" }, h("label", null, "Policy"), pol) : null, @@ -175,7 +180,9 @@ async function disableDialog(zone, s) { [h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."], s.parent_zone ? [h("strong", null, "You: "), "remove the DS record from ", display(s.parent_zone), " (", h("code", null, jarlCmd(zone, s, "delete", "")), ", or on its Records page), then click Check DS at parent so named knows it's gone."] - : [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."], + : s.ds_at === "registrar" + ? [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."] + : [h("strong", null, "You: "), "remove the DS record ", s.ds_place || "from the parent zone", " (if it was ever added), then click Check DS at parent so named knows it's gone."], [h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."], [h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."], ]),