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

+5
View File
@@ -37,6 +37,11 @@ All notable changes to Jarl are documented here. The format follows
delegation and its glue stay. delegation and its glue stay.
- Zone commands take a network for a reverse zone: `jarl zone show - Zone commands take a network for a reverse zone: `jarl zone show
10.0.10.0/24`. 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]`, - Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
and a **Compare** button on each record. and a **Compare** button on each record.
+33 -7
View File
@@ -1201,13 +1201,14 @@ type reverseSuggestion struct {
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
Zone string `json:"zone"` Zone string `json:"zone"`
Group string `json:"group"` Group string `json:"group"`
Hidden string `json:"hidden"`
Why string `json:"why"` Why string `json:"why"`
Fill int `json:"fill"` Fill int `json:"fill"`
Samples []string `json:"samples"` Samples []string `json:"samples"`
} }
func zoneSuggestReverseCmd() *cobra.Command { func zoneSuggestReverseCmd() *cobra.Command {
var create bool var create, all bool
var dismiss, undismiss, view string var dismiss, undismiss, view string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]", 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 changeset; create a public one with 'jarl zone create NETWORK --view V
--fill'. --dismiss hides a suggestion for good (--undismiss shows it --fill'. --dismiss hides a suggestion for good (--undismiss shows it
again). A view can stop suggestions: jarl view update V 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 Example: ` jarl zone suggest-reverse
jarl zone suggest-reverse --create jarl zone suggest-reverse --create
jarl zone create 139.144.144.154/32 --view internal --fill 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, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
@@ -1246,8 +1255,15 @@ again). A view can stop suggestions: jarl view update V
} }
return report(resp) 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 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 return err
} }
if !create { if !create {
@@ -1256,13 +1272,22 @@ again). A view can stop suggestions: jarl view update V
fmt.Fprintln(w, "No reverse zones missing.") fmt.Fprintln(w, "No reverse zones missing.")
return 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 { for _, s := range sugg {
v := s.View v := s.View
if v == "" { if v == "" {
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).") 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().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(&dismiss, "dismiss", "", "hide the suggestion for this network")
cmd.Flags().StringVar(&undismiss, "undismiss", "", "show a dismissed suggestion again") 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 return cmd
} }
+1 -1
View File
@@ -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}/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}/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)) | | 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 | | 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 | | 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 |
+1
View File
@@ -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 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 [--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 --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 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 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 jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
+19
View File
@@ -66,6 +66,25 @@ remembers, per view (`--undismiss` brings one back).
Loopback and link-local addresses, wildcards and disabled records are left Loopback and link-local addresses, wildcards and disabled records are left
out. 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 ## Creating and filling
```sh ```sh
+54 -10
View File
@@ -261,7 +261,10 @@ type ReverseSuggestion struct {
// Group is "yours" for networks that are yours for sure (private // Group is "yours" for networks that are yours for sure (private
// ranges, IPv6 prefixes this server has an address in), "public" for // ranges, IPv6 prefixes this server has an address in), "public" for
// a public address that may be someone else's (a web host's). // 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"` Why string `json:"why"`
Fill int `json:"fill"` // PTR records creating it with fill adds Fill int `json:"fill"` // PTR records creating it with fill adds
Samples []string `json:"samples"` // a few of them, "address name" 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 // Public views, views with suggestions turned off and dismissed networks
// get none: the internet asks the addresses' owners. // get none: the internet asks the addresses' owners.
func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []netip.Addr) []ReverseSuggestion { 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{} public := map[int64]bool{}
for _, e := range exposures { for _, e := range exposures {
public[e.ViewID] = e.Public public[e.ViewID] = e.Public
@@ -292,16 +311,31 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []
type scope struct { type scope struct {
id int64 id int64
dismissed []string dismissed []string
hidden string
} }
var scopes []scope var scopes []scope
if len(st.Views) > 0 { if len(st.Views) > 0 {
for _, v := range st.OrderedViews() { for _, v := range st.OrderedViews() {
if !public[v.ID] && !v.ReverseSuggestionsOff { if opt.View != "" && !strings.EqualFold(v.Name, opt.View) {
scopes = append(scopes, scope{v.ID, v.DismissedReverse}) 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] { } else if !public[0] || opt.All {
scopes = append(scopes, scope{0, st.Settings.DismissedReverse}) hidden := ""
if public[0] {
hidden = "public server"
}
scopes = append(scopes, scope{0, st.Settings.DismissedReverse, hidden})
} }
out := []ReverseSuggestion{} out := []ReverseSuggestion{}
for _, sc := range scopes { for _, sc := range scopes {
@@ -315,15 +349,24 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []
continue continue
} }
p, group, why := suggestedPrefix(a, local) p, group, why := suggestedPrefix(a, local)
if seen[p] || slices.Contains(sc.dismissed, p.String()) { hidden := sc.hidden
if seen[p] {
continue continue
} }
if slices.Contains(sc.dismissed, p.String()) {
if !opt.All {
continue
}
if hidden == "" {
hidden = "dismissed"
}
}
seen[p] = true seen[p] = true
name, err := dnsname.ReverseZone(p) name, err := dnsname.ReverseZone(p)
if err != nil { if err != nil {
continue 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) fills := PTRFills(st, sc.id, name, nil)
s.Fill = len(fills) s.Fill = len(fills)
for i, f := range 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" 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. // ReverseSuggestions lists the reverse zones the views lack (opt.All:
func (e *Engine) ReverseSuggestions(st *model.State) []ReverseSuggestion { // also where suggestions aren't made unasked).
func (e *Engine) ReverseSuggestions(st *model.State, opt ReverseOptions) []ReverseSuggestion {
var local []netip.Addr var local []netip.Addr
if e.cfg.LocalAddrs != nil { if e.cfg.LocalAddrs != nil {
local = e.cfg.LocalAddrs() 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. // 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") 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 // 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 { func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r) st, err := s.state(r)
if err != nil { if err != nil {
return err return err
} }
out := []engine.ReverseSuggestion{} 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) { if p.CanSeeZone(sg.Zone+".", sg.View) {
out = append(out, sg) 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"); if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
return { name, problems, notes }; 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 { 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 { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.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) { export async function render(el, params, query) {
const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"), 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: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")), 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, 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), h("div", { class: "panel" }, list),
suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null); suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null);
draw(); 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 = {}) { export async function newZoneDialog(views, zones, settings, preset = {}) {
const err = h("div"); const err = h("div");
// The zone's name matters most and is easy to get wrong, so the dialog // 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. // 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 name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
const nameLabel = h("label", { for: "zone-name" }, "Domain"); const nameLabel = h("label", { for: "zone-name" }, "Domain");
name.id = "zone-name"; name.id = "zone-name";
const preview = h("div", { class: "name-preview", "aria-live": "polite" }); 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 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 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 : "")); 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"; 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); for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
syncFill(); 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 –"; if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
check(); check();
name.focus(); 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"]]) { 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)); 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(); }); const changed = () => { mount(err); updateClones(); check(); drawHelper(); };
view.addEventListener("change", check); 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 = { const rows = {
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."), 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("SOA contact", contact, "Email address of the zone's administrator."),
field("Default TTL", ttl, "Seconds, used by records without their own TTL."), 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].")], secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
stub: [field("Primary servers", primaries, "One per line.")], stub: [field("Primary servers", primaries, "One per line.")],
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")], 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.")], "in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
}; };
const typeRows = h("div", { class: "form-grid" }); 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 = () => { const drawType = () => {
syncFill(); syncFill();
mount(typeRows, rows[type.value] || []); mount(typeRows, rows[type.value] || []);
@@ -185,14 +233,15 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
const body = h("div", null, const body = h("div", null,
h("div", { class: "form-grid" }, h("div", { class: "form-grid" },
h("label", null, "Kind"), h("div", null, kindButtons), 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, views.length ? field("View", view) : null,
field("Type", type), field("Type", type),
fillRow, fillRow,
field("Comment", comment)), field("Comment", comment)),
h("div", { style: { marginTop: "8px" } }, typeRows), h("div", { style: { marginTop: "8px" } }, typeRows),
err); 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: "Cancel", value: null },
{ label: "Create zone", kind: "primary", onclick: async () => { { label: "Create zone", kind: "primary", onclick: async () => {
const t = type.value; 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. */ /* Text that wraps anywhere, e.g. long statements in the Added by Jarl table. */
pre.wrap { white-space: pre-wrap; word-break: break-all; } 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; }
+2
View File
@@ -705,6 +705,8 @@ not_contains "^yours"
run "a view can turn suggestions off" 0 $J view update internal --reverse-suggestions=false 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 run "then none for it" 0 $J zone suggest-reverse
not_contains "internal" 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 run "and back on" 0 $J view update internal --reverse-suggestions
# A zone created inside another takes the parent's records under it. # 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 "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal