Compare commits

...
Author SHA1 Message Date
adamandClaude Opus 5.5 71d9a06e43 Release 0.6.1-rc.3
release / release (push) Successful in 4m39s
0.6.1's third release candidate: reverse zones list the addresses only
other views' records use, with clearer wording, and jarl zone show lists
missing PTR records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00
adamandClaude Opus 5.5 2fa9828bcf Reverse zones: list other views' addresses, clearer wording
The zone page and jarl zone show list the addresses only other views'
records use (ptr_other_views_list), with the name each would get and the
view it comes from, and say plainly that they aren't missing here. The
page's button reads 'Add PTR records for them in view X'. zone show
lists the missing PTRs too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00
adamandClaude Opus 5.5 a93373687e Release 0.6.1-rc.2
release / release (push) Successful in 6m30s
0.6.1's second release candidate: reverse zones sort numeric labels by
value (2 before 101), and a reverse zone no longer reports addresses
only other views' records use as missing PTR records; filling names
them from the other view only with --other-views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:42:34 +01:00
adamandClaude Opus 5.5 6cd3f6ef4e 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 <noreply@anthropic.com>
2026-10-20 11:40:05 +01:00
adamandClaude Opus 5.5 d18fe18ccd Sort numeric labels by value
Records, zones and findings now order all-digit labels numerically, so
reverse zones list 2 before 101. model.CanonicalLess is replaced by
model.NameLess; other labels keep byte order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:49:58 +01:00
25 changed files with 396 additions and 66 deletions

No files matched your search

