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:
1 parent
b1719ce362
commit
304c89dba4
12 files changed
+228
-31
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
+33
-7
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
-10
@@ -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.
|
||||
|
||||
@@ -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 ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user