New reverse zone: pick from the networks your records use, sized around them

The Zones page's New reverse zone lists every network the records use without a reverse zone, in every view, also where nothing is suggested (suggestions off, dismissed, public views), marked. Picking one fills in the network and view; size buttons (/48 /56 /64 /128, /16 /24 /32) re-mask it around a record's address, so a /48 becomes the records' own /64. Fill stays on. jarl zone suggest-reverse --all [--view V]; GET /reverse-zones/suggestions?all=1&view=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 11:20:03 +01:00
1 parent b1719ce362
commit 304c89dba4
12 files changed
+228 -31

No files matched your search

+54 -10
View File
@@ -261,7 +261,10 @@ type ReverseSuggestion struct {
// Group is "yours" for networks that are yours for sure (private
// ranges, IPv6 prefixes this server has an address in), "public" for
// a public address that may be someone else's (a web host's).
Group string `json:"group"`
Group string `json:"group"`
// Hidden says why the suggestion isn't made unasked (with All only):
// "public view", "suggestions off" or "dismissed".
Hidden string `json:"hidden,omitempty"`
Why string `json:"why"`
Fill int `json:"fill"` // PTR records creating it with fill adds
Samples []string `json:"samples"` // a few of them, "address name"
@@ -285,6 +288,22 @@ const (
// Public views, views with suggestions turned off and dismissed networks
// get none: the internet asks the addresses' owners.
func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []netip.Addr) []ReverseSuggestion {
return ReverseCandidates(st, exposures, local, ReverseOptions{})
}
// ReverseOptions widen or narrow ReverseCandidates.
type ReverseOptions struct {
// All includes what isn't suggested unasked, marked Hidden: public
// views, views with suggestions off, dismissed networks.
All bool
// View narrows it to one view (by name; "" all).
View string
}
// ReverseCandidates is ReverseSuggestions with options: with All, every
// network the views' records use that no reverse zone in their view
// covers, for creating one by hand where suggestions are off.
func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []netip.Addr, opt ReverseOptions) []ReverseSuggestion {
public := map[int64]bool{}
for _, e := range exposures {
public[e.ViewID] = e.Public
@@ -292,16 +311,31 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []
type scope struct {
id int64
dismissed []string
hidden string
}
var scopes []scope
if len(st.Views) > 0 {
for _, v := range st.OrderedViews() {
if !public[v.ID] && !v.ReverseSuggestionsOff {
scopes = append(scopes, scope{v.ID, v.DismissedReverse})
if opt.View != "" && !strings.EqualFold(v.Name, opt.View) {
continue
}
hidden := ""
switch {
case public[v.ID]:
hidden = "public view"
case v.ReverseSuggestionsOff:
hidden = "suggestions off"
}
if hidden == "" || opt.All {
scopes = append(scopes, scope{v.ID, v.DismissedReverse, hidden})
}
}
} else if !public[0] {
scopes = append(scopes, scope{0, st.Settings.DismissedReverse})
} else if !public[0] || opt.All {
hidden := ""
if public[0] {
hidden = "public server"
}
scopes = append(scopes, scope{0, st.Settings.DismissedReverse, hidden})
}
out := []ReverseSuggestion{}
for _, sc := range scopes {
@@ -315,15 +349,24 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []
continue
}
p, group, why := suggestedPrefix(a, local)
if seen[p] || slices.Contains(sc.dismissed, p.String()) {
hidden := sc.hidden
if seen[p] {
continue
}
if slices.Contains(sc.dismissed, p.String()) {
if !opt.All {
continue
}
if hidden == "" {
hidden = "dismissed"
}
}
seen[p] = true
name, err := dnsname.ReverseZone(p)
if err != nil {
continue
}
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Why: why, Samples: []string{}}
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
fills := PTRFills(st, sc.id, name, nil)
s.Fill = len(fills)
for i, f := range fills {
@@ -365,13 +408,14 @@ func suggestedPrefix(a netip.Addr, local []netip.Addr) (netip.Prefix, string, st
return netip.PrefixFrom(a, 128), SuggestPublic, "a public address outside this server's networks: create it only if it is yours"
}
// ReverseSuggestions lists the reverse zones the private views lack.
func (e *Engine) ReverseSuggestions(st *model.State) []ReverseSuggestion {
// ReverseSuggestions lists the reverse zones the views lack (opt.All:
// also where suggestions aren't made unasked).
func (e *Engine) ReverseSuggestions(st *model.State, opt ReverseOptions) []ReverseSuggestion {
var local []netip.Addr
if e.cfg.LocalAddrs != nil {
local = e.cfg.LocalAddrs()
}
return ReverseSuggestions(st, e.Exposures(st), local)
return ReverseCandidates(st, e.Exposures(st), local, opt)
}
// reverseDismiss expands reverse.dismiss and reverse.undismiss.
+27
View File
@@ -235,3 +235,30 @@ func TestChildZoneTakesRecords(t *testing.T) {
t.Error("the delegation left the parent")
}
}
// With All, the networks no suggestion is made for unasked are listed too,
// marked: views with suggestions off, dismissed ones, public views.
func TestReverseCandidatesAll(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "nas", Type: "AAAA", Data: []string{"2a02:8013:7f3d:10::5"}, Local: model.Ptr(true)}))
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"}))
internal := env.state().ViewByName("internal")
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
list := func(opt ReverseOptions) []string {
var out []string
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, opt) {
out = append(out, s.View+" "+s.Prefix+" "+s.Hidden)
}
return out
}
want(t, "unasked", list(ReverseOptions{}))
want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}),
"internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off")
got := list(ReverseOptions{All: true, View: "external"})
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view")
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)}))
want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}),
"internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ")
}
+4 -2
View File
@@ -22,14 +22,16 @@ func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Pri
}
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
// zones the private views lack for their records' addresses.
// zones the private views lack for their records' addresses. ?all=1 adds
// the ones not suggested unasked (public views, views with suggestions
// off, dismissed), marked hidden; ?view= narrows it to one view.
func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
out := []engine.ReverseSuggestion{}
for _, sg := range s.eng.ReverseSuggestions(st) {
for _, sg := range s.eng.ReverseSuggestions(st, engine.ReverseOptions{All: queryBool(r, "all"), View: r.URL.Query().Get("view")}) {
if p.CanSeeZone(sg.Zone+".", sg.View) {
out = append(out, sg)
}
+14
View File
@@ -182,3 +182,17 @@ export function checkDomain(input) {
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
return { name, problems, notes };
}
// maskNetwork returns the network of the given size an address (or a
// network, "2a02:8013:7f3d:10::/64") is in: maskNetwork("2a02:8013:7f3d:10::5", 48)
// is "2a02:8013:7f3d::/48". null when it isn't an address.
export function maskNetwork(input, bits) {
const addr = String(input).trim().split("/")[0];
const b4 = parseIPv4(addr);
const n6 = b4 ? null : parseIPv6(addr);
if (!b4 && !n6) return null;
const digits = b4 || n6.map((x) => parseInt(x, 16));
const step = b4 ? 8 : 4;
const net = digits.map((d, i) => { const k = Math.max(0, Math.min(step, bits - i * step)); return d & ~((1 << (step - k)) - 1) & ((1 << step) - 1); });
return (b4 ? net.join(".") : sixOf(net.map((d) => d.toString(16)))) + "/" + bits;
}
+60 -11
View File
@@ -1,7 +1,7 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural } from "../ui.js";
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js";
import { checkDomain, reverseZoneFor, maskNetwork } from "../names.js";
export async function render(el, params, query) {
const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"),
@@ -39,7 +39,8 @@ export async function render(el, params, query) {
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
can("zones", true) ? [h("button", { title: "A zone for the PTR records of a network: Jarl lists the networks your records use", onclick: () => newZoneDialog(views, zones, settings, { kind: "reverse" }) }, "New reverse zone"), " ",
h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone")] : null)),
h("div", { class: "panel" }, list),
suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null);
draw();
@@ -51,16 +52,25 @@ export async function render(el, params, query) {
}
}
// preset.view picks the view (opened from a view's page).
// preset.view picks the view (opened from a view's page), preset.kind
// "reverse" starts with a reverse zone.
export async function newZoneDialog(views, zones, settings, preset = {}) {
const err = h("div");
// The zone's name matters most and is easy to get wrong, so the dialog
// asks for a domain or a network and shows the resulting zone name.
let kind = "forward";
let kind = preset.kind === "reverse" ? "reverse" : "forward";
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
const nameLabel = h("label", { for: "zone-name" }, "Domain");
name.id = "zone-name";
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
// For a reverse zone: sizes to pick from, and the networks the records
// use without a reverse zone, in every view (docs/reverse.md).
const helper = h("div", { class: "reverse-helper" });
let cands = null;
// sizeFrom is the address the size buttons work from: a record's, once a
// network was picked, so a /48 made smaller becomes that record's /64,
// not the /48's first one.
let sizeFrom = null;
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
@@ -149,6 +159,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
syncFill();
if (k === "reverse" && !cands) get("/reverse-zones/suggestions", draftQuery({ all: 1 })).catch(() => []).then((c) => { cands = c; drawHelper(); });
drawHelper();
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
check();
name.focus();
@@ -156,14 +168,46 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
}
name.addEventListener("input", () => { mount(err); updateClones(); check(); });
view.addEventListener("change", check);
const changed = () => { mount(err); updateClones(); check(); drawHelper(); };
const typed = () => { sizeFrom = null; changed(); };
const drawHelper = () => {
if (kind !== "reverse") return mount(helper);
const v = name.value.trim();
const isNet = v && maskNetwork(v, 32) !== null;
const v6 = v.includes(":");
const bits = Number(v.split("/")[1]);
const sizes = isNet ? h("div", { class: "size-chips" }, h("span", { class: "muted small" }, "Size: "),
(v6 ? [[48, "a whole site (all its /64s)"], [56, "a smaller delegation"], [64, "one subnet"], [128, "one address"]] : [[16, "a /16"], [24, "one network"], [32, "one address"]])
.map(([b, title]) => h("button", { type: "button", class: "small" + (bits === b ? " active" : ""), title, onclick: () => { name.value = maskNetwork(sizeFrom || name.value, b); changed(); } }, "/" + b)),
sizeFrom ? h("span", { class: "muted small" }, " around " + sizeFrom) : null) : null;
const list = (cands || []).filter((c) => !view.value || c.view === view.value);
const shown = list.slice(0, 12);
mount(helper, sizes,
shown.length ? h("div", { class: "cands" },
h("div", { class: "muted small" }, "Networks your records use without a reverse zone" + (view.value ? " in view " + view.value : "") + ": pick one, then change its size if you need"),
shown.map((c) => h("button", { type: "button", class: "cand", title: c.why, onclick: () => {
name.value = c.prefix;
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
if (views.length && c.view) view.value = c.view;
changed();
} },
h("span", { class: "mono" }, c.prefix),
views.length && !view.value ? h("span", { class: "muted" }, " " + c.view) : null,
h("span", { class: "muted small" }, " " + c.fill + " PTR" + (c.samples.length ? " · " + c.samples.slice(0, 2).join(", ") : "")),
c.hidden ? [" ", badge(c.hidden, "off")] : null)),
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more (pick a view to narrow the list)") : null)
: cands && !list.length ? h("div", { class: "muted small" }, "Every address your records use" + (view.value ? " in view " + view.value : "") + " has a reverse zone.") : null);
};
name.addEventListener("input", typed);
view.addEventListener("change", () => { check(); drawHelper(); });
const glueRow = field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue).");
const cloneRow = field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).");
const rows = {
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
glueRow,
field("SOA contact", contact, "Email address of the zone's administrator."),
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).")],
cloneRow],
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
stub: [field("Primary servers", primaries, "One per line.")],
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
@@ -173,7 +217,11 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
};
const typeRows = h("div", { class: "form-grid" });
const syncFill = () => { for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none"; };
const syncFill = () => {
for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none";
// Glue and copying a zone from another view are for domains.
for (const n of [...glueRow, ...cloneRow]) if (n) n.style.display = kind === "reverse" ? "none" : "";
};
const drawType = () => {
syncFill();
mount(typeRows, rows[type.value] || []);
@@ -185,14 +233,15 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
const body = h("div", null,
h("div", { class: "form-grid" },
h("label", null, "Kind"), h("div", null, kindButtons),
nameLabel, h("div", null, name, preview),
nameLabel, h("div", { class: "grid-cell" }, name, preview, helper),
views.length ? field("View", view) : null,
field("Type", type),
fillRow,
field("Comment", comment)),
h("div", { style: { marginTop: "8px" } }, typeRows),
err);
await modal("New zone", body, [
if (kind === "reverse") setKind("reverse");
await modal(kind === "reverse" ? "New reverse zone" : "New zone", body, [
{ label: "Cancel", value: null },
{ label: "Create zone", kind: "primary", onclick: async () => {
const t = type.value;
+8
View File
@@ -582,3 +582,11 @@ table.compare tr.muted td { color: var(--muted); }
/* Text that wraps anywhere, e.g. long statements in the Added by Jarl table. */
pre.wrap { white-space: pre-wrap; word-break: break-all; }
/* New reverse zone: sizes and the networks the records use. */
.reverse-helper { margin-top: 6px; }
.form-grid > .grid-cell { min-width: 0; }
.size-chips { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
.size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; }
.cands button.cand { text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }