Reverse zones: name hosts from other views' records; search and bulk dismiss for long lists

A reverse zone's fill (and fill-ptr, the missing-PTR counts) names an address no record of its own view uses from another view's record, so an internal reverse zone can give the PTR of a host only external has an AAAA for; New reverse zone with a view chosen lists those addresses, marked. Automatic suggestions stay per view.

For hundreds of addresses: the New reverse zone list has a search; on the Zones page the public addresses fold away, have a search, show 25 at a time and Dismiss these N dismisses everything a search found in one change (reverse.dismiss takes prefixes; --dismiss takes several).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 17:26:43 +01:00
1 parent 304c89dba4
commit bfab83da6e
11 files changed
+239 -41

No files matched your search

+3 -1
View File
@@ -73,7 +73,9 @@ const (
type DismissInput struct {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Prefix string `json:"prefix"`
Prefix string `json:"prefix,omitempty"`
// Prefixes dismisses (or brings back) several at once.
Prefixes []string `json:"prefixes,omitempty"`
}
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
+79 -16
View File
@@ -21,6 +21,7 @@ type addrUse struct {
addr netip.Addr
name string // the owner name, absolute
apex bool
view string // another view's record (fillAddresses), else ""
}
// forwardAddresses returns the addresses of the enabled A and AAAA
@@ -46,19 +47,51 @@ func forwardAddresses(st *model.State, viewID int64) []addrUse {
return out
}
// fillAddresses returns a view's addresses (forwardAddresses), then those
// of the other views' records that none of its own records use, marked
// with their view: a reverse zone in the internal view can name a host
// only the external view has a record for.
func fillAddresses(st *model.State, viewID int64) []addrUse {
out := forwardAddresses(st, viewID)
if len(st.Views) == 0 {
return out
}
own := map[netip.Addr]bool{}
for _, u := range out {
own[u.addr] = true
}
for _, v := range st.OrderedViews() {
if v.ID == viewID {
continue
}
for _, u := range forwardAddresses(st, v.ID) {
if !own[u.addr] {
u.view = v.Name
out = append(out, u)
}
}
}
return out
}
// PTRFill is a PTR record filling a reverse zone would add.
type PTRFill struct {
Address string `json:"address"`
Name string `json:"name"` // the PTR owner name
Target string `json:"target"` // the host name it gives
Others []string `json:"others,omitempty"` // other names with this address
// From is the view whose record names the host, when the zone's own
// view has none for the address.
From string `json:"from,omitempty"`
}
// PTRFills returns the PTR records a reverse zone named zoneName in a view
// lacks for the addresses of the view's A and AAAA records: one per
// address, naming its mail or name server host when it is one, otherwise
// a host rather than the domain itself, then the shortest name.
// existing are the zone's records (none for a new zone).
// a host rather than the domain itself, then the shortest name. An
// address no record of the view uses is named from another view's
// records (fillAddresses), marked From. existing are the zone's records
// (none for a new zone).
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok {
@@ -73,7 +106,7 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
servers := serverNames(st, viewID)
byAddr := map[netip.Addr][]addrUse{}
var order []netip.Addr
for _, u := range forwardAddresses(st, viewID) {
for _, u := range fillAddresses(st, viewID) {
if !prefix.Contains(u.addr) {
continue
}
@@ -110,9 +143,11 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
}
return strings.Compare(x.name, y.name)
})
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name}
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name, From: uses[0].view}
for _, u := range uses[1:] {
f.Others = append(f.Others, u.name)
if u.name != f.Target {
f.Others = append(f.Others, u.name)
}
}
out = append(out, f)
}
@@ -143,6 +178,9 @@ func serverNames(st *model.State, viewID int64) map[string]bool {
func fillWarnings(fills []PTRFill) []string {
var out []string
for _, f := range fills {
if f.From != "" {
out = append(out, fmt.Sprintf("%s: only view %s has a record for it, so its PTR record gives %s from there", f.Address, f.From, dnsname.Display(f.Target)))
}
if len(f.Others) > 0 {
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
}
@@ -296,7 +334,9 @@ 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 narrows it to one view (by name; "" all). With All, the view
// also gets the addresses only other views' records use, marked
// Hidden "records in VIEW": a reverse zone there can name them.
View string
}
@@ -340,7 +380,11 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
out := []ReverseSuggestion{}
for _, sc := range scopes {
seen := map[netip.Prefix]bool{}
for _, u := range forwardAddresses(st, sc.id) {
addrs := forwardAddresses(st, sc.id)
if opt.All && opt.View != "" {
addrs = fillAddresses(st, sc.id)
}
for _, u := range addrs {
a := u.addr
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
continue
@@ -350,6 +394,9 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
}
p, group, why := suggestedPrefix(a, local)
hidden := sc.hidden
if hidden == "" && u.view != "" {
hidden = "records in " + u.view
}
if seen[p] {
continue
}
@@ -424,11 +471,23 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
if err := decode(req.Data, &in); err != nil {
return nil, err
}
p, err := netip.ParsePrefix(strings.TrimSpace(in.Prefix))
if err != nil {
return nil, errf(KindBadRequest, "prefix: %v", err)
raw := in.Prefixes
if in.Prefix != "" {
raw = append(raw, in.Prefix)
}
if len(raw) == 0 {
return nil, errf(KindBadRequest, "give the network (prefix) of the suggestion")
}
var prefixes []string
for _, r := range raw {
p, err := netip.ParsePrefix(strings.TrimSpace(r))
if err != nil {
return nil, errf(KindBadRequest, "prefix: %v", err)
}
if s := p.Masked().String(); !slices.Contains(prefixes, s) {
prefixes = append(prefixes, s)
}
}
p = p.Masked()
var v *model.View
switch {
case in.ViewID != 0:
@@ -446,16 +505,20 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
if v != nil {
list, where = v.DismissedReverse, "view "+v.Name
}
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return s == p.String() })
summary := "dismiss the reverse zone suggestion for " + p.String() + " in " + where
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return slices.Contains(prefixes, s) })
what := prefixes[0]
if len(prefixes) > 1 {
what = fmt.Sprintf("%d networks (%s, …)", len(prefixes), prefixes[0])
}
summary := "dismiss the reverse zone suggestion for " + what + " in " + where
if dismiss {
out = append(out, p.String())
out = append(out, prefixes...)
slices.Sort(out)
} else {
if len(out) == len(list) {
return nil, errf(KindConflict, "%s isn't dismissed in %s", p, where)
return nil, errf(KindConflict, "%s isn't dismissed in %s", what, where)
}
summary = "suggest a reverse zone for " + p.String() + " in " + where + " again"
summary = "suggest a reverse zone for " + what + " in " + where + " again"
}
if out == nil {
out = []string{}
+42 -1
View File
@@ -169,6 +169,12 @@ func TestReverseSuggestionGroups(t *testing.T) {
t.Errorf("undismiss of one not dismissed: %v", e)
}
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
// Several at once.
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) {
t.Errorf("dismissed together, still suggested: %q", got)
}
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("undismissed not suggested: %q", got)
}
@@ -256,9 +262,44 @@ func TestReverseCandidatesAll(t *testing.T) {
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")
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 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 ")
}
// An address only another view has a record for: a reverse zone in the
// internal view is offered for it, and filled from that view's record.
func TestReverseFromOtherView(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: "external", Name: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)}))
var got []string
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
got = append(got, s.Prefix+" "+s.Hidden)
}
if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") {
t.Errorf("internal candidates %q", got)
}
// Not suggested unasked: the internal view's own records don't use it.
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
if strings.HasPrefix(s.Prefix, "2600:") {
t.Errorf("suggested unasked: %+v", s)
}
}
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
st := env.state()
var zone string
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
zone = z.Name
}
}
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
t.Errorf("filled %q", p)
}
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
t.Errorf("warnings %q", res.Warnings)
}
}
+68 -9
View File
@@ -1,4 +1,4 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural } from "../ui.js";
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural, confirmDialog } from "../ui.js";
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor, maskNetwork } from "../names.js";
@@ -66,7 +66,20 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
// 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" });
// The networks per view picked ("" for all views): a chosen view's list
// also has the addresses only other views' records use.
const candsBy = {};
let cands = null;
const candQ = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { marginBottom: "6px" } });
candQ.addEventListener("input", () => drawHelper());
const loadCands = () => {
const k = view.value || "";
if (candsBy[k]) { cands = candsBy[k]; return drawHelper(); }
get("/reverse-zones/suggestions", draftQuery({ all: 1, view: k || undefined })).catch(() => []).then((c) => {
candsBy[k] = c;
if ((view.value || "") === k) { cands = c; drawHelper(); }
});
};
// 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.
@@ -159,7 +172,7 @@ 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(); });
if (k === "reverse") loadCands();
drawHelper();
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
check();
@@ -180,9 +193,13 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
(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);
const q = candQ.value.trim().toLowerCase();
// Yours first, then the networks with the most records.
const list = (cands || []).filter((c) => !q || [c.prefix, c.zone, c.view, c.hidden, ...c.samples].join(" ").toLowerCase().includes(q))
.sort((a, b) => (a.group === "yours" ? 0 : 1) - (b.group === "yours" ? 0 : 1) || b.fill - a.fill);
const shown = list.slice(0, 15);
mount(helper, sizes,
(cands || []).length > 8 ? candQ : null,
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: () => {
@@ -195,11 +212,12 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
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)
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more: search to find one") : null)
: q && cands && cands.length ? h("div", { class: "muted small" }, "No network matches " + JSON.stringify(candQ.value.trim()) + ".")
: 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(); });
view.addEventListener("change", () => { check(); if (kind === "reverse") loadCands(); else 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 = {
@@ -364,7 +382,48 @@ function suggestPanel(sugg, views) {
yours.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(yours) }, "Create all " + yours.length)) : null),
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
yours.length ? table(cols(false), yours) : null,
pub.length ? [h("h3", null, "Public addresses: only if they're yours"),
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's (a web host's). Create a zone for an address that is yours; dismiss the others. Each gets a zone of its own: the rest of its network belongs to others."),
table(cols(true), pub)] : null);
pub.length ? publicGroup(pub, cols(true), create) : null);
}
// publicGroup lists the public addresses, collapsed: there can be hundreds
// (a web host's), so it has a search, shows 25 at a time, and dismisses
// what the search found at once.
function publicGroup(pub, cols, create) {
const PAGE = 25;
let limit = PAGE;
const q = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { width: "300px" } });
const body = h("div");
const match = () => {
const t = q.value.trim().toLowerCase();
return pub.filter((s) => !t || [s.prefix, s.zone, s.view, ...s.samples].join(" ").toLowerCase().includes(t));
};
const dismissAll = async (list) => {
const byView = new Map();
for (const s of list) {
const k = s.view_id || 0;
if (!byView.has(k)) byView.set(k, []);
byView.get(k).push(s.prefix);
}
if (!await confirmDialog("Dismiss " + plural(list.length, "address", "addresses"), "Jarl stops suggesting reverse zones for " + (list.length === 1 ? list[0].prefix : list.length + " public addresses" + (q.value.trim() ? " matching " + JSON.stringify(q.value.trim()) : "")) + ". Bring one back with: jarl zone suggest-reverse --undismiss NETWORK --view VIEW.", "Dismiss")) return;
try {
const ops = [...byView].map(([vid, prefixes]) => ({ op: "reverse.dismiss", data: { view_id: vid || undefined, prefixes } }));
toast(describeResult(await changeMany(ops, "dismiss " + plural(list.length, "reverse zone suggestion"))));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const draw = () => {
const list = match();
const shown = list.slice(0, limit);
mount(body,
h("div", { class: "compare-controls" }, q,
list.length ? h("button", { class: "small", title: "Not yours: stop suggesting all of these", onclick: () => dismissAll(list) }, list.length === pub.length ? "Dismiss all " + list.length : "Dismiss these " + list.length) : null),
shown.length ? table(cols, shown) : h("div", { class: "muted small" }, "No address matches."),
list.length > shown.length ? h("button", { class: "small", onclick: () => { limit += PAGE; draw(); } }, "Show " + Math.min(PAGE, list.length - shown.length) + " more (of " + (list.length - shown.length) + ")") : null);
};
q.addEventListener("input", () => { limit = PAGE; draw(); });
draw();
return h("details", { class: "compare-only", open: pub.length <= 10 },
h("summary", null, h("strong", null, "Public addresses: " + pub.length), h("span", { class: "muted small" }, " · create a zone only for an address that is yours; dismiss the others (a web host's)")),
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's. Each gets a zone of its own: the rest of its network belongs to others."),
body);
}
+1 -1
View File
@@ -589,4 +589,4 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; }
.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; }
.cands button.cand { flex-shrink: 0; text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }