Reverse zones: name hosts from other views' records; search and bulk dismiss for long lists
A reverse zone's fill (and fill-ptr, the missing-PTR counts) names an address no record of its own view uses from another view's record, so an internal reverse zone can give the PTR of a host only external has an AAAA for; New reverse zone with a view chosen lists those addresses, marked. Automatic suggestions stay per view. For hundreds of addresses: the New reverse zone list has a search; on the Zones page the public addresses fold away, have a search, show 25 at a time and Dismiss these N dismisses everything a search found in one change (reverse.dismiss takes prefixes; --dismiss takes several). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
304c89dba4
commit
bfab83da6e
11 files changed
+236
-38
No files matched your search
+7
-1
@@ -41,7 +41,13 @@ All notable changes to Jarl are documented here. The format follows
|
||||
without a reverse zone, in every view, also where nothing is suggested
|
||||
(suggestions off, dismissed, public views); pick one, change its size
|
||||
(/48, /56, /64, /128 for IPv6, around the records' address) and create
|
||||
it filled. `jarl zone suggest-reverse --all [--view V]`.
|
||||
it filled. With a view chosen, it also lists the addresses only other
|
||||
views' records use, and fill names them from there (a VPS's AAAA in
|
||||
external, its PTR in internal). `jarl zone suggest-reverse --all
|
||||
[--view V]`. Both lists have a search, so hundreds of addresses stay
|
||||
manageable: the public addresses fold away, show 25 at a time, and
|
||||
**Dismiss these N** dismisses everything a search found in one change
|
||||
(`--dismiss` takes several networks).
|
||||
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
|
||||
and a **Compare** button on each record.
|
||||
|
||||
|
||||
+9
-8
@@ -1209,7 +1209,8 @@ type reverseSuggestion struct {
|
||||
|
||||
func zoneSuggestReverseCmd() *cobra.Command {
|
||||
var create, all bool
|
||||
var dismiss, undismiss, view string
|
||||
var dismiss, undismiss []string
|
||||
var view string
|
||||
cmd := &cobra.Command{
|
||||
Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]",
|
||||
Short: "The reverse zones your private views lack for their records' addresses",
|
||||
@@ -1244,12 +1245,12 @@ IPv6 can be created as a /56 or /64 instead.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dismiss != "" || undismiss != "" {
|
||||
path, prefix := "/reverse-zones/dismiss", dismiss
|
||||
if undismiss != "" {
|
||||
path, prefix = "/reverse-zones/undismiss", undismiss
|
||||
if len(dismiss) > 0 || len(undismiss) > 0 {
|
||||
path, prefixes := "/reverse-zones/dismiss", dismiss
|
||||
if len(undismiss) > 0 {
|
||||
path, prefixes = "/reverse-zones/undismiss", undismiss
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefix": prefix})
|
||||
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefixes": prefixes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1315,8 +1316,8 @@ IPv6 can be created as a /56 or /64 instead.`,
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&create, "create", false, "create the ones that are yours, filled with PTR records")
|
||||
cmd.Flags().StringVar(&dismiss, "dismiss", "", "hide the suggestion for this network")
|
||||
cmd.Flags().StringVar(&undismiss, "undismiss", "", "show a dismissed suggestion again")
|
||||
cmd.Flags().StringSliceVar(&dismiss, "dismiss", nil, "hide the suggestions for these networks (comma separated or repeated)")
|
||||
cmd.Flags().StringSliceVar(&undismiss, "undismiss", nil, "show dismissed suggestions again")
|
||||
cmd.Flags().StringVar(&view, "view", "", "only this view; with --dismiss/--undismiss: the suggestion's view")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "also networks not suggested unasked (suggestions off, dismissed, public views)")
|
||||
return cmd
|
||||
|
||||
+1
-1
@@ -362,7 +362,7 @@ and `linked_with` (the other views).
|
||||
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
||||
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) |
|
||||
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
|
||||
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}`: don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
||||
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
||||
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
|
||||
|
||||
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ jarl zone create 10.0.10.100/32 # a zone for just that ad
|
||||
jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs
|
||||
jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
|
||||
jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours)
|
||||
jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal # not yours: don't suggest it again
|
||||
jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
|
||||
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
|
||||
jarl view update vps --reverse-suggestions=false # no suggestions for this view
|
||||
jarl zone update adbraun.lol --auto-ptr # keep PTRs in step with the A/AAAA records (docs/reverse.md)
|
||||
|
||||
@@ -63,6 +63,14 @@ answer NXDOMAIN to your clients for all of their addresses. Create the
|
||||
ones that are yours one by one, and **Dismiss** the others: Jarl
|
||||
remembers, per view (`--undismiss` brings one back).
|
||||
|
||||
There can be hundreds of public addresses (records pointing at a web
|
||||
host or a CDN), so the group is folded away when it is long. Opened, it
|
||||
has a search and shows 25 at a time; **Dismiss these N** dismisses every
|
||||
address the search found, in one change: search `104.16.` and dismiss
|
||||
the CDN's addresses together. From the CLI, `--dismiss` takes several
|
||||
networks (comma separated or repeated). The New reverse zone list has a
|
||||
search too.
|
||||
|
||||
Loopback and link-local addresses, wildcards and disabled records are left
|
||||
out.
|
||||
|
||||
@@ -85,6 +93,15 @@ jarl zone suggest-reverse --all --view vps # every network,
|
||||
jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill
|
||||
```
|
||||
|
||||
**Records in another view.** With a view chosen (`--view internal`, or
|
||||
the dialog's View), the list also has the addresses only other views'
|
||||
records use, marked "records in external": say a VPS's AAAA record that
|
||||
only the external view has. Fill names those from the other view's
|
||||
record, when the zone's own view has none for the address, and says so.
|
||||
Keeping PTR records in step (below) stays within a zone's own view and
|
||||
its linked views: a later change to that external record doesn't reach
|
||||
the internal reverse zone.
|
||||
|
||||
## Creating and filling
|
||||
|
||||
```sh
|
||||
|
||||
@@ -73,7 +73,9 @@ const (
|
||||
type DismissInput struct {
|
||||
ViewID int64 `json:"view_id,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
Prefix string `json:"prefix"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
// Prefixes dismisses (or brings back) several at once.
|
||||
Prefixes []string `json:"prefixes,omitempty"`
|
||||
}
|
||||
|
||||
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||
|
||||
+76
-13
@@ -21,6 +21,7 @@ type addrUse struct {
|
||||
addr netip.Addr
|
||||
name string // the owner name, absolute
|
||||
apex bool
|
||||
view string // another view's record (fillAddresses), else ""
|
||||
}
|
||||
|
||||
// forwardAddresses returns the addresses of the enabled A and AAAA
|
||||
@@ -46,19 +47,51 @@ func forwardAddresses(st *model.State, viewID int64) []addrUse {
|
||||
return out
|
||||
}
|
||||
|
||||
// fillAddresses returns a view's addresses (forwardAddresses), then those
|
||||
// of the other views' records that none of its own records use, marked
|
||||
// with their view: a reverse zone in the internal view can name a host
|
||||
// only the external view has a record for.
|
||||
func fillAddresses(st *model.State, viewID int64) []addrUse {
|
||||
out := forwardAddresses(st, viewID)
|
||||
if len(st.Views) == 0 {
|
||||
return out
|
||||
}
|
||||
own := map[netip.Addr]bool{}
|
||||
for _, u := range out {
|
||||
own[u.addr] = true
|
||||
}
|
||||
for _, v := range st.OrderedViews() {
|
||||
if v.ID == viewID {
|
||||
continue
|
||||
}
|
||||
for _, u := range forwardAddresses(st, v.ID) {
|
||||
if !own[u.addr] {
|
||||
u.view = v.Name
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PTRFill is a PTR record filling a reverse zone would add.
|
||||
type PTRFill struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"` // the PTR owner name
|
||||
Target string `json:"target"` // the host name it gives
|
||||
Others []string `json:"others,omitempty"` // other names with this address
|
||||
// From is the view whose record names the host, when the zone's own
|
||||
// view has none for the address.
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
||||
// lacks for the addresses of the view's A and AAAA records: one per
|
||||
// address, naming its mail or name server host when it is one, otherwise
|
||||
// a host rather than the domain itself, then the shortest name.
|
||||
// existing are the zone's records (none for a new zone).
|
||||
// a host rather than the domain itself, then the shortest name. An
|
||||
// address no record of the view uses is named from another view's
|
||||
// records (fillAddresses), marked From. existing are the zone's records
|
||||
// (none for a new zone).
|
||||
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
|
||||
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
||||
if !ok {
|
||||
@@ -73,7 +106,7 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
|
||||
servers := serverNames(st, viewID)
|
||||
byAddr := map[netip.Addr][]addrUse{}
|
||||
var order []netip.Addr
|
||||
for _, u := range forwardAddresses(st, viewID) {
|
||||
for _, u := range fillAddresses(st, viewID) {
|
||||
if !prefix.Contains(u.addr) {
|
||||
continue
|
||||
}
|
||||
@@ -110,10 +143,12 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
|
||||
}
|
||||
return strings.Compare(x.name, y.name)
|
||||
})
|
||||
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name}
|
||||
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name, From: uses[0].view}
|
||||
for _, u := range uses[1:] {
|
||||
if u.name != f.Target {
|
||||
f.Others = append(f.Others, u.name)
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
@@ -143,6 +178,9 @@ func serverNames(st *model.State, viewID int64) map[string]bool {
|
||||
func fillWarnings(fills []PTRFill) []string {
|
||||
var out []string
|
||||
for _, f := range fills {
|
||||
if f.From != "" {
|
||||
out = append(out, fmt.Sprintf("%s: only view %s has a record for it, so its PTR record gives %s from there", f.Address, f.From, dnsname.Display(f.Target)))
|
||||
}
|
||||
if len(f.Others) > 0 {
|
||||
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
|
||||
}
|
||||
@@ -296,7 +334,9 @@ type ReverseOptions struct {
|
||||
// All includes what isn't suggested unasked, marked Hidden: public
|
||||
// views, views with suggestions off, dismissed networks.
|
||||
All bool
|
||||
// View narrows it to one view (by name; "" all).
|
||||
// View narrows it to one view (by name; "" all). With All, the view
|
||||
// also gets the addresses only other views' records use, marked
|
||||
// Hidden "records in VIEW": a reverse zone there can name them.
|
||||
View string
|
||||
}
|
||||
|
||||
@@ -340,7 +380,11 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
|
||||
out := []ReverseSuggestion{}
|
||||
for _, sc := range scopes {
|
||||
seen := map[netip.Prefix]bool{}
|
||||
for _, u := range forwardAddresses(st, sc.id) {
|
||||
addrs := forwardAddresses(st, sc.id)
|
||||
if opt.All && opt.View != "" {
|
||||
addrs = fillAddresses(st, sc.id)
|
||||
}
|
||||
for _, u := range addrs {
|
||||
a := u.addr
|
||||
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
|
||||
continue
|
||||
@@ -350,6 +394,9 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
|
||||
}
|
||||
p, group, why := suggestedPrefix(a, local)
|
||||
hidden := sc.hidden
|
||||
if hidden == "" && u.view != "" {
|
||||
hidden = "records in " + u.view
|
||||
}
|
||||
if seen[p] {
|
||||
continue
|
||||
}
|
||||
@@ -424,11 +471,23 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(in.Prefix))
|
||||
raw := in.Prefixes
|
||||
if in.Prefix != "" {
|
||||
raw = append(raw, in.Prefix)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, errf(KindBadRequest, "give the network (prefix) of the suggestion")
|
||||
}
|
||||
var prefixes []string
|
||||
for _, r := range raw {
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(r))
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "prefix: %v", err)
|
||||
}
|
||||
p = p.Masked()
|
||||
if s := p.Masked().String(); !slices.Contains(prefixes, s) {
|
||||
prefixes = append(prefixes, s)
|
||||
}
|
||||
}
|
||||
var v *model.View
|
||||
switch {
|
||||
case in.ViewID != 0:
|
||||
@@ -446,16 +505,20 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
|
||||
if v != nil {
|
||||
list, where = v.DismissedReverse, "view "+v.Name
|
||||
}
|
||||
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return s == p.String() })
|
||||
summary := "dismiss the reverse zone suggestion for " + p.String() + " in " + where
|
||||
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return slices.Contains(prefixes, s) })
|
||||
what := prefixes[0]
|
||||
if len(prefixes) > 1 {
|
||||
what = fmt.Sprintf("%d networks (%s, …)", len(prefixes), prefixes[0])
|
||||
}
|
||||
summary := "dismiss the reverse zone suggestion for " + what + " in " + where
|
||||
if dismiss {
|
||||
out = append(out, p.String())
|
||||
out = append(out, prefixes...)
|
||||
slices.Sort(out)
|
||||
} else {
|
||||
if len(out) == len(list) {
|
||||
return nil, errf(KindConflict, "%s isn't dismissed in %s", p, where)
|
||||
return nil, errf(KindConflict, "%s isn't dismissed in %s", what, where)
|
||||
}
|
||||
summary = "suggest a reverse zone for " + p.String() + " in " + where + " again"
|
||||
summary = "suggest a reverse zone for " + what + " in " + where + " again"
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{}
|
||||
|
||||
@@ -169,6 +169,12 @@ func TestReverseSuggestionGroups(t *testing.T) {
|
||||
t.Errorf("undismiss of one not dismissed: %v", e)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
|
||||
// Several at once.
|
||||
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) {
|
||||
t.Errorf("dismissed together, still suggested: %q", got)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
|
||||
t.Errorf("undismissed not suggested: %q", got)
|
||||
}
|
||||
@@ -256,9 +262,44 @@ func TestReverseCandidatesAll(t *testing.T) {
|
||||
want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}),
|
||||
"internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off")
|
||||
got := list(ReverseOptions{All: true, View: "external"})
|
||||
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view")
|
||||
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 public view")
|
||||
|
||||
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)}))
|
||||
want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}),
|
||||
"internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ")
|
||||
}
|
||||
|
||||
// An address only another view has a record for: a reverse zone in the
|
||||
// internal view is offered for it, and filled from that view's record.
|
||||
func TestReverseFromOtherView(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)}))
|
||||
var got []string
|
||||
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
||||
got = append(got, s.Prefix+" "+s.Hidden)
|
||||
}
|
||||
if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") {
|
||||
t.Errorf("internal candidates %q", got)
|
||||
}
|
||||
// Not suggested unasked: the internal view's own records don't use it.
|
||||
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
|
||||
if strings.HasPrefix(s.Prefix, "2600:") {
|
||||
t.Errorf("suggested unasked: %+v", s)
|
||||
}
|
||||
}
|
||||
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
||||
st := env.state()
|
||||
var zone string
|
||||
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
|
||||
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
|
||||
zone = z.Name
|
||||
}
|
||||
}
|
||||
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
||||
t.Errorf("filled %q", p)
|
||||
}
|
||||
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
||||
t.Errorf("warnings %q", res.Warnings)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural } from "../ui.js";
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural, confirmDialog } from "../ui.js";
|
||||
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { checkDomain, reverseZoneFor, maskNetwork } from "../names.js";
|
||||
@@ -66,7 +66,20 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
// For a reverse zone: sizes to pick from, and the networks the records
|
||||
// use without a reverse zone, in every view (docs/reverse.md).
|
||||
const helper = h("div", { class: "reverse-helper" });
|
||||
// The networks per view picked ("" for all views): a chosen view's list
|
||||
// also has the addresses only other views' records use.
|
||||
const candsBy = {};
|
||||
let cands = null;
|
||||
const candQ = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { marginBottom: "6px" } });
|
||||
candQ.addEventListener("input", () => drawHelper());
|
||||
const loadCands = () => {
|
||||
const k = view.value || "";
|
||||
if (candsBy[k]) { cands = candsBy[k]; return drawHelper(); }
|
||||
get("/reverse-zones/suggestions", draftQuery({ all: 1, view: k || undefined })).catch(() => []).then((c) => {
|
||||
candsBy[k] = c;
|
||||
if ((view.value || "") === k) { cands = c; drawHelper(); }
|
||||
});
|
||||
};
|
||||
// sizeFrom is the address the size buttons work from: a record's, once a
|
||||
// network was picked, so a /48 made smaller becomes that record's /64,
|
||||
// not the /48's first one.
|
||||
@@ -159,7 +172,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
|
||||
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
|
||||
syncFill();
|
||||
if (k === "reverse" && !cands) get("/reverse-zones/suggestions", draftQuery({ all: 1 })).catch(() => []).then((c) => { cands = c; drawHelper(); });
|
||||
if (k === "reverse") loadCands();
|
||||
drawHelper();
|
||||
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
|
||||
check();
|
||||
@@ -180,9 +193,13 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
(v6 ? [[48, "a whole site (all its /64s)"], [56, "a smaller delegation"], [64, "one subnet"], [128, "one address"]] : [[16, "a /16"], [24, "one network"], [32, "one address"]])
|
||||
.map(([b, title]) => h("button", { type: "button", class: "small" + (bits === b ? " active" : ""), title, onclick: () => { name.value = maskNetwork(sizeFrom || name.value, b); changed(); } }, "/" + b)),
|
||||
sizeFrom ? h("span", { class: "muted small" }, " around " + sizeFrom) : null) : null;
|
||||
const list = (cands || []).filter((c) => !view.value || c.view === view.value);
|
||||
const shown = list.slice(0, 12);
|
||||
const q = candQ.value.trim().toLowerCase();
|
||||
// Yours first, then the networks with the most records.
|
||||
const list = (cands || []).filter((c) => !q || [c.prefix, c.zone, c.view, c.hidden, ...c.samples].join(" ").toLowerCase().includes(q))
|
||||
.sort((a, b) => (a.group === "yours" ? 0 : 1) - (b.group === "yours" ? 0 : 1) || b.fill - a.fill);
|
||||
const shown = list.slice(0, 15);
|
||||
mount(helper, sizes,
|
||||
(cands || []).length > 8 ? candQ : null,
|
||||
shown.length ? h("div", { class: "cands" },
|
||||
h("div", { class: "muted small" }, "Networks your records use without a reverse zone" + (view.value ? " in view " + view.value : "") + ": pick one, then change its size if you need"),
|
||||
shown.map((c) => h("button", { type: "button", class: "cand", title: c.why, onclick: () => {
|
||||
@@ -195,11 +212,12 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
views.length && !view.value ? h("span", { class: "muted" }, " " + c.view) : null,
|
||||
h("span", { class: "muted small" }, " " + c.fill + " PTR" + (c.samples.length ? " · " + c.samples.slice(0, 2).join(", ") : "")),
|
||||
c.hidden ? [" ", badge(c.hidden, "off")] : null)),
|
||||
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more (pick a view to narrow the list)") : null)
|
||||
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more: search to find one") : null)
|
||||
: q && cands && cands.length ? h("div", { class: "muted small" }, "No network matches " + JSON.stringify(candQ.value.trim()) + ".")
|
||||
: cands && !list.length ? h("div", { class: "muted small" }, "Every address your records use" + (view.value ? " in view " + view.value : "") + " has a reverse zone.") : null);
|
||||
};
|
||||
name.addEventListener("input", typed);
|
||||
view.addEventListener("change", () => { check(); drawHelper(); });
|
||||
view.addEventListener("change", () => { check(); if (kind === "reverse") loadCands(); else drawHelper(); });
|
||||
const glueRow = field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue).");
|
||||
const cloneRow = field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).");
|
||||
const rows = {
|
||||
@@ -364,7 +382,48 @@ function suggestPanel(sugg, views) {
|
||||
yours.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(yours) }, "Create all " + yours.length)) : null),
|
||||
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
|
||||
yours.length ? table(cols(false), yours) : null,
|
||||
pub.length ? [h("h3", null, "Public addresses: only if they're yours"),
|
||||
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's (a web host's). Create a zone for an address that is yours; dismiss the others. Each gets a zone of its own: the rest of its network belongs to others."),
|
||||
table(cols(true), pub)] : null);
|
||||
pub.length ? publicGroup(pub, cols(true), create) : null);
|
||||
}
|
||||
|
||||
// publicGroup lists the public addresses, collapsed: there can be hundreds
|
||||
// (a web host's), so it has a search, shows 25 at a time, and dismisses
|
||||
// what the search found at once.
|
||||
function publicGroup(pub, cols, create) {
|
||||
const PAGE = 25;
|
||||
let limit = PAGE;
|
||||
const q = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { width: "300px" } });
|
||||
const body = h("div");
|
||||
const match = () => {
|
||||
const t = q.value.trim().toLowerCase();
|
||||
return pub.filter((s) => !t || [s.prefix, s.zone, s.view, ...s.samples].join(" ").toLowerCase().includes(t));
|
||||
};
|
||||
const dismissAll = async (list) => {
|
||||
const byView = new Map();
|
||||
for (const s of list) {
|
||||
const k = s.view_id || 0;
|
||||
if (!byView.has(k)) byView.set(k, []);
|
||||
byView.get(k).push(s.prefix);
|
||||
}
|
||||
if (!await confirmDialog("Dismiss " + plural(list.length, "address", "addresses"), "Jarl stops suggesting reverse zones for " + (list.length === 1 ? list[0].prefix : list.length + " public addresses" + (q.value.trim() ? " matching " + JSON.stringify(q.value.trim()) : "")) + ". Bring one back with: jarl zone suggest-reverse --undismiss NETWORK --view VIEW.", "Dismiss")) return;
|
||||
try {
|
||||
const ops = [...byView].map(([vid, prefixes]) => ({ op: "reverse.dismiss", data: { view_id: vid || undefined, prefixes } }));
|
||||
toast(describeResult(await changeMany(ops, "dismiss " + plural(list.length, "reverse zone suggestion"))));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const draw = () => {
|
||||
const list = match();
|
||||
const shown = list.slice(0, limit);
|
||||
mount(body,
|
||||
h("div", { class: "compare-controls" }, q,
|
||||
list.length ? h("button", { class: "small", title: "Not yours: stop suggesting all of these", onclick: () => dismissAll(list) }, list.length === pub.length ? "Dismiss all " + list.length : "Dismiss these " + list.length) : null),
|
||||
shown.length ? table(cols, shown) : h("div", { class: "muted small" }, "No address matches."),
|
||||
list.length > shown.length ? h("button", { class: "small", onclick: () => { limit += PAGE; draw(); } }, "Show " + Math.min(PAGE, list.length - shown.length) + " more (of " + (list.length - shown.length) + ")") : null);
|
||||
};
|
||||
q.addEventListener("input", () => { limit = PAGE; draw(); });
|
||||
draw();
|
||||
return h("details", { class: "compare-only", open: pub.length <= 10 },
|
||||
h("summary", null, h("strong", null, "Public addresses: " + pub.length), h("span", { class: "muted small" }, " · create a zone only for an address that is yours; dismiss the others (a web host's)")),
|
||||
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's. Each gets a zone of its own: the rest of its network belongs to others."),
|
||||
body);
|
||||
}
|
||||
@@ -589,4 +589,4 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; }
|
||||
.size-chips { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; }
|
||||
.cands button.cand { text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cands button.cand { flex-shrink: 0; text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
+11
-2
@@ -696,9 +696,11 @@ expect_dig "PTR gone with it" "" -x 10.99.1.13
|
||||
run "a web host's address" 0 $J record add revtest.test blog A 185.199.108.153 --view internal
|
||||
run "suggested as public" 0 $J zone suggest-reverse
|
||||
contains "^public *internal *185.199.108.153/32"
|
||||
run "dismiss it" 0 $J zone suggest-reverse --dismiss 185.199.108.153/32 --view internal
|
||||
run "two more web host addresses" 0 $J record add revtest.test docs A 185.199.109.153 --view internal
|
||||
run "and" 0 $J record add revtest.test pages A 185.199.110.153 --view internal
|
||||
run "dismiss three at once" 0 $J zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --dismiss 185.199.110.153/32 --view internal
|
||||
run "gone from the suggestions" 0 $J zone suggest-reverse
|
||||
not_contains "185.199.108.153"
|
||||
not_contains "185.199."
|
||||
run "suggest-reverse --create: yours only" 0 $J zone suggest-reverse --create
|
||||
run "none of yours missing now" 0 $J zone suggest-reverse
|
||||
not_contains "^yours"
|
||||
@@ -708,6 +710,13 @@ not_contains "internal"
|
||||
run "but --all still lists its networks" 0 $J zone suggest-reverse --all --view internal
|
||||
contains "^public *internal *185.199.108.153/32 .* suggestions off"
|
||||
run "and back on" 0 $J view update internal --reverse-suggestions
|
||||
# An address only the external view has a record for: a reverse zone in
|
||||
# the internal view can still name it.
|
||||
run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external
|
||||
run "offered for internal" 0 $J zone suggest-reverse --all --view internal
|
||||
contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external"
|
||||
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill
|
||||
expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1
|
||||
# A zone created inside another takes the parent's records under it.
|
||||
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal
|
||||
run "with a PTR made by hand" 0 $J record add 10.88.0.0/16 10.88.5.5 PTR printer.revtest.test --view internal
|
||||
|
||||
Reference in new issue
Block a user