Reverse zone suggestions: yours and public, dismiss, per-view switch; child zones take their records

Suggestions split into networks that are yours (private IPv4 /24, ULA /48, an IPv6 /48 this server has an address in), which Create all creates, and public addresses (/32 or /128) to create only if they are yours or dismiss. A view can turn suggestions off, which also keeps it out of choosing a reverse zone's view (database version 13). A zone created inside another in the same view takes the parent's records under it; a delegation and its glue stay, a linked parent keeps them with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 22:01:18 +01:00
1 parent aa4b004a14
commit b24c730b97
21 files changed
+538 -102

No files matched your search

+12 -2
View File
@@ -25,13 +25,23 @@ All notable changes to Jarl are documented here. The format follows
--auto-ptr`, zone settings → Reverse DNS): adding, changing, --auto-ptr`, zone settings → Reverse DNS): adding, changing,
disabling or deleting an A or AAAA record does the same to its PTR disabling or deleting an A or AAAA record does the same to its PTR
record, as the Update PTR box does for one change. record, as the Update PTR box does for one change.
- Suggestions come in two groups: **yours** (private networks, a ULA or
an IPv6 /48 this server has an address in), which Create all
creates, and **public addresses**, each a /32 or /128 to create only
if it is yours (not a web host's) or **Dismiss**. A view can turn
suggestions off (`jarl view update vps --reverse-suggestions=false`),
e.g. one only a VPS uses.
- A zone created inside another in the same view (a /24 inside a /8,
`lab.adbraun.lol` inside `adbraun.lol`) takes the parent's records
under it, which named would no longer serve from the parent; a
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`.
### Upgrading ### Upgrading
- The database gets a new version (12), applied when jarld starts; a - The database gets two new versions (12 and 13), applied when jarld
0.5 jarld can't open it afterwards, so take a backup first (`sudo starts; a 0.5 jarld can't open it afterwards, so take a backup first (`sudo
jarld backup`). Nothing changes until you turn `auto_ptr` on for a jarld backup`). Nothing changes until you turn `auto_ptr` on for a
zone. zone.
+5 -1
View File
@@ -37,7 +37,7 @@ func viewCmd() *cobra.Command {
} }
var clients, dests []string var clients, dests []string
var recursiveOnly, comment, exposure string var recursiveOnly, comment, exposure string
var adopt, cascade, yes bool var adopt, cascade, yes, reverseSuggest bool
viewBody := func(cmd *cobra.Command) map[string]any { viewBody := func(cmd *cobra.Command) map[string]any {
body := map[string]any{} body := map[string]any{}
f := cmd.Flags() f := cmd.Flags()
@@ -63,6 +63,9 @@ func viewCmd() *cobra.Command {
if f.Changed("exposure") { if f.Changed("exposure") {
body["exposure"] = exposure body["exposure"] = exposure
} }
if f.Changed("reverse-suggestions") {
body["reverse_suggestions"] = reverseSuggest
}
return body return body
} }
addViewFlags := func(c *cobra.Command) { addViewFlags := func(c *cobra.Command) {
@@ -71,6 +74,7 @@ func viewCmd() *cobra.Command {
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default") c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
c.Flags().StringVar(&comment, "comment", "", "comment") c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)") c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
c.Flags().BoolVar(&reverseSuggest, "reverse-suggestions", true, "suggest reverse zones for the view's addresses (--reverse-suggestions=false: don't; docs/reverse.md)")
} }
create := &cobra.Command{ create := &cobra.Command{
Use: "create NAME", Use: "create NAME",
+44 -13
View File
@@ -1081,6 +1081,7 @@ type reverseSuggestion struct {
View string `json:"view"` View string `json:"view"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
Zone string `json:"zone"` Zone string `json:"zone"`
Group string `json:"group"`
Why string `json:"why"` Why string `json:"why"`
Fill int `json:"fill"` Fill int `json:"fill"`
Samples []string `json:"samples"` Samples []string `json:"samples"`
@@ -1088,20 +1089,44 @@ type reverseSuggestion struct {
func zoneSuggestReverseCmd() *cobra.Command { func zoneSuggestReverseCmd() *cobra.Command {
var create bool var create bool
var dismiss, undismiss, view string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "suggest-reverse [--create]", Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]",
Short: "The reverse zones your private views lack for their records' addresses", Short: "The reverse zones your private views lack for their records' addresses",
Long: `Lists the reverse zones the private views (or the server, without views) Long: `Lists the reverse zones the private views (or the server, without views)
lack for the addresses of their A and AAAA records: the /24 of a private lack for the addresses of their A and AAAA records, in two groups:
IPv4 network, a /32 for a public IPv4 address (the rest of its /24 isn't
yours), the /48 of IPv6 addresses. --create creates them all, filled with yours the /24 of a private IPv4 network, the /48 of a ULA or of an IPv6
PTR records, in one changeset (docs/reverse.md).`, prefix this server has an address in
public a /32 (or /128) for any other public address: create it only if
the address is yours, not a web host's
--create creates the ones that are yours, filled with PTR records, in one
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).`,
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`,
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()
if err != nil { if err != nil {
return err return err
} }
if dismiss != "" || undismiss != "" {
path, prefix := "/reverse-zones/dismiss", dismiss
if undismiss != "" {
path, prefix = "/reverse-zones/undismiss", undismiss
}
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefix": prefix})
if err != nil {
return err
}
return report(resp)
}
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", nil, &sugg); err != nil {
return err return err
@@ -1112,23 +1137,22 @@ PTR records, in one changeset (docs/reverse.md).`,
fmt.Fprintln(w, "No reverse zones missing.") fmt.Fprintln(w, "No reverse zones missing.")
return return
} }
fmt.Fprintln(w, "VIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE") fmt.Fprintln(w, "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE")
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%d\t%s\n", v, s.Prefix, s.Zone, s.Fill, strings.Join(s.Samples, ", ")) 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, ", "))
} }
fmt.Fprintln(w, "\nCreate them, filled: jarl zone suggest-reverse --create (or one: jarl zone create NETWORK --view VIEW --fill)") 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).")
}) })
} }
if len(sugg) == 0 {
fmt.Println("No reverse zones missing.")
return nil
}
var reqs []api.ChangeRequest var reqs []api.ChangeRequest
for _, s := range sugg { for _, s := range sugg {
if s.Group != "yours" {
continue
}
body := map[string]any{"name": s.Prefix, "fill": true} body := map[string]any{"name": s.Prefix, "fill": true}
if s.ViewID != 0 { if s.ViewID != 0 {
body["view_id"] = s.ViewID body["view_id"] = s.ViewID
@@ -1139,9 +1163,16 @@ PTR records, in one changeset (docs/reverse.md).`,
} }
reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data}) reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data})
} }
if len(reqs) == 0 {
fmt.Println("No reverse zones of yours missing (public addresses are created one by one: see 'jarl zone suggest-reverse').")
return nil
}
return submitBatch(c, fmt.Sprintf("%d suggested reverse zones", len(reqs)), reqs) return submitBatch(c, fmt.Sprintf("%d suggested reverse zones", len(reqs)), reqs)
}, },
} }
cmd.Flags().BoolVar(&create, "create", false, "create them all, 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(&undismiss, "undismiss", "", "show a dismissed suggestion again")
cmd.Flags().StringVar(&view, "view", "", "with --dismiss/--undismiss: the suggestion's view")
return cmd return cmd
} }
+2 -1
View File
@@ -361,7 +361,8 @@ 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`, `why`, `fill` (PTR records it would get), `samples` | | 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` |
| 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 across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view | | GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view |
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with `POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
+3 -1
View File
@@ -179,7 +179,9 @@ jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.i
jarl zone create 10.0.10.100/32 # a zone for just that address jarl zone create 10.0.10.100/32 # a zone for just that address
jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs 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 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 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 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
jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer" jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
+2 -1
View File
@@ -260,7 +260,8 @@ boundary. So:
Jarl suggests the reverse zones your private views lack, creates them in Jarl suggests the reverse zones your private views lack, creates them in
the right view filled with PTR records, and can keep PTR records in step the right view filled with PTR records, and can keep PTR records in step
with a zone's A and AAAA records: [reverse.md](reverse.md). with a zone's A and AAAA records: [reverse.md](reverse.md). A zone created
inside another (a /24 inside a /8) takes the parent's records under it.
### Dynamic updates and TSIG keys ### Dynamic updates and TSIG keys
+45 -19
View File
@@ -19,32 +19,49 @@ Who asks matters:
delegated it to you. delegated it to you.
So reverse zones belong in private views. A view whose clients reach it So reverse zones belong in private views. A view whose clients reach it
from the internet gets no suggestions. from the internet gets no suggestions, and neither does a view you turn
them off for: a view only a few servers use, such as one for your VPS,
matches only their addresses and so counts as private, but its clients
don't need reverse zones (`jarl view update vps
--reverse-suggestions=false`, or the view's settings). Exposure doesn't
change.
## Missing reverse zones ## Missing reverse zones
The Zones page lists, under **Missing reverse zones**, the reverse zones a The Zones page lists, under **Missing reverse zones**, the reverse zones a
private view lacks for the addresses of its A and AAAA records, with private view lacks for the addresses of its A and AAAA records, in two
**Create** (or **Create all**): groups:
``` ```
$ jarl zone suggest-reverse $ jarl zone suggest-reverse
VIEW NETWORK ZONE PTRS FOR EXAMPLE GROUP VIEW NETWORK ZONE PTRS FOR EXAMPLE
internal 10.0.10.0/24 10.0.10.in-addr.arpa 14 10.0.10.1 router.adbraun.lol, 10.0.10.5 nas.adbraun.lol, ... yours internal 10.0.10.0/24 10.0.10.in-addr.arpa 14 10.0.10.1 router.adbraun.lol, ...
internal 139.144.144.154/32 154.144.144.139.in-addr.arpa 1 139.144.144.154 ldn.adbraun.lol yours internal 2a02:8013:7f3d::/48 d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa 9 2a02:8013:7f3d:10::5 nas.adbraun.lol, ...
internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa 9 fdab:abab:abab:10::5 nas.adbraun.lol, ... public internal 139.144.144.154/32 154.144.144.139.in-addr.arpa 1 139.144.144.154 ldn.adbraun.lol
public internal 185.199.108.153/32 153.108.199.185.in-addr.arpa 1 185.199.108.153 blog.adbraun.lol
$ jarl zone suggest-reverse --create # all of them, filled, in one changeset $ jarl zone suggest-reverse --create # yours, filled, in one changeset
$ jarl zone create 139.144.144.154/32 --view internal --fill # a public one that is yours
$ jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal # GitHub's, not yours
``` ```
* A private IPv4 address (10/8, 172.16/12, 192.168/16, 100.64/10) gets its **Yours**, created by **Create all** (`--create`):
**/24**.
* A public IPv4 address gets a **/32** zone of its own. The rest of its /24 * the **/24** of a private IPv4 address (10/8, 172.16/12, 192.168/16,
belongs to others, and a /24 zone here would answer NXDOMAIN to your 100.64/10);
clients for all of their addresses. * the **/48** of a ULA (fd00::/8), or of a public IPv6 prefix this server
* An IPv6 address gets its **/48**: one zone for every subnet of your has an address in (the /48 your ISP delegates): one zone for all its
prefix (a ULA, or the /48 your ISP delegates). If you have a /56, create subnets. If you have a /56, create that instead (`jarl zone create
that instead (`jarl zone create 2a02:db8:1:ab00::/56`). 2a02:db8:1:ab00::/56`).
**Public addresses, only if they're yours**: any other public address,
each as a /32 (or /128) of its own. Jarl can't tell your VPS from a web
host's server (a `blog` record pointing at GitHub Pages), and a zone for
someone else's address would show your clients your name for it instead
of theirs. The rest of the /24 belongs to others: a /24 zone here would
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).
Loopback and link-local addresses, wildcards and disabled records are left Loopback and link-local addresses, wildcards and disabled records are left
out. out.
@@ -57,9 +74,18 @@ jarl zone fill-ptr 10.0.10.0/24 # later: add what is missing
``` ```
**The view.** A reverse zone created without `--view` goes to the private **The view.** A reverse zone created without `--view` goes to the private
view whose records use addresses in its network. If none does, it goes to view whose records use addresses in its network (views with suggestions
the only private view. If several do, Jarl asks you to name the view, or off don't count). If none does, it goes to the only private view. If
to create the zone in each. several do, Jarl asks you to name the view, or to create the zone in each.
**Inside another zone.** A zone created inside an existing one in the same
view, a /24 inside a /8 or `lab.adbraun.lol` inside `adbraun.lol`, takes
the parent's records under it, in the same changeset: named answers those
names from the new zone, so left in the parent they would never be served
again. PTR records you added by hand come along; `--fill` then adds only
what is still missing. A delegation (NS records at the new zone's name,
with their glue) stays in the parent. A linked parent keeps its records,
with a warning, since its other views would lose them too.
**Fill** adds a PTR record for every address in the network that the **Fill** adds a PTR record for every address in the network that the
view's A and AAAA records use. An address with several names gets one PTR: view's A and AAAA records use. An address with several names gets one PTR:
+3 -2
View File
@@ -25,7 +25,7 @@ func DiffOps(from, to *model.State) []model.Op {
s := to.Settings s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName, add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings") Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse))}, "restore settings")
} }
for _, k := range sortedKeys(to.Keys) { for _, k := range sortedKeys(to.Keys) {
@@ -50,7 +50,8 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Views[k] n := to.Views[k]
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)), in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position), MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))} Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged)),
ReverseSuggestions: model.Ptr(!n.ReverseSuggestionsOff), DismissedReverse: model.Ptr(slices.Clone(n.DismissedReverse))}
if n.MatchRecursiveOnly != nil { if n.MatchRecursiveOnly != nil {
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly) in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
} else { } else {
+77 -2
View File
@@ -157,6 +157,8 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
return x.zoneCreate(req) return x.zoneCreate(req)
case OpZoneFillPTR: case OpZoneFillPTR:
return x.zoneFillPTR(req) return x.zoneFillPTR(req)
case OpReverseDismiss, OpReverseUndismiss:
return x.reverseDismiss(req, req.Op == OpReverseDismiss)
case OpZoneUpdate, OpZoneDelete: case OpZoneUpdate, OpZoneDelete:
z := x.st.Zones[req.ID] z := x.st.Zones[req.ID]
if z == nil { if z == nil {
@@ -403,6 +405,41 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
in.ViewID = &v.ID in.ViewID = &v.ID
} }
} }
var moveOps []model.Op
var moved []*model.Record
if zt == model.ZonePrimary {
// The records a parent zone in the view has under the new zone
// move into it: named answers those names from the new zone, so
// left behind they would never be served again.
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
if parent := x.parentZone(name, vid); parent != nil {
moved = x.recordsUnder(parent, name)
switch {
case len(moved) == 0:
case parent.LinkGroup != 0:
x.warnings = append(x.warnings, fmt.Sprintf("zone %s is linked, so its %d records under %s stay there (hidden by the new zone): move them by hand", zoneDesc(x.st, parent), len(moved), dnsname.Display(name)))
moved = nil
default:
for _, r := range moved {
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data)}
if r.TTL != nil {
ri.TTL = model.Some(*r.TTL)
}
if r.Comment != "" {
ri.Comment = model.Ptr(r.Comment)
}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
in.Records = append(in.Records, ri)
moveOps = append(moveOps, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("move %s %s %s from zone %s into the new zone", r.Name, r.Type, r.Data, zoneDesc(x.st, parent))))
}
}
}
}
fill := in.Fill fill := in.Fill
in.Fill = false in.Fill = false
if fill { if fill {
@@ -416,7 +453,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
if in.ViewID != nil { if in.ViewID != nil {
vid = *in.ViewID vid = *in.ViewID
} }
fills := PTRFills(x.st, vid, name, nil) fills := PTRFills(x.st, vid, name, moved)
if len(fills) == 0 { if len(fills) == 0 {
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name))) x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
} }
@@ -642,7 +679,45 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
r.in.ZoneID = ids[0] r.in.ZoneID = ids[0]
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary)) ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
} }
return ops, nil return append(ops, moveOps...), nil
}
// parentZone returns the most specific primary zone in a view that
// contains name (not name itself).
func (x *expander) parentZone(name string, viewID int64) *model.Zone {
var best *model.Zone
for _, z := range x.st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && dnsname.IsStrictSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
return best
}
// recordsUnder returns a zone's records at or below name, except a
// delegation's NS and DS records and its glue, which belong to the parent.
func (x *expander) recordsUnder(z *model.Zone, name string) []*model.Record {
all := x.st.ZoneRecords(z.ID)
var ns []string
for _, r := range all {
if r.Name == name && r.Type == "NS" {
ns = append(ns, strings.ToLower(r.Data))
}
}
var out []*model.Record
for _, r := range all {
if !dnsname.IsSubdomain(r.Name, name) {
continue
}
if r.Name == name && (r.Type == "NS" || r.Type == "DS") {
continue
}
if (r.Type == "A" || r.Type == "AAAA") && slices.Contains(ns, strings.ToLower(r.Name)) {
continue // glue
}
out = append(out, r)
}
return out
} }
func deref(s *string) string { func deref(s *string) string {
+20
View File
@@ -62,8 +62,20 @@ const (
// Filling a reverse zone with the PTR records of its view's A and // Filling a reverse zone with the PTR records of its view's A and
// AAAA records (docs/reverse.md) becomes record.create operations. // AAAA records (docs/reverse.md) becomes record.create operations.
OpZoneFillPTR = "zone.fill_ptr" OpZoneFillPTR = "zone.fill_ptr"
// Dismissing a reverse zone suggestion, and taking that back, become
// view.update or settings.update operations.
OpReverseDismiss = "reverse.dismiss"
OpReverseUndismiss = "reverse.undismiss"
) )
// DismissInput dismisses a reverse zone suggestion: a network in a view
// (by ID or name), or the server's (neither given).
type DismissInput struct {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Prefix string `json:"prefix"`
}
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view // AckInput acknowledges (or un-acknowledges) an exposure finding of a view
// (by ID or name), or of the server (neither given). // (by ID or name), or of the server (neither given).
type AckInput struct { type AckInput struct {
@@ -246,6 +258,11 @@ type ViewInput struct {
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md). // Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"` Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"` Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
// ReverseSuggestions turns the reverse zone suggestions for the
// view's addresses on or off (docs/reverse.md).
ReverseSuggestions *bool `json:"reverse_suggestions,omitempty"`
// DismissedReverse are the networks whose suggestion was dismissed.
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
} }
// ReorderInput sets the order of views. // ReorderInput sets the order of views.
@@ -292,6 +309,9 @@ type SettingsInput struct {
// Exposure and Acknowledged are the server's (docs/exposure.md). // Exposure and Acknowledged are the server's (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"` Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"` Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
// DismissedReverse are dismissed reverse zone suggestions (without
// views).
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
} }
// DeleteInput carries options for delete operations. // DeleteInput carries options for delete operations.
+10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/netip" "net/netip"
"slices"
"strings" "strings"
"time" "time"
@@ -808,6 +809,12 @@ func setViewFields(v *model.View, in *ViewInput) error {
if in.Acknowledged != nil { if in.Acknowledged != nil {
v.Acknowledged = cleanAcks(*in.Acknowledged) v.Acknowledged = cleanAcks(*in.Acknowledged)
} }
if in.ReverseSuggestions != nil {
v.ReverseSuggestionsOff = !*in.ReverseSuggestions
}
if in.DismissedReverse != nil {
v.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
return nil return nil
} }
@@ -1404,6 +1411,9 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
if in.Acknowledged != nil { if in.Acknowledged != nil {
s.Acknowledged = cleanAcks(*in.Acknowledged) s.Acknowledged = cleanAcks(*in.Acknowledged)
} }
if in.DismissedReverse != nil {
s.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
s.Version++ s.Version++
return nil return nil
} }
+117 -32
View File
@@ -219,7 +219,8 @@ func (x *expander) publicViews() map[int64]bool {
// reverseViewFor chooses the view of a new reverse zone given without one: // reverseViewFor chooses the view of a new reverse zone given without one:
// the private view whose records use addresses in its range, or the only // the private view whose records use addresses in its range, or the only
// private view. "" with no error leaves the choice to the usual rules. // private view; views with reverse suggestions off don't count. nil with
// no error leaves the choice to the usual rules.
func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) { func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) {
prefix, ok := dnsname.PrefixFromReverse(zoneName) prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok || len(st.Views) == 0 { if !ok || len(st.Views) == 0 {
@@ -227,7 +228,7 @@ func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*m
} }
var private, using []*model.View var private, using []*model.View
for _, v := range st.OrderedViews() { for _, v := range st.OrderedViews() {
if public[v.ID] { if public[v.ID] || v.ReverseSuggestionsOff {
continue continue
} }
private = append(private, v) private = append(private, v)
@@ -257,58 +258,64 @@ type ReverseSuggestion struct {
View string `json:"view,omitempty"` View string `json:"view,omitempty"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
Zone string `json:"zone"` Zone string `json:"zone"`
// 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"`
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"
} }
// Suggestion groups.
const (
SuggestYours = "yours"
SuggestPublic = "public"
)
// ReverseSuggestions returns the reverse zones the private views (or the // ReverseSuggestions returns the reverse zones the private views (or the
// server, without views) lack for their A and AAAA records' addresses: // server, without views) lack for their A and AAAA records' addresses:
// the /24 of a private IPv4 address, a /32 for a public one (the /24 //
// belongs to someone else, and a zone for it here would hide the // - yours: the /24 of a private IPv4 address, the /48 of a ULA or of a
// neighbours' names from your clients), the /48 of an IPv6 address. // public IPv6 prefix this server has an address in (local);
// Public views get none: the internet asks the addresses' owners. // - public: a /32 (or /128) for any other public address. It may be a
func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []ReverseSuggestion { // web host's; the rest of its /24 surely belongs to others, and a
// zone for it here would hide their names from your clients.
//
// 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 {
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
} }
viewIDs := []int64{0} type scope struct {
id int64
dismissed []string
}
var scopes []scope
if len(st.Views) > 0 { if len(st.Views) > 0 {
viewIDs = viewIDs[:0]
for _, v := range st.OrderedViews() { for _, v := range st.OrderedViews() {
if !public[v.ID] { if !public[v.ID] && !v.ReverseSuggestionsOff {
viewIDs = append(viewIDs, v.ID) scopes = append(scopes, scope{v.ID, v.DismissedReverse})
} }
} }
} else if public[0] { } else if !public[0] {
return []ReverseSuggestion{} scopes = append(scopes, scope{0, st.Settings.DismissedReverse})
} }
out := []ReverseSuggestion{} out := []ReverseSuggestion{}
for _, vid := range viewIDs { for _, sc := range scopes {
seen := map[netip.Prefix]bool{} seen := map[netip.Prefix]bool{}
for _, u := range forwardAddresses(st, vid) { for _, u := range forwardAddresses(st, sc.id) {
a := u.addr a := u.addr
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() { if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
continue continue
} }
if rz, _ := reverseZoneFor(st, vid, a); rz != nil { if rz, _ := reverseZoneFor(st, sc.id, a); rz != nil {
continue continue
} }
var p netip.Prefix p, group, why := suggestedPrefix(a, local)
why := "" if seen[p] || slices.Contains(sc.dismissed, p.String()) {
switch {
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
p, why = netip.PrefixFrom(a, 24).Masked(), "private network"
case a.Is4():
p, why = netip.PrefixFrom(a, 32), "a public address: its own zone, since the rest of its /24 belongs to others"
default:
p, why = netip.PrefixFrom(a, 48).Masked(), "the /48 of your IPv6 addresses (one zone for all their subnets)"
if a.IsPrivate() {
why = "your ULA /48 (one zone for all its subnets)"
}
}
if seen[p] {
continue continue
} }
seen[p] = true seen[p] = true
@@ -316,8 +323,8 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers
if err != nil { if err != nil {
continue continue
} }
s := ReverseSuggestion{ViewID: vid, View: viewName(st, vid), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Why: why, Samples: []string{}} s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Why: why, Samples: []string{}}
fills := PTRFills(st, vid, 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 {
if i == 3 { if i == 3 {
@@ -329,6 +336,9 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers
} }
} }
slices.SortStableFunc(out, func(a, b ReverseSuggestion) int { slices.SortStableFunc(out, func(a, b ReverseSuggestion) int {
if a.Group != b.Group {
return strings.Compare(b.Group, a.Group) // yours first
}
if a.View != b.View { if a.View != b.View {
return strings.Compare(a.View, b.View) return strings.Compare(a.View, b.View)
} }
@@ -337,6 +347,81 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers
return out return out
} }
// suggestedPrefix returns the reverse zone network for an address, its
// group and why.
func suggestedPrefix(a netip.Addr, local []netip.Addr) (netip.Prefix, string, string) {
switch {
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
return netip.PrefixFrom(a, 24).Masked(), SuggestYours, "private network"
case a.Is4():
return netip.PrefixFrom(a, 32), SuggestPublic, "a public address: create it only if it is yours (not a web host's); the rest of its /24 belongs to others"
case a.IsPrivate():
return netip.PrefixFrom(a, 48).Masked(), SuggestYours, "your ULA /48 (one zone for all its subnets)"
}
p := netip.PrefixFrom(a, 48).Masked()
if slices.ContainsFunc(local, func(l netip.Addr) bool { return p.Contains(l.Unmap()) }) {
return p, SuggestYours, "your IPv6 /48 (this server has an address in it): one zone for all its subnets"
}
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 {
var local []netip.Addr
if e.cfg.LocalAddrs != nil {
local = e.cfg.LocalAddrs()
}
return ReverseSuggestions(st, e.Exposures(st), local)
}
// reverseDismiss expands reverse.dismiss and reverse.undismiss.
func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error) {
var in DismissInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
p, err := netip.ParsePrefix(strings.TrimSpace(in.Prefix))
if err != nil {
return nil, errf(KindBadRequest, "prefix: %v", err)
}
p = p.Masked()
var v *model.View
switch {
case in.ViewID != 0:
v = x.st.Views[in.ViewID]
case in.View != "":
v = x.st.ViewByName(in.View)
}
if (in.ViewID != 0 || in.View != "") && v == nil {
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
}
if v == nil && len(x.st.Views) > 0 {
return nil, errf(KindBadRequest, "give the view of the suggestion")
}
list, where := x.st.Settings.DismissedReverse, "the server"
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
if dismiss {
out = append(out, p.String())
slices.Sort(out)
} else {
if len(out) == len(list) {
return nil, errf(KindConflict, "%s isn't dismissed in %s", p, where)
}
summary = "suggest a reverse zone for " + p.String() + " in " + where + " again"
}
if out == nil {
out = []string{}
}
if v != nil {
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{DismissedReverse: &out}, summary)}, nil
}
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{DismissedReverse: &out}, summary)}, nil
}
var cgnat = netip.MustParsePrefix("100.64.0.0/10") var cgnat = netip.MustParsePrefix("100.64.0.0/10")
// autoPTR reports whether a record's PTR is kept in step by its zone's // autoPTR reports whether a record's PTR is kept in step by its zone's
+106 -7
View File
@@ -1,6 +1,7 @@
package engine package engine
import ( import (
"net/netip"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -40,16 +41,16 @@ func TestReverseFillAndSuggest(t *testing.T) {
recIn("internal", "*.dev", "A", "10.0.10.99"), recIn("internal", "*.dev", "A", "10.0.10.99"),
recIn("internal", "lo", "A", "127.0.0.1")) recIn("internal", "lo", "A", "127.0.0.1"))
sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state())) sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil)
var got []string var got []string
for _, s := range sugg { for _, s := range sugg {
got = append(got, s.View+" "+s.Prefix+" "+s.Zone) got = append(got, s.Group+" "+s.View+" "+s.Prefix+" "+s.Zone)
} }
want(t, "suggestions", got, want(t, "suggestions", got,
"internal 10.0.10.0/24 10.0.10.in-addr.arpa", "yours internal 10.0.10.0/24 10.0.10.in-addr.arpa",
"internal 192.0.2.10/32 10.2.0.192.in-addr.arpa", "yours internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa",
"internal 192.0.2.25/32 25.2.0.192.in-addr.arpa", "public internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
"internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa") "public internal 192.0.2.25/32 25.2.0.192.in-addr.arpa")
if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" { if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" {
t.Errorf("10.0.10.0/24: %+v", sugg[0]) t.Errorf("10.0.10.0/24: %+v", sugg[0])
} }
@@ -77,7 +78,7 @@ func TestReverseFillAndSuggest(t *testing.T) {
} }
got = got[:0] got = got[:0]
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state())) { for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
got = append(got, s.Prefix) got = append(got, s.Prefix)
} }
want(t, "suggestions left", got, "192.0.2.10/32") want(t, "suggestions left", got, "192.0.2.10/32")
@@ -136,3 +137,101 @@ func TestAutoPTR(t *testing.T) {
env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50")) env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50"))
want(t, "off", env.ptrs("internal", rz)) want(t, "off", env.ptrs("internal", rz))
} }
func suggested(env *testEnv, local ...netip.Addr) []string {
var out []string
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), local) {
out = append(out, s.Group+" "+s.View+" "+s.Prefix)
}
return out
}
// Public IPv6: the /48 only when this server has an address in it, else
// the address alone, as a public suggestion. Dismissed networks and views
// with suggestions off get none.
func TestReverseSuggestionGroups(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin,
recIn("internal", "home", "AAAA", "2a02:8013:7f3d:10::5"),
recIn("internal", "gh", "AAAA", "2606:50c0:8000::153"))
want(t, "no local addresses", suggested(env),
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32",
"public internal 2606:50c0:8000::153/128", "public internal 2a02:8013:7f3d:10::5/128")
want(t, "this server is in 2a02:8013:7f3d::/48", suggested(env, netip.MustParseAddr("2a02:8013:7f3d:10:1266:6aff:fef9:f5b1")),
"yours internal 2a02:8013:7f3d::/48",
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32", "public internal 2606:50c0:8000::153/128")
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
if got := suggested(env); slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("dismissed still suggested: %q", got)
}
if e := env.submitErr(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"})); e.Kind != KindConflict {
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"}))
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("undismissed not suggested: %q", got)
}
// A second private view using the same address (a VPS's view): with
// its suggestions off it gets none, and doesn't count when choosing
// the view of a new reverse zone.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("vps"), MatchClients: &[]string{"198.51.100.7"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("vps"), LinkWith: "internal"}))
if got := suggested(env); !slices.Contains(got, "public vps 192.0.2.25/32") {
t.Errorf("vps view: %q", got)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32")})); !strings.Contains(e.Message, "views internal and vps") {
t.Errorf("two views: %v", e)
}
vps := env.state().ViewByName("vps")
env.submit(env.admin, NewRequest(OpViewUpdate, vps.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, " vps ") }) {
t.Errorf("vps view with suggestions off: %q", got)
}
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
want(t, "went to internal", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
}
// A zone created inside another takes the parent's records under it.
func TestChildZoneTakesRecords(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.0.0/8"), View: model.Ptr("internal")}))
ptr := func(name, data string) Request {
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "10.in-addr.arpa", View: "internal", Name: &name, Type: model.Ptr("PTR"), Data: &data})
}
env.submit(env.admin, ptr("20.10.0.10.in-addr.arpa.", "printer.example.com."), ptr("5.20.0.10.in-addr.arpa.", "other.example.com."))
env.submit(env.admin, recIn("internal", "nas", "A", "10.0.10.30"))
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal"), Fill: true}))
want(t, "/24", env.ptrs("internal", "10.0.10.in-addr.arpa."),
"20.10.0.10.in-addr.arpa. printer.example.com.", "30.10.0.10.in-addr.arpa. nas.example.com.")
want(t, "/8 keeps the rest", env.ptrs("internal", "10.in-addr.arpa."), "5.20.0.10.in-addr.arpa. other.example.com.")
for _, p := range res.Problems {
if p.Code == "shadowed_by_zone" {
t.Errorf("still hidden records: %v", p)
}
}
// A forward child: its delegation and glue stay in the parent.
env.submit(env.admin,
recIn("internal", "lab", "NS", "ns.lab.example.com."),
recIn("internal", "ns.lab", "A", "10.0.30.1"),
recIn("internal", "www.lab", "A", "10.0.30.2"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.example.com"), View: model.Ptr("internal")}))
st := env.state()
lab := st.FindZone("lab.example.com.", st.ViewByName("internal").ID)
var labRecs []string
for _, r := range st.ZoneRecords(lab.ID) {
if r.Type != "NS" {
labRecs = append(labRecs, r.Name+" "+r.Type)
}
}
want(t, "lab", labRecs, "www.lab.example.com. A")
v := env.view("internal") // without NS records
if !slices.Contains(v, "ns.lab A 10.0.30.1 L") || slices.ContainsFunc(v, func(s string) bool { return strings.HasPrefix(s, "www.lab ") }) {
t.Errorf("parent after: %q", v)
}
if env.recordID("internal", "lab", "NS") == 0 {
t.Error("the delegation left the parent")
}
}
+8
View File
@@ -54,6 +54,11 @@ type View struct {
// internet (docs/exposure.md): "" works it out, ExposurePublic or // internet (docs/exposure.md): "" works it out, ExposurePublic or
// ExposurePrivate say so. // ExposurePrivate say so.
Exposure string `json:"exposure,omitempty"` Exposure string `json:"exposure,omitempty"`
// ReverseSuggestionsOff stops suggesting reverse zones for the view's
// addresses (docs/reverse.md), e.g. for a view only a few servers use;
// DismissedReverse are the networks whose suggestion was dismissed.
ReverseSuggestionsOff bool `json:"reverse_suggestions_off,omitempty"`
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
// Acknowledged are exposure findings accepted on purpose; they are // Acknowledged are exposure findings accepted on purpose; they are
// shown as info instead of warnings. // shown as info instead of warnings.
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"` Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
@@ -267,6 +272,9 @@ type Settings struct {
// for server-wide findings such as a visible version): see View. // for server-wide findings such as a visible version): see View.
Exposure string `json:"exposure,omitempty"` Exposure string `json:"exposure,omitempty"`
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"` Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
// DismissedReverse are dismissed reverse zone suggestions (without
// views; see View).
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
Version int `json:"version"` Version int `json:"version"`
} }
+18 -1
View File
@@ -29,7 +29,7 @@ func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *a
return err return err
} }
out := []engine.ReverseSuggestion{} out := []engine.ReverseSuggestion{}
for _, sg := range engine.ReverseSuggestions(st, s.eng.Exposures(st)) { for _, sg := range s.eng.ReverseSuggestions(st) {
if p.CanSeeZone(sg.Zone+".", sg.View) { if p.CanSeeZone(sg.Zone+".", sg.View) {
out = append(out, sg) out = append(out, sg)
} }
@@ -37,3 +37,20 @@ func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *a
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil return nil
} }
// reverseDismiss: POST /reverse-zones/dismiss {"view": "internal",
// "prefix": "185.199.108.153/32"} hides a suggestion; /undismiss shows it
// again.
func (s *Server) reverseDismiss(dismiss bool) handler {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.DismissInput
if err := decodeJSON(r, &in); err != nil {
return err
}
op := engine.OpReverseUndismiss
if dismiss {
op = engine.OpReverseDismiss
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
}
}
+2
View File
@@ -116,6 +116,8 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false))) m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/fill-ptr", s.authed(s.zoneFillPTR)) m.HandleFunc("POST "+p+"/zones/{zone}/fill-ptr", s.authed(s.zoneFillPTR))
m.HandleFunc("GET "+p+"/reverse-zones/suggestions", s.authed(s.reverseSuggestions)) m.HandleFunc("GET "+p+"/reverse-zones/suggestions", s.authed(s.reverseSuggestions))
m.HandleFunc("POST "+p+"/reverse-zones/dismiss", s.authed(s.reverseDismiss(true)))
m.HandleFunc("POST "+p+"/reverse-zones/undismiss", s.authed(s.reverseDismiss(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile)) m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords)) m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord)) m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
+8 -5
View File
@@ -31,15 +31,15 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
} }
} }
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`) rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, reverse_suggestions_off, dismissed_reverse, version, created_at, updated_at, created_by, updated_by FROM views`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for rows.Next() {
v := &model.View{} v := &model.View{}
var mc, md, acks, ca, ua string var mc, md, acks, dismissed, ca, ua string
var mro sql.NullInt64 var mro sql.NullInt64
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil { if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.ReverseSuggestionsOff, &dismissed, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
@@ -48,6 +48,9 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
if acks != "[]" && acks != "" { if acks != "[]" && acks != "" {
json.Unmarshal([]byte(acks), &v.Acknowledged) json.Unmarshal([]byte(acks), &v.Acknowledged)
} }
if dismissed != "[]" && dismissed != "" {
json.Unmarshal([]byte(dismissed), &v.DismissedReverse)
}
if mro.Valid { if mro.Valid {
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0) v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
} }
@@ -267,8 +270,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
if v.MatchRecursiveOnly != nil { if v.MatchRecursiveOnly != nil {
mro = boolInt(*v.MatchRecursiveOnly) mro = boolInt(*v.MatchRecursiveOnly)
} }
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, reverse_suggestions_off, dismissed_reverse, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy) v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.ReverseSuggestionsOff, jsonText(nonNil(v.DismissedReverse)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
return err return err
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
return err return err
+5
View File
@@ -370,6 +370,11 @@ ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
// 12: PTR records kept in step per zone (docs/reverse.md). // 12: PTR records kept in step per zone (docs/reverse.md).
` `
ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0; ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
`,
// 13: reverse zone suggestions per view (docs/reverse.md).
`
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
`, `,
} }
+4 -1
View File
@@ -125,6 +125,7 @@ async function viewForm(v, viewless) {
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : ""); const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
const comment = input({ value: v ? v.comment : "" }); const comment = input({ value: v ? v.comment : "" });
const color = colorPicker(v ? v.color : ""); const color = colorPicker(v ? v.color : "");
const revSuggest = checkbox("Suggest reverse zones for the addresses this view's records use", !(v && v.reverse_suggestions_off));
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true); const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
const body = h("div", null, h("div", { class: "form-grid" }, const body = h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Name", name),
@@ -132,6 +133,7 @@ async function viewForm(v, viewless) {
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."), field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
field("Recursive only", mro, "If yes, only recursive queries match this view."), field("Recursive only", mro, "If yes, only recursive queries match this view."),
field("Comment", comment), field("Comment", comment),
field("Reverse zones", revSuggest, "Turn it off for a view only some servers use (a VPS's view, say): its addresses then get no suggestions under Zones, and it doesn't count when Jarl picks the view of a new reverse zone. Public views never get suggestions."),
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")), field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null, !v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
err); err);
@@ -139,6 +141,7 @@ async function viewForm(v, viewless) {
mount(err); mount(err);
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value }; const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
data.match_recursive_only = mro.value === "" ? null : mro.value === "true"; data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
data.reverse_suggestions = revSuggest.input.checked;
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true; if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
const colorChanged = color.value !== ((v && v.color) || ""); const colorChanged = color.value !== ((v && v.color) || "");
try { try {
@@ -170,7 +173,7 @@ function configChanged(v, data) {
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []); const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only; const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only;
return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) || return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) ||
data.match_recursive_only !== mro || data.comment !== (v.comment || ""); data.match_recursive_only !== mro || data.comment !== (v.comment || "") || data.reverse_suggestions === !!v.reverse_suggestions_off;
} }
async function deleteView(v) { async function deleteView(v) {
+24 -9
View File
@@ -281,7 +281,9 @@ export function pendingBadge(kind) {
} }
// suggestPanel lists the reverse zones the private views lack for their // suggestPanel lists the reverse zones the private views lack for their
// records' addresses, to create filled with PTR records (docs/reverse.md). // records' addresses, to create filled with PTR records (docs/reverse.md):
// networks that are yours, and public addresses, which may be a web
// host's, to create one by one or dismiss.
function suggestPanel(sugg, views) { function suggestPanel(sugg, views) {
const create = async (list) => { const create = async (list) => {
const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } })); const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } }));
@@ -292,15 +294,28 @@ function suggestPanel(sugg, views) {
rerender(); rerender();
} catch (e) { toast(e.message, "err", 8000); } } catch (e) { toast(e.message, "err", 8000); }
}; };
const dismiss = async (s) => {
try {
const r = await change("POST", "/reverse-zones/dismiss", { view_id: s.view_id || undefined, prefix: s.prefix });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const cols = (pub) => [
views.length ? { label: "View", render: (s) => s.view } : null,
{ label: pub ? "Address" : "Network", render: (s) => [h("span", { class: "mono" }, pub ? s.prefix.replace(/\/(32|128)$/, "") : s.prefix), pub ? null : h("div", { class: "muted small" }, s.why)] },
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
{ label: pub ? "Name" : "PTR records", render: (s) => pub ? h("span", { class: "mono small" }, s.samples.map((x) => x.split(" ")[1]).join(", ")) : [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
{ label: "", render: (s) => [h("button", { class: "small", onclick: () => create([s]) }, "Create"), pub ? [" ", h("button", { class: "small", title: "Not yours (a web host's, say): don't suggest it again", onclick: () => dismiss(s) }, "Dismiss")] : null], tdClass: "actions-cell" },
].filter(Boolean);
const yours = sugg.filter((s) => s.group === "yours");
const pub = sugg.filter((s) => s.group !== "yours");
return h("div", { class: "panel" }, return h("div", { class: "panel" },
h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"), h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"),
sugg.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(sugg) }, "Create all " + sugg.length)) : null), 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."), 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."),
table([ yours.length ? table(cols(false), yours) : null,
views.length ? { label: "View", render: (s) => s.view } : null, pub.length ? [h("h3", null, "Public addresses: only if they're yours"),
{ label: "Network", render: (s) => [h("span", { class: "mono" }, s.prefix), h("div", { class: "muted small" }, s.why)] }, 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."),
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) }, table(cols(true), pub)] : null);
{ label: "PTR records", render: (s) => [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
{ label: "", render: (s) => h("button", { class: "small", onclick: () => create([s]) }, "Create"), tdClass: "actions-cell" },
].filter(Boolean), sugg));
} }
+23 -5
View File
@@ -648,8 +648,8 @@ run "a second name for nas" 0 $J record add revtest.test files A 10.99.1.10 --vi
run "another host" 0 $J record add revtest.test pi A 10.99.1.11 --view internal run "another host" 0 $J record add revtest.test pi A 10.99.1.11 --view internal
run "and IPv6" 0 $J record add revtest.test nas AAAA fdab:99:0:10::10 --view internal run "and IPv6" 0 $J record add revtest.test nas AAAA fdab:99:0:10::10 --view internal
run "suggest-reverse" 0 $J zone suggest-reverse run "suggest-reverse" 0 $J zone suggest-reverse
contains "^internal *10.99.1.0/24 *1.99.10.in-addr.arpa *2 *10.99.1.10 files.revtest.test, 10.99.1.11 pi.revtest.test" contains "^yours *internal *10.99.1.0/24 *1.99.10.in-addr.arpa *2 *10.99.1.10 files.revtest.test, 10.99.1.11 pi.revtest.test"
contains "^internal *fdab:99::/48 *0.0.0.0.9.9.0.0.b.a.d.f.ip6.arpa *1" contains "^yours *internal *fdab:99::/48 *0.0.0.0.9.9.0.0.b.a.d.f.ip6.arpa *1"
run "create it without a view, filled" 0 $J zone create 10.99.1.0/24 --fill run "create it without a view, filled" 0 $J zone create 10.99.1.0/24 --fill
contains "10.99.1.10 has several names" contains "10.99.1.10 has several names"
run "it went to the internal view" 0 $J zone show 1.99.10.in-addr.arpa run "it went to the internal view" 0 $J zone show 1.99.10.in-addr.arpa
@@ -676,9 +676,27 @@ expect_dig "PTR followed: new address" "cam.revtest.test." -x 10.99.1.13
expect_dig "PTR followed: old address gone" "" -x 10.99.1.12 expect_dig "PTR followed: old address gone" "" -x 10.99.1.12
run "the host goes" 0 $J record delete --id $CAM run "the host goes" 0 $J record delete --id $CAM
expect_dig "PTR gone with it" "" -x 10.99.1.13 expect_dig "PTR gone with it" "" -x 10.99.1.13
run "suggest-reverse --create" 0 $J zone suggest-reverse --create run "a web host's address" 0 $J record add revtest.test blog A 185.199.108.153 --view internal
run "nothing missing now" 0 $J zone suggest-reverse run "suggested as public" 0 $J zone suggest-reverse
contains "No reverse zones missing." 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 "gone from the suggestions" 0 $J zone suggest-reverse
not_contains "185.199.108.153"
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"
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 "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
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
expect_dig "the /16 answers" "printer.revtest.test." -x 10.88.5.5
run "a /24 inside it" 0 $J zone create 10.88.5.0/24 --view internal
run "the PTR moved" 0 $J record list 10.88.5.0/24 --view internal
contains "printer.revtest.test."
expect_dig "and still answers, from the /24" "printer.revtest.test." -x 10.88.5.5
# Blocking (docs/blocking.md). It only changes answers to recursive # Blocking (docs/blocking.md). It only changes answers to recursive
# queries, so these ask with recursion and look at which policy zone (if # queries, so these ask with recursion and look at which policy zone (if