From bfab83da6e08d49a6d3beadfa1f73761a4a6bcb5 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 13 Oct 2026 17:26:43 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 8 +- cmd/jarl/zones.go | 17 ++-- docs/api.md | 2 +- docs/cli.md | 2 +- docs/reverse.md | 17 ++++ internal/engine/inputs.go | 4 +- internal/engine/reverse.go | 95 +++++++++++++++++++---- internal/engine/reverse_test.go | 43 +++++++++- internal/web/static/assets/pages/zones.js | 77 +++++++++++++++--- internal/web/static/assets/style.css | 2 +- scripts/e2e.sh | 13 +++- 11 files changed, 239 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec2956..b7249dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,13 @@ All notable changes to Jarl are documented here. The format follows without a reverse zone, in every view, also where nothing is suggested (suggestions off, dismissed, public views); pick one, change its size (/48, /56, /64, /128 for IPv6, around the records' address) and create - it filled. `jarl zone suggest-reverse --all [--view V]`. + it filled. With a view chosen, it also lists the addresses only other + views' records use, and fill names them from there (a VPS's AAAA in + external, its PTR in internal). `jarl zone suggest-reverse --all + [--view V]`. Both lists have a search, so hundreds of addresses stay + manageable: the public addresses fold away, show 25 at a time, and + **Dismiss these N** dismisses everything a search found in one change + (`--dismiss` takes several networks). - Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`, and a **Compare** button on each record. diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index 87001f9..20e678f 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -1209,7 +1209,8 @@ type reverseSuggestion struct { func zoneSuggestReverseCmd() *cobra.Command { var create, all bool - var dismiss, undismiss, view string + var dismiss, undismiss []string + var view string cmd := &cobra.Command{ Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]", Short: "The reverse zones your private views lack for their records' addresses", @@ -1244,12 +1245,12 @@ IPv6 can be created as a /56 or /64 instead.`, if err != nil { return err } - if dismiss != "" || undismiss != "" { - path, prefix := "/reverse-zones/dismiss", dismiss - if undismiss != "" { - path, prefix = "/reverse-zones/undismiss", undismiss + if len(dismiss) > 0 || len(undismiss) > 0 { + path, prefixes := "/reverse-zones/dismiss", dismiss + if len(undismiss) > 0 { + path, prefixes = "/reverse-zones/undismiss", undismiss } - resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefix": prefix}) + resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefixes": prefixes}) if err != nil { return err } @@ -1315,8 +1316,8 @@ IPv6 can be created as a /56 or /64 instead.`, }, } cmd.Flags().BoolVar(&create, "create", false, "create the ones that are yours, filled with PTR records") - cmd.Flags().StringVar(&dismiss, "dismiss", "", "hide the suggestion for this network") - cmd.Flags().StringVar(&undismiss, "undismiss", "", "show a dismissed suggestion again") + cmd.Flags().StringSliceVar(&dismiss, "dismiss", nil, "hide the suggestions for these networks (comma separated or repeated)") + cmd.Flags().StringSliceVar(&undismiss, "undismiss", nil, "show dismissed suggestions again") cmd.Flags().StringVar(&view, "view", "", "only this view; with --dismiss/--undismiss: the suggestion's view") cmd.Flags().BoolVar(&all, "all", false, "also networks not suggested unasked (suggestions off, dismissed, public views)") return cmd diff --git a/docs/api.md b/docs/api.md index 37cdcc0..4b160f2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -362,7 +362,7 @@ and `linked_with` (the other views). | POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local | | POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) | | GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` | -| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}`: don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions | +| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions | | GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it | `POST /zones` takes `"link_with": "VIEW"` to create a zone linked with diff --git a/docs/cli.md b/docs/cli.md index b30f544..17d71f7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -180,7 +180,7 @@ jarl zone create 10.0.10.100/32 # a zone for just that ad jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too) jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours) -jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal # not yours: don't suggest it again +jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones jarl view update vps --reverse-suggestions=false # no suggestions for this view jarl zone update adbraun.lol --auto-ptr # keep PTRs in step with the A/AAAA records (docs/reverse.md) diff --git a/docs/reverse.md b/docs/reverse.md index 1596161..af07430 100644 --- a/docs/reverse.md +++ b/docs/reverse.md @@ -63,6 +63,14 @@ answer NXDOMAIN to your clients for all of their addresses. Create the ones that are yours one by one, and **Dismiss** the others: Jarl remembers, per view (`--undismiss` brings one back). +There can be hundreds of public addresses (records pointing at a web +host or a CDN), so the group is folded away when it is long. Opened, it +has a search and shows 25 at a time; **Dismiss these N** dismisses every +address the search found, in one change: search `104.16.` and dismiss +the CDN's addresses together. From the CLI, `--dismiss` takes several +networks (comma separated or repeated). The New reverse zone list has a +search too. + Loopback and link-local addresses, wildcards and disabled records are left out. @@ -85,6 +93,15 @@ jarl zone suggest-reverse --all --view vps # every network, jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill ``` +**Records in another view.** With a view chosen (`--view internal`, or +the dialog's View), the list also has the addresses only other views' +records use, marked "records in external": say a VPS's AAAA record that +only the external view has. Fill names those from the other view's +record, when the zone's own view has none for the address, and says so. +Keeping PTR records in step (below) stays within a zone's own view and +its linked views: a later change to that external record doesn't reach +the internal reverse zone. + ## Creating and filling ```sh diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index 8be1391..8f41e2a 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -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 diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go index 7e36caf..506bd8f 100644 --- a/internal/engine/reverse.go +++ b/internal/engine/reverse.go @@ -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{} diff --git a/internal/engine/reverse_test.go b/internal/engine/reverse_test.go index 80f1f67..237cca0 100644 --- a/internal/engine/reverse_test.go +++ b/internal/engine/reverse_test.go @@ -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) + } +} diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index edc588e..f9fd747 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -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); } diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 317bf0d..1e5d23f 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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; } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 1ca73ae..b68e232 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -696,9 +696,11 @@ expect_dig "PTR gone with it" "" -x 10.99.1.13 run "a web host's address" 0 $J record add revtest.test blog A 185.199.108.153 --view internal run "suggested as public" 0 $J zone suggest-reverse contains "^public *internal *185.199.108.153/32" -run "dismiss it" 0 $J zone suggest-reverse --dismiss 185.199.108.153/32 --view internal +run "two more web host addresses" 0 $J record add revtest.test docs A 185.199.109.153 --view internal +run "and" 0 $J record add revtest.test pages A 185.199.110.153 --view internal +run "dismiss three at once" 0 $J zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --dismiss 185.199.110.153/32 --view internal run "gone from the suggestions" 0 $J zone suggest-reverse -not_contains "185.199.108.153" +not_contains "185.199." run "suggest-reverse --create: yours only" 0 $J zone suggest-reverse --create run "none of yours missing now" 0 $J zone suggest-reverse not_contains "^yours" @@ -708,6 +710,13 @@ not_contains "internal" run "but --all still lists its networks" 0 $J zone suggest-reverse --all --view internal contains "^public *internal *185.199.108.153/32 .* suggestions off" run "and back on" 0 $J view update internal --reverse-suggestions +# An address only the external view has a record for: a reverse zone in +# the internal view can still name it. +run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external +run "offered for internal" 0 $J zone suggest-reverse --all --view internal +contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external" +run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill +expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1 # A zone created inside another takes the parent's records under it. run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal run "with a PTR made by hand" 0 $J record add 10.88.0.0/16 10.88.5.5 PTR printer.revtest.test --view internal