diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7b845..106f0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,9 @@ All notable changes to Jarl are documented here. The format follows (`fill_other_views`, `?other_views=1`, a checkbox in the New reverse zone dialog), so one view's host names don't leak into another. When there is nothing else to fill, Jarl says how many there are and how to - add them. + add them. The page and `jarl zone show` list those addresses with the + name each would get, and the view it comes from + (`ptr_other_views_list`); `jarl zone show` lists the missing ones too. - Records, zones and validation findings sort numeric labels by value, so `2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index e87f4a3..8908859 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -8,6 +8,7 @@ import ( "net/netip" "net/url" "os" + "slices" "strconv" "strings" "unicode/utf8" @@ -100,9 +101,16 @@ func zoneCmd() *cobra.Command { fmt.Fprintf(w, "Network:\t%s\n", z.Prefix) if z.PTRMissing > 0 { fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0]) + printPTRFills(w, z.PTRMissingList, z.PTRMissing) } if z.PTROtherViews > 0 { - fmt.Fprintf(w, "Other views:\t%d addresses here only other views' records use, without a PTR record (to name them from there: jarl zone fill-ptr %s --other-views)\n", z.PTROtherViews, args[0]) + what := "addresses" + if z.PTROtherViews == 1 { + what = "address" + } + fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n", + z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0]) + printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews) } } if len(z.LinkedWith) > 0 { @@ -1174,6 +1182,39 @@ func terminalWidth() int { return width } +// printPTRFills lists PTR records a reverse zone lacks under zone show's +// line about them: address -> name, and the view the name is from. +func printPTRFills(w io.Writer, fills []api.PTRFill, total int) { + for _, f := range fills { + from := "" + if f.From != "" { + from = " (view " + f.From + ")" + } + fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from) + } + if total > len(fills) { + fmt.Fprintf(w, "\t and %d more\n", total-len(fills)) + } +} + +// fromViews names the views PTR fills take their names from: "view +// internal", "views internal and lab". +func fromViews(fills []api.PTRFill) string { + var vs []string + for _, f := range fills { + if f.From != "" && !slices.Contains(vs, f.From) { + vs = append(vs, f.From) + } + } + switch len(vs) { + case 0: + return "other views" + case 1: + return "view " + vs[0] + } + return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1] +} + func zoneFillPTRCmd() *cobra.Command { var view string var otherViews bool diff --git a/docs/api.md b/docs/api.md index 822f100..e6d854d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -215,7 +215,10 @@ A reverse zone created without a view goes to the private view whose records use its addresses. Zones come back with `prefix` (reverse zones: the network), `ptr_missing` (addresses in it that the view's A and AAAA records use, without a PTR record) and `ptr_other_views` (addresses in it -without a PTR record that only other views' records use; not missing). +without a PTR record that only other views' records use; not missing), +with the first 25 of each in `ptr_missing_list` and +`ptr_other_views_list` (`address`, `name`, `target`, and `from`, the view +the name comes from). `/serials` and `/delegation` send DNS queries from the server, and only when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)). diff --git a/docs/reverse.md b/docs/reverse.md index 7e9dfa8..400a23c 100644 --- a/docs/reverse.md +++ b/docs/reverse.md @@ -147,9 +147,19 @@ zone show` shows how many as `Missing PTRs`; the API lists them in `ptr_missing_list`. Only the zone's own view's records count: an address that only another view's records use isn't missing (say internal's `claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`). -Those are shown apart, as `Other views` in `jarl zone show` -(`ptr_other_views` in the API) and a quiet note with a **Name them from -there** button on the page; filling never adds them unasked. +Those are listed apart, with the name each would get: `Other views` in +`jarl zone show`, `ptr_other_views` and `ptr_other_views_list` in the +API, and a quiet note on the page with an **Add PTR records for them in +view vps** button. Filling never adds them unasked. `jarl zone show` +lists the missing ones too: + +``` +Missing PTRs: 1 (addresses the view's records use here; add them: jarl zone fill-ptr 10.0.10.0/24) + 10.0.10.7 -> printer.adbraun.lol +Other views: 2 addresses with A or AAAA records only in view internal, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr 10.0.10.0/24 --other-views) + 10.0.10.26 -> gomft.adbraun.lol (view internal) + 10.0.10.101 -> claude.adbraun.lol (view internal) +``` ## Keeping PTR records in step diff --git a/internal/api/types.go b/internal/api/types.go index 2eefc51..72b0885 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -50,8 +50,10 @@ type Zone struct { PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"` // PTROtherViews counts the addresses in it, without a PTR record, // that only other views' records use: not missing, but fill-ptr with - // other_views=1 can name them from there. - PTROtherViews int `json:"ptr_other_views,omitempty"` + // other_views=1 adds them, naming them from there. PTROtherViewsList + // are the first 25 (From says which view). + PTROtherViews int `json:"ptr_other_views,omitempty"` + PTROtherViewsList []PTRFill `json:"ptr_other_views_list,omitempty"` // Pending is set when listing with a changeset: add, change or delete. Pending string `json:"pending,omitempty"` } diff --git a/internal/engine/expand.go b/internal/engine/expand.go index 878f66f..1469ac4 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -489,7 +489,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) { x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name))) } if !fillOther { - if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to name them from there, fill it with other views (jarl zone fill-ptr ZONE --other-views)"); h != "" { + if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" { x.warnings = append(x.warnings, h) } } diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go index c6dd52a..1aff638 100644 --- a/internal/engine/reverse.go +++ b/internal/engine/reverse.go @@ -207,11 +207,11 @@ func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []* if n <= 0 { return "" } - what := "addresses" + what, has, they := "addresses", "have", "they are" if n == 1 { - what = "address" + what, has, they = "address", "has", "it is" } - return fmt.Sprintf("%d %s in %s only other views' records use; %s", n, what, dnsname.Display(zoneName), how) + return fmt.Sprintf("%d %s in %s %s A or AAAA records only in other views, so %s not missing here; %s", n, what, dnsname.Display(zoneName), has, they, how) } // zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks. @@ -238,7 +238,7 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) { if len(fills) == 0 { msg := fmt.Sprintf("nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z)) if !in.OtherViews { - if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to name them from there, fill with other views (--other-views)"); h != "" { + if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to add PTR records for them in this view too: --other-views"); h != "" { msg += "; " + h } } diff --git a/internal/server/dsat_test.go b/internal/server/dsat_test.go index 29cf7a0..0bb6cd0 100644 --- a/internal/server/dsat_test.go +++ b/internal/server/dsat_test.go @@ -110,6 +110,8 @@ func TestPTROtherViews(t *testing.T) { } if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_missing"] != nil || z["ptr_other_views"] != float64(1) { t.Errorf("vps: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"]) + } else if l, _ := z["ptr_other_views_list"].([]any); len(l) != 1 || l[0].(map[string]any)["address"] != "10.0.10.101" || l[0].(map[string]any)["target"] != "claude.adbraun.lol." || l[0].(map[string]any)["from"] != "internal" { + t.Errorf("vps: ptr_other_views_list %v", z["ptr_other_views_list"]) } e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps", e.token, nil, 400) e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps&other_views=1", e.token, nil, 201) diff --git a/internal/server/present.go b/internal/server/present.go index 457fd0e..6fea1e3 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -43,12 +43,19 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re if z.Type == model.ZonePrimary { fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false) out.PTRMissing = len(fills) - if z.ViewID != 0 { - out.PTROtherViews = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true)) - len(fills) - } for _, f := range fills[:min(len(fills), 25)] { out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f)) } + if z.ViewID != 0 { + for _, f := range engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true) { + if f.From == "" { + continue + } + if out.PTROtherViews++; out.PTROtherViews <= 25 { + out.PTROtherViewsList = append(out.PTROtherViewsList, api.PTRFill(f)) + } + } + } } } for _, o := range st.LinkedWith(z) { diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index 7cdb45c..f5a0432 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -73,9 +73,20 @@ export async function render(el, params, query) { f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null, f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)), zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null)) - : zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" }, - plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " only other views' records use, without a PTR record here. ", - h("button", { class: "small", title: "Adds PTR records naming them from the other views' records, publishing those names in view " + zone.view, onclick: () => fillPTR(zone, true) }, "Name them from there")) : null; + : null; + // Addresses only other views have records for: not missing here, but + // they can be added (publishing those views' names in this one). + const others = zone.ptr_other_views_list || []; + const fromViews = [...new Set(others.map((f) => f.from))]; + const otherNote = !zone.disabled && zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" }, + plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " " + (zone.ptr_other_views === 1 ? "has" : "have") + " A or AAAA records only in view " + fromViews.join(", ") + + ", not in view " + zone.view + ", so " + (zone.ptr_other_views === 1 ? "it isn't" : "they aren't") + " counted as missing here. ", + h("button", { class: "small", title: "Clients of view " + zone.view + " will then get these names, which come from view " + fromViews.join(", "), onclick: () => fillPTR(zone, true) }, + "Add PTR records for " + (zone.ptr_other_views === 1 ? "it" : "them") + " in view " + zone.view), + h("ul", { class: "small ptr-missing" }, others.map((f) => h("li", null, + h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)), + fromViews.length > 1 ? h("span", { class: "muted" }, " (view " + f.from + ")") : null)), + zone.ptr_other_views > others.length ? h("li", { class: "muted" }, "and " + (zone.ptr_other_views - others.length) + " more") : null)) : null; const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))); // The header and tabs show at once. Below them, switching tabs keeps the // previous tab's content (faded) until the new one is loaded, rather @@ -86,7 +97,7 @@ export async function render(el, params, query) { holder.classList.add("stale"); body.className = "zone-body"; body.dataset.zone = id; - mount(el, head, banner, tabBar, holder); + mount(el, head, banner, otherNote, tabBar, holder); if (tab === "records") await recordsTab(body, zone, query); else if (tab === "settings") await settingsTab(body, zone); else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" }); diff --git a/scripts/e2e.sh b/scripts/e2e.sh index a6baa62..31d50ed 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -706,10 +706,11 @@ contains "nothing to add" # The same network in another view: internal's hosts aren't missing there, # and are named from internal only when asked. run "the network in external too, filled" 0 $J zone create 10.99.1.0/24 --view external --fill -contains "only other views' records use" +contains "A or AAAA records only in other views" run "nothing missing there" 0 $J zone show 1.99.10.in-addr.arpa --view external not_contains "Missing PTRs" -contains "^Other views: *2 addresses" +contains "^Other views: *2 addresses with A or AAAA records only in view internal" +contains "10.99.1.11 -> pi.revtest.test (view internal)" run "fill-ptr there: nothing of its own (exit 6)" 6 $J zone fill-ptr 10.99.1.0/24 --view external contains "--other-views" run "fill-ptr --other-views" 0 $J zone fill-ptr 10.99.1.0/24 --view external --other-views