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.
|
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
@@ -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
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 ")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user