DNSSEC: say where the DS record really goes
The DNSSEC tab said "give this to your registrar" for every zone whose parent wasn't in Jarl in the same view. The DNSSEC response now has ds_at/ds_place/ds_note: the parent zone in Jarl (any view, its own first, then a public one), the registrar for a registered domain, the parent zone wherever it is hosted for a subdomain, the ISP or RIR for a reverse zone, and nowhere for a private name. The page and the CLI use them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82c282cd32
commit
a36c54ce60
8 files changed
+161
-26
No files matched your search
+13
-1
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-10
@@ -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.
|
||||
|
||||
@@ -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."],
|
||||
]),
|
||||
|
||||
Reference in new issue
Block a user