diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3cecc..11d8a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,13 +25,23 @@ All notable changes to Jarl are documented here. The format follows --auto-ptr`, zone settings → Reverse DNS): adding, changing, disabling or deleting an A or AAAA record does the same to its PTR 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 10.0.10.0/24`. ### Upgrading -- The database gets a new version (12), applied when jarld starts; a - 0.5 jarld can't open it afterwards, so take a backup first (`sudo +- The database gets two new versions (12 and 13), applied when jarld + 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 zone. diff --git a/cmd/jarl/config_cmds.go b/cmd/jarl/config_cmds.go index 2f4dc0f..2959048 100644 --- a/cmd/jarl/config_cmds.go +++ b/cmd/jarl/config_cmds.go @@ -37,7 +37,7 @@ func viewCmd() *cobra.Command { } var clients, dests []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 { body := map[string]any{} f := cmd.Flags() @@ -63,6 +63,9 @@ func viewCmd() *cobra.Command { if f.Changed("exposure") { body["exposure"] = exposure } + if f.Changed("reverse-suggestions") { + body["reverse_suggestions"] = reverseSuggest + } return body } 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(&comment, "comment", "", "comment") 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{ Use: "create NAME", diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index a4b8ef1..cff58ba 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -1081,6 +1081,7 @@ type reverseSuggestion struct { View string `json:"view"` Prefix string `json:"prefix"` Zone string `json:"zone"` + Group string `json:"group"` Why string `json:"why"` Fill int `json:"fill"` Samples []string `json:"samples"` @@ -1088,20 +1089,44 @@ type reverseSuggestion struct { func zoneSuggestReverseCmd() *cobra.Command { var create bool + var dismiss, undismiss, view string 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", 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 -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 -PTR records, in one changeset (docs/reverse.md).`, +lack for the addresses of their A and AAAA records, in two groups: + + yours the /24 of a private IPv4 network, the /48 of a ULA or of an IPv6 + 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, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { 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 if err := c.Get(ctx(), "/reverse-zones/suggestions", nil, &sugg); err != nil { return err @@ -1112,23 +1137,22 @@ PTR records, in one changeset (docs/reverse.md).`, fmt.Fprintln(w, "No reverse zones missing.") return } - fmt.Fprintln(w, "VIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE") + fmt.Fprintln(w, "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE") for _, s := range sugg { v := s.View if 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 for _, s := range sugg { + if s.Group != "yours" { + continue + } body := map[string]any{"name": s.Prefix, "fill": true} if s.ViewID != 0 { 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}) } + 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) }, } - 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 } diff --git a/docs/api.md b/docs/api.md index aca3c21..96d675b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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}/unlink` | Takes the zone out of its link; its records stay, all local | | POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) | -| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack: `view_id`, `view`, `prefix`, `zone`, `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 | `POST /zones` takes `"link_with": "VIEW"` to create a zone linked with diff --git a/docs/cli.md b/docs/cli.md index c6460b2..1b35372 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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.0/24 --fill # in the view whose records use it, with their PTRs jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too) -jarl zone suggest-reverse [--create] # reverse zones your private views lack +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 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" diff --git a/docs/concepts.md b/docs/concepts.md index 5c9eb0b..8c568db 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -260,7 +260,8 @@ boundary. So: 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 -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 diff --git a/docs/reverse.md b/docs/reverse.md index 1f7c5ef..8103419 100644 --- a/docs/reverse.md +++ b/docs/reverse.md @@ -19,32 +19,49 @@ Who asks matters: delegated it to you. 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 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 -**Create** (or **Create all**): +private view lacks for the addresses of its A and AAAA records, in two +groups: ``` $ jarl zone suggest-reverse -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, ... -internal 139.144.144.154/32 154.144.144.139.in-addr.arpa 1 139.144.144.154 ldn.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, ... +GROUP VIEW NETWORK ZONE PTRS FOR EXAMPLE +yours internal 10.0.10.0/24 10.0.10.in-addr.arpa 14 10.0.10.1 router.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, ... +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 - **/24**. -* A public IPv4 address gets a **/32** zone of its own. The rest of its /24 - belongs to others, and a /24 zone here would answer NXDOMAIN to your - clients for all of their addresses. -* An IPv6 address gets its **/48**: one zone for every subnet of your - prefix (a ULA, or the /48 your ISP delegates). If you have a /56, create - that instead (`jarl zone create 2a02:db8:1:ab00::/56`). +**Yours**, created by **Create all** (`--create`): + +* the **/24** of a private IPv4 address (10/8, 172.16/12, 192.168/16, + 100.64/10); +* the **/48** of a ULA (fd00::/8), or of a public IPv6 prefix this server + has an address in (the /48 your ISP delegates): one zone for all its + subnets. If you have a /56, create that instead (`jarl zone create + 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 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 -view whose records use addresses in its network. If none does, it goes to -the only private view. If several do, Jarl asks you to name the view, or -to create the zone in each. +view whose records use addresses in its network (views with suggestions +off don't count). If none does, it goes to the only private view. If +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 view's A and AAAA records use. An address with several names gets one PTR: diff --git a/internal/engine/diffops.go b/internal/engine/diffops.go index 15d1c6b..804b7bc 100644 --- a/internal/engine/diffops.go +++ b/internal/engine/diffops.go @@ -25,7 +25,7 @@ func DiffOps(from, to *model.State) []model.Op { s := to.Settings 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, - 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) { @@ -50,7 +50,8 @@ func DiffOps(from, to *model.State) []model.Op { n := to.Views[k] 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), - 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 { in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly) } else { diff --git a/internal/engine/expand.go b/internal/engine/expand.go index b779cb4..7d83e93 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -157,6 +157,8 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) { return x.zoneCreate(req) case OpZoneFillPTR: return x.zoneFillPTR(req) + case OpReverseDismiss, OpReverseUndismiss: + return x.reverseDismiss(req, req.Op == OpReverseDismiss) case OpZoneUpdate, OpZoneDelete: z := x.st.Zones[req.ID] if z == nil { @@ -403,6 +405,41 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) { 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 in.Fill = false if fill { @@ -416,7 +453,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) { if in.ViewID != nil { vid = *in.ViewID } - fills := PTRFills(x.st, vid, name, nil) + fills := PTRFills(x.st, vid, name, moved) 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))) } @@ -642,7 +679,45 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) { r.in.ZoneID = ids[0] 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 { diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index eff8bd9..8be1391 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -62,8 +62,20 @@ const ( // Filling a reverse zone with the PTR records of its view's A and // AAAA records (docs/reverse.md) becomes record.create operations. 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 // (by ID or name), or of the server (neither given). type AckInput struct { @@ -246,6 +258,11 @@ type ViewInput struct { // Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md). Exposure *string `json:"exposure,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. @@ -292,6 +309,9 @@ type SettingsInput struct { // Exposure and Acknowledged are the server's (docs/exposure.md). Exposure *string `json:"exposure,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. diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 3c28ae4..4515161 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/netip" + "slices" "strings" "time" @@ -808,6 +809,12 @@ func setViewFields(v *model.View, in *ViewInput) error { if in.Acknowledged != nil { 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 } @@ -1404,6 +1411,9 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error { if in.Acknowledged != nil { s.Acknowledged = cleanAcks(*in.Acknowledged) } + if in.DismissedReverse != nil { + s.DismissedReverse = slices.Clone(*in.DismissedReverse) + } s.Version++ return nil } diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go index f042cf1..69e88d9 100644 --- a/internal/engine/reverse.go +++ b/internal/engine/reverse.go @@ -219,7 +219,8 @@ func (x *expander) publicViews() map[int64]bool { // 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 -// 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) { prefix, ok := dnsname.PrefixFromReverse(zoneName) 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 for _, v := range st.OrderedViews() { - if public[v.ID] { + if public[v.ID] || v.ReverseSuggestionsOff { continue } private = append(private, v) @@ -253,62 +254,68 @@ func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*m // ReverseSuggestion is a reverse zone a view lacks for addresses its // records use. type ReverseSuggestion struct { - ViewID int64 `json:"view_id"` - View string `json:"view,omitempty"` - Prefix string `json:"prefix"` - Zone string `json:"zone"` + ViewID int64 `json:"view_id"` + View string `json:"view,omitempty"` + Prefix string `json:"prefix"` + 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"` Fill int `json:"fill"` // PTR records creating it with fill adds 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 // 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 -// neighbours' names from your clients), the /48 of an IPv6 address. -// Public views get none: the internet asks the addresses' owners. -func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []ReverseSuggestion { +// +// - yours: the /24 of a private IPv4 address, the /48 of a ULA or of a +// public IPv6 prefix this server has an address in (local); +// - public: a /32 (or /128) for any other public address. It may be a +// 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{} for _, e := range exposures { public[e.ViewID] = e.Public } - viewIDs := []int64{0} + type scope struct { + id int64 + dismissed []string + } + var scopes []scope if len(st.Views) > 0 { - viewIDs = viewIDs[:0] for _, v := range st.OrderedViews() { - if !public[v.ID] { - viewIDs = append(viewIDs, v.ID) + if !public[v.ID] && !v.ReverseSuggestionsOff { + scopes = append(scopes, scope{v.ID, v.DismissedReverse}) } } - } else if public[0] { - return []ReverseSuggestion{} + } else if !public[0] { + scopes = append(scopes, scope{0, st.Settings.DismissedReverse}) } out := []ReverseSuggestion{} - for _, vid := range viewIDs { + for _, sc := range scopes { seen := map[netip.Prefix]bool{} - for _, u := range forwardAddresses(st, vid) { + for _, u := range forwardAddresses(st, sc.id) { a := u.addr if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() { continue } - if rz, _ := reverseZoneFor(st, vid, a); rz != nil { + if rz, _ := reverseZoneFor(st, sc.id, a); rz != nil { continue } - var p netip.Prefix - why := "" - 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] { + p, group, why := suggestedPrefix(a, local) + if seen[p] || slices.Contains(sc.dismissed, p.String()) { continue } seen[p] = true @@ -316,8 +323,8 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers if err != nil { continue } - s := ReverseSuggestion{ViewID: vid, View: viewName(st, vid), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Why: why, Samples: []string{}} - fills := PTRFills(st, vid, name, nil) + 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, sc.id, name, nil) s.Fill = len(fills) for i, f := range fills { if i == 3 { @@ -329,6 +336,9 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers } } 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 { return strings.Compare(a.View, b.View) } @@ -337,6 +347,81 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers 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") // autoPTR reports whether a record's PTR is kept in step by its zone's diff --git a/internal/engine/reverse_test.go b/internal/engine/reverse_test.go index 4219f2b..4479c97 100644 --- a/internal/engine/reverse_test.go +++ b/internal/engine/reverse_test.go @@ -1,6 +1,7 @@ package engine import ( + "net/netip" "slices" "strings" "testing" @@ -40,16 +41,16 @@ func TestReverseFillAndSuggest(t *testing.T) { recIn("internal", "*.dev", "A", "10.0.10.99"), 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 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, - "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", - "internal 192.0.2.25/32 25.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") + "yours internal 10.0.10.0/24 10.0.10.in-addr.arpa", + "yours internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa", + "public internal 192.0.2.10/32 10.2.0.192.in-addr.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" { t.Errorf("10.0.10.0/24: %+v", sugg[0]) } @@ -77,7 +78,7 @@ func TestReverseFillAndSuggest(t *testing.T) { } 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) } 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")) 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") + } +} diff --git a/internal/model/model.go b/internal/model/model.go index ba916fc..46e6f60 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -54,6 +54,11 @@ type View struct { // internet (docs/exposure.md): "" works it out, ExposurePublic or // ExposurePrivate say so. 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 // shown as info instead of warnings. Acknowledged []Acknowledgement `json:"acknowledged,omitempty"` @@ -267,7 +272,10 @@ type Settings struct { // for server-wide findings such as a visible version): see View. Exposure string `json:"exposure,omitempty"` Acknowledged []Acknowledgement `json:"acknowledged,omitempty"` - Version int `json:"version"` + // DismissedReverse are dismissed reverse zone suggestions (without + // views; see View). + DismissedReverse []string `json:"dismissed_reverse,omitempty"` + Version int `json:"version"` } // BlockingPaused reports whether blocking is paused, for a time or until diff --git a/internal/server/h_reverse.go b/internal/server/h_reverse.go index 079e79b..2ccc1ee 100644 --- a/internal/server/h_reverse.go +++ b/internal/server/h_reverse.go @@ -29,7 +29,7 @@ func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *a return err } 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) { 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) 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) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 6a428f7..432ccec 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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}/fill-ptr", s.authed(s.zoneFillPTR)) 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("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords)) m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord)) diff --git a/internal/store/state.go b/internal/store/state.go index 48fc5d2..b1185ef 100644 --- a/internal/store/state.go +++ b/internal/store/state.go @@ -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 { return nil, err } for rows.Next() { v := &model.View{} - var mc, md, acks, ca, ua string + var mc, md, acks, dismissed, ca, ua string 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() return nil, err } @@ -48,6 +48,9 @@ func loadState(ctx context.Context, e execer) (*model.State, error) { if acks != "[]" && acks != "" { json.Unmarshal([]byte(acks), &v.Acknowledged) } + if dismissed != "[]" && dismissed != "" { + json.Unmarshal([]byte(dismissed), &v.DismissedReverse) + } if mro.Valid { 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 { 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - 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) + _, 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.ReverseSuggestionsOff, jsonText(nonNil(v.DismissedReverse)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy) return err }, func(id int64) error { return del(ctx, e, "views", id) }); err != nil { return err diff --git a/internal/store/store.go b/internal/store/store.go index 40fd569..13dc921 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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). ` 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 '[]'; `, } diff --git a/internal/web/static/assets/pages/views.js b/internal/web/static/assets/pages/views.js index b7803eb..fd871c0 100644 --- a/internal/web/static/assets/pages/views.js +++ b/internal/web/static/assets/pages/views.js @@ -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 comment = input({ value: v ? v.comment : "" }); 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 body = h("div", null, h("div", { class: "form-grid" }, 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("Recursive only", mro, "If yes, only recursive queries match this view."), 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.")), !v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null, err); @@ -139,6 +141,7 @@ async function viewForm(v, viewless) { mount(err); 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.reverse_suggestions = revSuggest.input.checked; if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true; const colorChanged = color.value !== ((v && v.color) || ""); try { @@ -170,7 +173,7 @@ function configChanged(v, data) { const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []); 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) || - 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) { diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index ad1fabb..52acfc5 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -281,7 +281,9 @@ export function pendingBadge(kind) { } // 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) { const create = async (list) => { 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(); } 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" }, 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."), - table([ - views.length ? { label: "View", render: (s) => s.view } : null, - { label: "Network", render: (s) => [h("span", { class: "mono" }, s.prefix), h("div", { class: "muted small" }, s.why)] }, - { label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) }, - { 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)); + yours.length ? table(cols(false), yours) : null, + pub.length ? [h("h3", null, "Public addresses: only if they're yours"), + h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's (a web host's). Create a zone for an address that is yours; dismiss the others. Each gets a zone of its own: the rest of its network belongs to others."), + table(cols(true), pub)] : null); } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 33eff11..a2529e4 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -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 "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 -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 "^internal *fdab:99::/48 *0.0.0.0.9.9.0.0.b.a.d.f.ip6.arpa *1" +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 "^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 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 @@ -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 run "the host goes" 0 $J record delete --id $CAM expect_dig "PTR gone with it" "" -x 10.99.1.13 -run "suggest-reverse --create" 0 $J zone suggest-reverse --create -run "nothing missing now" 0 $J zone suggest-reverse -contains "No reverse zones missing." +run "a web host's address" 0 $J record add revtest.test blog A 185.199.108.153 --view internal +run "suggested as public" 0 $J zone suggest-reverse +contains "^public *internal *185.199.108.153/32" +run "dismiss it" 0 $J zone suggest-reverse --dismiss 185.199.108.153/32 --view internal +run "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 # queries, so these ask with recursion and look at which policy zone (if