Reverse zones made easy: suggested, filled, in the right view, PTRs kept in step

Missing reverse zones are suggested for the private views' A and AAAA records (/24 for private IPv4, /32 for a public address, /48 for IPv6) and created filled with PTR records (jarl zone suggest-reverse, the Zones page). zone create --fill and zone fill-ptr add the PTRs a reverse zone lacks; a reverse zone created without a view goes to the private view using its addresses. Per zone, auto_ptr keeps the PTR records of A and AAAA records in step on every change (database version 12). Zone commands take a network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 21:23:59 +01:00
1 parent a3b054cc86
commit aa4b004a14
27 files changed
+1173 -38

No files matched your search

+8
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"net/netip"
"net/url"
"os"
"path/filepath"
@@ -23,6 +24,7 @@ import (
"jarl/internal/api"
"jarl/internal/client"
"jarl/internal/dnsname"
"jarl/internal/version"
)
@@ -569,7 +571,13 @@ func q(kv ...string) url.Values {
}
// zonePath returns the API path for a zone given by name or ID.
// A network names its reverse zone: 10.0.10.0/24 is 10.0.10.in-addr.arpa.
func zonePath(zone, view string) (string, url.Values) {
if p, err := netip.ParsePrefix(strings.TrimSpace(zone)); err == nil {
if name, err := dnsname.ReverseZone(p); err == nil {
zone = name
}
}
z := strings.TrimSuffix(zone, ".")
if zone == "." {
z = "root"
+123 -2
View File
@@ -93,6 +93,15 @@ func zoneCmd() *cobra.Command {
if z.InView != "" {
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
}
if z.AutoPTR {
fmt.Fprintf(w, "PTR records:\tkept in step with the A and AAAA records\n")
}
if z.Prefix != "" {
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
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 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, ", "))
}
@@ -110,17 +119,21 @@ 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
create := &cobra.Command{
Use: "create NAME",
Short: "Create a zone",
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to
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).
(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).
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 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
jarl zone create 10.0.10.0/24 --fill # with PTR records for the view's A records there
jarl zone create partner.example --type secondary --primary 192.0.2.53
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
jarl zone create example.com --view external --clone-from example.com --clone-view internal
@@ -156,6 +169,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
if linkWith != "" {
body["link_with"] = linkWith
}
if fill {
body["fill"] = true
}
var recs []map[string]any
for _, r := range records {
f := strings.Fields(r)
@@ -228,9 +244,11 @@ and a network between octet boundaries, like a /22, gets one zone per /24
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
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")
var refresh, retry, expire, minimum, serial uint32
var mname, policy, moveView string
var autoPTR bool
update := &cobra.Command{
Use: "update ZONE",
Short: "Change a zone's settings (SOA, TTL, view, comment)",
@@ -272,6 +290,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
if f.Changed("file") {
body["file"] = file
}
if f.Changed("auto-ptr") {
body["auto_ptr"] = autoPTR
}
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
}
@@ -296,6 +317,7 @@ and a network between octet boundaries, like a /22, gets one zone per /24
update.Flags().StringVar(&comment, "comment", "", "comment")
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view")
update.Flags().StringVar(&file, "file", "", "zone file (secondary, hint and redirect zones)")
update.Flags().BoolVar(&autoPTR, "auto-ptr", false, "keep the PTR records of the zone's A and AAAA records in step (--auto-ptr=false: stop)")
var yes bool
del := &cobra.Command{
@@ -424,7 +446,7 @@ equal to the file: records not in the file are deleted.`,
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd())
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd(), zoneFillPTRCmd(), zoneSuggestReverseCmd())
return cmd
}
@@ -1024,3 +1046,102 @@ func boolCount(b bool, n int) int {
}
return 0
}
func zoneFillPTRCmd() *cobra.Command {
var view string
cmd := &cobra.Command{
Use: "fill-ptr ZONE [--view VIEW]",
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).`,
Example: ` jarl zone fill-ptr 10.0.10.0/24
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
return cmd
}
type reverseSuggestion struct {
ViewID int64 `json:"view_id"`
View string `json:"view"`
Prefix string `json:"prefix"`
Zone string `json:"zone"`
Why string `json:"why"`
Fill int `json:"fill"`
Samples []string `json:"samples"`
}
func zoneSuggestReverseCmd() *cobra.Command {
var create bool
cmd := &cobra.Command{
Use: "suggest-reverse [--create]",
Short: "The reverse zones your private views lack for their records' addresses",
Long: `Lists the reverse zones the private views (or the server, without views)
lack for the addresses of their A and AAAA records: the /24 of a private
IPv4 network, a /32 for a public IPv4 address (the rest of its /24 isn't
yours), the /48 of IPv6 addresses. --create creates them all, filled with
PTR records, in one changeset (docs/reverse.md).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var sugg []reverseSuggestion
if err := c.Get(ctx(), "/reverse-zones/suggestions", nil, &sugg); err != nil {
return err
}
if !create {
return output(sugg, func(w io.Writer) {
if len(sugg) == 0 {
fmt.Fprintln(w, "No reverse zones missing.")
return
}
fmt.Fprintln(w, "VIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE")
for _, s := range sugg {
v := s.View
if v == "" {
v = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", v, s.Prefix, s.Zone, s.Fill, strings.Join(s.Samples, ", "))
}
fmt.Fprintln(w, "\nCreate them, filled: jarl zone suggest-reverse --create (or one: jarl zone create NETWORK --view VIEW --fill)")
})
}
if len(sugg) == 0 {
fmt.Println("No reverse zones missing.")
return nil
}
var reqs []api.ChangeRequest
for _, s := range sugg {
body := map[string]any{"name": s.Prefix, "fill": true}
if s.ViewID != 0 {
body["view_id"] = s.ViewID
}
data, err := json.Marshal(body)
if err != nil {
return err
}
reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data})
}
return submitBatch(c, fmt.Sprintf("%d suggested reverse zones", len(reqs)), reqs)
},
}
cmd.Flags().BoolVar(&create, "create", false, "create them all, filled with PTR records")
return cmd
}