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
+1171 -36

No files matched your search

+29
View File
@@ -6,6 +6,35 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Added
- Reverse zones made easy (docs/reverse.md):
- **Missing reverse zones**: the Zones page and `jarl zone
suggest-reverse` list the reverse zones the private views lack for
their A and AAAA records' addresses: the /24 of a private IPv4 network,
a /32 for a public address (the rest of its /24 belongs to others),
the /48 of IPv6 addresses. Create one, or all (`--create`), filled.
- **Fill**: `jarl zone create 10.0.10.0/24 --fill` (ticked in the New
zone dialog) adds a PTR record for each address the view's records use
there; `jarl zone fill-ptr` and the reverse zone's page add the ones
missing later. An address with several names gets its mail or name
server's name, else a host's.
- **The right view**: a reverse zone created without a view goes to the
private view whose records use its addresses.
- **PTR records kept in step** per zone (`jarl zone update ZONE
--auto-ptr`, zone settings → Reverse DNS): adding, changing,
disabling or deleting an A or AAAA record does the same to its PTR
record, as the Update PTR box does for one change.
- Zone commands take a network for a reverse zone: `jarl zone show
10.0.10.0/24`.
### Upgrading
- The database gets a new version (12), applied when jarld starts; a
0.5 jarld can't open it afterwards, so take a backup first (`sudo
jarld backup`). Nothing changes until you turn `auto_ptr` on for a
zone.
## [0.5.2] - 2026-10-12 ## [0.5.2] - 2026-10-12
### Added ### Added
+3 -1
View File
@@ -25,7 +25,8 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
## Features ## Features
* **Everything in one place**: primary, secondary, forward, stub, mirror, * **Everything in one place**: primary, secondary, forward, stub, mirror,
hint, redirect and in-view zones, reverse zones from a CIDR, split DNS with hint, redirect and in-view zones, reverse zones from a CIDR (suggested,
filled and kept in step from your A records), split DNS with
views, ACLs, TSIG keys, and every BIND 9.20 option at server, view, zone views, ACLs, TSIG keys, and every BIND 9.20 option at server, view, zone
and top level, with the ARM's description and grammar built in. and top level, with the ARM's description and grammar built in.
* **Safe changes.** Every change is validated as a whole (CNAME rules, * **Safe changes.** Every change is validated as a whole (CNAME rules,
@@ -147,6 +148,7 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
| [docs/encrypted-dns.md](docs/encrypted-dns.md) | DNS over TLS and HTTPS for your clients: setup, ports, client settings | | [docs/encrypted-dns.md](docs/encrypted-dns.md) | DNS over TLS and HTTPS for your clients: setup, ports, client settings |
| [docs/certificates.md](docs/certificates.md) | TLS certificates Jarl obtains and renews for its web UI and named's DNS over TLS/HTTPS | | [docs/certificates.md](docs/certificates.md) | TLS certificates Jarl obtains and renews for its web UI and named's DNS over TLS/HTTPS |
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention | | [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
| [docs/reverse.md](docs/reverse.md) | Reverse zones: suggested from your records, created in the right view and filled, PTR records kept in step |
| [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting | | [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts | | [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory | | [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
+8
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/netip"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -23,6 +24,7 @@ import (
"jarl/internal/api" "jarl/internal/api"
"jarl/internal/client" "jarl/internal/client"
"jarl/internal/dnsname"
"jarl/internal/version" "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. // 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) { 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, ".") z := strings.TrimSuffix(zone, ".")
if zone == "." { if zone == "." {
z = "root" z = "root"
+123 -2
View File
@@ -93,6 +93,15 @@ func zoneCmd() *cobra.Command {
if z.InView != "" { if z.InView != "" {
fmt.Fprintf(w, "In view:\t%s\n", 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 { 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, ", ")) 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 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
create := &cobra.Command{ create := &cobra.Command{
Use: "create NAME", Use: "create NAME",
Short: "Create a zone", Short: "Create a zone",
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to 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, 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). (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 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
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa 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 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 consul --type forward --forwarder "127.0.0.1 port 8600"
jarl zone create example.com --view external --clone-from example.com --clone-view internal 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 != "" { if linkWith != "" {
body["link_with"] = linkWith body["link_with"] = linkWith
} }
if fill {
body["fill"] = 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)
@@ -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(&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(&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")
var refresh, retry, expire, minimum, serial uint32 var refresh, retry, expire, minimum, serial uint32
var mname, policy, moveView string var mname, policy, moveView string
var autoPTR bool
update := &cobra.Command{ update := &cobra.Command{
Use: "update ZONE", Use: "update ZONE",
Short: "Change a zone's settings (SOA, TTL, view, comment)", 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") { if f.Changed("file") {
body["file"] = file body["file"] = file
} }
if f.Changed("auto-ptr") {
body["auto_ptr"] = autoPTR
}
if len(body) == 0 { if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)") 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(&comment, "comment", "", "comment")
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view") 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().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 var yes bool
del := &cobra.Command{ 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(&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") 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 return cmd
} }
@@ -1024,3 +1046,102 @@ func boolCount(b bool, n int) int {
} }
return 0 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
}
+9
View File
@@ -190,6 +190,7 @@ ZoneInput:
```json ```json
{ {
"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
"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
@@ -199,6 +200,7 @@ ZoneInput:
"default_ttl": 3600, "default_ttl": 3600,
"serial_policy": "date", // date, increment, unixtime "serial_policy": "date", // date, increment, unixtime
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off) "dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
"auto_ptr": true, // keep the PTRs of the zone's A/AAAA records in step (docs/reverse.md)
"comment": "", "comment": "",
// create only: // create only:
"nameservers": ["ns1.adbraun.lol."], // default: settings "nameservers": ["ns1.adbraun.lol."], // default: settings
@@ -208,6 +210,11 @@ 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).
`/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)).
`/serials` works for primary, secondary and mirror zones: `/serials` works for primary, secondary and mirror zones:
@@ -353,6 +360,8 @@ and `linked_with` (the other views).
|--------|------|------| |--------|------|------|
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local | | POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local | | POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) |
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack: `view_id`, `view`, `prefix`, `zone`, `why`, `fill` (PTR records it would get), `samples` |
| GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view | | GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view |
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with `POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
+4
View File
@@ -177,6 +177,10 @@ jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol.
jarl zone create 192.168.20.0/24 # reverse zone from a network jarl zone create 192.168.20.0/24 # reverse zone from a network
jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.in-addr.arpa jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.in-addr.arpa
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 fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
jarl zone suggest-reverse [--create] # reverse zones your private views lack
jarl zone update adbraun.lol --auto-ptr # keep PTRs in step with the A/AAAA records (docs/reverse.md)
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer" jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600" jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
+4
View File
@@ -258,6 +258,10 @@ boundary. So:
(RFC 2317 delegation, where an ISP hands you part of a /24, is set up by (RFC 2317 delegation, where an ISP hands you part of a /24, is set up by
hand with CNAMEs.) hand with CNAMEs.)
Jarl suggests the reverse zones your private views lack, creates them in
the right view filled with PTR records, and can keep PTR records in step
with a zone's A and AAAA records: [reverse.md](reverse.md).
### Dynamic updates and TSIG keys ### Dynamic updates and TSIG keys
Jarl writes every zone file, so it can't let named change a zone through Jarl writes every zone file, so it can't let named change a zone through
+101
View File
@@ -0,0 +1,101 @@
# Reverse zones
A reverse zone answers "which host has this address?" with PTR records:
`20.10.0.10.in-addr.arpa. PTR nas.adbraun.lol.`. Your own clients use them
for names in logs, `traceroute`, the router's client list and `ssh`
warnings. For your private networks they only exist if this server has
them, and they are easy to forget, so Jarl can work them out from the
records you already have.
Who asks matters:
* **Your own clients** ask this server, so a reverse zone in a private
view (internal) answers them.
* **The internet** asks whoever owns the address: your ISP or hosting
provider for public addresses. Mail servers check those PTR records, and
you set them in the provider's panel, not in Jarl. The Exposure page
checks them ([exposure.md](exposure.md#mail)). A reverse zone for a
public address in a public view (external) does nothing unless the owner
delegated it to you.
So reverse zones belong in private views. A view whose clients reach it
from the internet gets no suggestions.
## Missing reverse zones
The Zones page lists, under **Missing reverse zones**, the reverse zones a
private view lacks for the addresses of its A and AAAA records, with
**Create** (or **Create all**):
```
$ jarl zone suggest-reverse
VIEW NETWORK ZONE PTRS FOR EXAMPLE
internal 10.0.10.0/24 10.0.10.in-addr.arpa 14 10.0.10.1 router.adbraun.lol, 10.0.10.5 nas.adbraun.lol, ...
internal 139.144.144.154/32 154.144.144.139.in-addr.arpa 1 139.144.144.154 ldn.adbraun.lol
internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa 9 fdab:abab:abab:10::5 nas.adbraun.lol, ...
$ jarl zone suggest-reverse --create # all of them, filled, in one changeset
```
* A private IPv4 address (10/8, 172.16/12, 192.168/16, 100.64/10) gets its
**/24**.
* A public IPv4 address gets a **/32** zone of its own. The rest of its /24
belongs to others, and a /24 zone here would answer NXDOMAIN to your
clients for all of their addresses.
* An IPv6 address gets its **/48**: one zone for every subnet of your
prefix (a ULA, or the /48 your ISP delegates). If you have a /56, create
that instead (`jarl zone create 2a02:db8:1:ab00::/56`).
Loopback and link-local addresses, wildcards and disabled records are left
out.
## Creating and filling
```sh
jarl zone create 10.0.10.0/24 --fill # the view whose records use 10.0.10.x
jarl zone fill-ptr 10.0.10.0/24 # later: add what is missing
```
**The view.** A reverse zone created without `--view` goes to the private
view whose records use addresses in its network. If none does, it goes to
the only private view. If several do, Jarl asks you to name the view, or
to create the zone in each.
**Fill** adds a PTR record for every address in the network that the
view's A and AAAA records use. An address with several names gets one PTR:
1. the name of a mail or name server (an MX or NS target), else
2. a host's name rather than the domain's own (`nas.adbraun.lol`, not
`adbraun.lol`), else
3. the shortest name, then the first alphabetically.
Jarl says which name it chose, and which it didn't, so you can change it.
A reverse zone's page shows how many addresses still lack a PTR, with a
button to add them. `jarl zone show` shows it as `Missing PTRs`.
## Keeping PTR records in step
The **Update PTR** box on an A or AAAA record (`--ptr`) moves the PTR record
along with that one change. To have that on every change, turn it on for
the forward zone:
```sh
jarl zone update adbraun.lol --view internal --auto-ptr # off: --auto-ptr=false
```
(Zone settings → **Reverse DNS** in the web UI.) Then adding, changing,
disabling, enabling or deleting an A or AAAA record in the zone does the
same to its PTR record, in the reverse zones of the zone's view and of the
views it is linked with ([views.md](views.md#linked-zones)). A whole set
replaced at once (`jarl record set`, a batch) works too.
* **One name per address.** When an address's PTR record names another
host, it stays: a second A record for the same address doesn't add a
second PTR record.
* **No reverse zone, no PTR.** An address without a reverse zone in the
view, such as a public address in the external view, is skipped without
a warning. The suggestions above show what is missing.
* **Wildcards** (`*.dev`) never get a PTR record.
The PTR changes are part of the same changeset, so you review them, and
they are applied or undone together with the record.
+5
View File
@@ -37,6 +37,11 @@ type Zone struct {
// OtherViews are the other views with a primary zone of this name // OtherViews are the other views with a primary zone of this name
// (linked or not): what GET /zones/{zone}/compare compares. // (linked or not): what GET /zones/{zone}/compare compares.
OtherViews []string `json:"other_views,omitempty"` OtherViews []string `json:"other_views,omitempty"`
// Prefix is the network a reverse zone answers for; PTRMissing how
// many of its view's A and AAAA addresses in it have no PTR record
// (POST /zones/{zone}/fill-ptr adds them).
Prefix string `json:"prefix,omitempty"`
PTRMissing int `json:"ptr_missing,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"`
} }
+44
View File
@@ -469,3 +469,47 @@ func BestZone(name string, zones []string) string {
} }
return best return best
} }
// PrefixFromReverse returns the network a reverse zone answers for:
// 10.0.10.in-addr.arpa. is 10.0.10.0/24, a /32 zone a single address, and
// an ip6.arpa zone of 12 nibbles a /48. False for names that aren't whole
// octets or nibbles (classless delegations such as 0-63.1.0.10.in-addr.arpa.).
func PrefixFromReverse(zone string) (netip.Prefix, bool) {
zone = strings.ToLower(Fqdn(zone))
switch {
case strings.HasSuffix(zone, ".in-addr.arpa."):
labels := strings.Split(strings.TrimSuffix(zone, ".in-addr.arpa."), ".")
if len(labels) > 4 {
return netip.Prefix{}, false
}
var b [4]byte
for i, l := range labels {
v, err := strconv.Atoi(l)
if err != nil || v < 0 || v > 255 || strconv.Itoa(v) != l {
return netip.Prefix{}, false
}
b[len(labels)-1-i] = byte(v)
}
return netip.PrefixFrom(netip.AddrFrom4(b), 8*len(labels)), true
case strings.HasSuffix(zone, ".ip6.arpa."):
labels := strings.Split(strings.TrimSuffix(zone, ".ip6.arpa."), ".")
if len(labels) > 32 {
return netip.Prefix{}, false
}
var b [16]byte
for i, l := range labels {
v := strings.IndexByte("0123456789abcdef", l[0])
if len(l) != 1 || v < 0 {
return netip.Prefix{}, false
}
n := len(labels) - 1 - i // nibble position from the left
if n%2 == 0 {
b[n/2] |= byte(v) << 4
} else {
b[n/2] |= byte(v)
}
}
return netip.PrefixFrom(netip.AddrFrom16(b), 4*len(labels)), true
}
return netip.Prefix{}, false
}
+32
View File
@@ -217,3 +217,35 @@ func TestReverseZones(t *testing.T) {
t.Errorf("ReverseZone /22: %v", err) t.Errorf("ReverseZone /22: %v", err)
} }
} }
func TestPrefixFromReverse(t *testing.T) {
for zone, want := range map[string]string{
"10.0.10.in-addr.arpa.": "10.0.10.0/24",
"10.in-addr.arpa": "10.0.0.0/8",
"154.144.144.139.in-addr.arpa.": "139.144.144.154/32",
"d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa.": "2a02:8013:7f3d::/48",
"b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.": "fdab:abab:abab::/48",
"0-63.1.0.10.in-addr.arpa.": "",
"example.com.": "",
"256.10.in-addr.arpa.": "",
} {
p, ok := PrefixFromReverse(zone)
got := ""
if ok {
got = p.String()
}
if got != want {
t.Errorf("PrefixFromReverse(%q) = %q, want %q", zone, got, want)
}
}
// Round trip with ReverseZone.
for _, s := range []string{"192.168.1.0/24", "2a02:8013:7f3d::/48", "fd00::/8", "10.0.0.5/32"} {
z, err := ReverseZone(netip.MustParsePrefix(s))
if err != nil {
t.Fatal(err)
}
if p, ok := PrefixFromReverse(z); !ok || p.String() != s {
t.Errorf("%s -> %s -> %v", s, z, p)
}
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Zones[k] n := to.Zones[k]
s := n.SOA s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File, in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)} DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup), AutoPTR: model.Ptr(n.AutoPTR)}
if n.Type == model.ZonePrimary { if n.Type == model.ZonePrimary {
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum} in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
} }
+1 -1
View File
@@ -285,7 +285,7 @@ func (e *Engine) expandAll(ctx context.Context, work *model.State, reqs []Reques
var warnings []string var warnings []string
c := &applyCtx{p: p, now: e.Now()} c := &applyCtx{p: p, now: e.Now()}
for i, req := range reqs { for i, req := range reqs {
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now()} x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now(), exposures: e.Exposures}
more, err := x.expand(req) more, err := x.expand(req)
if err != nil { if err != nil {
if ee, ok := AsError(err); ok && len(reqs) > 1 { if ee, ok := AsError(err); ok && len(reqs) > 1 {
+134 -17
View File
@@ -28,6 +28,9 @@ type expander struct {
p *auth.Principal p *auth.Principal
now time.Time now time.Time
warnings []string warnings []string
// exposures classifies the views as public or private (nil in tests:
// every view private).
exposures func(*model.State) []validate.Exposure
} }
func (x *expander) ids(n int) ([]int64, error) { func (x *expander) ids(n int) ([]int64, error) {
@@ -137,7 +140,7 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
return nil, err return nil, err
} }
switch req.Op { switch req.Op {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable: case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable, OpZoneFillPTR:
// Changes to linked zones reach the other zones of the link. // Changes to linked zones reach the other zones of the link.
return x.linkSync(ops) return x.linkSync(ops)
} }
@@ -152,6 +155,8 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
return x.zoneUnlink(req) return x.zoneUnlink(req)
case OpZoneCreate: case OpZoneCreate:
return x.zoneCreate(req) return x.zoneCreate(req)
case OpZoneFillPTR:
return x.zoneFillPTR(req)
case OpZoneUpdate, OpZoneDelete: case OpZoneUpdate, OpZoneDelete:
z := x.st.Zones[req.ID] z := x.st.Zones[req.ID]
if z == nil { if z == nil {
@@ -376,6 +381,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
return nil, errf(KindBadRequest, "%v", err) return nil, errf(KindBadRequest, "%v", err)
} }
in.Type = &zt in.Type = &zt
viewGiven := in.View != nil || in.ViewID != nil
if in.View != nil { if in.View != nil {
if *in.View != "" { if *in.View != "" {
v := x.st.ViewByName(*in.View) v := x.st.ViewByName(*in.View)
@@ -386,6 +392,39 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
} }
in.View = nil in.View = nil
} }
if !viewGiven && in.LinkWith == "" && in.CloneFrom == 0 && dnsname.IsReverse(name) {
// A reverse zone belongs in the private view whose records use
// its addresses (docs/reverse.md).
v, err := reverseViewFor(x.st, x.publicViews(), name)
if err != nil {
return nil, err
}
if v != nil {
in.ViewID = &v.ID
}
}
fill := in.Fill
in.Fill = false
if fill {
if zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "fill only applies to primary zones")
}
if _, ok := dnsname.PrefixFromReverse(name); !ok {
return nil, errf(KindBadRequest, "fill only applies to reverse zones of whole octets or nibbles (like 10.0.10.0/24)")
}
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
fills := PTRFills(x.st, vid, name, nil)
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, 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)})
}
}
nameservers := in.Nameservers nameservers := in.Nameservers
records := in.Records records := in.Records
cloneFrom := in.CloneFrom cloneFrom := in.CloneFrom
@@ -638,7 +677,11 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
} }
// ptrOps returns operations creating the PTR record for an address record. // ptrOps returns operations creating the PTR record for an address record.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) { //
// auto is set when the zone's auto_ptr asked rather than the request: a
// missing reverse zone isn't worth a warning then (most addresses have
// none), and an address whose PTR record names another host keeps it.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) {
addr, ok := addrOf(p) addr, ok := addrOf(p)
if !ok { if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records") return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
@@ -651,13 +694,13 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
continue continue
} }
found = true found = true
more, err := x.ptrCreateIn(rz, ptrName, owner) more, err := x.ptrCreateIn(rz, ptrName, owner, auto)
if err != nil { if err != nil {
return nil, err return nil, err
} }
ops = append(ops, more...) ops = append(ops, more...)
} }
if !found { if !found && !auto {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen())) x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
} }
return ops, nil return ops, nil
@@ -678,7 +721,8 @@ func ptrViews(st *model.State, z *model.Zone) []int64 {
// ptrCreateIn returns operations creating the PTR record ptrName -> owner // ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled). // in reverse zone rz (none when it is there; enabling it when disabled).
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.Op, error) { func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
var other string
for _, r := range x.st.ZoneRecords(rz.ID) { for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" { if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) { if strings.EqualFold(r.Data, owner) {
@@ -688,9 +732,18 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.O
} }
return nil, nil // already there return nil, nil // already there
} }
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data)) if !r.Disabled {
other = r.Data
} }
} }
}
if other != "" {
if auto {
// One name per address: the one it has stays.
return nil, nil
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, other))
}
ids, err := x.ids(1) ids, err := x.ids(1)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -778,7 +831,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
return nil, err return nil, err
} }
x.parseWarnings(owner, p) x.parseWarnings(owner, p)
ptr := in.PTR ptr, auto := in.PTR, !in.PTR && autoPTR(z, owner, p)
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
ids, err := x.ids(1) ids, err := x.ids(1)
@@ -807,8 +860,8 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
// local on a zone that isn't linked: its records are local anyway. // local on a zone that isn't linked: its records are local anyway.
in.Local = nil in.Local = nil
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)} ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
if ptr { if ptr || auto && (in.Disabled == nil || !*in.Disabled) {
more, err := x.ptrCreate(z, owner, p) more, err := x.ptrCreate(z, owner, p, auto)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -939,13 +992,43 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if in.Disabled != nil { if in.Disabled != nil {
disabled = *in.Disabled disabled = *in.Disabled
} }
moved := p.Data != r.Data || owner != r.Name moved := p.Data != r.Data || owner != r.Name || p.Type != r.Type
if !ptr && z.AutoPTR {
// The zone keeps PTR records in step (docs/reverse.md): as if
// asked, for whichever of the old and new record is an address.
old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data)
oldAuto := err == nil && autoPTR(z, r.Name, old)
newAuto := autoPTR(z, owner, p)
switch {
case moved:
if oldAuto {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if newAuto && !disabled {
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
case toggled && newAuto && disabled:
ops = append(ops, x.ptrSetDisabled(z, owner, p, true)...)
case toggled && newAuto:
// Enables the PTR record, or adds it when it is missing.
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
if ptr && moved { if ptr && moved {
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, old)...) ops = append(ops, x.ptrDelete(z, r.Name, old)...)
} }
if !disabled { if !disabled {
more, err := x.ptrCreate(z, owner, p) more, err := x.ptrCreate(z, owner, p, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -961,7 +1044,7 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if ptr && !moved && !toggled && !disabled { if ptr && !moved && !toggled && !disabled {
// Nothing else changes: make sure the PTR record is there (the // Nothing else changes: make sure the PTR record is there (the
// reverse zone may be newer than the record). // reverse zone may be newer than the record).
more, err := x.ptrCreate(z, owner, p) more, err := x.ptrCreate(z, owner, p, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -988,12 +1071,10 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
base = r.Version base = r.Version
} }
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))} ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
if in.PTR {
z := x.st.Zones[r.ZoneID] z := x.st.Zones[r.ZoneID]
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) {
ops = append(ops, x.ptrDelete(z, r.Name, p)...) ops = append(ops, x.ptrDelete(z, r.Name, p)...)
} }
}
return ops, nil return ops, nil
} }
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View) z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
@@ -1032,8 +1113,19 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type = &owner, &typ in.Name, in.Type = &owner, &typ
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)} ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
if ptr && parsed != nil { switch {
case ptr && parsed != nil:
ops = append(ops, x.ptrDelete(z, owner, parsed)...) ops = append(ops, x.ptrDelete(z, owner, parsed)...)
case z.AutoPTR:
// Every matching record's PTR record goes too.
for _, r := range x.st.RRset(z.ID, owner, typ) {
if parsed != nil && r.Data != parsed.Data {
continue
}
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && autoPTR(z, r.Name, p) {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
} }
return ops, nil return ops, nil
} }
@@ -1169,7 +1261,32 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
if len(canon) == 0 { if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where) summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
} }
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary))
if z.AutoPTR && (typ == "A" || typ == "AAAA") && !strings.HasPrefix(owner, "*.") {
// PTR records follow the addresses that go and come.
old := map[string]bool{}
for _, r := range x.st.RRset(z.ID, owner, typ) {
old[r.Data] = true
if !slices.Contains(canon, r.Data) {
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
}
for _, d := range canon {
if old[d] {
continue
}
if _, p, err := parseRecord(z, owner, typ, d); err == nil {
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
}
}
return ops, nil
} }
func (x *expander) viewCreate(req Request) ([]model.Op, error) { func (x *expander) viewCreate(req Request) ([]model.Op, error) {
+9
View File
@@ -59,6 +59,9 @@ const (
// into zone and record updates. // into zone and record updates.
OpZoneLink = "zone.link" OpZoneLink = "zone.link"
OpZoneUnlink = "zone.unlink" OpZoneUnlink = "zone.unlink"
// Filling a reverse zone with the PTR records of its view's A and
// AAAA records (docs/reverse.md) becomes record.create operations.
OpZoneFillPTR = "zone.fill_ptr"
) )
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view // AckInput acknowledges (or un-acknowledges) an exposure finding of a view
@@ -142,6 +145,12 @@ type ZoneInput struct {
// name in this view, copying its shared records (or all of its // name in this view, copying its shared records (or all of its
// records, when it isn't linked yet). // records, when it isn't linked yet).
LinkWith string `json:"link_with,omitempty"` LinkWith string `json:"link_with,omitempty"`
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
// 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"`
// 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
+3
View File
@@ -268,6 +268,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.LinkGroup != nil { if in.LinkGroup != nil {
z.LinkGroup = *in.LinkGroup z.LinkGroup = *in.LinkGroup
} }
if in.AutoPTR != nil {
z.AutoPTR = *in.AutoPTR
}
return nil return nil
} }
+357
View File
@@ -0,0 +1,357 @@
package engine
import (
"fmt"
"net/netip"
"slices"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
"jarl/internal/validate"
)
// Reverse zones made easy (docs/reverse.md): filling a reverse zone from
// the A and AAAA records of its view, choosing its view, and suggesting
// the reverse zones a view lacks.
// addrUse is an address a forward record gives a name.
type addrUse struct {
addr netip.Addr
name string // the owner name, absolute
apex bool
}
// forwardAddresses returns the addresses of the enabled A and AAAA
// records of a view's primary forward zones (wildcards aside: a PTR record
// can't name them).
func forwardAddresses(st *model.State, viewID int64) []addrUse {
var out []addrUse
for _, z := range st.ZonesInView(viewID) {
if z.Type != model.ZonePrimary || z.Disabled || dnsname.IsReverse(z.Name) {
continue
}
for _, r := range st.ZoneRecords(z.ID) {
if r.Disabled || (r.Type != "A" && r.Type != "AAAA") || strings.HasPrefix(r.Name, "*.") {
continue
}
a, err := netip.ParseAddr(r.Data)
if err != nil {
continue
}
out = append(out, addrUse{addr: a.Unmap(), name: r.Name, apex: r.Name == z.Name})
}
}
return out
}
// PTRFill is a PTR record filling a reverse zone would add.
type PTRFill struct {
Address string `json:"address"`
Name string `json:"name"` // the PTR owner name
Target string `json:"target"` // the host name it gives
Others []string `json:"others,omitempty"` // other names with this address
}
// 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.
// existing are the zone's records (none for a new zone).
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok {
return nil
}
has := map[string]bool{}
for _, r := range existing {
if r.Type == "PTR" {
has[r.Name] = true
}
}
servers := serverNames(st, viewID)
byAddr := map[netip.Addr][]addrUse{}
var order []netip.Addr
for _, u := range forwardAddresses(st, viewID) {
if !prefix.Contains(u.addr) {
continue
}
if _, seen := byAddr[u.addr]; !seen {
order = append(order, u.addr)
}
if !slices.ContainsFunc(byAddr[u.addr], func(o addrUse) bool { return o.name == u.name }) {
byAddr[u.addr] = append(byAddr[u.addr], u)
}
}
slices.SortFunc(order, func(a, b netip.Addr) int { return a.Compare(b) })
var out []PTRFill
for _, a := range order {
owner := dnsname.ReverseName(a)
if has[owner] {
continue
}
uses := byAddr[a]
slices.SortStableFunc(uses, func(x, y addrUse) int {
rank := func(u addrUse) int {
switch {
case servers[u.name]:
return 0
case !u.apex:
return 1
}
return 2
}
if d := rank(x) - rank(y); d != 0 {
return d
}
if d := strings.Count(x.name, ".") - strings.Count(y.name, "."); d != 0 {
return d
}
return strings.Compare(x.name, y.name)
})
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name}
for _, u := range uses[1:] {
f.Others = append(f.Others, u.name)
}
out = append(out, f)
}
return out
}
// serverNames are the hosts the view's MX and NS records name.
func serverNames(st *model.State, viewID int64) map[string]bool {
out := map[string]bool{}
for _, z := range st.ZonesInView(viewID) {
if z.Type != model.ZonePrimary {
continue
}
for _, r := range st.ZoneRecords(z.ID) {
switch r.Type {
case "MX":
if f := strings.Fields(r.Data); len(f) == 2 {
out[strings.ToLower(f[1])] = true
}
case "NS":
out[strings.ToLower(r.Data)] = true
}
}
}
return out
}
func fillWarnings(fills []PTRFill) []string {
var out []string
for _, f := range fills {
if len(f.Others) > 0 {
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
}
}
return out
}
func displayAll(names []string) []string {
out := make([]string, len(names))
for i, n := range names {
out[i] = dnsname.Display(n)
}
return out
}
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
var in LinkInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
id := in.ZoneID
if id == 0 {
id = req.ID
}
z := x.st.Zones[id]
if z == nil {
return nil, errf(KindNotFound, "zone %d does not exist", id)
}
if z.Type != model.ZonePrimary {
return nil, errf(KindBadRequest, "zone %s isn't a primary zone", zoneDesc(x.st, z))
}
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))
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))
}
x.warnings = append(x.warnings, fillWarnings(fills)...)
ids, err := x.ids(len(fills))
if err != nil {
return nil, err
}
var ops []model.Op
for i, f := range fills {
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
if z.LinkGroup != 0 {
ri.SharedID = model.Ptr(ids[i])
}
ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
}
return ops, nil
}
func viewOrServer(st *model.State, viewID int64) string {
if v := st.Views[viewID]; v != nil {
return "view " + v.Name
}
return "the server"
}
// publicViews returns which views the internet reaches.
func (x *expander) publicViews() map[int64]bool {
out := map[int64]bool{}
if x.exposures == nil {
return out
}
for _, e := range x.exposures(x.st) {
out[e.ViewID] = e.Public
}
return out
}
// reverseViewFor chooses the view of a new reverse zone given without one:
// the private view whose records use addresses in its range, or the only
// private view. "" with no error leaves the choice to the usual rules.
func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok || len(st.Views) == 0 {
return nil, nil
}
var private, using []*model.View
for _, v := range st.OrderedViews() {
if public[v.ID] {
continue
}
private = append(private, v)
if slices.ContainsFunc(forwardAddresses(st, v.ID), func(u addrUse) bool { return prefix.Contains(u.addr) }) {
using = append(using, v)
}
}
switch {
case len(using) == 1:
return using[0], nil
case len(using) > 1:
var names []string
for _, v := range using {
names = append(names, v.Name)
}
return nil, errf(KindBadRequest, "views %s all have addresses in %s: give the view (or create the zone in each)", strings.Join(names, " and "), prefix)
case len(private) == 1:
return private[0], nil
}
return nil, nil
}
// ReverseSuggestion is a reverse zone a view lacks for addresses its
// records use.
type ReverseSuggestion struct {
ViewID int64 `json:"view_id"`
View string `json:"view,omitempty"`
Prefix string `json:"prefix"`
Zone string `json:"zone"`
Why string `json:"why"`
Fill int `json:"fill"` // PTR records creating it with fill adds
Samples []string `json:"samples"` // a few of them, "address name"
}
// ReverseSuggestions returns the reverse zones the private views (or the
// server, without views) lack for their A and AAAA records' addresses:
// the /24 of a private IPv4 address, a /32 for a public one (the /24
// belongs to someone else, and a zone for it here would hide the
// neighbours' names from your clients), the /48 of an IPv6 address.
// Public views get none: the internet asks the addresses' owners.
func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []ReverseSuggestion {
public := map[int64]bool{}
for _, e := range exposures {
public[e.ViewID] = e.Public
}
viewIDs := []int64{0}
if len(st.Views) > 0 {
viewIDs = viewIDs[:0]
for _, v := range st.OrderedViews() {
if !public[v.ID] {
viewIDs = append(viewIDs, v.ID)
}
}
} else if public[0] {
return []ReverseSuggestion{}
}
out := []ReverseSuggestion{}
for _, vid := range viewIDs {
seen := map[netip.Prefix]bool{}
for _, u := range forwardAddresses(st, vid) {
a := u.addr
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
continue
}
if rz, _ := reverseZoneFor(st, vid, a); rz != nil {
continue
}
var p netip.Prefix
why := ""
switch {
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
p, why = netip.PrefixFrom(a, 24).Masked(), "private network"
case a.Is4():
p, why = netip.PrefixFrom(a, 32), "a public address: its own zone, since the rest of its /24 belongs to others"
default:
p, why = netip.PrefixFrom(a, 48).Masked(), "the /48 of your IPv6 addresses (one zone for all their subnets)"
if a.IsPrivate() {
why = "your ULA /48 (one zone for all its subnets)"
}
}
if seen[p] {
continue
}
seen[p] = true
name, err := dnsname.ReverseZone(p)
if err != nil {
continue
}
s := ReverseSuggestion{ViewID: vid, View: viewName(st, vid), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Why: why, Samples: []string{}}
fills := PTRFills(st, vid, name, nil)
s.Fill = len(fills)
for i, f := range fills {
if i == 3 {
break
}
s.Samples = append(s.Samples, f.Address+" "+dnsname.Display(f.Target))
}
out = append(out, s)
}
}
slices.SortStableFunc(out, func(a, b ReverseSuggestion) int {
if a.View != b.View {
return strings.Compare(a.View, b.View)
}
return netip.MustParsePrefix(a.Prefix).Addr().Compare(netip.MustParsePrefix(b.Prefix).Addr())
})
return out
}
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
// autoPTR reports whether a record's PTR is kept in step by its zone's
// auto_ptr setting.
func autoPTR(z *model.Zone, owner string, p *rr.Parsed) bool {
if z == nil || !z.AutoPTR || strings.HasPrefix(owner, "*.") {
return false
}
_, ok := addrOf(p)
return ok
}
func viewName(st *model.State, id int64) string {
if v := st.Views[id]; v != nil {
return v.Name
}
return ""
}
+138
View File
@@ -0,0 +1,138 @@
package engine
import (
"slices"
"strings"
"testing"
"jarl/internal/model"
)
// ptrs returns a reverse zone's PTR records in a view as "owner target",
// with " disabled" when so.
func (env *testEnv) ptrs(view, zone string) []string {
env.t.Helper()
s := env.state()
z := s.FindZone(zone, s.ViewByName(view).ID)
if z == nil {
env.t.Fatalf("no zone %s in %s", zone, view)
}
var out []string
for _, r := range s.ZoneRecords(z.ID) {
if r.Type == "PTR" {
d := r.Name + " " + r.Data
if r.Disabled {
d += " disabled"
}
out = append(out, d)
}
}
return out
}
func TestReverseFillAndSuggest(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin,
recIn("internal", "@", "A", "192.0.2.25"),
recIn("internal", "nas", "A", "10.0.10.20"),
recIn("internal", "files", "A", "10.0.10.20"),
recIn("internal", "nas", "AAAA", "fdab:abab:abab:10::20"),
recIn("internal", "*.dev", "A", "10.0.10.99"),
recIn("internal", "lo", "A", "127.0.0.1"))
sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()))
var got []string
for _, s := range sugg {
got = append(got, s.View+" "+s.Prefix+" "+s.Zone)
}
want(t, "suggestions", got,
"internal 10.0.10.0/24 10.0.10.in-addr.arpa",
"internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
"internal 192.0.2.25/32 25.2.0.192.in-addr.arpa",
"internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa")
if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" {
t.Errorf("10.0.10.0/24: %+v", sugg[0])
}
// No view given: the private view whose records use the addresses.
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), Fill: true}))
want(t, "10.0.10/24", env.ptrs("internal", "10.0.10.in-addr.arpa."), "20.10.0.10.in-addr.arpa. files.example.com.")
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "10.0.10.20 has several names") }) {
t.Errorf("warnings %q", res.Warnings)
}
// The mail server's name beats the domain's own.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
want(t, "192.0.2.25", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
// Filling an existing zone; then there is nothing left to add.
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{}))
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") {
t.Errorf("second fill: %v", e)
}
got = got[:0]
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state())) {
got = append(got, s.Prefix)
}
want(t, "suggestions left", got, "192.0.2.10/32")
// Addresses that two private views use: the view must be given.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("lab"), MatchClients: &[]string{"172.16.0.0/12"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.test"), View: model.Ptr("lab")}),
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "lab.test", View: "lab", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.10")}))
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.10/32")})); !strings.Contains(e.Message, "views internal and lab all have addresses") {
t.Errorf("two views: %v", e)
}
}
func TestAutoPTR(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
st := env.state()
zid := st.FindZone("example.com.", st.ViewByName("internal").ID).ID
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(true)}))
rz := "10.0.10.in-addr.arpa."
res := env.submit(env.admin, recIn("internal", "host", "A", "10.0.10.30"), recIn("internal", "pub", "A", "203.0.113.5"))
want(t, "added", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
if len(res.Warnings) > 0 {
t.Errorf("no reverse zone for 203.0.113.5 isn't worth a warning: %q", res.Warnings)
}
// A second name for the address: its PTR stays.
res = env.submit(env.admin, recIn("internal", "alias", "A", "10.0.10.30"))
want(t, "second name", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
if len(res.Warnings) > 0 {
t.Errorf("warnings %q", res.Warnings)
}
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "alias", "A"), nil))
// Moved, disabled, enabled, deleted.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Data: model.Ptr("10.0.10.31")}))
want(t, "moved", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(true)}))
want(t, "disabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com. disabled")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(false)}))
want(t, "enabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "host", "A"), nil))
want(t, "deleted", env.ptrs("internal", rz))
// A whole set replaced.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.40", "10.0.10.41"}}))
want(t, "set", env.ptrs("internal", rz), "40.10.0.10.in-addr.arpa. multi.example.com.", "41.10.0.10.in-addr.arpa. multi.example.com.")
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.41"}}))
want(t, "set shrunk", env.ptrs("internal", rz), "41.10.0.10.in-addr.arpa. multi.example.com.")
// Deleting by name and type.
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("multi"), Type: model.Ptr("A")}))
want(t, "deleted by match", env.ptrs("internal", rz))
// Off again: records come and go alone.
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(false)}))
env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50"))
want(t, "off", env.ptrs("internal", rz))
}
+4
View File
@@ -170,6 +170,10 @@ type Zone struct {
// keeps their shared records in step (docs/views.md#linked-zones). 0 // keeps their shared records in step (docs/views.md#linked-zones). 0
// for a zone that isn't linked; linked zones share the ID. // for a zone that isn't linked; linked zones share the ID.
LinkGroup int64 `json:"link_group,omitempty"` LinkGroup int64 `json:"link_group,omitempty"`
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
// step with them, in the reverse zones of the zone's views, as if every
// change asked for it (docs/reverse.md).
AutoPTR bool `json:"auto_ptr,omitempty"`
Meta Meta
} }
+39
View File
@@ -0,0 +1,39 @@
package server
import (
"net/http"
"jarl/internal/auth"
"jarl/internal/engine"
)
// 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).
func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
z, err := s.zoneByPath(r, st, p)
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)
}
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
// zones the private views lack for their records' addresses.
func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
out := []engine.ReverseSuggestion{}
for _, sg := range engine.ReverseSuggestions(st, s.eng.Exposures(st)) {
if p.CanSeeZone(sg.Zone+".", sg.View) {
out = append(out, sg)
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
+6
View File
@@ -29,6 +29,12 @@ 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 {
out.PTRMissing = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID)))
}
}
for _, o := range st.LinkedWith(z) { for _, o := range st.LinkedWith(z) {
out.LinkedWith = append(out.LinkedWith, st.ViewName(o)) out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)}) out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
+2
View File
@@ -114,6 +114,8 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/zones/{zone}/compare", s.authed(s.zoneCompare)) m.HandleFunc("GET "+p+"/zones/{zone}/compare", s.authed(s.zoneCompare))
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true))) m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false))) m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/fill-ptr", s.authed(s.zoneFillPTR))
m.HandleFunc("GET "+p+"/reverse-zones/suggestions", s.authed(s.reverseSuggestions))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile)) m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords)) m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord)) m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
+4 -4
View File
@@ -95,14 +95,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err return nil, err
} }
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by FROM zones`) rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, auto_ptr, version, created_at, updated_at, created_by, updated_by FROM zones`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for rows.Next() {
z := &model.Zone{} z := &model.Zone{}
var ca, ua string var ca, ua string
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.LinkGroup, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil { if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.LinkGroup, &z.AutoPTR, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
@@ -288,8 +288,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
return err return err
} }
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error { if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, auto_ptr, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.LinkGroup, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy) z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.LinkGroup, z.AutoPTR, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
return err return err
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
return err return err
+4
View File
@@ -366,6 +366,10 @@ CREATE TABLE certificates (
` `
ALTER TABLE zones ADD COLUMN link_group INTEGER NOT NULL DEFAULT 0; ALTER TABLE zones ADD COLUMN link_group INTEGER NOT NULL DEFAULT 0;
ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0; ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
`,
// 12: PTR records kept in step per zone (docs/reverse.md).
`
ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
`, `,
} }
+27 -6
View File
@@ -1,5 +1,5 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js"; import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList, plural } from "../ui.js";
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery } from "../api.js"; import { api, get, stream, change, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { optionsEditor } from "./options.js"; import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js"; import { emailOf, pendingBadge } from "./zones.js";
@@ -34,6 +34,8 @@ export async function render(el, params, query) {
zone.pending ? [pendingBadge(zone.pending), " "] : null, zone.pending ? [pendingBadge(zone.pending), " "] : null,
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null, zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null, zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null,
zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "ok")), " "] : null,
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null, zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
zone.links ? [h("span", { class: "badge accent" }, "linked with ", zone.links ? [h("span", { class: "badge accent" }, "linked with ",
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null, zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null,
@@ -46,7 +48,10 @@ export async function render(el, params, query) {
zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null, zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null,
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))); h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
const banner = zone.disabled ? h("div", { class: "msg warn" }, const banner = zone.disabled ? h("div", { class: "msg warn" },
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null; h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.")
: zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" },
plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ",
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them"))) : 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
@@ -442,7 +447,7 @@ async function recordsTab(el, zone, query) {
const t = typeInfo[fType.value] || {}; const t = typeInfo[fType.value] || {};
fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || ""; fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || "";
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null); mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA")); fPTR.classList.toggle("hidden", zone.auto_ptr || !(fType.value === "A" || fType.value === "AAAA"));
}; };
fType.addEventListener("change", () => { showHint(); preview(); }); fType.addEventListener("change", () => { showHint(); preview(); });
showHint(); showHint();
@@ -622,7 +627,9 @@ async function recordsTab(el, zone, query) {
const del = async (r) => { const del = async (r) => {
const isAddr = r.type === "A" || r.type === "AAAA"; const isAddr = r.type === "A" || r.type === "AAAA";
let ptr = false; let ptr = false;
if (isAddr) { if (isAddr && zone.auto_ptr) {
if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data + ", and its PTR record (this zone keeps them in step)", "Delete", "danger"))) return;
} else if (isAddr) {
const c = checkbox("Also delete the matching PTR record", true); const c = checkbox("Also delete the matching PTR record", true);
const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]); const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
if (ok !== true) return; if (ok !== true) return;
@@ -693,7 +700,7 @@ async function recordsTab(el, zone, query) {
h("div", { class: "f" }, h("label", null, "TTL"), eTTL), h("div", { class: "f" }, h("label", null, "TTL"), eTTL),
h("div", { class: "f grow" }, h("label", null, "Data"), eData), h("div", { class: "f grow" }, h("label", null, "Data"), eData),
h("div", { class: "f" }, h("label", null, "Comment"), eComment), h("div", { class: "f" }, h("label", null, "Comment"), eComment),
(r.type === "A" || r.type === "AAAA") ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null, (r.type === "A" || r.type === "AAAA") && !zone.auto_ptr ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))), h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))),
h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."), h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."),
eErr); eErr);
@@ -733,6 +740,7 @@ async function settingsTab(el, zone) {
const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || "")); const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || ""));
const file = input({ value: zone.file || "", class: "mono" }); const file = input({ value: zone.file || "", class: "mono" });
const comment = textarea(zone.comment, { rows: 2 }); const comment = textarea(zone.comment, { rows: 2 });
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
if (zone.type === "primary") { if (zone.type === "primary") {
rows.push( rows.push(
field("Primary name server", mname, "SOA MNAME."), field("Primary name server", mname, "SOA MNAME."),
@@ -743,6 +751,8 @@ async function settingsTab(el, zone) {
field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."), field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."),
field("Default TTL", ttl, "TTL for records that don't set their own."), field("Default TTL", ttl, "TTL for records that don't set their own."),
field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes.")); field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes."));
if (!zone.prefix && !/\.(in-addr|ip6)\.arpa\.$/.test(zone.name)) rows.push(field("Reverse DNS", autoPTR,
"Adding, changing, disabling or deleting an address record does the same to its PTR record, in the reverse zones of " + (zone.view ? "view " + zone.view + (zone.linked_with ? " and the linked views" : "") : "the server") + ". An address keeps the name it has; one without a reverse zone is left alone (Zones lists the reverse zones you're missing)."));
} }
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones).")); if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones)."));
if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it.")); if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it."));
@@ -754,6 +764,7 @@ async function settingsTab(el, zone) {
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value }; body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
body.default_ttl = +ttl.value; body.default_ttl = +ttl.value;
body.serial_policy = policy.value; body.serial_policy = policy.value;
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
} }
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim(); if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim();
if (views.length) body.view_id = view.value ? Number(view.value) : 0; if (views.length) body.view_id = view.value ? Number(view.value) : 0;
@@ -824,3 +835,13 @@ async function toggleZone(zone) {
rerender(); rerender();
} catch (e) { toast(e.message, "err", 8000); } } catch (e) { toast(e.message, "err", 8000); }
} }
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
async function fillPTR(zone) {
try {
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr");
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
+40 -4
View File
@@ -1,10 +1,11 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox } from "../ui.js"; import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural } from "../ui.js";
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js"; import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js"; import { checkDomain, reverseZoneFor } from "../names.js";
export async function render(el, params, query) { export async function render(el, params, query) {
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]); const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"),
get("/reverse-zones/suggestions", draftQuery()).catch(() => [])]);
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" }); const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || ""); const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || ""); const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
@@ -23,7 +24,7 @@ export async function render(el, params, query) {
lastList.zones = hash; lastList.zones = hash;
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value)); const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
mount(list, table([ mount(list, table([
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" }, { label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.prefix ? h("span", { class: "muted small" }, " " + z.prefix) : null, z.ptr_missing ? [" ", badge(z.ptr_missing + " PTR missing", "warn")] : null, dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null, views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") }, { label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" }, { label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
@@ -39,7 +40,8 @@ export async function render(el, params, query) {
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")), h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel, h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)), can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
h("div", { class: "panel" }, list)); h("div", { class: "panel" }, list),
suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null);
draw(); draw();
if (query.get("new")) { if (query.get("new")) {
// Drop ?new=1 so re-rendering after the zone is created doesn't open // Drop ?new=1 so re-rendering after the zone is created doesn't open
@@ -77,6 +79,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; }; const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
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 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.");
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(/\.$/, "")) + ".";
@@ -144,6 +148,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
nameLabel.textContent = k === "reverse" ? "Network" : "Domain"; nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol"; name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k); for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
syncFill();
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
check(); check();
name.focus(); name.focus();
}; };
@@ -167,7 +173,9 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")], "in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
}; };
const typeRows = h("div", { class: "form-grid" }); const typeRows = h("div", { class: "form-grid" });
const syncFill = () => { for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none"; };
const drawType = () => { const drawType = () => {
syncFill();
mount(typeRows, rows[type.value] || []); mount(typeRows, rows[type.value] || []);
if (type.value === "hint" && !name.value.trim()) name.value = "."; if (type.value === "hint" && !name.value.trim()) name.value = ".";
check(); check();
@@ -180,6 +188,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
nameLabel, h("div", null, name, preview), nameLabel, h("div", null, name, preview),
views.length ? field("View", view) : null, views.length ? field("View", view) : null,
field("Type", type), field("Type", type),
fillRow,
field("Comment", comment)), field("Comment", comment)),
h("div", { style: { marginTop: "8px" } }, typeRows), h("div", { style: { marginTop: "8px" } }, typeRows),
err); err);
@@ -197,6 +206,7 @@ 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") { 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() };
@@ -229,6 +239,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim()) ? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
: await change("POST", "/zones", data); : await change("POST", "/zones", data);
toast(describeResult(r)); toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender(); rerender();
return true; return true;
} catch (e) { } catch (e) {
@@ -268,3 +279,28 @@ export function dnssecLock(z) {
export function pendingBadge(kind) { export function pendingBadge(kind) {
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn"); return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
} }
// suggestPanel lists the reverse zones the private views lack for their
// records' addresses, to create filled with PTR records (docs/reverse.md).
function suggestPanel(sugg, views) {
const create = async (list) => {
const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } }));
try {
const r = reqs.length === 1 ? await change("POST", "/zones", reqs[0].data) : await changeMany(reqs, plural(reqs.length, "reverse zone"));
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
return h("div", { class: "panel" },
h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"),
sugg.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(sugg) }, "Create all " + sugg.length)) : null),
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
table([
views.length ? { label: "View", render: (s) => s.view } : null,
{ label: "Network", render: (s) => [h("span", { class: "mono" }, s.prefix), h("div", { class: "muted small" }, s.why)] },
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
{ label: "PTR records", render: (s) => [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
{ label: "", render: (s) => h("button", { class: "small", onclick: () => create([s]) }, "Create"), tdClass: "actions-cell" },
].filter(Boolean), sugg));
}
+40
View File
@@ -640,6 +640,46 @@ expect_dig "enabled zone served again" "192.0.2.70" www.parked.test A
expect_dig "with the change made while disabled" "192.0.2.71" api.parked.test A expect_dig "with the change made while disabled" "192.0.2.71" api.parked.test A
run "delete it" 0 $J zone delete parked.test --view internal --yes run "delete it" 0 $J zone delete parked.test --view internal --yes
# Reverse zones made easy (docs/reverse.md): suggested from the records'
# addresses, created in the right view and filled, PTRs kept in step.
run "zone for reverse" 0 $J zone create revtest.test --view internal --ns ns1.adbraun.test.
run "its hosts" 0 $J record add revtest.test nas A 10.99.1.10 --view internal
run "a second name for nas" 0 $J record add revtest.test files A 10.99.1.10 --view internal
run "another host" 0 $J record add revtest.test pi A 10.99.1.11 --view internal
run "and IPv6" 0 $J record add revtest.test nas AAAA fdab:99:0:10::10 --view internal
run "suggest-reverse" 0 $J zone suggest-reverse
contains "^internal *10.99.1.0/24 *1.99.10.in-addr.arpa *2 *10.99.1.10 files.revtest.test, 10.99.1.11 pi.revtest.test"
contains "^internal *fdab:99::/48 *0.0.0.0.9.9.0.0.b.a.d.f.ip6.arpa *1"
run "create it without a view, filled" 0 $J zone create 10.99.1.0/24 --fill
contains "10.99.1.10 has several names"
run "it went to the internal view" 0 $J zone show 1.99.10.in-addr.arpa
contains "^View: *internal"
contains "^Network: *10.99.1.0/24"
not_contains "Missing PTRs"
expect_dig "filled: PTR for pi" "pi.revtest.test." -x 10.99.1.11
expect_dig "filled: one name for nas" "files.revtest.test." -x 10.99.1.10
run "an empty /48 zone" 0 $J zone create fdab:99::/48
run "shows what it lacks" 0 $J zone show fdab:99::/48
contains "^Missing PTRs: *1 "
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"
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"
run "a new host" 0 $J record add revtest.test cam A 10.99.1.12 --view internal
expect_dig "its PTR came along" "cam.revtest.test." -x 10.99.1.12
CAM=$($J record list revtest.test --view internal -o json | python3 -c 'import json,sys; print([r["id"] for r in json.load(sys.stdin) if r["name"].startswith("cam.")][0])')
run "the host moves" 0 $J record update $CAM --data 10.99.1.13
expect_dig "PTR followed: new address" "cam.revtest.test." -x 10.99.1.13
expect_dig "PTR followed: old address gone" "" -x 10.99.1.12
run "the host goes" 0 $J record delete --id $CAM
expect_dig "PTR gone with it" "" -x 10.99.1.13
run "suggest-reverse --create" 0 $J zone suggest-reverse --create
run "nothing missing now" 0 $J zone suggest-reverse
contains "No reverse zones missing."
# Blocking (docs/blocking.md). It only changes answers to recursive # Blocking (docs/blocking.md). It only changes answers to recursive
# queries, so these ask with recursion and look at which policy zone (if # queries, so these ask with recursion and look at which policy zone (if
# any) the answer came from. # any) the answer came from.