Compare commits

...
2 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 b24c730b97 Reverse zone suggestions: yours and public, dismiss, per-view switch; child zones take their records
Suggestions split into networks that are yours (private IPv4 /24, ULA /48, an IPv6 /48 this server has an address in), which Create all creates, and public addresses (/32 or /128) to create only if they are yours or dismiss. A view can turn suggestions off, which also keeps it out of choosing a reverse zone's view (database version 13). A zone created inside another in the same view takes the parent's records under it; a delegation and its glue stay, a linked parent keeps them with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:01:18 +01:00
adamandClaude Opus 5.5 aa4b004a14 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>
2026-10-12 21:23:59 +01:00
29 changed files with 1620 additions and 49 deletions

No files matched your search

+39
View File
@@ -6,6 +6,45 @@ 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.
- Suggestions come in two groups: **yours** (private networks, a ULA or
an IPv6 /48 this server has an address in), which Create all
creates, and **public addresses**, each a /32 or /128 to create only
if it is yours (not a web host's) or **Dismiss**. A view can turn
suggestions off (`jarl view update vps --reverse-suggestions=false`),
e.g. one only a VPS uses.
- A zone created inside another in the same view (a /24 inside a /8,
`lab.adbraun.lol` inside `adbraun.lol`) takes the parent's records
under it, which named would no longer serve from the parent; a
delegation and its glue stay.
- Zone commands take a network for a reverse zone: `jarl zone show
10.0.10.0/24`.
### Upgrading
- The database gets two new versions (12 and 13), applied when jarld
starts; a 0.5 jarld can't open it afterwards, so take a backup first (`sudo
jarld backup`). Nothing changes until you turn `auto_ptr` on for a
zone.
## [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 |
+5 -1
View File
@@ -37,7 +37,7 @@ func viewCmd() *cobra.Command {
} }
var clients, dests []string var clients, dests []string
var recursiveOnly, comment, exposure string var recursiveOnly, comment, exposure string
var adopt, cascade, yes bool var adopt, cascade, yes, reverseSuggest bool
viewBody := func(cmd *cobra.Command) map[string]any { viewBody := func(cmd *cobra.Command) map[string]any {
body := map[string]any{} body := map[string]any{}
f := cmd.Flags() f := cmd.Flags()
@@ -63,6 +63,9 @@ func viewCmd() *cobra.Command {
if f.Changed("exposure") { if f.Changed("exposure") {
body["exposure"] = exposure body["exposure"] = exposure
} }
if f.Changed("reverse-suggestions") {
body["reverse_suggestions"] = reverseSuggest
}
return body return body
} }
addViewFlags := func(c *cobra.Command) { addViewFlags := func(c *cobra.Command) {
@@ -71,6 +74,7 @@ func viewCmd() *cobra.Command {
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default") c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
c.Flags().StringVar(&comment, "comment", "", "comment") c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)") c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
c.Flags().BoolVar(&reverseSuggest, "reverse-suggestions", true, "suggest reverse zones for the view's addresses (--reverse-suggestions=false: don't; docs/reverse.md)")
} }
create := &cobra.Command{ create := &cobra.Command{
Use: "create NAME", Use: "create NAME",
+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"
+154 -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,133 @@ 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"`
Group string `json:"group"`
Why string `json:"why"`
Fill int `json:"fill"`
Samples []string `json:"samples"`
}
func zoneSuggestReverseCmd() *cobra.Command {
var create bool
var dismiss, undismiss, view string
cmd := &cobra.Command{
Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]",
Short: "The reverse zones your private views lack for their records' addresses",
Long: `Lists the reverse zones the private views (or the server, without views)
lack for the addresses of their A and AAAA records, in two groups:
yours the /24 of a private IPv4 network, the /48 of a ULA or of an IPv6
prefix this server has an address in
public a /32 (or /128) for any other public address: create it only if
the address is yours, not a web host's
--create creates the ones that are yours, filled with PTR records, in one
changeset; create a public one with 'jarl zone create NETWORK --view V
--fill'. --dismiss hides a suggestion for good (--undismiss shows it
again). A view can stop suggestions: jarl view update V
--reverse-suggestions=false (docs/reverse.md).`,
Example: ` jarl zone suggest-reverse
jarl zone suggest-reverse --create
jarl zone create 139.144.144.154/32 --view internal --fill
jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if dismiss != "" || undismiss != "" {
path, prefix := "/reverse-zones/dismiss", dismiss
if undismiss != "" {
path, prefix = "/reverse-zones/undismiss", undismiss
}
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefix": prefix})
if err != nil {
return err
}
return report(resp)
}
var sugg []reverseSuggestion
if err := c.Get(ctx(), "/reverse-zones/suggestions", nil, &sugg); err != nil {
return err
}
if !create {
return output(sugg, func(w io.Writer) {
if len(sugg) == 0 {
fmt.Fprintln(w, "No reverse zones missing.")
return
}
fmt.Fprintln(w, "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE")
for _, s := range sugg {
v := s.View
if v == "" {
v = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", s.Group, v, s.Prefix, s.Zone, s.Fill, strings.Join(s.Samples, ", "))
}
fmt.Fprintln(w, "\nCreate yours, filled: jarl zone suggest-reverse --create. A public one only if the address is yours:\njarl zone create NETWORK --view VIEW --fill (or hide it: --dismiss NETWORK --view VIEW).")
})
}
var reqs []api.ChangeRequest
for _, s := range sugg {
if s.Group != "yours" {
continue
}
body := map[string]any{"name": s.Prefix, "fill": true}
if s.ViewID != 0 {
body["view_id"] = s.ViewID
}
data, err := json.Marshal(body)
if err != nil {
return err
}
reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data})
}
if len(reqs) == 0 {
fmt.Println("No reverse zones of yours missing (public addresses are created one by one: see 'jarl zone suggest-reverse').")
return nil
}
return submitBatch(c, fmt.Sprintf("%d suggested reverse zones", len(reqs)), reqs)
},
}
cmd.Flags().BoolVar(&create, "create", false, "create the ones that are yours, filled with PTR records")
cmd.Flags().StringVar(&dismiss, "dismiss", "", "hide the suggestion for this network")
cmd.Flags().StringVar(&undismiss, "undismiss", "", "show a dismissed suggestion again")
cmd.Flags().StringVar(&view, "view", "", "with --dismiss/--undismiss: the suggestion's view")
return cmd
}
+10
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,9 @@ 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`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}`: don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
| GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view | | 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
+6
View File
@@ -177,6 +177,12 @@ 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 (--create: yours)
jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal # not yours: don't suggest it again
jarl view update vps --reverse-suggestions=false # no suggestions for this view
jarl zone update adbraun.lol --auto-ptr # keep PTRs in step with the A/AAAA records (docs/reverse.md)
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal jarl zone create 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"
+5
View File
@@ -258,6 +258,11 @@ 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). A zone created
inside another (a /24 inside a /8) takes the parent's records under it.
### 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
+127
View File
@@ -0,0 +1,127 @@
# 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, and neither does a view you turn
them off for: a view only a few servers use, such as one for your VPS,
matches only their addresses and so counts as private, but its clients
don't need reverse zones (`jarl view update vps
--reverse-suggestions=false`, or the view's settings). Exposure doesn't
change.
## Missing reverse zones
The Zones page lists, under **Missing reverse zones**, the reverse zones a
private view lacks for the addresses of its A and AAAA records, in two
groups:
```
$ jarl zone suggest-reverse
GROUP VIEW NETWORK ZONE PTRS FOR EXAMPLE
yours internal 10.0.10.0/24 10.0.10.in-addr.arpa 14 10.0.10.1 router.adbraun.lol, ...
yours internal 2a02:8013:7f3d::/48 d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa 9 2a02:8013:7f3d:10::5 nas.adbraun.lol, ...
public internal 139.144.144.154/32 154.144.144.139.in-addr.arpa 1 139.144.144.154 ldn.adbraun.lol
public internal 185.199.108.153/32 153.108.199.185.in-addr.arpa 1 185.199.108.153 blog.adbraun.lol
$ jarl zone suggest-reverse --create # yours, filled, in one changeset
$ jarl zone create 139.144.144.154/32 --view internal --fill # a public one that is yours
$ jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal # GitHub's, not yours
```
**Yours**, created by **Create all** (`--create`):
* the **/24** of a private IPv4 address (10/8, 172.16/12, 192.168/16,
100.64/10);
* the **/48** of a ULA (fd00::/8), or of a public IPv6 prefix this server
has an address in (the /48 your ISP delegates): one zone for all its
subnets. If you have a /56, create that instead (`jarl zone create
2a02:db8:1:ab00::/56`).
**Public addresses, only if they're yours**: any other public address,
each as a /32 (or /128) of its own. Jarl can't tell your VPS from a web
host's server (a `blog` record pointing at GitHub Pages), and a zone for
someone else's address would show your clients your name for it instead
of theirs. The rest of the /24 belongs to others: a /24 zone here would
answer NXDOMAIN to your clients for all of their addresses. Create the
ones that are yours one by one, and **Dismiss** the others: Jarl
remembers, per view (`--undismiss` brings one back).
Loopback and link-local addresses, wildcards and disabled records are left
out.
## 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 (views with suggestions
off don't count). If none does, it goes to the only private view. If
several do, Jarl asks you to name the view, or to create the zone in each.
**Inside another zone.** A zone created inside an existing one in the same
view, a /24 inside a /8 or `lab.adbraun.lol` inside `adbraun.lol`, takes
the parent's records under it, in the same changeset: named answers those
names from the new zone, so left in the parent they would never be served
again. PTR records you added by hand come along; `--fill` then adds only
what is still missing. A delegation (NS records at the new zone's name,
with their glue) stays in the parent. A linked parent keeps its records,
with a warning, since its other views would lose them too.
**Fill** adds a PTR record for every address in the network that the
view's A and AAAA records use. An address with several names gets one PTR:
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)
}
}
}
+4 -3
View File
@@ -25,7 +25,7 @@ func DiffOps(from, to *model.State) []model.Op {
s := to.Settings s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName, add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings") Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse))}, "restore settings")
} }
for _, k := range sortedKeys(to.Keys) { for _, k := range sortedKeys(to.Keys) {
@@ -50,7 +50,8 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Views[k] n := to.Views[k]
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)), in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position), MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))} Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged)),
ReverseSuggestions: model.Ptr(!n.ReverseSuggestionsOff), DismissedReverse: model.Ptr(slices.Clone(n.DismissedReverse))}
if n.MatchRecursiveOnly != nil { if n.MatchRecursiveOnly != nil {
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly) in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
} else { } else {
@@ -66,7 +67,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 {
+212 -20
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,10 @@ 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 OpReverseDismiss, OpReverseUndismiss:
return x.reverseDismiss(req, req.Op == OpReverseDismiss)
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 +383,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 +394,74 @@ 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
}
}
var moveOps []model.Op
var moved []*model.Record
if zt == model.ZonePrimary {
// The records a parent zone in the view has under the new zone
// move into it: named answers those names from the new zone, so
// left behind they would never be served again.
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
if parent := x.parentZone(name, vid); parent != nil {
moved = x.recordsUnder(parent, name)
switch {
case len(moved) == 0:
case parent.LinkGroup != 0:
x.warnings = append(x.warnings, fmt.Sprintf("zone %s is linked, so its %d records under %s stay there (hidden by the new zone): move them by hand", zoneDesc(x.st, parent), len(moved), dnsname.Display(name)))
moved = nil
default:
for _, r := range moved {
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data)}
if r.TTL != nil {
ri.TTL = model.Some(*r.TTL)
}
if r.Comment != "" {
ri.Comment = model.Ptr(r.Comment)
}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
in.Records = append(in.Records, ri)
moveOps = append(moveOps, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("move %s %s %s from zone %s into the new zone", r.Name, r.Type, r.Data, zoneDesc(x.st, parent))))
}
}
}
}
fill := in.Fill
in.Fill = false
if fill {
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, moved)
if len(fills) == 0 {
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
}
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
@@ -603,7 +679,45 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
r.in.ZoneID = ids[0] r.in.ZoneID = ids[0]
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary)) ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
} }
return ops, nil return append(ops, moveOps...), nil
}
// parentZone returns the most specific primary zone in a view that
// contains name (not name itself).
func (x *expander) parentZone(name string, viewID int64) *model.Zone {
var best *model.Zone
for _, z := range x.st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && dnsname.IsStrictSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
return best
}
// recordsUnder returns a zone's records at or below name, except a
// delegation's NS and DS records and its glue, which belong to the parent.
func (x *expander) recordsUnder(z *model.Zone, name string) []*model.Record {
all := x.st.ZoneRecords(z.ID)
var ns []string
for _, r := range all {
if r.Name == name && r.Type == "NS" {
ns = append(ns, strings.ToLower(r.Data))
}
}
var out []*model.Record
for _, r := range all {
if !dnsname.IsSubdomain(r.Name, name) {
continue
}
if r.Name == name && (r.Type == "NS" || r.Type == "DS") {
continue
}
if (r.Type == "A" || r.Type == "AAAA") && slices.Contains(ns, strings.ToLower(r.Name)) {
continue // glue
}
out = append(out, r)
}
return out
} }
func deref(s *string) string { func deref(s *string) string {
@@ -638,7 +752,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 +769,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 +796,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 +807,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 +906,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 +935,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 +1067,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 +1119,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,11 +1146,9 @@ 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 && (in.PTR || autoPTR(z, r.Name, p)) {
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { ops = append(ops, x.ptrDelete(z, r.Name, p)...)
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
} }
return ops, nil return ops, nil
} }
@@ -1032,8 +1188,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 +1336,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) {
+29
View File
@@ -59,8 +59,23 @@ 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"
// Dismissing a reverse zone suggestion, and taking that back, become
// view.update or settings.update operations.
OpReverseDismiss = "reverse.dismiss"
OpReverseUndismiss = "reverse.undismiss"
) )
// DismissInput dismisses a reverse zone suggestion: a network in a view
// (by ID or name), or the server's (neither given).
type DismissInput struct {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Prefix string `json:"prefix"`
}
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view // AckInput acknowledges (or un-acknowledges) an exposure finding of a view
// (by ID or name), or of the server (neither given). // (by ID or name), or of the server (neither given).
type AckInput struct { type AckInput struct {
@@ -142,6 +157,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
@@ -237,6 +258,11 @@ type ViewInput struct {
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md). // Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"` Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"` Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
// ReverseSuggestions turns the reverse zone suggestions for the
// view's addresses on or off (docs/reverse.md).
ReverseSuggestions *bool `json:"reverse_suggestions,omitempty"`
// DismissedReverse are the networks whose suggestion was dismissed.
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
} }
// ReorderInput sets the order of views. // ReorderInput sets the order of views.
@@ -283,6 +309,9 @@ type SettingsInput struct {
// Exposure and Acknowledged are the server's (docs/exposure.md). // Exposure and Acknowledged are the server's (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"` Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"` Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
// DismissedReverse are dismissed reverse zone suggestions (without
// views).
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
} }
// DeleteInput carries options for delete operations. // DeleteInput carries options for delete operations.
+13
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/netip" "net/netip"
"slices"
"strings" "strings"
"time" "time"
@@ -268,6 +269,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
} }
@@ -805,6 +809,12 @@ func setViewFields(v *model.View, in *ViewInput) error {
if in.Acknowledged != nil { if in.Acknowledged != nil {
v.Acknowledged = cleanAcks(*in.Acknowledged) v.Acknowledged = cleanAcks(*in.Acknowledged)
} }
if in.ReverseSuggestions != nil {
v.ReverseSuggestionsOff = !*in.ReverseSuggestions
}
if in.DismissedReverse != nil {
v.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
return nil return nil
} }
@@ -1401,6 +1411,9 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
if in.Acknowledged != nil { if in.Acknowledged != nil {
s.Acknowledged = cleanAcks(*in.Acknowledged) s.Acknowledged = cleanAcks(*in.Acknowledged)
} }
if in.DismissedReverse != nil {
s.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
s.Version++ s.Version++
return nil return nil
} }
+442
View File
@@ -0,0 +1,442 @@
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; views with reverse suggestions off don't count. nil with
// no error leaves the choice to the usual rules.
func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok || len(st.Views) == 0 {
return nil, nil
}
var private, using []*model.View
for _, v := range st.OrderedViews() {
if public[v.ID] || v.ReverseSuggestionsOff {
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"`
// Group is "yours" for networks that are yours for sure (private
// ranges, IPv6 prefixes this server has an address in), "public" for
// a public address that may be someone else's (a web host's).
Group string `json:"group"`
Why string `json:"why"`
Fill int `json:"fill"` // PTR records creating it with fill adds
Samples []string `json:"samples"` // a few of them, "address name"
}
// Suggestion groups.
const (
SuggestYours = "yours"
SuggestPublic = "public"
)
// ReverseSuggestions returns the reverse zones the private views (or the
// server, without views) lack for their A and AAAA records' addresses:
//
// - yours: the /24 of a private IPv4 address, the /48 of a ULA or of a
// public IPv6 prefix this server has an address in (local);
// - public: a /32 (or /128) for any other public address. It may be a
// web host's; the rest of its /24 surely belongs to others, and a
// zone for it here would hide their names from your clients.
//
// Public views, views with suggestions turned off and dismissed networks
// get none: the internet asks the addresses' owners.
func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []netip.Addr) []ReverseSuggestion {
public := map[int64]bool{}
for _, e := range exposures {
public[e.ViewID] = e.Public
}
type scope struct {
id int64
dismissed []string
}
var scopes []scope
if len(st.Views) > 0 {
for _, v := range st.OrderedViews() {
if !public[v.ID] && !v.ReverseSuggestionsOff {
scopes = append(scopes, scope{v.ID, v.DismissedReverse})
}
}
} else if !public[0] {
scopes = append(scopes, scope{0, st.Settings.DismissedReverse})
}
out := []ReverseSuggestion{}
for _, sc := range scopes {
seen := map[netip.Prefix]bool{}
for _, u := range forwardAddresses(st, sc.id) {
a := u.addr
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
continue
}
if rz, _ := reverseZoneFor(st, sc.id, a); rz != nil {
continue
}
p, group, why := suggestedPrefix(a, local)
if seen[p] || slices.Contains(sc.dismissed, p.String()) {
continue
}
seen[p] = true
name, err := dnsname.ReverseZone(p)
if err != nil {
continue
}
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Why: why, Samples: []string{}}
fills := PTRFills(st, sc.id, name, nil)
s.Fill = len(fills)
for i, f := range fills {
if i == 3 {
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.Group != b.Group {
return strings.Compare(b.Group, a.Group) // yours first
}
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
}
// suggestedPrefix returns the reverse zone network for an address, its
// group and why.
func suggestedPrefix(a netip.Addr, local []netip.Addr) (netip.Prefix, string, string) {
switch {
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
return netip.PrefixFrom(a, 24).Masked(), SuggestYours, "private network"
case a.Is4():
return netip.PrefixFrom(a, 32), SuggestPublic, "a public address: create it only if it is yours (not a web host's); the rest of its /24 belongs to others"
case a.IsPrivate():
return netip.PrefixFrom(a, 48).Masked(), SuggestYours, "your ULA /48 (one zone for all its subnets)"
}
p := netip.PrefixFrom(a, 48).Masked()
if slices.ContainsFunc(local, func(l netip.Addr) bool { return p.Contains(l.Unmap()) }) {
return p, SuggestYours, "your IPv6 /48 (this server has an address in it): one zone for all its subnets"
}
return netip.PrefixFrom(a, 128), SuggestPublic, "a public address outside this server's networks: create it only if it is yours"
}
// ReverseSuggestions lists the reverse zones the private views lack.
func (e *Engine) ReverseSuggestions(st *model.State) []ReverseSuggestion {
var local []netip.Addr
if e.cfg.LocalAddrs != nil {
local = e.cfg.LocalAddrs()
}
return ReverseSuggestions(st, e.Exposures(st), local)
}
// reverseDismiss expands reverse.dismiss and reverse.undismiss.
func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error) {
var in DismissInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
p, err := netip.ParsePrefix(strings.TrimSpace(in.Prefix))
if err != nil {
return nil, errf(KindBadRequest, "prefix: %v", err)
}
p = p.Masked()
var v *model.View
switch {
case in.ViewID != 0:
v = x.st.Views[in.ViewID]
case in.View != "":
v = x.st.ViewByName(in.View)
}
if (in.ViewID != 0 || in.View != "") && v == nil {
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
}
if v == nil && len(x.st.Views) > 0 {
return nil, errf(KindBadRequest, "give the view of the suggestion")
}
list, where := x.st.Settings.DismissedReverse, "the server"
if v != nil {
list, where = v.DismissedReverse, "view "+v.Name
}
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return s == p.String() })
summary := "dismiss the reverse zone suggestion for " + p.String() + " in " + where
if dismiss {
out = append(out, p.String())
slices.Sort(out)
} else {
if len(out) == len(list) {
return nil, errf(KindConflict, "%s isn't dismissed in %s", p, where)
}
summary = "suggest a reverse zone for " + p.String() + " in " + where + " again"
}
if out == nil {
out = []string{}
}
if v != nil {
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{DismissedReverse: &out}, summary)}, nil
}
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{DismissedReverse: &out}, summary)}, nil
}
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
// autoPTR reports whether a record's PTR is kept in step by its zone's
// 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 ""
}
+237
View File
@@ -0,0 +1,237 @@
package engine
import (
"net/netip"
"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()), nil)
var got []string
for _, s := range sugg {
got = append(got, s.Group+" "+s.View+" "+s.Prefix+" "+s.Zone)
}
want(t, "suggestions", got,
"yours internal 10.0.10.0/24 10.0.10.in-addr.arpa",
"yours internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa",
"public internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
"public internal 192.0.2.25/32 25.2.0.192.in-addr.arpa")
if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" {
t.Errorf("10.0.10.0/24: %+v", sugg[0])
}
// 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()), nil) {
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))
}
func suggested(env *testEnv, local ...netip.Addr) []string {
var out []string
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), local) {
out = append(out, s.Group+" "+s.View+" "+s.Prefix)
}
return out
}
// Public IPv6: the /48 only when this server has an address in it, else
// the address alone, as a public suggestion. Dismissed networks and views
// with suggestions off get none.
func TestReverseSuggestionGroups(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin,
recIn("internal", "home", "AAAA", "2a02:8013:7f3d:10::5"),
recIn("internal", "gh", "AAAA", "2606:50c0:8000::153"))
want(t, "no local addresses", suggested(env),
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32",
"public internal 2606:50c0:8000::153/128", "public internal 2a02:8013:7f3d:10::5/128")
want(t, "this server is in 2a02:8013:7f3d::/48", suggested(env, netip.MustParseAddr("2a02:8013:7f3d:10:1266:6aff:fef9:f5b1")),
"yours internal 2a02:8013:7f3d::/48",
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32", "public internal 2606:50c0:8000::153/128")
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
if got := suggested(env); slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("dismissed still suggested: %q", got)
}
if e := env.submitErr(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"})); e.Kind != KindConflict {
t.Errorf("undismiss of one not dismissed: %v", e)
}
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("undismissed not suggested: %q", got)
}
// A second private view using the same address (a VPS's view): with
// its suggestions off it gets none, and doesn't count when choosing
// the view of a new reverse zone.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("vps"), MatchClients: &[]string{"198.51.100.7"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("vps"), LinkWith: "internal"}))
if got := suggested(env); !slices.Contains(got, "public vps 192.0.2.25/32") {
t.Errorf("vps view: %q", got)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32")})); !strings.Contains(e.Message, "views internal and vps") {
t.Errorf("two views: %v", e)
}
vps := env.state().ViewByName("vps")
env.submit(env.admin, NewRequest(OpViewUpdate, vps.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, " vps ") }) {
t.Errorf("vps view with suggestions off: %q", got)
}
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
want(t, "went to internal", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
}
// A zone created inside another takes the parent's records under it.
func TestChildZoneTakesRecords(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.0.0/8"), View: model.Ptr("internal")}))
ptr := func(name, data string) Request {
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "10.in-addr.arpa", View: "internal", Name: &name, Type: model.Ptr("PTR"), Data: &data})
}
env.submit(env.admin, ptr("20.10.0.10.in-addr.arpa.", "printer.example.com."), ptr("5.20.0.10.in-addr.arpa.", "other.example.com."))
env.submit(env.admin, recIn("internal", "nas", "A", "10.0.10.30"))
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal"), Fill: true}))
want(t, "/24", env.ptrs("internal", "10.0.10.in-addr.arpa."),
"20.10.0.10.in-addr.arpa. printer.example.com.", "30.10.0.10.in-addr.arpa. nas.example.com.")
want(t, "/8 keeps the rest", env.ptrs("internal", "10.in-addr.arpa."), "5.20.0.10.in-addr.arpa. other.example.com.")
for _, p := range res.Problems {
if p.Code == "shadowed_by_zone" {
t.Errorf("still hidden records: %v", p)
}
}
// A forward child: its delegation and glue stay in the parent.
env.submit(env.admin,
recIn("internal", "lab", "NS", "ns.lab.example.com."),
recIn("internal", "ns.lab", "A", "10.0.30.1"),
recIn("internal", "www.lab", "A", "10.0.30.2"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.example.com"), View: model.Ptr("internal")}))
st := env.state()
lab := st.FindZone("lab.example.com.", st.ViewByName("internal").ID)
var labRecs []string
for _, r := range st.ZoneRecords(lab.ID) {
if r.Type != "NS" {
labRecs = append(labRecs, r.Name+" "+r.Type)
}
}
want(t, "lab", labRecs, "www.lab.example.com. A")
v := env.view("internal") // without NS records
if !slices.Contains(v, "ns.lab A 10.0.30.1 L") || slices.ContainsFunc(v, func(s string) bool { return strings.HasPrefix(s, "www.lab ") }) {
t.Errorf("parent after: %q", v)
}
if env.recordID("internal", "lab", "NS") == 0 {
t.Error("the delegation left the parent")
}
}
+13 -1
View File
@@ -54,6 +54,11 @@ type View struct {
// internet (docs/exposure.md): "" works it out, ExposurePublic or // internet (docs/exposure.md): "" works it out, ExposurePublic or
// ExposurePrivate say so. // ExposurePrivate say so.
Exposure string `json:"exposure,omitempty"` Exposure string `json:"exposure,omitempty"`
// ReverseSuggestionsOff stops suggesting reverse zones for the view's
// addresses (docs/reverse.md), e.g. for a view only a few servers use;
// DismissedReverse are the networks whose suggestion was dismissed.
ReverseSuggestionsOff bool `json:"reverse_suggestions_off,omitempty"`
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
// Acknowledged are exposure findings accepted on purpose; they are // Acknowledged are exposure findings accepted on purpose; they are
// shown as info instead of warnings. // shown as info instead of warnings.
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"` Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
@@ -170,6 +175,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
} }
@@ -263,7 +272,10 @@ type Settings struct {
// for server-wide findings such as a visible version): see View. // for server-wide findings such as a visible version): see View.
Exposure string `json:"exposure,omitempty"` Exposure string `json:"exposure,omitempty"`
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"` Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
Version int `json:"version"` // DismissedReverse are dismissed reverse zone suggestions (without
// views; see View).
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
Version int `json:"version"`
} }
// BlockingPaused reports whether blocking is paused, for a time or until // BlockingPaused reports whether blocking is paused, for a time or until
+56
View File
@@ -0,0 +1,56 @@
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 s.eng.ReverseSuggestions(st) {
if p.CanSeeZone(sg.Zone+".", sg.View) {
out = append(out, sg)
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// reverseDismiss: POST /reverse-zones/dismiss {"view": "internal",
// "prefix": "185.199.108.153/32"} hides a suggestion; /undismiss shows it
// again.
func (s *Server) reverseDismiss(dismiss bool) handler {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.DismissInput
if err := decodeJSON(r, &in); err != nil {
return err
}
op := engine.OpReverseUndismiss
if dismiss {
op = engine.OpReverseDismiss
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
}
}
+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)})
+4
View File
@@ -114,6 +114,10 @@ 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+"/reverse-zones/dismiss", s.authed(s.reverseDismiss(true)))
m.HandleFunc("POST "+p+"/reverse-zones/undismiss", s.authed(s.reverseDismiss(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile)) m.HandleFunc("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))
+12 -9
View File
@@ -31,15 +31,15 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
} }
} }
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`) rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, reverse_suggestions_off, dismissed_reverse, version, created_at, updated_at, created_by, updated_by FROM views`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for rows.Next() {
v := &model.View{} v := &model.View{}
var mc, md, acks, ca, ua string var mc, md, acks, dismissed, ca, ua string
var mro sql.NullInt64 var mro sql.NullInt64
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil { if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.ReverseSuggestionsOff, &dismissed, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
@@ -48,6 +48,9 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
if acks != "[]" && acks != "" { if acks != "[]" && acks != "" {
json.Unmarshal([]byte(acks), &v.Acknowledged) json.Unmarshal([]byte(acks), &v.Acknowledged)
} }
if dismissed != "[]" && dismissed != "" {
json.Unmarshal([]byte(dismissed), &v.DismissedReverse)
}
if mro.Valid { if mro.Valid {
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0) v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
} }
@@ -95,14 +98,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
} }
@@ -267,8 +270,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
if v.MatchRecursiveOnly != nil { if v.MatchRecursiveOnly != nil {
mro = boolInt(*v.MatchRecursiveOnly) mro = boolInt(*v.MatchRecursiveOnly)
} }
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, reverse_suggestions_off, dismissed_reverse, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy) v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.ReverseSuggestionsOff, jsonText(nonNil(v.DismissedReverse)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
return err return err
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
return err return err
@@ -288,8 +291,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
+9
View File
@@ -366,6 +366,15 @@ 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;
`,
// 13: reverse zone suggestions per view (docs/reverse.md).
`
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
`, `,
} }
+4 -1
View File
@@ -125,6 +125,7 @@ async function viewForm(v, viewless) {
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : ""); const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
const comment = input({ value: v ? v.comment : "" }); const comment = input({ value: v ? v.comment : "" });
const color = colorPicker(v ? v.color : ""); const color = colorPicker(v ? v.color : "");
const revSuggest = checkbox("Suggest reverse zones for the addresses this view's records use", !(v && v.reverse_suggestions_off));
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true); const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
const body = h("div", null, h("div", { class: "form-grid" }, const body = h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Name", name),
@@ -132,6 +133,7 @@ async function viewForm(v, viewless) {
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."), field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
field("Recursive only", mro, "If yes, only recursive queries match this view."), field("Recursive only", mro, "If yes, only recursive queries match this view."),
field("Comment", comment), field("Comment", comment),
field("Reverse zones", revSuggest, "Turn it off for a view only some servers use (a VPS's view, say): its addresses then get no suggestions under Zones, and it doesn't count when Jarl picks the view of a new reverse zone. Public views never get suggestions."),
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")), field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null, !v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
err); err);
@@ -139,6 +141,7 @@ async function viewForm(v, viewless) {
mount(err); mount(err);
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value }; const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
data.match_recursive_only = mro.value === "" ? null : mro.value === "true"; data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
data.reverse_suggestions = revSuggest.input.checked;
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true; if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
const colorChanged = color.value !== ((v && v.color) || ""); const colorChanged = color.value !== ((v && v.color) || "");
try { try {
@@ -170,7 +173,7 @@ function configChanged(v, data) {
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []); const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only; const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only;
return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) || return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) ||
data.match_recursive_only !== mro || data.comment !== (v.comment || ""); data.match_recursive_only !== mro || data.comment !== (v.comment || "") || data.reverse_suggestions === !!v.reverse_suggestions_off;
} }
async function deleteView(v) { async function deleteView(v) {
+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); }
}
+55 -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,43 @@ 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):
// networks that are yours, and public addresses, which may be a web
// host's, to create one by one or dismiss.
function suggestPanel(sugg, views) {
const create = async (list) => {
const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } }));
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); }
};
const dismiss = async (s) => {
try {
const r = await change("POST", "/reverse-zones/dismiss", { view_id: s.view_id || undefined, prefix: s.prefix });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const cols = (pub) => [
views.length ? { label: "View", render: (s) => s.view } : null,
{ label: pub ? "Address" : "Network", render: (s) => [h("span", { class: "mono" }, pub ? s.prefix.replace(/\/(32|128)$/, "") : s.prefix), pub ? null : h("div", { class: "muted small" }, s.why)] },
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
{ label: pub ? "Name" : "PTR records", render: (s) => pub ? h("span", { class: "mono small" }, s.samples.map((x) => x.split(" ")[1]).join(", ")) : [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
{ label: "", render: (s) => [h("button", { class: "small", onclick: () => create([s]) }, "Create"), pub ? [" ", h("button", { class: "small", title: "Not yours (a web host's, say): don't suggest it again", onclick: () => dismiss(s) }, "Dismiss")] : null], tdClass: "actions-cell" },
].filter(Boolean);
const yours = sugg.filter((s) => s.group === "yours");
const pub = sugg.filter((s) => s.group !== "yours");
return h("div", { class: "panel" },
h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"),
yours.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(yours) }, "Create all " + yours.length)) : null),
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
yours.length ? table(cols(false), yours) : null,
pub.length ? [h("h3", null, "Public addresses: only if they're yours"),
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's (a web host's). Create a zone for an address that is yours; dismiss the others. Each gets a zone of its own: the rest of its network belongs to others."),
table(cols(true), pub)] : null);
}
+58
View File
@@ -640,6 +640,64 @@ 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 "^yours *internal *10.99.1.0/24 *1.99.10.in-addr.arpa *2 *10.99.1.10 files.revtest.test, 10.99.1.11 pi.revtest.test"
contains "^yours *internal *fdab:99::/48 *0.0.0.0.9.9.0.0.b.a.d.f.ip6.arpa *1"
run "create it without a view, filled" 0 $J zone create 10.99.1.0/24 --fill
contains "10.99.1.10 has several names"
run "it went to the internal view" 0 $J zone show 1.99.10.in-addr.arpa
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 "a web host's address" 0 $J record add revtest.test blog A 185.199.108.153 --view internal
run "suggested as public" 0 $J zone suggest-reverse
contains "^public *internal *185.199.108.153/32"
run "dismiss it" 0 $J zone suggest-reverse --dismiss 185.199.108.153/32 --view internal
run "gone from the suggestions" 0 $J zone suggest-reverse
not_contains "185.199.108.153"
run "suggest-reverse --create: yours only" 0 $J zone suggest-reverse --create
run "none of yours missing now" 0 $J zone suggest-reverse
not_contains "^yours"
run "a view can turn suggestions off" 0 $J view update internal --reverse-suggestions=false
run "then none for it" 0 $J zone suggest-reverse
not_contains "internal"
run "and back on" 0 $J view update internal --reverse-suggestions
# A zone created inside another takes the parent's records under it.
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal
run "with a PTR made by hand" 0 $J record add 10.88.0.0/16 10.88.5.5 PTR printer.revtest.test --view internal
expect_dig "the /16 answers" "printer.revtest.test." -x 10.88.5.5
run "a /24 inside it" 0 $J zone create 10.88.5.0/24 --view internal
run "the PTR moved" 0 $J record list 10.88.5.0/24 --view internal
contains "printer.revtest.test."
expect_dig "and still answers, from the /24" "printer.revtest.test." -x 10.88.5.5
# Blocking (docs/blocking.md). It only changes answers to recursive # 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.