+22
View File
@@ -42,6 +42,28 @@ All notable changes to Jarl are documented here. The format follows
### Changed ### 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. The page and `jarl zone show` list those addresses with the
name each would get, and the view it comes from
(`ptr_other_views_list`); `jarl zone show` lists the missing ones too.
- 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
from the right (subdomains stay under their parent); other labels keep
the old byte order (`host10` before `host2`). Zone files are rewritten
in the new order the next time they change; named doesn't care about
record order.
- `cname_chain` no longer warns about one CNAME pointing at another (a - `cname_chain` no longer warns about one CNAME pointing at another (a
service name at a host alias is common, and named answers the chain at service name at a host alias is common, and named answers the chain at
once). A chain of three or more CNAMEs gets an info note that shows once). A chain of three or more CNAMEs gets an info note that shows
+3 -1
View File
@@ -203,11 +203,13 @@ review step.
## Status ## Status
Version 0.6.0 is the current release, and 0.6.1 is in testing Version 0.6.0 is the current release, and 0.6.1 is in testing
(0.6.1-rc.1): it gets certificates onto your other hosts (`jarl cert (0.6.1-rc.3): it gets certificates onto your other hosts (`jarl cert
get`: Jarl obtains and renews them, a host downloads them and runs a get`: Jarl obtains and renews them, a host downloads them and runs a
hook, a daily timer keeps them current), exports certificates, fixes hook, a daily timer keeps them current), exports certificates, fixes
PTR records and overrides in linked zones, and makes one-view tokens PTR records and overrides in linked zones, and makes one-view tokens
(such as ACME tokens for the external view) work with linked zones. (such as ACME tokens for the external view) work with linked zones.
Reverse zones list addresses in numeric order and no longer report
another view's hosts as missing PTR records.
0.6.0 makes reverse zones easy (suggested from your records, 0.6.0 makes reverse zones easy (suggested from your records,
created in the right view and filled, PTR records kept in step), rebuilds created in the right view and filled, PTR records kept in step), rebuilds
comparing a zone's views, lets you explore the statistics down to each comparing a zone's views, lets you explore the statistics down to each
+1 -1
View File
@@ -1 +1 @@
0.6.1-rc.1 0.6.1-rc.3
+68 -7
View File
@@ -8,6 +8,7 @@ import (
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -100,6 +101,16 @@ func zoneCmd() *cobra.Command {
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix) fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
if z.PTRMissing > 0 { 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]) 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])
printPTRFills(w, z.PTRMissingList, z.PTRMissing)
}
if z.PTROtherViews > 0 {
what := "addresses"
if z.PTROtherViews == 1 {
what = "address"
}
fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n",
z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0])
printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews)
} }
} }
if len(z.LinkedWith) > 0 { if len(z.LinkedWith) > 0 {
@@ -119,7 +130,7 @@ func zoneCmd() *cobra.Command {
var ns, records, primaries, forwarders, opts []string var ns, records, primaries, forwarders, opts []string
var contact, comment, cloneFrom, file, inView, forward, linkWith string var contact, comment, cloneFrom, file, inView, forward, linkWith string
var ttl uint32 var ttl uint32
var fill bool var fill, fillOther bool
create := &cobra.Command{ create := &cobra.Command{
Use: "create NAME", Use: "create NAME",
Short: "Create a zone", Short: "Create a zone",
@@ -128,7 +139,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 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 (created together). Without --view, a reverse zone goes to the private
view whose records use its addresses; --fill adds a PTR record for each 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 example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
jarl zone create 192.168.1.0/24 jarl zone create 192.168.1.0/24
@@ -169,9 +181,12 @@ of them (docs/reverse.md).
if linkWith != "" { if linkWith != "" {
body["link_with"] = linkWith body["link_with"] = linkWith
} }
if fill { if fill || fillOther {
body["fill"] = true body["fill"] = true
} }
if fillOther {
body["fill_other_views"] = true
}
var recs []map[string]any var recs []map[string]any
for _, r := range records { for _, r := range records {
f := strings.Fields(r) f := strings.Fields(r)
@@ -245,6 +260,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(&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().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(&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 refresh, retry, expire, minimum, serial uint32
var mname, policy, moveView string var mname, policy, moveView string
@@ -1166,17 +1182,56 @@ func terminalWidth() int {
return width return width
} }
// printPTRFills lists PTR records a reverse zone lacks under zone show's
// line about them: address -> name, and the view the name is from.
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
for _, f := range fills {
from := ""
if f.From != "" {
from = " (view " + f.From + ")"
}
fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from)
}
if total > len(fills) {
fmt.Fprintf(w, "\t and %d more\n", total-len(fills))
}
}
// fromViews names the views PTR fills take their names from: "view
// internal", "views internal and lab".
func fromViews(fills []api.PTRFill) string {
var vs []string
for _, f := range fills {
if f.From != "" && !slices.Contains(vs, f.From) {
vs = append(vs, f.From)
}
}
switch len(vs) {
case 0:
return "other views"
case 1:
return "view " + vs[0]
}
return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1]
}
func zoneFillPTRCmd() *cobra.Command { func zoneFillPTRCmd() *cobra.Command {
var view string var view string
var otherViews bool
cmd := &cobra.Command{ 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", 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 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 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 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 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), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
@@ -1184,6 +1239,9 @@ than the domain's, else the shortest (docs/reverse.md).`,
return err return err
} }
path, qv := zonePath(args[0], view) path, qv := zonePath(args[0], view)
if otherViews {
qv.Set("other_views", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil) resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
if err != nil { if err != nil {
return err return err
@@ -1192,6 +1250,7 @@ than the domain's, else the shortest (docs/reverse.md).`,
}, },
} }
cmd.Flags().StringVar(&view, "view", "", "view of the zone") 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 return cmd
} }
@@ -1231,7 +1290,9 @@ again). A view can stop suggestions: jarl view update V
--all lists every network the views' records use without a reverse zone, --all lists every network the views' records use without a reverse zone,
also where nothing is suggested unasked (a view with suggestions off, a also where nothing is suggested unasked (a view with suggestions off, a
dismissed network, a public view), to create one by hand; --view narrows 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.`, IPv6 can be created as a /56 or /64 instead.`,
Example: ` jarl zone suggest-reverse Example: ` jarl zone suggest-reverse
jarl zone suggest-reverse --create jarl zone suggest-reverse --create
+8 -3
View File
@@ -191,6 +191,7 @@ ZoneInput:
{ {
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone "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": 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 "view": "internal", // or "view_id"; moving a zone = PATCH with another view
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view "type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
"in_view": "internal", // type in-view only "in_view": "internal", // type in-view only
@@ -212,8 +213,12 @@ ZoneInput:
A reverse zone created without a view goes to the private view whose A reverse zone created without a view goes to the private view whose
records use its addresses. Zones come back with `prefix` (reverse zones: 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 the network), `ptr_missing` (addresses in it that the view's A and AAAA
records use, without a PTR record). 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),
with the first 25 of each in `ptr_missing_list` and
`ptr_other_views_list` (`address`, `name`, `target`, and `from`, the view
the name comes from).
`/serials` and `/delegation` send DNS queries from the server, and only `/serials` and `/delegation` send DNS queries from the server, and only
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)). when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
@@ -372,7 +377,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}/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; `?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` | | 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 | | 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 | | 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 |
+1
View File
@@ -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.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 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 [--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 --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
+29 -5
View File
@@ -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' 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 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 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. record only when asked: `--other-views` (with `--fill` or `jarl zone
Keeping PTR records in step (below) stays within a zone's own view and fill-ptr`), or the dialog's **Also for addresses only other views'
its linked views: a later change to that external record doesn't reach records use**, which is ticked for you when you pick a "records in"
the internal reverse zone. 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 ## Creating and filling
@@ -135,7 +144,22 @@ 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 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 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 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 listed apart, with the name each would get: `Other views` in
`jarl zone show`, `ptr_other_views` and `ptr_other_views_list` in the
API, and a quiet note on the page with an **Add PTR records for them in
view vps** button. Filling never adds them unasked. `jarl zone show`
lists the missing ones too:
```
Missing PTRs: 1 (addresses the view's records use here; add them: jarl zone fill-ptr 10.0.10.0/24)
10.0.10.7 -> printer.adbraun.lol
Other views: 2 addresses with A or AAAA records only in view internal, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr 10.0.10.0/24 --other-views)
10.0.10.26 -> gomft.adbraun.lol (view internal)
10.0.10.101 -> claude.adbraun.lol (view internal)
```
## Keeping PTR records in step ## Keeping PTR records in step
+6
View File
@@ -48,6 +48,12 @@ type Zone struct {
// PTRMissingList are the first 25 of them: the PTR records adding // PTRMissingList are the first 25 of them: the PTR records adding
// them would create. // them would create.
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"` 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 adds them, naming them from there. PTROtherViewsList
// are the first 25 (From says which view).
PTROtherViews int `json:"ptr_other_views,omitempty"`
PTROtherViewsList []PTRFill `json:"ptr_other_views_list,omitempty"`
// Pending is set when listing with a changeset: add, change or delete. // Pending is set when listing with a changeset: add, change or delete.
Pending string `json:"pending,omitempty"` Pending string `json:"pending,omitempty"`
} }
+8 -3
View File
@@ -471,8 +471,8 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
} }
} }
} }
fill := in.Fill fill, fillOther := in.Fill, in.FillOtherViews
in.Fill = false in.Fill, in.FillOtherViews = false, false
if fill { if fill {
if zt != model.ZonePrimary { if zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "fill only applies to primary zones") 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 { if in.ViewID != nil {
vid = *in.ViewID vid = *in.ViewID
} }
fills := PTRFills(x.st, vid, name, moved) fills := PTRFills(x.st, vid, name, moved, fillOther)
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)))
} }
if !fillOther {
if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" {
x.warnings = append(x.warnings, h)
}
}
x.warnings = append(x.warnings, fillWarnings(fills)...) x.warnings = append(x.warnings, fillWarnings(fills)...)
for _, f := range 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)}) in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
+12 -2
View File
@@ -163,8 +163,11 @@ type ZoneInput struct {
// step (docs/reverse.md). // step (docs/reverse.md).
AutoPTR *bool `json:"auto_ptr,omitempty"` AutoPTR *bool `json:"auto_ptr,omitempty"`
// Fill (create only, reverse zones) adds a PTR record for every // 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. // address of the view's A and AAAA records in the zone's range;
Fill bool `json:"fill,omitempty"` // 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 // Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to // configured default name servers), additional records, and a zone to
@@ -224,6 +227,13 @@ type LinkInput struct {
With []string `json:"with,omitempty"` 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. // RRSetInput replaces all records of one name and type.
type RRSetInput struct { type RRSetInput struct {
ZoneID int64 `json:"zone_id,omitempty"` ZoneID int64 `json:"zone_id,omitempty"`
+35 -10
View File
@@ -87,11 +87,12 @@ type PTRFill struct {
// PTRFills returns the PTR records a reverse zone named zoneName in a view // 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 // 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 // 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 // a host rather than the domain itself, then the shortest name. With
// address no record of the view uses is named from another view's // otherViews, an address no record of the view uses is named from another
// records (fillAddresses), marked From. existing are the zone's records // view's records (fillAddresses), marked From; that is only on request,
// (none for a new zone). // since it publishes one view's host names in another. existing are the
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill { // 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) prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok { if !ok {
return nil return nil
@@ -105,7 +106,11 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
servers := serverNames(st, viewID) servers := serverNames(st, viewID)
byAddr := map[netip.Addr][]addrUse{} byAddr := map[netip.Addr][]addrUse{}
var order []netip.Addr 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) { if !prefix.Contains(u.addr) {
continue continue
} }
@@ -195,9 +200,23 @@ func displayAll(names []string) []string {
return out 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, has, they := "addresses", "have", "they are"
if n == 1 {
what, has, they = "address", "has", "it is"
}
return fmt.Sprintf("%d %s in %s %s A or AAAA records only in other views, so %s not missing here; %s", n, what, dnsname.Display(zoneName), has, they, how)
}
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks. // zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) { func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
var in LinkInput var in FillPTRInput
if err := decode(req.Data, &in); err != nil { if err := decode(req.Data, &in); err != nil {
return nil, err return nil, err
} }
@@ -215,9 +234,15 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok { 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)) 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 { 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 add PTR records for them in this view too: --other-views"); h != "" {
msg += "; " + h
}
}
return nil, errf(KindBadRequest, "%s", msg)
} }
x.warnings = append(x.warnings, fillWarnings(fills)...) x.warnings = append(x.warnings, fillWarnings(fills)...)
ids, err := x.ids(len(fills)) ids, err := x.ids(len(fills))
@@ -415,7 +440,7 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
continue 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{}} 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) s.Fill = len(fills)
for i, f := range fills { for i, f := range fills {
if i == 3 { if i == 3 {
+32 -9
View File
@@ -69,11 +69,11 @@ func TestReverseFillAndSuggest(t *testing.T) {
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")})) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
st := env.state() st := env.state()
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID) 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.") { if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
t.Errorf("ip6 fill: %q", p) 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) t.Errorf("second fill: %v", e)
} }
@@ -288,15 +288,38 @@ func TestReverseFromOtherView(t *testing.T) {
t.Errorf("suggested unasked: %+v", s) 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})) for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
st := env.state() if strings.HasPrefix(s.Prefix, "2600:") && (s.Fill != 1 || len(s.Samples) != 1) {
var zone string t.Errorf("candidate counts the other view's address: %+v", s)
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
zone = z.Name
} }
} }
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") { // 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) 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") }) { if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
+1 -1
View File
@@ -204,7 +204,7 @@ func (s *State) SortedBlockRules() []*BlockRule {
sort.Slice(out, func(i, j int) bool { sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j] a, b := out[i], out[j]
if a.Name != b.Name { if a.Name != b.Name {
return CanonicalLess(a.Name+".", b.Name+".") return NameLess(a.Name+".", b.Name+".")
} }
if a.Exact != b.Exact { if a.Exact != b.Exact {
return !a.Exact return !a.Exact
+41 -10
View File
@@ -524,14 +524,14 @@ func (s *State) SortedZones() []*Zone {
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj { if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
return pi < pj return pi < pj
} }
return CanonicalLess(out[i].Name, out[j].Name) return NameLess(out[i].Name, out[j].Name)
}) })
return out return out
} }
// SortZones sorts zones by name in DNS canonical order. // SortZones sorts zones by name (see NameLess).
func SortZones(zs []*Zone) { func SortZones(zs []*Zone) {
sort.Slice(zs, func(i, j int) bool { return CanonicalLess(zs[i].Name, zs[j].Name) }) sort.Slice(zs, func(i, j int) bool { return NameLess(zs[i].Name, zs[j].Name) })
} }
// FindZone finds a zone by name within a view (viewID 0 for no view). // FindZone finds a zone by name within a view (viewID 0 for no view).
@@ -646,7 +646,7 @@ func typeOrder(t string) string {
} }
// SortRecords sorts records in zone-file order: apex first, then names in // SortRecords sorts records in zone-file order: apex first, then names in
// DNS canonical order (so subdomains group together), then by type and // NameLess order (so subdomains group together), then by type and
// data. // data.
func SortRecords(rs []*Record, z *Zone) { func SortRecords(rs []*Record, z *Zone) {
apex := "" apex := ""
@@ -662,7 +662,7 @@ func SortRecords(rs []*Record, z *Zone) {
if b.Name == apex { if b.Name == apex {
return false return false
} }
return CanonicalLess(a.Name, b.Name) return NameLess(a.Name, b.Name)
} }
if a.Type != b.Type { if a.Type != b.Type {
return typeOrder(a.Type) < typeOrder(b.Type) return typeOrder(a.Type) < typeOrder(b.Type)
@@ -674,19 +674,50 @@ func SortRecords(rs []*Record, z *Zone) {
}) })
} }
// CanonicalLess compares names in DNS canonical order (RFC 4034 section // NameLess compares names label by label from the right, like DNS
// 6.1): by label from the right. // canonical order (RFC 4034 section 6.1), except that two all-digit
func CanonicalLess(a, b string) bool { // labels compare as numbers, so 2.10.0.10.in-addr.arpa comes before
// 101.10.0.10.in-addr.arpa. It orders names for people and zone files;
// BIND does its own canonical ordering where DNSSEC needs it.
func NameLess(a, b string) bool {
la := strings.Split(strings.TrimSuffix(a, "."), ".") la := strings.Split(strings.TrimSuffix(a, "."), ".")
lb := strings.Split(strings.TrimSuffix(b, "."), ".") lb := strings.Split(strings.TrimSuffix(b, "."), ".")
for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 { for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
if la[i] != lb[j] { if c := compareLabel(la[i], lb[j]); c != 0 {
return la[i] < lb[j] return c < 0
} }
} }
return len(la) < len(lb) return len(la) < len(lb)
} }
func compareLabel(a, b string) int {
if a == b {
return 0
}
if allDigits(a) && allDigits(b) {
na, nb := strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0")
if len(na) != len(nb) {
return len(na) - len(nb)
}
if c := strings.Compare(na, nb); c != 0 {
return c
}
}
return strings.Compare(a, b)
}
func allDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// RRSetKey identifies an RRset. // RRSetKey identifies an RRset.
type RRSetKey struct { type RRSetKey struct {
Name string Name string
+42
View File
@@ -0,0 +1,42 @@
package model
import (
"slices"
"testing"
)
func TestNameLess(t *testing.T) {
want := []string{
"10.0.10.in-addr.arpa.",
"1.10.0.10.in-addr.arpa.",
"02.10.0.10.in-addr.arpa.",
"2.10.0.10.in-addr.arpa.",
"10.10.0.10.in-addr.arpa.",
"101.10.0.10.in-addr.arpa.",
"x.101.10.0.10.in-addr.arpa.",
"254.10.0.10.in-addr.arpa.",
"a.10.0.10.in-addr.arpa.",
}
got := slices.Clone(want)
slices.Reverse(got)
slices.SortFunc(got, func(a, b string) int {
if NameLess(a, b) {
return -1
}
if NameLess(b, a) {
return 1
}
return 0
})
if !slices.Equal(got, want) {
t.Fatalf("got %v\nwant %v", got, want)
}
// Non-numeric labels keep canonical (byte) order.
if !NameLess("host10.example.", "host2.example.") {
t.Error("host10 should sort before host2")
}
if !NameLess("example.", "www.example.") {
t.Error("parent should sort before child")
}
}
+26
View File
@@ -93,3 +93,29 @@ func TestPTRMissingList(t *testing.T) {
t.Errorf("ptr_missing %v, list %v", z["ptr_missing"], z["ptr_missing_list"]) 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"])
} else if l, _ := z["ptr_other_views_list"].([]any); len(l) != 1 || l[0].(map[string]any)["address"] != "10.0.10.101" || l[0].(map[string]any)["target"] != "claude.adbraun.lol." || l[0].(map[string]any)["from"] != "internal" {
t.Errorf("vps: ptr_other_views_list %v", z["ptr_other_views_list"])
}
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"])
}
}
+1 -1
View File
@@ -637,7 +637,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
} }
sort.Slice(recs, func(i, j int) bool { sort.Slice(recs, func(i, j int) bool {
if recs[i].Name != recs[j].Name { if recs[i].Name != recs[j].Name {
return model.CanonicalLess(recs[i].Name, recs[j].Name) return model.NameLess(recs[i].Name, recs[j].Name)
} }
return recs[i].ID < recs[j].ID return recs[i].ID < recs[j].ID
}) })
+3 -2
View File
@@ -8,7 +8,8 @@ import (
) )
// zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse // 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 { func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r) st, err := s.state(r)
if err != nil { if err != nil {
@@ -18,7 +19,7 @@ func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Pri
if err != nil { if err != nil {
return err 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 // reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
+1 -1
View File
@@ -215,7 +215,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
} }
slices.SortFunc(recs, func(a, b *model.Record) int { slices.SortFunc(recs, func(a, b *model.Record) int {
if a.Name != b.Name { if a.Name != b.Name {
if model.CanonicalLess(a.Name, b.Name) { if model.NameLess(a.Name, b.Name) {
return -1 return -1
} }
return 1 return 1
+11 -1
View File
@@ -41,11 +41,21 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok { if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
out.Prefix = pf.String() out.Prefix = pf.String()
if z.Type == model.ZonePrimary { 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) out.PTRMissing = len(fills)
for _, f := range fills[:min(len(fills), 25)] { for _, f := range fills[:min(len(fills), 25)] {
out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f)) out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f))
} }
if z.ViewID != 0 {
for _, f := range engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true) {
if f.From == "" {
continue
}
if out.PTROtherViews++; out.PTROtherViews <= 25 {
out.PTROtherViewsList = append(out.PTROtherViewsList, api.PTRFill(f))
}
}
}
} }
} }
for _, o := range st.LinkedWith(z) { for _, o := range st.LinkedWith(z) {
+1 -1
View File
@@ -186,7 +186,7 @@ func (c *checker) checkZoneRecords(z *model.Zone) {
for n := range ix.types { for n := range ix.types {
names = append(names, n) names = append(names, n)
} }
sort.Slice(names, func(i, j int) bool { return model.CanonicalLess(names[i], names[j]) }) sort.Slice(names, func(i, j int) bool { return model.NameLess(names[i], names[j]) })
for _, name := range names { for _, name := range names {
types := ix.types[name] types := ix.types[name]
+1 -1
View File
@@ -189,7 +189,7 @@ func sortProblems(ps []Problem) {
return a.Zone < b.Zone return a.Zone < b.Zone
} }
if a.Name != b.Name { if a.Name != b.Name {
return model.CanonicalLess(a.Name, b.Name) return model.NameLess(a.Name, b.Name)
} }
return a.Message < b.Message return a.Message < b.Message
}) })
+18 -4
View File
@@ -72,7 +72,21 @@ export async function render(el, params, query) {
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)), 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.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)), 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))
: null;
// Addresses only other views have records for: not missing here, but
// they can be added (publishing those views' names in this one).
const others = zone.ptr_other_views_list || [];
const fromViews = [...new Set(others.map((f) => f.from))];
const otherNote = !zone.disabled && zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" },
plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " " + (zone.ptr_other_views === 1 ? "has" : "have") + " A or AAAA records only in view " + fromViews.join(", ") +
", not in view " + zone.view + ", so " + (zone.ptr_other_views === 1 ? "it isn't" : "they aren't") + " counted as missing here. ",
h("button", { class: "small", title: "Clients of view " + zone.view + " will then get these names, which come from view " + fromViews.join(", "), onclick: () => fillPTR(zone, true) },
"Add PTR records for " + (zone.ptr_other_views === 1 ? "it" : "them") + " in view " + zone.view),
h("ul", { class: "small ptr-missing" }, others.map((f) => h("li", null,
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
fromViews.length > 1 ? h("span", { class: "muted" }, " (view " + f.from + ")") : null)),
zone.ptr_other_views > others.length ? h("li", { class: "muted" }, "and " + (zone.ptr_other_views - others.length) + " more") : null)) : null;
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))); 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 // 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 // previous tab's content (faded) until the new one is loaded, rather
@@ -83,7 +97,7 @@ export async function render(el, params, query) {
holder.classList.add("stale"); holder.classList.add("stale");
body.className = "zone-body"; body.className = "zone-body";
body.dataset.zone = id; body.dataset.zone = id;
mount(el, head, banner, tabBar, holder); mount(el, head, banner, otherNote, tabBar, holder);
if (tab === "records") await recordsTab(body, zone, query); if (tab === "records") await recordsTab(body, zone, query);
else if (tab === "settings") await settingsTab(body, zone); else if (tab === "settings") await settingsTab(body, zone);
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" }); else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
@@ -951,9 +965,9 @@ async function toggleZone(zone) {
} }
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md). // fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
async function fillPTR(zone) { async function fillPTR(zone, otherViews) {
try { 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)); toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000)); (r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender(); rerender();
+9 -2
View File
@@ -103,7 +103,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
cloneSel.addEventListener("change", syncLink); cloneSel.addEventListener("change", syncLink);
const comment = input(); 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 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 = () => { const updateClones = () => {
// Records can only be copied into a single zone. // Records can only be copied into a single zone.
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + "."; 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; name.value = c.prefix;
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null; sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
if (views.length && c.view) view.value = c.view; if (views.length && c.view) view.value = c.view;
if ((c.hidden || "").startsWith("records in ")) { fill.input.checked = true; fillOther.input.checked = true; }
changed(); changed();
} }, } },
h("span", { class: "mono" }, c.prefix), 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; if (view.value) data.view = view.value;
const opts = []; const opts = [];
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" "); 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") { if (t === "primary") {
data.nameservers = lines(ns.value); data.nameservers = lines(ns.value);
if (contact.value.trim()) data.soa = { rname: contact.value.trim() }; if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
+16 -1
View File
@@ -703,6 +703,21 @@ 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 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 run "fill-ptr again: nothing to add (exit 6)" 6 $J zone fill-ptr fdab:99::/48
contains "nothing to add" 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 "A or AAAA records only in other views"
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 with A or AAAA records only in view internal"
contains "10.99.1.11 -> pi.revtest.test (view internal)"
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 "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 run "zone show says so" 0 $J zone show revtest.test --view internal
contains "^PTR records: *kept in step" contains "^PTR records: *kept in step"
@@ -736,7 +751,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 "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 run "offered for internal" 0 $J zone suggest-reverse --all --view internal
contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external" 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 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. # A zone created inside another takes the parent's records under it.
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal