From 6cd3f6ef4e3e8b1948fd7e1f048b341e853cb9e8 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 20 Oct 2026 11:40:05 +0100 Subject: [PATCH] Reverse zones: other views' addresses aren't missing PTRs A reverse zone's missing PTR count covers only its own view's A and AAAA records. Addresses only other views' records use are counted apart (ptr_other_views, 'Other views' in zone show, a note on the zone page) and filled only when asked: fill-ptr --other-views, zone create --fill --other-views, or the New reverse zone dialog's checkbox. This keeps one view's host names out of another view's reverse zones. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++ cmd/jarl/zones.go | 34 +++++++++++++---- docs/api.md | 8 ++-- docs/cli.md | 1 + docs/reverse.md | 24 +++++++++--- internal/api/types.go | 4 ++ internal/engine/expand.go | 11 ++++-- internal/engine/inputs.go | 14 ++++++- internal/engine/reverse.go | 45 ++++++++++++++++++----- internal/engine/reverse_test.go | 41 ++++++++++++++++----- internal/server/dsat_test.go | 24 ++++++++++++ internal/server/h_reverse.go | 5 ++- internal/server/present.go | 5 ++- internal/web/static/assets/pages/zone.js | 9 +++-- internal/web/static/assets/pages/zones.js | 11 +++++- scripts/e2e.sh | 16 +++++++- 16 files changed, 216 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 076a9a1..bc7b845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,18 @@ All notable changes to Jarl are documented here. The format follows ### Changed +- A reverse zone's missing PTR records count only its own view's A and + AAAA records. An address only another view's records use (an internal + host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no + longer reported as missing; it shows apart, as `Other views` in `jarl + zone show`, `ptr_other_views` in the API and a note on the zone's page. + Filling (`--fill`, `jarl zone fill-ptr`, the page's button) names those + from the other view only when asked, with `--other-views` + (`fill_other_views`, `?other_views=1`, a checkbox in the New reverse + zone dialog), so one view's host names don't leak into another. When + there is nothing else to fill, Jarl says how many there are and how to + add them. + - Records, zones and validation findings sort numeric labels by value, so `2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the web UI, the CLI and the rendered zone files. Names still group by label diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index 20e678f..e87f4a3 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -101,6 +101,9 @@ func zoneCmd() *cobra.Command { if z.PTRMissing > 0 { fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0]) } + if z.PTROtherViews > 0 { + fmt.Fprintf(w, "Other views:\t%d addresses here only other views' records use, without a PTR record (to name them from there: jarl zone fill-ptr %s --other-views)\n", z.PTROtherViews, args[0]) + } } if len(z.LinkedWith) > 0 { fmt.Fprintf(w, "Linked with:\t%s (shared records are kept in step; 'jarl record list' shows which are local)\n", strings.Join(z.LinkedWith, ", ")) @@ -119,7 +122,7 @@ func zoneCmd() *cobra.Command { var ns, records, primaries, forwarders, opts []string var contact, comment, cloneFrom, file, inView, forward, linkWith string var ttl uint32 - var fill bool + var fill, fillOther bool create := &cobra.Command{ Use: "create NAME", Short: "Create a zone", @@ -128,7 +131,8 @@ create its reverse zone: a /32 (or /128) gives a zone for that one address, and a network between octet boundaries, like a /22, gets one zone per /24 (created together). Without --view, a reverse zone goes to the private view whose records use its addresses; --fill adds a PTR record for each -of them (docs/reverse.md). +of them, and --other-views also for the addresses only other views' +records use, named from there (docs/reverse.md). jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1" jarl zone create 192.168.1.0/24 @@ -169,9 +173,12 @@ of them (docs/reverse.md). if linkWith != "" { body["link_with"] = linkWith } - if fill { + if fill || fillOther { body["fill"] = true } + if fillOther { + body["fill_other_views"] = true + } var recs []map[string]any for _, r := range records { f := strings.Fields(r) @@ -245,6 +252,7 @@ of them (docs/reverse.md). create.Flags().StringVar(&linkWith, "link-with", "", "link with the zone of the same name in this view: copy its shared records and keep them in step (docs/views.md#linked-zones)") create.Flags().StringVar(&comment, "comment", "", "comment") create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network") + create.Flags().BoolVar(&fillOther, "other-views", false, "reverse zones: --fill, also naming the addresses only other views' records use") var refresh, retry, expire, minimum, serial uint32 var mname, policy, moveView string @@ -1168,15 +1176,21 @@ func terminalWidth() int { func zoneFillPTRCmd() *cobra.Command { var view string + var otherViews bool cmd := &cobra.Command{ - Use: "fill-ptr ZONE [--view VIEW]", + Use: "fill-ptr ZONE [--view VIEW] [--other-views]", Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records", Long: `Adds a PTR record to a reverse zone for every address in its network that the view's A and AAAA records use and that has none yet. An address with several names gets the mail or name server's name, else a host's rather -than the domain's, else the shortest (docs/reverse.md).`, +than the domain's, else the shortest (docs/reverse.md). + +--other-views also adds the addresses in the network that only other +views' records use, naming them from there. That publishes those views' +host names in this one, so it is never done unasked.`, Example: ` jarl zone fill-ptr 10.0.10.0/24 - jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal`, + jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal + jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() @@ -1184,6 +1198,9 @@ than the domain's, else the shortest (docs/reverse.md).`, return err } path, qv := zonePath(args[0], view) + if otherViews { + qv.Set("other_views", "1") + } resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil) if err != nil { return err @@ -1192,6 +1209,7 @@ than the domain's, else the shortest (docs/reverse.md).`, }, } cmd.Flags().StringVar(&view, "view", "", "view of the zone") + cmd.Flags().BoolVar(&otherViews, "other-views", false, "also name the addresses only other views' records use") return cmd } @@ -1231,7 +1249,9 @@ again). A view can stop suggestions: jarl view update V --all lists every network the views' records use without a reverse zone, also where nothing is suggested unasked (a view with suggestions off, a dismissed network, a public view), to create one by hand; --view narrows -the list to one view. Change the size as you need: a /48 suggested for +the list to one view and adds the networks only other views' records use +(hidden "records in VIEW"): create one with --fill --other-views to name +them from there. Change the size as you need: a /48 suggested for IPv6 can be created as a /56 or /64 instead.`, Example: ` jarl zone suggest-reverse jarl zone suggest-reverse --create diff --git a/docs/api.md b/docs/api.md index 792ffb6..822f100 100644 --- a/docs/api.md +++ b/docs/api.md @@ -191,6 +191,7 @@ ZoneInput: { "name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone "fill": true, // reverse zones: PTR records for the view's A/AAAA records there + "fill_other_views": true, // with fill: also for addresses only other views' records use "view": "internal", // or "view_id"; moving a zone = PATCH with another view "type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view "in_view": "internal", // type in-view only @@ -212,8 +213,9 @@ ZoneInput: A reverse zone created without a view goes to the private view whose records use its addresses. Zones come back with `prefix` (reverse zones: -the network) and `ptr_missing` (addresses in it that the view's A and AAAA -records use, without a PTR record). +the network), `ptr_missing` (addresses in it that the view's A and AAAA +records use, without a PTR record) and `ptr_other_views` (addresses in it +without a PTR record that only other views' records use; not missing). `/serials` and `/delegation` send DNS queries from the server, and only when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)). @@ -372,7 +374,7 @@ and of any type, in view order (`[{"id": 289, "view": "external", "type": |--------|------|------| | 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)) | +| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records; `?other_views=1` also for the addresses only other views' records use, named from there ([reverse.md](reverse.md)) | | GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` | | POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions | | GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it | diff --git a/docs/cli.md b/docs/cli.md index b292528..55ed46d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -179,6 +179,7 @@ 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 fill-ptr 10.0.10.0/24 --view vps --other-views # also name addresses only other views' records use jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours) jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones diff --git a/docs/reverse.md b/docs/reverse.md index 26665a2..7e9dfa8 100644 --- a/docs/reverse.md +++ b/docs/reverse.md @@ -97,10 +97,19 @@ jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill the dialog's View), the list also has the addresses only other views' records use, marked "records in external": say a VPS's AAAA record that only the external view has. Fill names those from the other view's -record, when the zone's own view has none for the address, and says so. -Keeping PTR records in step (below) stays within a zone's own view and -its linked views: a later change to that external record doesn't reach -the internal reverse zone. +record only when asked: `--other-views` (with `--fill` or `jarl zone +fill-ptr`), or the dialog's **Also for addresses only other views' +records use**, which is ticked for you when you pick a "records in" +network. Otherwise one view's host names would turn up in another's +reverse zone, which is what views are for keeping apart. Jarl says which +names came from another view. Keeping PTR records in step (below) stays +within a zone's own view and its linked views: a later change to that +external record doesn't reach the internal reverse zone. + +```sh +jarl zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views +jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views +``` ## Creating and filling @@ -135,7 +144,12 @@ Jarl says which name it chose, and which it didn't, so you can change it. A reverse zone's page shows the addresses that still lack a PTR record and the name each would get (the first 25), with a button to add them. `jarl zone show` shows how many as `Missing PTRs`; the API lists them in -`ptr_missing_list`. +`ptr_missing_list`. Only the zone's own view's records count: an address +that only another view's records use isn't missing (say internal's +`claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`). +Those are shown apart, as `Other views` in `jarl zone show` +(`ptr_other_views` in the API) and a quiet note with a **Name them from +there** button on the page; filling never adds them unasked. ## Keeping PTR records in step diff --git a/internal/api/types.go b/internal/api/types.go index 908f6bd..2eefc51 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -48,6 +48,10 @@ type Zone struct { // PTRMissingList are the first 25 of them: the PTR records adding // them would create. PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"` + // PTROtherViews counts the addresses in it, without a PTR record, + // that only other views' records use: not missing, but fill-ptr with + // other_views=1 can name them from there. + PTROtherViews int `json:"ptr_other_views,omitempty"` // Pending is set when listing with a changeset: add, change or delete. Pending string `json:"pending,omitempty"` } diff --git a/internal/engine/expand.go b/internal/engine/expand.go index ce43a48..878f66f 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -471,8 +471,8 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) { } } } - fill := in.Fill - in.Fill = false + fill, fillOther := in.Fill, in.FillOtherViews + in.Fill, in.FillOtherViews = false, false if fill { if zt != model.ZonePrimary { return nil, errf(KindBadRequest, "fill only applies to primary zones") @@ -484,10 +484,15 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) { if in.ViewID != nil { vid = *in.ViewID } - fills := PTRFills(x.st, vid, name, moved) + fills := PTRFills(x.st, vid, name, moved, fillOther) 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))) } + if !fillOther { + if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to name them from there, fill it with other views (jarl zone fill-ptr ZONE --other-views)"); h != "" { + x.warnings = append(x.warnings, h) + } + } x.warnings = append(x.warnings, fillWarnings(fills)...) for _, f := range fills { in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}) diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index 798b504..31a8109 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -163,8 +163,11 @@ type ZoneInput struct { // step (docs/reverse.md). AutoPTR *bool `json:"auto_ptr,omitempty"` // Fill (create only, reverse zones) adds a PTR record for every - // address of the view's A and AAAA records in the zone's range. - Fill bool `json:"fill,omitempty"` + // address of the view's A and AAAA records in the zone's range; + // FillOtherViews also for the addresses only other views' records + // use, named from there. + Fill bool `json:"fill,omitempty"` + FillOtherViews bool `json:"fill_other_views,omitempty"` // Create only: name servers for the apex NS records (default: the // configured default name servers), additional records, and a zone to @@ -224,6 +227,13 @@ type LinkInput struct { With []string `json:"with,omitempty"` } +// FillPTRInput is zone.fill_ptr: OtherViews also fills the addresses only +// other views' records use, naming them from there. +type FillPTRInput struct { + ZoneID int64 `json:"zone_id,omitempty"` + OtherViews bool `json:"other_views,omitempty"` +} + // RRSetInput replaces all records of one name and type. type RRSetInput struct { ZoneID int64 `json:"zone_id,omitempty"` diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go index 778fc94..c6dd52a 100644 --- a/internal/engine/reverse.go +++ b/internal/engine/reverse.go @@ -87,11 +87,12 @@ type PTRFill struct { // PTRFills returns the PTR records a reverse zone named zoneName in a view // lacks for the addresses of the view's A and AAAA records: one per // address, naming its mail or name server host when it is one, otherwise -// a host rather than the domain itself, then the shortest name. An -// address no record of the view uses is named from another view's -// records (fillAddresses), marked From. existing are the zone's records -// (none for a new zone). -func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill { +// a host rather than the domain itself, then the shortest name. With +// otherViews, an address no record of the view uses is named from another +// view's records (fillAddresses), marked From; that is only on request, +// since it publishes one view's host names in another. existing are the +// zone's records (none for a new zone). +func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record, otherViews bool) []PTRFill { prefix, ok := dnsname.PrefixFromReverse(zoneName) if !ok { return nil @@ -105,7 +106,11 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model. servers := serverNames(st, viewID) byAddr := map[netip.Addr][]addrUse{} var order []netip.Addr - for _, u := range fillAddresses(st, viewID) { + addrs := forwardAddresses(st, viewID) + if otherViews { + addrs = fillAddresses(st, viewID) + } + for _, u := range addrs { if !prefix.Contains(u.addr) { continue } @@ -195,9 +200,23 @@ func displayAll(names []string) []string { return out } +// otherViewsHint says how many addresses only other views' records use in +// a reverse zone, and how to add them; "" when there are none. +func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []*model.Record, own int, how string) string { + n := len(PTRFills(st, viewID, zoneName, existing, true)) - own + if n <= 0 { + return "" + } + what := "addresses" + if n == 1 { + what = "address" + } + return fmt.Sprintf("%d %s in %s only other views' records use; %s", n, what, dnsname.Display(zoneName), how) +} + // zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks. func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) { - var in LinkInput + var in FillPTRInput if err := decode(req.Data, &in); err != nil { return nil, err } @@ -215,9 +234,15 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) { if _, ok := dnsname.PrefixFromReverse(z.Name); !ok { return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z)) } - fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID)) + fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), in.OtherViews) if len(fills) == 0 { - return nil, errf(KindBadRequest, "nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z)) + msg := fmt.Sprintf("nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z)) + if !in.OtherViews { + if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to name them from there, fill with other views (--other-views)"); h != "" { + msg += "; " + h + } + } + return nil, errf(KindBadRequest, "%s", msg) } x.warnings = append(x.warnings, fillWarnings(fills)...) ids, err := x.ids(len(fills)) @@ -415,7 +440,7 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n continue } s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}} - fills := PTRFills(st, sc.id, name, nil) + fills := PTRFills(st, sc.id, name, nil, opt.All && opt.View != "") s.Fill = len(fills) for i, f := range fills { if i == 3 { diff --git a/internal/engine/reverse_test.go b/internal/engine/reverse_test.go index eab3fc4..7ebd6b6 100644 --- a/internal/engine/reverse_test.go +++ b/internal/engine/reverse_test.go @@ -69,11 +69,11 @@ func TestReverseFillAndSuggest(t *testing.T) { env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")})) st := env.state() z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID) - env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})) + env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{})) if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") { t.Errorf("ip6 fill: %q", p) } - if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})); !strings.Contains(e.Message, "nothing to add") { + if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") { t.Errorf("second fill: %v", e) } @@ -288,15 +288,38 @@ func TestReverseFromOtherView(t *testing.T) { t.Errorf("suggested unasked: %+v", s) } } - res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true})) - st := env.state() - var zone string - for _, z := range st.ZonesInView(st.ViewByName("internal").ID) { - if strings.HasSuffix(z.Name, ".ip6.arpa.") { - zone = z.Name + for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) { + if strings.HasPrefix(s.Prefix, "2600:") && (s.Fill != 1 || len(s.Samples) != 1) { + t.Errorf("candidate counts the other view's address: %+v", s) } } - if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") { + // Filling doesn't take names from other views unasked: it would + // publish one view's host names in another. It says how to. + res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true})) + st := env.state() + var z *model.Zone + for _, o := range st.ZonesInView(st.ViewByName("internal").ID) { + if strings.HasSuffix(o.Name, ".ip6.arpa.") { + z = o + } + } + if p := env.ptrs("internal", z.Name); len(p) != 0 { + t.Errorf("filled from another view unasked: %q", p) + } + if !slices.ContainsFunc(res.Warnings, func(w string) bool { + return strings.Contains(w, "1 address in") && strings.Contains(w, "--other-views") + }) { + t.Errorf("warnings %q", res.Warnings) + } + // Not missing: no record of the zone's own view uses the address. + if f := PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false); len(f) != 0 { + t.Errorf("missing %+v", f) + } + if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") || !strings.Contains(e.Message, "--other-views") { + t.Errorf("fill-ptr: %v", e) + } + res = env.submit(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{OtherViews: true})) + if p := env.ptrs("internal", z.Name); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") { t.Errorf("filled %q", p) } if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) { diff --git a/internal/server/dsat_test.go b/internal/server/dsat_test.go index 4f5dea2..29cf7a0 100644 --- a/internal/server/dsat_test.go +++ b/internal/server/dsat_test.go @@ -93,3 +93,27 @@ func TestPTRMissingList(t *testing.T) { t.Errorf("ptr_missing %v, list %v", z["ptr_missing"], z["ptr_missing_list"]) } } + +// An address only another view's records use isn't missing from a +// reverse zone: it is counted apart, and filled only when asked. +func TestPTROtherViews(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201) + e.do("POST", "/views", e.token, map[string]any{"name": "vps"}, 201) + e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."}, + "records": []map[string]any{{"name": "claude", "type": "A", "data": "10.0.10.101"}}}, 201) + for _, v := range []string{"internal", "vps"} { + e.do("POST", "/zones", e.token, map[string]any{"name": "10.0.10.0/24", "view": v, "nameservers": []string{"ns1.example.net."}}, 201) + } + if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=internal", e.token, nil, 200); z["ptr_missing"] != float64(1) || z["ptr_other_views"] != nil { + t.Errorf("internal: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"]) + } + if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_missing"] != nil || z["ptr_other_views"] != float64(1) { + t.Errorf("vps: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"]) + } + e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps", e.token, nil, 400) + e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps&other_views=1", e.token, nil, 201) + if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_other_views"] != nil { + t.Errorf("vps after filling: ptr_other_views %v", z["ptr_other_views"]) + } +} diff --git a/internal/server/h_reverse.go b/internal/server/h_reverse.go index acc05bd..fa9f861 100644 --- a/internal/server/h_reverse.go +++ b/internal/server/h_reverse.go @@ -8,7 +8,8 @@ import ( ) // zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse -// zone lacks for its view's A and AAAA records (docs/reverse.md). +// zone lacks for its view's A and AAAA records; ?other_views=1 also for +// the addresses only other views' records use (docs/reverse.md). func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { @@ -18,7 +19,7 @@ func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Pri if err != nil { return err } - return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.LinkInput{ZoneID: z.ID})}, nil) + return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.FillPTRInput{ZoneID: z.ID, OtherViews: queryBool(r, "other_views")})}, nil) } // reverseSuggestions: GET /reverse-zones/suggestions lists the reverse diff --git a/internal/server/present.go b/internal/server/present.go index b7285a0..457fd0e 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -41,8 +41,11 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re if pf, ok := dnsname.PrefixFromReverse(z.Name); ok { out.Prefix = pf.String() if z.Type == model.ZonePrimary { - fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID)) + fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false) out.PTRMissing = len(fills) + if z.ViewID != 0 { + out.PTROtherViews = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true)) - len(fills) + } for _, f := range fills[:min(len(fills), 25)] { out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f)) } diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index 77c4fa4..7cdb45c 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -72,7 +72,10 @@ export async function render(el, params, query) { h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)), f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null, f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)), - zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null)) : null; + zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null)) + : zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" }, + plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " only other views' records use, without a PTR record here. ", + h("button", { class: "small", title: "Adds PTR records naming them from the other views' records, publishing those names in view " + zone.view, onclick: () => fillPTR(zone, true) }, "Name them from there")) : null; const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))); // The header and tabs show at once. Below them, switching tabs keeps the // previous tab's content (faded) until the new one is loaded, rather @@ -951,9 +954,9 @@ async function toggleZone(zone) { } // fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md). -async function fillPTR(zone) { +async function fillPTR(zone, otherViews) { try { - const r = await change("POST", "/zones/" + zone.id + "/fill-ptr"); + const r = await change("POST", "/zones/" + zone.id + "/fill-ptr", undefined, otherViews ? { query: { other_views: "1" } } : {}); toast(describeResult(r)); (r.warnings || []).forEach((w) => toast(w, "warn", 10000)); rerender(); diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index f9fd747..c39416b 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -103,7 +103,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) { cloneSel.addEventListener("change", syncLink); const comment = input(); const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true); - const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself."); + // Off unless asked (or a network only other views use is picked): it + // publishes one view's host names in another. + const fillOther = checkbox("Also for addresses only other views' records use, named from there", false); + const fillRow = field("PTR records", h("div", null, fill, views.length ? fillOther : null), "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself."); const updateClones = () => { // Records can only be copied into a single zone. const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + "."; @@ -206,6 +209,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) { name.value = c.prefix; sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null; if (views.length && c.view) view.value = c.view; + if ((c.hidden || "").startsWith("records in ")) { fill.input.checked = true; fillOther.input.checked = true; } changed(); } }, h("span", { class: "mono" }, c.prefix), @@ -273,7 +277,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) { if (view.value) data.view = view.value; const opts = []; const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" "); - if (t === "primary" && kind === "reverse" && fill.input.checked) data.fill = true; + if (t === "primary" && kind === "reverse" && fill.input.checked) { + data.fill = true; + if (fillOther.input.checked) data.fill_other_views = true; + } if (t === "primary") { data.nameservers = lines(ns.value); if (contact.value.trim()) data.soa = { rname: contact.value.trim() }; diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 1373639..a6baa62 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -703,6 +703,20 @@ run "fill-ptr" 0 $J zone fill-ptr fdab:99::/48 expect_dig "filled later: IPv6 PTR" "nas.revtest.test." -x fdab:99:0:10::10 run "fill-ptr again: nothing to add (exit 6)" 6 $J zone fill-ptr fdab:99::/48 contains "nothing to add" +# The same network in another view: internal's hosts aren't missing there, +# and are named from internal only when asked. +run "the network in external too, filled" 0 $J zone create 10.99.1.0/24 --view external --fill +contains "only other views' records use" +run "nothing missing there" 0 $J zone show 1.99.10.in-addr.arpa --view external +not_contains "Missing PTRs" +contains "^Other views: *2 addresses" +run "fill-ptr there: nothing of its own (exit 6)" 6 $J zone fill-ptr 10.99.1.0/24 --view external +contains "--other-views" +run "fill-ptr --other-views" 0 $J zone fill-ptr 10.99.1.0/24 --view external --other-views +contains "only view internal has a record for it" +run "now named there" 0 $J record list 1.99.10.in-addr.arpa --view external +contains "pi.revtest.test" +run "drop the external copy" 0 $J zone delete 1.99.10.in-addr.arpa --view external --yes run "keep PTRs in step" 0 $J zone update revtest.test --view internal --auto-ptr run "zone show says so" 0 $J zone show revtest.test --view internal contains "^PTR records: *kept in step" @@ -736,7 +750,7 @@ run "and back on" 0 $J view update internal --reverse-suggestions run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external run "offered for internal" 0 $J zone suggest-reverse --all --view internal contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external" -run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill +run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1 # A zone created inside another takes the parent's records under it. run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal