diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ecbc8b..9ec2956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,11 @@ All notable changes to Jarl are documented here. The format follows delegation and its glue stay. - Zone commands take a network for a reverse zone: `jarl zone show 10.0.10.0/24`. +- **New reverse zone** (Zones page) lists the networks your records use + 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]`. - 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 a173096..87001f9 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -1201,13 +1201,14 @@ type reverseSuggestion struct { Prefix string `json:"prefix"` Zone string `json:"zone"` Group string `json:"group"` + Hidden string `json:"hidden"` Why string `json:"why"` Fill int `json:"fill"` Samples []string `json:"samples"` } func zoneSuggestReverseCmd() *cobra.Command { - var create bool + var create, all bool var dismiss, undismiss, view string cmd := &cobra.Command{ Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]", @@ -1224,11 +1225,19 @@ lack for the addresses of their A and AAAA records, in two groups: changeset; create a public one with 'jarl zone create NETWORK --view V --fill'. --dismiss hides a suggestion for good (--undismiss shows it again). A view can stop suggestions: jarl view update V ---reverse-suggestions=false (docs/reverse.md).`, +--reverse-suggestions=false (docs/reverse.md). + +--all lists every network the views' records use without a reverse zone, +also where nothing is suggested unasked (a view with suggestions off, a +dismissed network, a public view), to create one by hand; --view narrows +the list to one view. Change the size as you need: a /48 suggested for +IPv6 can be created as a /56 or /64 instead.`, Example: ` jarl zone suggest-reverse jarl zone suggest-reverse --create jarl zone create 139.144.144.154/32 --view internal --fill - jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal`, + jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal + jarl zone suggest-reverse --all --view vps + jarl zone create 2a02:8013:7f3d::/48 --view internal --fill`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() @@ -1246,8 +1255,15 @@ again). A view can stop suggestions: jarl view update V } return report(resp) } + if create && (all || view != "") { + return fmt.Errorf("--create creates the suggestions that are yours; create others one by one: jarl zone create NETWORK --view VIEW --fill") + } + qv := q("view", view) + if all { + qv.Set("all", "1") + } var sugg []reverseSuggestion - if err := c.Get(ctx(), "/reverse-zones/suggestions", nil, &sugg); err != nil { + if err := c.Get(ctx(), "/reverse-zones/suggestions", qv, &sugg); err != nil { return err } if !create { @@ -1256,13 +1272,22 @@ again). A view can stop suggestions: jarl view update V fmt.Fprintln(w, "No reverse zones missing.") return } - fmt.Fprintln(w, "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE") + head := "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE" + if all { + head = "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tHIDDEN\tFOR EXAMPLE" + } + fmt.Fprintln(w, head) for _, s := range sugg { v := s.View if v == "" { v = "-" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", s.Group, v, s.Prefix, s.Zone, s.Fill, strings.Join(s.Samples, ", ")) + cols := []string{s.Group, v, s.Prefix, s.Zone, fmt.Sprint(s.Fill)} + if all { + cols = append(cols, orDash(s.Hidden)) + } + cols = append(cols, strings.Join(s.Samples, ", ")) + fmt.Fprintln(w, strings.Join(cols, "\t")) } fmt.Fprintln(w, "\nCreate yours, filled: jarl zone suggest-reverse --create. A public one only if the address is yours:\njarl zone create NETWORK --view VIEW --fill (or hide it: --dismiss NETWORK --view VIEW).") }) @@ -1292,6 +1317,7 @@ again). A view can stop suggestions: jarl view update V 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().StringVar(&view, "view", "", "with --dismiss/--undismiss: the suggestion's view") + 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 6d0a44d..37cdcc0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -361,7 +361,7 @@ and `linked_with` (the other views). | POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local | | 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: `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` | +| 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 | | 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 | diff --git a/docs/cli.md b/docs/cli.md index 14b3264..b30f544 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -181,6 +181,7 @@ jarl zone create 10.0.10.0/24 --fill # in the view whose recor 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 --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) jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal diff --git a/docs/reverse.md b/docs/reverse.md index 8103419..1596161 100644 --- a/docs/reverse.md +++ b/docs/reverse.md @@ -66,6 +66,25 @@ remembers, per view (`--undismiss` brings one back). Loopback and link-local addresses, wildcards and disabled records are left out. +## Creating one by hand + +Where nothing is suggested (a view with suggestions off, a dismissed +network, a public view) or the suggested size isn't the one you want, +**New reverse zone** on the Zones page lists every network your records +use that has no reverse zone, in every view, marked when it isn't +suggested unasked. Pick one: the network and the view are filled in. +Then the **Size** buttons change it, around the address of one of the +records: a suggested `2a02:8013:7f3d::/48` becomes +`2a02:8013:7f3d:10::/64` for the subnet the records are in, or `/128` +for one address. The zone name shows as you go, and **PTR records** stays +ticked, so the zone is created with a PTR record for each address +(IPv6 nibble names written for you). + +```sh +jarl zone suggest-reverse --all --view vps # every network, hidden ones marked +jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill +``` + ## Creating and filling ```sh diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go index 69e88d9..7e36caf 100644 --- a/internal/engine/reverse.go +++ b/internal/engine/reverse.go @@ -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. diff --git a/internal/engine/reverse_test.go b/internal/engine/reverse_test.go index 4479c97..80f1f67 100644 --- a/internal/engine/reverse_test.go +++ b/internal/engine/reverse_test.go @@ -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 ") +} diff --git a/internal/server/h_reverse.go b/internal/server/h_reverse.go index 2ccc1ee..acc05bd 100644 --- a/internal/server/h_reverse.go +++ b/internal/server/h_reverse.go @@ -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) } diff --git a/internal/web/static/assets/names.js b/internal/web/static/assets/names.js index 0fcd232..23ed71d 100644 --- a/internal/web/static/assets/names.js +++ b/internal/web/static/assets/names.js @@ -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; +} diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index 52acfc5..edc588e 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -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; diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 33d368f..317bf0d 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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; } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index e1f180f..1ca73ae 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -705,6 +705,8 @@ not_contains "^yours" run "a view can turn suggestions off" 0 $J view update internal --reverse-suggestions=false run "then none for it" 0 $J zone suggest-reverse 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 # 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