Compare commits
6
Commits
v0.5.2
...
bfab83da6e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfab83da6e | ||
|
|
304c89dba4 | ||
|
|
b1719ce362 | ||
|
|
ec16de875d | ||
|
|
b24c730b97 | ||
|
|
aa4b004a14 |
No files matched your search
@@ -6,6 +6,75 @@ 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`.
|
||||||
|
- **New reverse zone** (Zones page) lists the networks your records use
|
||||||
|
without a reverse zone, in every view, also where nothing is suggested
|
||||||
|
(suggestions off, dismissed, public views); pick one, change its size
|
||||||
|
(/48, /56, /64, /128 for IPv6, around the records' address) and create
|
||||||
|
it filled. With a view chosen, it also lists the addresses only other
|
||||||
|
views' records use, and fill names them from there (a VPS's AAAA in
|
||||||
|
external, its PTR in internal). `jarl zone suggest-reverse --all
|
||||||
|
[--view V]`. Both lists have a search, so hundreds of addresses stay
|
||||||
|
manageable: the public addresses fold away, show 25 at a time, and
|
||||||
|
**Dismiss these N** dismisses everything a search found in one change
|
||||||
|
(`--dismiss` takes several networks).
|
||||||
|
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
|
||||||
|
and a **Compare** button on each record.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Compare views** and `jarl zone diff` are rebuilt around the view
|
||||||
|
you're on (docs/views.md#comparing-views): a summary per view, then
|
||||||
|
what is answered differently (a view's own records in linked zones,
|
||||||
|
drift otherwise) with **Use this everywhere**, the names only some
|
||||||
|
views have, grouped, with **Add to VIEW**, and how many are the same.
|
||||||
|
One line per record: the CLI cuts long TXT records to the terminal
|
||||||
|
(`--wide` shows them whole). `--with` picks the views to compare with.
|
||||||
|
`GET /zones/{zone}/compare` returns these sections instead of `rows`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Server options → Added by Jarl: long statements didn't wrap, and the
|
||||||
|
browser console reported a Content Security Policy violation (an inline
|
||||||
|
style the page's policy blocks).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintCompare(t *testing.T) {
|
||||||
|
long := `"v=spf1 ` + strings.Repeat("include:_spf.example.net ", 10) + `-all"`
|
||||||
|
r := &compareReport{Zone: "adbraun.lol", Base: "internal", Views: []string{"internal", "external"}, Linked: true}
|
||||||
|
r.Summary = append(r.Summary, struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Different int `json:"different"`
|
||||||
|
OnlyBase int `json:"only_base"`
|
||||||
|
OnlyOther int `json:"only_other"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
}{View: "external", Different: 1, OnlyBase: 1, Same: 5})
|
||||||
|
r.Different = []compareRow{{Name: "@", Type: "TXT", Views: []compareSet{
|
||||||
|
{View: "internal", Records: []string{long, `"short"`}, IDs: []int64{7, 8}, Linked: "local"},
|
||||||
|
{View: "external", Records: []string{`"other"`}, IDs: []int64{9}, Linked: "local"},
|
||||||
|
}}}
|
||||||
|
r.Only = append(r.Only, struct {
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Rows []compareRow `json:"rows"`
|
||||||
|
}{Views: []string{"internal"}, Rows: []compareRow{{Name: "nas", Type: "A", Views: []compareSet{{View: "internal", Records: []string{"10.0.10.20"}}, {View: "external"}}}}})
|
||||||
|
r.Same = 5
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
printCompare(&b, r, false, 60)
|
||||||
|
out := b.String()
|
||||||
|
// Record lines fit (the heading and summary are prose, and may wrap).
|
||||||
|
for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n")[3:] {
|
||||||
|
if n := utf8.RuneCountInString(l); n > 60 {
|
||||||
|
t.Errorf("line of %d characters: %q", n, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"adbraun.lol in internal, compared with external\n linked: ",
|
||||||
|
" external: 1 answered differently, 1 only in internal, 0 only in external, 5 the same",
|
||||||
|
"ANSWERED DIFFERENTLY (1)\n @ TXT\n internal \"v=spf1 include:",
|
||||||
|
"…\n \"short\"\n external \"other\" [local, id 9]",
|
||||||
|
"ONLY IN INTERNAL (1)\n nas A 10.0.10.20",
|
||||||
|
"5 names and types are the same (--all lists them)",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("output lacks %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Width 0: whole.
|
||||||
|
b.Reset()
|
||||||
|
printCompare(&b, r, false, 0)
|
||||||
|
if !strings.Contains(b.String(), long+" [local, id 7]") {
|
||||||
|
t.Errorf("whole: %s", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+367
-69
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,87 +962,363 @@ zone is dissolved.`
|
|||||||
|
|
||||||
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
|
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
|
||||||
// that have it.
|
// that have it.
|
||||||
|
type compareSet struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Records []string `json:"records"`
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
Linked string `json:"linked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type compareRow struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Views []compareSet `json:"views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type compareReport struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Linked bool `json:"linked"`
|
||||||
|
Summary []struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Different int `json:"different"`
|
||||||
|
OnlyBase int `json:"only_base"`
|
||||||
|
OnlyOther int `json:"only_other"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
} `json:"summary"`
|
||||||
|
Different []compareRow `json:"different"`
|
||||||
|
Only []struct {
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Rows []compareRow `json:"rows"`
|
||||||
|
} `json:"only"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
SameRows []compareRow `json:"same_rows"`
|
||||||
|
}
|
||||||
|
|
||||||
func zoneDiffCmd() *cobra.Command {
|
func zoneDiffCmd() *cobra.Command {
|
||||||
var views []string
|
var view string
|
||||||
var all bool
|
var with []string
|
||||||
|
var all, wide bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "diff ZONE [--view V --view W] [--all]",
|
Use: "diff ZONE [NAME [TYPE]] [--view V] [--with W...] [--all] [--wide]",
|
||||||
Short: "Show what differs between the views that have a zone",
|
Short: "Compare a zone with the same zone in other views",
|
||||||
Long: `Lines up a zone's records in the views that have it (or the views
|
Long: `Compares a zone in one view (--view, default the first view that has it)
|
||||||
given), one row per name and type, and shows the ones that differ: a
|
with the same zone in the other views (--with, default all of them): how
|
||||||
local override in a linked zone, a record one view lacks. With --all, the
|
many names each answers differently, the names and types only some views
|
||||||
ones that are the same too. In a linked zone, 'jarl record update ID
|
have, and how many are the same. NAME (and TYPE) narrows it to one name,
|
||||||
--shared' makes a view's version every view's.`,
|
the same records included. One line per record; long values are cut to
|
||||||
|
fit the terminal (--wide shows them whole).
|
||||||
|
|
||||||
|
In a linked zone a difference is a view's own (local) records, on
|
||||||
|
purpose; between zones that aren't linked it is drift. 'jarl record
|
||||||
|
update ID --shared' makes a linked view's version every view's.`,
|
||||||
Example: ` jarl zone diff adbraun.lol
|
Example: ` jarl zone diff adbraun.lol
|
||||||
jarl zone diff adbraun.lol --view internal --view external --all`,
|
jarl zone diff adbraun.lol --view internal --with external
|
||||||
|
jarl zone diff adbraun.lol nas
|
||||||
|
jarl zone diff adbraun.lol @ TXT --wide`,
|
||||||
|
Args: cobra.RangeArgs(1, 3),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, qv := zonePath(args[0], view)
|
||||||
|
if len(with) > 0 {
|
||||||
|
qv.Set("with", strings.Join(with, ","))
|
||||||
|
}
|
||||||
|
if len(args) > 1 {
|
||||||
|
qv.Set("name", args[1])
|
||||||
|
}
|
||||||
|
if len(args) > 2 {
|
||||||
|
qv.Set("type", args[2])
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
qv.Set("all", "1")
|
||||||
|
}
|
||||||
|
var r compareReport
|
||||||
|
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
width := 0
|
||||||
|
if !wide {
|
||||||
|
width = terminalWidth()
|
||||||
|
}
|
||||||
|
return output(r, func(w io.Writer) { printCompare(w, &r, len(args) > 1, width) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&view, "view", "", "the view to compare from (default: the first that has the zone)")
|
||||||
|
cmd.Flags().StringArrayVar(&with, "with", nil, "a view to compare with (repeat; default: every other view with the zone)")
|
||||||
|
cmd.Flags().BoolVar(&all, "all", false, "also list what is the same")
|
||||||
|
cmd.Flags().BoolVarP(&wide, "wide", "w", false, "show long values whole instead of cutting them to the terminal")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// printCompare prints a comparison as sections, one line per record,
|
||||||
|
// each cut to width characters (0: whole).
|
||||||
|
func printCompare(w io.Writer, r *compareReport, oneName bool, width int) {
|
||||||
|
vw := 0 // the views column
|
||||||
|
for _, v := range r.Views {
|
||||||
|
vw = max(vw, len(v))
|
||||||
|
}
|
||||||
|
line := func(indent, label, text string) {
|
||||||
|
s := indent + label + text
|
||||||
|
if width > 0 && utf8.RuneCountInString(s) > width {
|
||||||
|
keep := max(width-utf8.RuneCountInString(indent+label)-1, 8)
|
||||||
|
text = string([]rune(text)[:min(keep, utf8.RuneCountInString(text))]) + "…"
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, indent+label+text)
|
||||||
|
}
|
||||||
|
link := "not linked, so a difference may have crept in ('jarl zone link " + r.Zone + "' keeps them in step)"
|
||||||
|
if r.Linked {
|
||||||
|
link = "linked: a difference is a view's own records, there on purpose"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s in %s, compared with %s\n %s\n", r.Zone, r.Base, strings.Join(r.Views[1:], ", "), link)
|
||||||
|
if !oneName {
|
||||||
|
for _, s := range r.Summary {
|
||||||
|
fmt.Fprintf(w, " %s: %d answered differently, %d only in %s, %d only in %s, %d the same\n", s.View, s.Different, s.OnlyBase, r.Base, s.OnlyOther, s.View, s.Same)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := func(row compareRow) string { return row.Name + " " + row.Type }
|
||||||
|
sets := func(row compareRow, local bool) {
|
||||||
|
for _, vs := range row.Views {
|
||||||
|
label := fmt.Sprintf(" %-*s ", vw, vs.View)
|
||||||
|
if len(vs.Records) == 0 {
|
||||||
|
line("", label, "-")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, d := range vs.Records {
|
||||||
|
if i > 0 {
|
||||||
|
label = strings.Repeat(" ", 4+vw+2)
|
||||||
|
}
|
||||||
|
suffix := ""
|
||||||
|
if local && vs.Linked == "local" && i == 0 {
|
||||||
|
suffix = " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
||||||
|
}
|
||||||
|
line("", label, d+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.Different) > 0 {
|
||||||
|
fmt.Fprintf(w, "\nANSWERED DIFFERENTLY (%d)\n", len(r.Different))
|
||||||
|
for _, row := range r.Different {
|
||||||
|
fmt.Fprintln(w, " "+name(row))
|
||||||
|
sets(row, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range r.Only {
|
||||||
|
fmt.Fprintf(w, "\nONLY IN %s (%d)\n", strings.ToUpper(strings.Join(o.Views, ", ")), len(o.Rows))
|
||||||
|
nw := 0
|
||||||
|
for _, row := range o.Rows {
|
||||||
|
nw = max(nw, len(name(row)))
|
||||||
|
}
|
||||||
|
for _, row := range o.Rows {
|
||||||
|
var recs []string
|
||||||
|
for _, vs := range row.Views {
|
||||||
|
if len(vs.Records) > 0 {
|
||||||
|
recs = vs.Records
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, d := range recs {
|
||||||
|
label := fmt.Sprintf(" %-*s ", nw, name(row))
|
||||||
|
if i > 0 {
|
||||||
|
label = strings.Repeat(" ", 2+nw+2)
|
||||||
|
}
|
||||||
|
line("", label, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.SameRows) > 0 {
|
||||||
|
fmt.Fprintf(w, "\nTHE SAME IN %s (%d)\n", strings.ToUpper(strings.Join(r.Views, ", ")), len(r.SameRows))
|
||||||
|
nw := 0
|
||||||
|
for _, row := range r.SameRows {
|
||||||
|
nw = max(nw, len(name(row)))
|
||||||
|
}
|
||||||
|
for _, row := range r.SameRows {
|
||||||
|
for i, d := range row.Views[0].Records {
|
||||||
|
label := fmt.Sprintf(" %-*s ", nw, name(row))
|
||||||
|
if i > 0 {
|
||||||
|
label = strings.Repeat(" ", 2+nw+2)
|
||||||
|
}
|
||||||
|
line("", label, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if r.Same > 0 && !oneName {
|
||||||
|
fmt.Fprintf(w, "\n%d names and types are the same (--all lists them)\n", r.Same)
|
||||||
|
}
|
||||||
|
if oneName && len(r.Different) == 0 && len(r.Only) == 0 && len(r.SameRows) == 0 {
|
||||||
|
fmt.Fprintln(w, "\nNo records of that name in these views.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalWidth returns the terminal's width, or 0 when standard output
|
||||||
|
// isn't a terminal (then nothing is cut).
|
||||||
|
func terminalWidth() int {
|
||||||
|
fd := int(os.Stdout.Fd())
|
||||||
|
if !term.IsTerminal(fd) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
width, _, err := term.GetSize(fd)
|
||||||
|
if err != nil || width <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
path, qv := zonePath(args[0], "")
|
path, qv := zonePath(args[0], view)
|
||||||
if len(views) > 0 {
|
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
|
||||||
qv.Set("views", strings.Join(views, ","))
|
if err != nil {
|
||||||
}
|
|
||||||
if all {
|
|
||||||
qv.Set("all", "1")
|
|
||||||
}
|
|
||||||
var r struct {
|
|
||||||
Zone string `json:"zone"`
|
|
||||||
Views []string `json:"views"`
|
|
||||||
Linked bool `json:"linked"`
|
|
||||||
Same int `json:"same"`
|
|
||||||
Rows []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Same bool `json:"same"`
|
|
||||||
Views []struct {
|
|
||||||
Records []string `json:"records"`
|
|
||||||
IDs []int64 `json:"ids"`
|
|
||||||
Linked string `json:"linked"`
|
|
||||||
} `json:"views"`
|
|
||||||
} `json:"rows"`
|
|
||||||
}
|
|
||||||
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(r, func(w io.Writer) {
|
return report(resp)
|
||||||
head := []string{"NAME", "TYPE"}
|
|
||||||
for _, v := range r.Views {
|
|
||||||
head = append(head, strings.ToUpper(v))
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w, strings.Join(head, "\t"))
|
|
||||||
for _, row := range r.Rows {
|
|
||||||
cols := []string{row.Name, row.Type}
|
|
||||||
for _, vs := range row.Views {
|
|
||||||
cell := "-"
|
|
||||||
if len(vs.Records) > 0 {
|
|
||||||
cell = strings.Join(vs.Records, ", ")
|
|
||||||
if vs.Linked == "local" {
|
|
||||||
cell += " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cols = append(cols, cell)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w, strings.Join(cols, "\t"))
|
|
||||||
}
|
|
||||||
link := "not linked (jarl zone link " + r.Zone + ")"
|
|
||||||
if r.Linked {
|
|
||||||
link = "linked"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "\n%d names and types differ, %d are the same; %s\n", len(r.Rows)-boolCount(all, r.Same), r.Same, link)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringArrayVar(&views, "view", nil, "a view to compare (repeat; default: every view with the zone)")
|
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||||
cmd.Flags().BoolVar(&all, "all", false, "also show what is the same everywhere")
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolCount(b bool, n int) int {
|
type reverseSuggestion struct {
|
||||||
if b {
|
ViewID int64 `json:"view_id"`
|
||||||
return n
|
View string `json:"view"`
|
||||||
}
|
Prefix string `json:"prefix"`
|
||||||
return 0
|
Zone string `json:"zone"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Hidden string `json:"hidden"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Fill int `json:"fill"`
|
||||||
|
Samples []string `json:"samples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoneSuggestReverseCmd() *cobra.Command {
|
||||||
|
var create, all bool
|
||||||
|
var dismiss, undismiss []string
|
||||||
|
var 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).
|
||||||
|
|
||||||
|
--all lists every network the views' records use without a reverse zone,
|
||||||
|
also where nothing is suggested unasked (a view with suggestions off, a
|
||||||
|
dismissed network, a public view), to create one by hand; --view narrows
|
||||||
|
the list to one view. Change the size as you need: a /48 suggested for
|
||||||
|
IPv6 can be created as a /56 or /64 instead.`,
|
||||||
|
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
|
||||||
|
jarl zone suggest-reverse --all --view vps
|
||||||
|
jarl zone create 2a02:8013:7f3d::/48 --view internal --fill`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(dismiss) > 0 || len(undismiss) > 0 {
|
||||||
|
path, prefixes := "/reverse-zones/dismiss", dismiss
|
||||||
|
if len(undismiss) > 0 {
|
||||||
|
path, prefixes = "/reverse-zones/undismiss", undismiss
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefixes": prefixes})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
}
|
||||||
|
if create && (all || view != "") {
|
||||||
|
return fmt.Errorf("--create creates the suggestions that are yours; create others one by one: jarl zone create NETWORK --view VIEW --fill")
|
||||||
|
}
|
||||||
|
qv := q("view", view)
|
||||||
|
if all {
|
||||||
|
qv.Set("all", "1")
|
||||||
|
}
|
||||||
|
var sugg []reverseSuggestion
|
||||||
|
if err := c.Get(ctx(), "/reverse-zones/suggestions", qv, &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
|
||||||
|
}
|
||||||
|
head := "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE"
|
||||||
|
if all {
|
||||||
|
head = "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tHIDDEN\tFOR EXAMPLE"
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, head)
|
||||||
|
for _, s := range sugg {
|
||||||
|
v := s.View
|
||||||
|
if v == "" {
|
||||||
|
v = "-"
|
||||||
|
}
|
||||||
|
cols := []string{s.Group, v, s.Prefix, s.Zone, fmt.Sprint(s.Fill)}
|
||||||
|
if all {
|
||||||
|
cols = append(cols, orDash(s.Hidden))
|
||||||
|
}
|
||||||
|
cols = append(cols, strings.Join(s.Samples, ", "))
|
||||||
|
fmt.Fprintln(w, strings.Join(cols, "\t"))
|
||||||
|
}
|
||||||
|
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().StringSliceVar(&dismiss, "dismiss", nil, "hide the suggestions for these networks (comma separated or repeated)")
|
||||||
|
cmd.Flags().StringSliceVar(&undismiss, "undismiss", nil, "show dismissed suggestions again")
|
||||||
|
cmd.Flags().StringVar(&view, "view", "", "only this view; with --dismiss/--undismiss: the suggestion's view")
|
||||||
|
cmd.Flags().BoolVar(&all, "all", false, "also networks not suggested unasked (suggestions off, dismissed, public views)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
+11
-1
@@ -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,7 +360,10 @@ 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 |
|
||||||
| 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/{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 (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
|
||||||
|
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
||||||
|
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
|
||||||
|
|
||||||
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||||
that view's zone of the same name.
|
that view's zone of the same name.
|
||||||
|
|||||||
+9
-1
@@ -177,6 +177,13 @@ 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,185.199.109.153/32 --view internal # not yours: don't suggest them again
|
||||||
|
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
|
||||||
|
jarl 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"
|
||||||
@@ -221,7 +228,8 @@ jarl zone unlink adbraun.lol --view vps
|
|||||||
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
|
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
|
||||||
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
|
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
|
||||||
jarl record update 1234 --local | --shared
|
jarl record update 1234 --local | --shared
|
||||||
jarl zone diff adbraun.lol [--view V --view W] [--all] # what differs between the views
|
jarl zone diff adbraun.lol [--view V] [--with W] [--all] [--wide] # compared with the same zone in other views
|
||||||
|
jarl zone diff adbraun.lol nas [A] # just one name
|
||||||
```
|
```
|
||||||
|
|
||||||
When a zone exists in several views, add `--view NAME` to commands that
|
When a zone exists in several views, add `--view NAME` to commands that
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
There can be hundreds of public addresses (records pointing at a web
|
||||||
|
host or a CDN), so the group is folded away when it is long. Opened, it
|
||||||
|
has a search and shows 25 at a time; **Dismiss these N** dismisses every
|
||||||
|
address the search found, in one change: search `104.16.` and dismiss
|
||||||
|
the CDN's addresses together. From the CLI, `--dismiss` takes several
|
||||||
|
networks (comma separated or repeated). The New reverse zone list has a
|
||||||
|
search too.
|
||||||
|
|
||||||
|
Loopback and link-local addresses, wildcards and disabled records are left
|
||||||
|
out.
|
||||||
|
|
||||||
|
## Creating one by hand
|
||||||
|
|
||||||
|
Where nothing is suggested (a view with suggestions off, a dismissed
|
||||||
|
network, a public view) or the suggested size isn't the one you want,
|
||||||
|
**New reverse zone** on the Zones page lists every network your records
|
||||||
|
use that has no reverse zone, in every view, marked when it isn't
|
||||||
|
suggested unasked. Pick one: the network and the view are filled in.
|
||||||
|
Then the **Size** buttons change it, around the address of one of the
|
||||||
|
records: a suggested `2a02:8013:7f3d::/48` becomes
|
||||||
|
`2a02:8013:7f3d:10::/64` for the subnet the records are in, or `/128`
|
||||||
|
for one address. The zone name shows as you go, and **PTR records** stays
|
||||||
|
ticked, so the zone is created with a PTR record for each address
|
||||||
|
(IPv6 nibble names written for you).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone suggest-reverse --all --view vps # every network, hidden ones marked
|
||||||
|
jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill
|
||||||
|
```
|
||||||
|
|
||||||
|
**Records in another view.** With a view chosen (`--view internal`, or
|
||||||
|
the dialog's View), the list also has the addresses only other views'
|
||||||
|
records use, marked "records in external": say a VPS's AAAA record that
|
||||||
|
only the external view has. Fill names those from the other view's
|
||||||
|
record, when the zone's own view has none for the address, and says so.
|
||||||
|
Keeping PTR records in step (below) stays within a zone's own view and
|
||||||
|
its linked views: a later change to that external record doesn't reach
|
||||||
|
the internal reverse zone.
|
||||||
|
|
||||||
|
## 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.
|
||||||
+48
-14
@@ -209,20 +209,6 @@ each view (Jarl lists them). Share the right version afterwards. Unlinking
|
|||||||
keeps every record, all local; a link left with one zone is dissolved, as
|
keeps every record, all local; a link left with one zone is dissolved, as
|
||||||
when the other zone is deleted.
|
when the other zone is deleted.
|
||||||
|
|
||||||
**Comparing the views.** The zone's **Compare views** tab, or `jarl zone
|
|
||||||
diff adbraun.lol`, lines the views up and shows each name and type that
|
|
||||||
differs: a view's own records, or a name one view lacks. **Use this
|
|
||||||
version** (`jarl record update ID --shared`) makes one view's version
|
|
||||||
every view's. It works for zones that aren't linked too, to see what
|
|
||||||
linking them would keep local.
|
|
||||||
|
|
||||||
```
|
|
||||||
$ jarl zone diff adbraun.lol
|
|
||||||
NAME TYPE INTERNAL EXTERNAL
|
|
||||||
nas A 10.0.10.20 [local, id 812] 203.0.113.20 [local, id 790]
|
|
||||||
5 names and types differ, 21 are the same; linked
|
|
||||||
```
|
|
||||||
|
|
||||||
`--local` also works on a zone that isn't linked (its records are local
|
`--local` also works on a zone that isn't linked (its records are local
|
||||||
anyway), so scripts don't need to know; `--shared` there is refused.
|
anyway), so scripts don't need to know; `--shared` there is refused.
|
||||||
|
|
||||||
@@ -246,6 +232,54 @@ as it is: linked zones can differ per view, keep their own options, and
|
|||||||
don't depend on the view order (`in-view` can only use a view defined
|
don't depend on the view order (`in-view` can only use a view defined
|
||||||
before it).
|
before it).
|
||||||
|
|
||||||
|
## Comparing views
|
||||||
|
|
||||||
|
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
|
||||||
|
same zone in another view (or all of them), from the view you're on:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ jarl zone diff adbraun.lol --view internal
|
||||||
|
adbraun.lol in internal, compared with external
|
||||||
|
linked: a difference is a view's own records, there on purpose
|
||||||
|
external: 1 answered differently, 24 only in internal, 2 only in external, 31 the same
|
||||||
|
|
||||||
|
ANSWERED DIFFERENTLY (1)
|
||||||
|
nas A
|
||||||
|
internal 10.0.10.20 [local, id 812]
|
||||||
|
external 203.0.113.20 [local, id 790]
|
||||||
|
|
||||||
|
ONLY IN INTERNAL (24)
|
||||||
|
printer A 10.0.10.30
|
||||||
|
router A 10.0.10.1
|
||||||
|
...
|
||||||
|
|
||||||
|
ONLY IN EXTERNAL (2)
|
||||||
|
_acme-challenge TXT "…"
|
||||||
|
...
|
||||||
|
|
||||||
|
31 names and types are the same (--all lists them)
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Answered differently**: names and types more than one view has, with
|
||||||
|
different records. In linked zones that is a view's own (local)
|
||||||
|
records, there on purpose; between zones that aren't linked, it is a
|
||||||
|
difference that crept in. **Use this everywhere** gives every view one
|
||||||
|
view's records (in linked zones by sharing them: `jarl record update ID
|
||||||
|
--shared`).
|
||||||
|
* **Only in …**: names and types some views lack. **Add to external**
|
||||||
|
gives that view the same records, its own.
|
||||||
|
* **The same**: how many; `--all` (or "List them") shows them.
|
||||||
|
|
||||||
|
One line per record: the CLI cuts long values to fit the terminal
|
||||||
|
(`--wide` shows them whole); the web UI shows the whole value on hover.
|
||||||
|
The apex NS records are left out, since views often list their name
|
||||||
|
servers differently on purpose.
|
||||||
|
|
||||||
|
**One name.** `jarl zone diff adbraun.lol nas` (and a type: `... @ TXT`)
|
||||||
|
shows just that name in every view, the same records included. In the
|
||||||
|
web UI, a record's **Compare** button does the same. `--with external`
|
||||||
|
(repeat it) picks the views to compare with.
|
||||||
|
|
||||||
## Living with views
|
## Living with views
|
||||||
|
|
||||||
* Commands that name a zone that exists in several views need `--view`
|
* Commands that name a zone that exists in several views need `--view`
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ type ViewSet struct {
|
|||||||
ZoneID int64 `json:"zone_id"`
|
ZoneID int64 `json:"zone_id"`
|
||||||
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
|
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
|
||||||
IDs []int64 `json:"ids"` // the records, in the same order
|
IDs []int64 `json:"ids"` // the records, in the same order
|
||||||
|
Data []string `json:"data"` // their data alone, in the same order
|
||||||
|
TTL *uint32 `json:"ttl,omitempty"` // the first record's own TTL
|
||||||
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
|
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
|
|||||||
}
|
}
|
||||||
var first string
|
var first string
|
||||||
for i, z := range zones {
|
for i, z := range zones {
|
||||||
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}}
|
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}, Data: []string{}}
|
||||||
rs := slices.Clone(sets[i][k])
|
rs := slices.Clone(sets[i][k])
|
||||||
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
@@ -76,6 +79,10 @@ func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
|
|||||||
}
|
}
|
||||||
vs.Records = append(vs.Records, d)
|
vs.Records = append(vs.Records, d)
|
||||||
vs.IDs = append(vs.IDs, r.ID)
|
vs.IDs = append(vs.IDs, r.ID)
|
||||||
|
vs.Data = append(vs.Data, r.Data)
|
||||||
|
if vs.TTL == nil && r.TTL != nil {
|
||||||
|
vs.TTL = model.Ptr(*r.TTL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if z.LinkGroup != 0 && len(rs) > 0 {
|
if z.LinkGroup != 0 && len(rs) > 0 {
|
||||||
vs.Linked = "shared"
|
vs.Linked = "shared"
|
||||||
@@ -102,3 +109,128 @@ func relName(name, zone string) string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSuffix(name, "."+zone)
|
return strings.TrimSuffix(name, "."+zone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompareReport is a zone in one view (the base) next to the same zone in
|
||||||
|
// other views (docs/views.md#comparing-views): what the other views answer
|
||||||
|
// differently, the names only some views have, and how much is the same.
|
||||||
|
type CompareReport struct {
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
Base string `json:"base"` // the view compared from
|
||||||
|
Views []string `json:"views"` // the base first
|
||||||
|
Linked bool `json:"linked"` // all in one link
|
||||||
|
// Summary is, per other view, how it differs from the base.
|
||||||
|
Summary []CompareSummary `json:"summary"`
|
||||||
|
// Different are names and types more than one view has, with
|
||||||
|
// different records.
|
||||||
|
Different []CompareRow `json:"different"`
|
||||||
|
// Only are the names and types only some views have, by the views
|
||||||
|
// that have them.
|
||||||
|
Only []CompareOnly `json:"only"`
|
||||||
|
Same int `json:"same"`
|
||||||
|
// SameRows are the ones that are the same (when asked for).
|
||||||
|
SameRows []CompareRow `json:"same_rows,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareSummary counts how a view differs from the base view.
|
||||||
|
type CompareSummary struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Different int `json:"different"` // both have it, with different records
|
||||||
|
OnlyBase int `json:"only_base"` // the base has it, this view doesn't
|
||||||
|
OnlyOther int `json:"only_other"` // this view has it, the base doesn't
|
||||||
|
Same int `json:"same"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareOnly are names and types that only some views have.
|
||||||
|
type CompareOnly struct {
|
||||||
|
Views []string `json:"views"`
|
||||||
|
Rows []CompareRow `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareFilter narrows a comparison to a name, and optionally a type.
|
||||||
|
type CompareFilter struct {
|
||||||
|
Name string // relative ("www", "@") or absolute
|
||||||
|
Type string
|
||||||
|
All bool // include what is the same
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare builds the report for zones (the base first). Apex NS records are
|
||||||
|
// left out: they name the servers, which views often list differently on
|
||||||
|
// purpose.
|
||||||
|
func Compare(st *model.State, zones []*model.Zone, f CompareFilter) *CompareReport {
|
||||||
|
rep := &CompareReport{Zone: dnsname.Display(zones[0].Name), Base: st.ViewName(zones[0]), Different: []CompareRow{}, Only: []CompareOnly{}, Linked: true}
|
||||||
|
for _, z := range zones {
|
||||||
|
rep.Views = append(rep.Views, st.ViewName(z))
|
||||||
|
rep.Linked = rep.Linked && z.LinkGroup != 0 && z.LinkGroup == zones[0].LinkGroup
|
||||||
|
}
|
||||||
|
for _, v := range rep.Views[1:] {
|
||||||
|
rep.Summary = append(rep.Summary, CompareSummary{View: v})
|
||||||
|
}
|
||||||
|
want := ""
|
||||||
|
if f.Name != "" {
|
||||||
|
if o, err := dnsname.Owner(f.Name, zones[0].Name); err == nil {
|
||||||
|
want = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
only := map[string]int{}
|
||||||
|
for _, row := range CompareZones(st, zones) {
|
||||||
|
if row.FQDN == zones[0].Name && row.Type == "NS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if want != "" && (row.FQDN != want || f.Type != "" && !strings.EqualFold(row.Type, f.Type)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := row.Views[0]
|
||||||
|
for i, vs := range row.Views[1:] {
|
||||||
|
s := &rep.Summary[i]
|
||||||
|
switch {
|
||||||
|
case len(base.Records) > 0 && len(vs.Records) > 0 && slices.Equal(base.Records, vs.Records):
|
||||||
|
s.Same++
|
||||||
|
case len(base.Records) > 0 && len(vs.Records) > 0:
|
||||||
|
s.Different++
|
||||||
|
case len(base.Records) > 0:
|
||||||
|
s.OnlyBase++
|
||||||
|
case len(vs.Records) > 0:
|
||||||
|
s.OnlyOther++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.Same {
|
||||||
|
rep.Same++
|
||||||
|
if f.All || want != "" {
|
||||||
|
rep.SameRows = append(rep.SameRows, row)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var having []string
|
||||||
|
versions := map[string]bool{}
|
||||||
|
for _, vs := range row.Views {
|
||||||
|
if len(vs.Records) > 0 {
|
||||||
|
having = append(having, vs.View)
|
||||||
|
versions[strings.Join(vs.Records, "\n")] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(versions) > 1 {
|
||||||
|
rep.Different = append(rep.Different, row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := strings.Join(having, ",")
|
||||||
|
i, ok := only[k]
|
||||||
|
if !ok {
|
||||||
|
i = len(rep.Only)
|
||||||
|
only[k] = i
|
||||||
|
rep.Only = append(rep.Only, CompareOnly{Views: having})
|
||||||
|
}
|
||||||
|
rep.Only[i].Rows = append(rep.Only[i].Rows, row)
|
||||||
|
}
|
||||||
|
// The base's own names first, then by how many views have them.
|
||||||
|
slices.SortStableFunc(rep.Only, func(a, b CompareOnly) int {
|
||||||
|
ab, bb := slices.Contains(a.Views, rep.Base), slices.Contains(b.Views, rep.Base)
|
||||||
|
if ab != bb {
|
||||||
|
if ab {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return len(b.Views) - len(a.Views)
|
||||||
|
})
|
||||||
|
return rep
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -59,8 +59,25 @@ 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,omitempty"`
|
||||||
|
// Prefixes dismisses (or brings back) several at once.
|
||||||
|
Prefixes []string `json:"prefixes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +159,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 +260,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 +311,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.
|
||||||
|
|||||||
@@ -341,3 +341,48 @@ func TestCompareZones(t *testing.T) {
|
|||||||
t.Errorf("sharing the shared version: external %q", v)
|
t.Errorf("sharing the shared version: external %q", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompareReport(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}),
|
||||||
|
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}),
|
||||||
|
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("printer"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.30"), Local: model.Ptr(true)}),
|
||||||
|
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("shop"), Type: model.Ptr("CNAME"), Data: model.Ptr("shops.example.net."), Local: model.Ptr(true)}))
|
||||||
|
st := env.state()
|
||||||
|
zones := []*model.Zone{st.FindZone("example.com.", st.ViewByName("internal").ID), st.FindZone("example.com.", st.ViewByName("external").ID)}
|
||||||
|
|
||||||
|
rep := Compare(st, zones, CompareFilter{})
|
||||||
|
if rep.Base != "internal" || !rep.Linked || !slices.Equal(rep.Views, []string{"internal", "external"}) {
|
||||||
|
t.Errorf("report %+v", rep)
|
||||||
|
}
|
||||||
|
if s := rep.Summary[0]; s.View != "external" || s.Different != 1 || s.OnlyBase != 2 || s.OnlyOther != 1 || s.Same != 2 {
|
||||||
|
t.Errorf("summary %+v", s)
|
||||||
|
}
|
||||||
|
if len(rep.Different) != 1 || rep.Different[0].Name != "www" {
|
||||||
|
t.Errorf("different %+v", rep.Different)
|
||||||
|
}
|
||||||
|
var only []string
|
||||||
|
for _, o := range rep.Only {
|
||||||
|
var names []string
|
||||||
|
for _, r := range o.Rows {
|
||||||
|
names = append(names, r.Name)
|
||||||
|
}
|
||||||
|
only = append(only, strings.Join(o.Views, "+")+": "+strings.Join(names, ","))
|
||||||
|
}
|
||||||
|
want(t, "only", only, "internal: nas,printer", "external: shop")
|
||||||
|
if rep.Same != 2 || rep.SameRows != nil {
|
||||||
|
t.Errorf("same %d, rows %v", rep.Same, rep.SameRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One name: just its rows, the same ones included.
|
||||||
|
rep = Compare(st, zones, CompareFilter{Name: "www"})
|
||||||
|
if len(rep.Different) != 1 || len(rep.Only) != 0 || len(rep.SameRows) != 0 {
|
||||||
|
t.Errorf("www: %+v", rep)
|
||||||
|
}
|
||||||
|
rep = Compare(st, zones, CompareFilter{Name: "mail.example.com.", Type: "A"})
|
||||||
|
if len(rep.SameRows) != 1 || rep.SameRows[0].Name != "mail" || len(rep.Different)+len(rep.Only) != 0 {
|
||||||
|
t.Errorf("mail A: %+v", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
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
|
||||||
|
view string // another view's record (fillAddresses), else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillAddresses returns a view's addresses (forwardAddresses), then those
|
||||||
|
// of the other views' records that none of its own records use, marked
|
||||||
|
// with their view: a reverse zone in the internal view can name a host
|
||||||
|
// only the external view has a record for.
|
||||||
|
func fillAddresses(st *model.State, viewID int64) []addrUse {
|
||||||
|
out := forwardAddresses(st, viewID)
|
||||||
|
if len(st.Views) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
own := map[netip.Addr]bool{}
|
||||||
|
for _, u := range out {
|
||||||
|
own[u.addr] = true
|
||||||
|
}
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if v.ID == viewID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, u := range forwardAddresses(st, v.ID) {
|
||||||
|
if !own[u.addr] {
|
||||||
|
u.view = v.Name
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// From is the view whose record names the host, when the zone's own
|
||||||
|
// view has none for the address.
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
||||||
|
// lacks for the addresses of the view's A and AAAA records: one per
|
||||||
|
// address, naming its mail or name server host when it is one, otherwise
|
||||||
|
// a host rather than the domain itself, then the shortest name. An
|
||||||
|
// address no record of the view uses is named from another view's
|
||||||
|
// records (fillAddresses), marked From. existing are the zone's records
|
||||||
|
// (none for a new zone).
|
||||||
|
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
|
||||||
|
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 fillAddresses(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, From: uses[0].view}
|
||||||
|
for _, u := range uses[1:] {
|
||||||
|
if u.name != f.Target {
|
||||||
|
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 f.From != "" {
|
||||||
|
out = append(out, fmt.Sprintf("%s: only view %s has a record for it, so its PTR record gives %s from there", f.Address, f.From, dnsname.Display(f.Target)))
|
||||||
|
}
|
||||||
|
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"`
|
||||||
|
// Hidden says why the suggestion isn't made unasked (with All only):
|
||||||
|
// "public view", "suggestions off" or "dismissed".
|
||||||
|
Hidden string `json:"hidden,omitempty"`
|
||||||
|
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 {
|
||||||
|
return ReverseCandidates(st, exposures, local, ReverseOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseOptions widen or narrow ReverseCandidates.
|
||||||
|
type ReverseOptions struct {
|
||||||
|
// All includes what isn't suggested unasked, marked Hidden: public
|
||||||
|
// views, views with suggestions off, dismissed networks.
|
||||||
|
All bool
|
||||||
|
// View narrows it to one view (by name; "" all). With All, the view
|
||||||
|
// also gets the addresses only other views' records use, marked
|
||||||
|
// Hidden "records in VIEW": a reverse zone there can name them.
|
||||||
|
View string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseCandidates is ReverseSuggestions with options: with All, every
|
||||||
|
// network the views' records use that no reverse zone in their view
|
||||||
|
// covers, for creating one by hand where suggestions are off.
|
||||||
|
func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []netip.Addr, opt ReverseOptions) []ReverseSuggestion {
|
||||||
|
public := map[int64]bool{}
|
||||||
|
for _, e := range exposures {
|
||||||
|
public[e.ViewID] = e.Public
|
||||||
|
}
|
||||||
|
type scope struct {
|
||||||
|
id int64
|
||||||
|
dismissed []string
|
||||||
|
hidden string
|
||||||
|
}
|
||||||
|
var scopes []scope
|
||||||
|
if len(st.Views) > 0 {
|
||||||
|
for _, v := range st.OrderedViews() {
|
||||||
|
if opt.View != "" && !strings.EqualFold(v.Name, opt.View) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hidden := ""
|
||||||
|
switch {
|
||||||
|
case public[v.ID]:
|
||||||
|
hidden = "public view"
|
||||||
|
case v.ReverseSuggestionsOff:
|
||||||
|
hidden = "suggestions off"
|
||||||
|
}
|
||||||
|
if hidden == "" || opt.All {
|
||||||
|
scopes = append(scopes, scope{v.ID, v.DismissedReverse, hidden})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !public[0] || opt.All {
|
||||||
|
hidden := ""
|
||||||
|
if public[0] {
|
||||||
|
hidden = "public server"
|
||||||
|
}
|
||||||
|
scopes = append(scopes, scope{0, st.Settings.DismissedReverse, hidden})
|
||||||
|
}
|
||||||
|
out := []ReverseSuggestion{}
|
||||||
|
for _, sc := range scopes {
|
||||||
|
seen := map[netip.Prefix]bool{}
|
||||||
|
addrs := forwardAddresses(st, sc.id)
|
||||||
|
if opt.All && opt.View != "" {
|
||||||
|
addrs = fillAddresses(st, sc.id)
|
||||||
|
}
|
||||||
|
for _, u := range addrs {
|
||||||
|
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)
|
||||||
|
hidden := sc.hidden
|
||||||
|
if hidden == "" && u.view != "" {
|
||||||
|
hidden = "records in " + u.view
|
||||||
|
}
|
||||||
|
if seen[p] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(sc.dismissed, p.String()) {
|
||||||
|
if !opt.All {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hidden == "" {
|
||||||
|
hidden = "dismissed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, Hidden: hidden, 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 views lack (opt.All:
|
||||||
|
// also where suggestions aren't made unasked).
|
||||||
|
func (e *Engine) ReverseSuggestions(st *model.State, opt ReverseOptions) []ReverseSuggestion {
|
||||||
|
var local []netip.Addr
|
||||||
|
if e.cfg.LocalAddrs != nil {
|
||||||
|
local = e.cfg.LocalAddrs()
|
||||||
|
}
|
||||||
|
return ReverseCandidates(st, e.Exposures(st), local, opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
raw := in.Prefixes
|
||||||
|
if in.Prefix != "" {
|
||||||
|
raw = append(raw, in.Prefix)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, errf(KindBadRequest, "give the network (prefix) of the suggestion")
|
||||||
|
}
|
||||||
|
var prefixes []string
|
||||||
|
for _, r := range raw {
|
||||||
|
p, err := netip.ParsePrefix(strings.TrimSpace(r))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errf(KindBadRequest, "prefix: %v", err)
|
||||||
|
}
|
||||||
|
if s := p.Masked().String(); !slices.Contains(prefixes, s) {
|
||||||
|
prefixes = append(prefixes, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 slices.Contains(prefixes, s) })
|
||||||
|
what := prefixes[0]
|
||||||
|
if len(prefixes) > 1 {
|
||||||
|
what = fmt.Sprintf("%d networks (%s, …)", len(prefixes), prefixes[0])
|
||||||
|
}
|
||||||
|
summary := "dismiss the reverse zone suggestion for " + what + " in " + where
|
||||||
|
if dismiss {
|
||||||
|
out = append(out, prefixes...)
|
||||||
|
slices.Sort(out)
|
||||||
|
} else {
|
||||||
|
if len(out) == len(list) {
|
||||||
|
return nil, errf(KindConflict, "%s isn't dismissed in %s", what, where)
|
||||||
|
}
|
||||||
|
summary = "suggest a reverse zone for " + what + " 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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
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"}))
|
||||||
|
// Several at once.
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||||
|
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) {
|
||||||
|
t.Errorf("dismissed together, still suggested: %q", got)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With All, the networks no suggestion is made for unasked are listed too,
|
||||||
|
// marked: views with suggestions off, dismissed ones, public views.
|
||||||
|
func TestReverseCandidatesAll(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "nas", Type: "AAAA", Data: []string{"2a02:8013:7f3d:10::5"}, Local: model.Ptr(true)}))
|
||||||
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"}))
|
||||||
|
internal := env.state().ViewByName("internal")
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
|
||||||
|
list := func(opt ReverseOptions) []string {
|
||||||
|
var out []string
|
||||||
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, opt) {
|
||||||
|
out = append(out, s.View+" "+s.Prefix+" "+s.Hidden)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
want(t, "unasked", list(ReverseOptions{}))
|
||||||
|
want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}),
|
||||||
|
"internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off")
|
||||||
|
got := list(ReverseOptions{All: true, View: "external"})
|
||||||
|
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 public view")
|
||||||
|
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)}))
|
||||||
|
want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}),
|
||||||
|
"internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An address only another view has a record for: a reverse zone in the
|
||||||
|
// internal view is offered for it, and filled from that view's record.
|
||||||
|
func TestReverseFromOtherView(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)}))
|
||||||
|
var got []string
|
||||||
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
||||||
|
got = append(got, s.Prefix+" "+s.Hidden)
|
||||||
|
}
|
||||||
|
if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") {
|
||||||
|
t.Errorf("internal candidates %q", got)
|
||||||
|
}
|
||||||
|
// Not suggested unasked: the internal view's own records don't use it.
|
||||||
|
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
|
||||||
|
if strings.HasPrefix(s.Prefix, "2600:") {
|
||||||
|
t.Errorf("suggested unasked: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
||||||
|
st := env.state()
|
||||||
|
var zone string
|
||||||
|
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
|
||||||
|
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
|
||||||
|
zone = z.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
||||||
|
t.Errorf("filled %q", p)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-1
@@ -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
|
||||||
|
|||||||
@@ -9,15 +9,29 @@ import (
|
|||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// zoneCompare: GET /zones/{zone}/compare lines up a zone's records across
|
// zoneCompare: GET /zones/{zone}/compare compares a zone with the same
|
||||||
// the views that have it (docs/views.md#linked-zones). ?views=a,b picks the
|
// zone in other views (docs/views.md#comparing-views), from the zone's own
|
||||||
// views (default: every view with a primary zone of that name), ?all=1
|
// view: ?with=a,b picks the other views (default: every view with a
|
||||||
// includes the names and types that are the same everywhere.
|
// primary zone of that name), ?name=www[&type=A] narrows it to one name,
|
||||||
|
// ?all=1 includes what is the same. A zone named without its view is
|
||||||
|
// compared from the first view that has it. (?views=a,b, the views to
|
||||||
|
// compare with the first as the base, works too.)
|
||||||
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
st, err := s.state(r)
|
st, err := s.state(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
list := func(k string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, v := range strings.Split(q.Get(k), ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
with, views := list("with"), list("views")
|
||||||
z, err := s.zoneByPath(r, st, p)
|
z, err := s.zoneByPath(r, st, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A name in several views is what there is to compare.
|
// A name in several views is what there is to compare.
|
||||||
@@ -26,56 +40,45 @@ func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
ref = "."
|
ref = "."
|
||||||
}
|
}
|
||||||
name, perr := engine.ParseZoneName(ref)
|
name, perr := engine.ParseZoneName(ref)
|
||||||
if zs := st.ZonesNamed(name); perr != nil || len(zs) < 2 {
|
zs := st.ZonesNamed(name)
|
||||||
|
if perr != nil || len(zs) < 2 {
|
||||||
return err
|
return err
|
||||||
} else {
|
}
|
||||||
z = zs[0]
|
st.SortByView(zs)
|
||||||
|
z = zs[0]
|
||||||
|
if len(views) > 0 {
|
||||||
|
for _, o := range zs {
|
||||||
|
if strings.EqualFold(st.ViewName(o), views[0]) {
|
||||||
|
z = o
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var want []string
|
readable := func(o *model.Zone) bool {
|
||||||
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
vn := st.ViewName(o)
|
||||||
if v = strings.TrimSpace(v); v != "" {
|
return o.Type == model.ZonePrimary && p.CanSeeZone(o.Name, vn) && p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn})
|
||||||
want = append(want, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var zones []*model.Zone
|
if !readable(z) {
|
||||||
|
return badRequest("zone %s isn't a primary zone whose records you may read", engine.Display(z.Name))
|
||||||
|
}
|
||||||
|
zones := []*model.Zone{z}
|
||||||
|
var others []*model.Zone
|
||||||
for _, o := range st.ZonesNamed(z.Name) {
|
for _, o := range st.ZonesNamed(z.Name) {
|
||||||
vn := st.ViewName(o)
|
vn := st.ViewName(o)
|
||||||
if o.Type != model.ZonePrimary || !p.CanSeeZone(o.Name, vn) || !p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn}) {
|
if o.ID == z.ID || !readable(o) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(want) > 0 && !containsFold(want, vn) {
|
if len(with) > 0 && !containsFold(with, vn) || len(views) > 0 && !containsFold(views, vn) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
zones = append(zones, o)
|
others = append(others, o)
|
||||||
}
|
}
|
||||||
st.SortByView(zones)
|
st.SortByView(others)
|
||||||
|
zones = append(zones, others...)
|
||||||
if len(zones) < 2 {
|
if len(zones) < 2 {
|
||||||
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
|
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
|
||||||
}
|
}
|
||||||
rows := engine.CompareZones(st, zones)
|
writeJSON(w, http.StatusOK, engine.Compare(st, zones, engine.CompareFilter{Name: q.Get("name"), Type: q.Get("type"), All: queryBool(r, "all")}))
|
||||||
all := queryBool(r, "all")
|
|
||||||
out := struct {
|
|
||||||
Zone string `json:"zone"`
|
|
||||||
Views []string `json:"views"`
|
|
||||||
Linked bool `json:"linked"` // all of them in one link
|
|
||||||
Same int `json:"same"` // names and types the same everywhere
|
|
||||||
Rows []engine.CompareRow `json:"rows"`
|
|
||||||
}{Zone: engine.Display(z.Name), Rows: []engine.CompareRow{}, Linked: true}
|
|
||||||
for _, o := range zones {
|
|
||||||
out.Views = append(out.Views, st.ViewName(o))
|
|
||||||
out.Linked = out.Linked && o.LinkGroup != 0 && o.LinkGroup == zones[0].LinkGroup
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.Same {
|
|
||||||
out.Same++
|
|
||||||
if !all {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.Rows = append(out.Rows, row)
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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. ?all=1 adds
|
||||||
|
// the ones not suggested unasked (public views, views with suggestions
|
||||||
|
// off, dismissed), marked hidden; ?view= narrows it to one view.
|
||||||
|
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, engine.ReverseOptions{All: queryBool(r, "all"), View: r.URL.Query().Get("view")}) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)})
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 '[]';
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,3 +182,17 @@ export function checkDomain(input) {
|
|||||||
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
|
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
|
||||||
return { name, problems, notes };
|
return { name, problems, notes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maskNetwork returns the network of the given size an address (or a
|
||||||
|
// network, "2a02:8013:7f3d:10::/64") is in: maskNetwork("2a02:8013:7f3d:10::5", 48)
|
||||||
|
// is "2a02:8013:7f3d::/48". null when it isn't an address.
|
||||||
|
export function maskNetwork(input, bits) {
|
||||||
|
const addr = String(input).trim().split("/")[0];
|
||||||
|
const b4 = parseIPv4(addr);
|
||||||
|
const n6 = b4 ? null : parseIPv6(addr);
|
||||||
|
if (!b4 && !n6) return null;
|
||||||
|
const digits = b4 || n6.map((x) => parseInt(x, 16));
|
||||||
|
const step = b4 ? 8 : 4;
|
||||||
|
const net = digits.map((d, i) => { const k = Math.max(0, Math.min(step, bits - i * step)); return d & ~((1 << (step - k)) - 1) & ((1 << step) - 1); });
|
||||||
|
return (b4 ? net.join(".") : sixOf(net.map((d) => d.toString(16)))) + "/" + bits;
|
||||||
|
}
|
||||||
@@ -182,7 +182,7 @@ function generatedPanel(el, list) {
|
|||||||
h("thead", null, h("tr", null, h("th", null, "Where"), h("th", null, "Statement"), h("th", null, "Why"), h("th", null, "Controlled by"))),
|
h("thead", null, h("tr", null, h("th", null, "Where"), h("th", null, "Statement"), h("th", null, "Why"), h("th", null, "Controlled by"))),
|
||||||
h("tbody", null, list.map((g) => h("tr", null,
|
h("tbody", null, list.map((g) => h("tr", null,
|
||||||
h("td", { class: "small" }, g.where),
|
h("td", { class: "small" }, g.where),
|
||||||
h("td", null, h("pre", { class: "code optval", style: "white-space: pre-wrap; word-break: break-all" }, g.text)),
|
h("td", null, h("pre", { class: "code optval wrap" }, g.text)),
|
||||||
h("td", { class: "small" }, g.why),
|
h("td", { class: "small" }, g.why),
|
||||||
h("td", { class: "small mono" }, g.setting))))))));
|
h("td", { class: "small mono" }, g.setting))))))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, changeMany, 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
|
||||||
@@ -65,7 +70,7 @@ export async function render(el, params, query) {
|
|||||||
else if (tab === "servers") await serversTab(body, zone);
|
else if (tab === "servers") await serversTab(body, zone);
|
||||||
else if (tab === "dnssec") await dnssecTab(body, zone);
|
else if (tab === "dnssec") await dnssecTab(body, zone);
|
||||||
else if (tab === "registrar") await registrarTab(body, zone);
|
else if (tab === "registrar") await registrarTab(body, zone);
|
||||||
else if (tab === "compare") await compareTab(body, zone);
|
else if (tab === "compare") await compareTab(body, zone, query);
|
||||||
// A later click (another tab) has taken over the page.
|
// A later click (another tab) has taken over the page.
|
||||||
if (seq !== renderSeq || !holder.isConnected) return;
|
if (seq !== renderSeq || !holder.isConnected) return;
|
||||||
holder.replaceWith(body);
|
holder.replaceWith(body);
|
||||||
@@ -77,34 +82,110 @@ export async function render(el, params, query) {
|
|||||||
|
|
||||||
// compareTab shows the names and types that differ between the views that
|
// compareTab shows the names and types that differ between the views that
|
||||||
// have the zone, with a button to share one view's version (linked zones).
|
// have the zone, with a button to share one view's version (linked zones).
|
||||||
async function compareTab(el, zone) {
|
// compareTab compares the zone with the same zone in other views, from
|
||||||
let all = false;
|
// this one (docs/views.md#comparing-views): a summary, what the views
|
||||||
|
// answer differently, the names only some views have, and how much is
|
||||||
|
// the same. ?name= (and &type=) narrows it to one name.
|
||||||
|
async function compareTab(el, zone, query) {
|
||||||
|
const others = zone.other_views || [];
|
||||||
|
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || others[0] || "");
|
||||||
|
const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } });
|
||||||
|
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
|
||||||
|
let showSame = false;
|
||||||
const out = h("div");
|
const out = h("div");
|
||||||
|
const one = (d) => h("div", { class: "oneline mono small", title: d }, d);
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const r = await get("/zones/" + zone.id + "/compare", { all: all ? 1 : "" });
|
const q = { with: withSel.value || undefined, name: nameIn.value.trim() || undefined, type: nameIn.value.trim() ? typeIn.value.trim() || undefined : undefined, all: showSame ? 1 : undefined };
|
||||||
const head = h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)));
|
let r;
|
||||||
const rows = r.rows.map((row) => h("tr", { class: row.same ? "muted" : "" },
|
try { r = await get("/zones/" + zone.id + "/compare", draftQuery(q)); } catch (e) { mount(out, errorBox(e)); return; }
|
||||||
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
const oneName = !!q.name;
|
||||||
row.views.map((vs) => h("td", { class: "compare-cell" },
|
const parts = [];
|
||||||
vs.records.length ? vs.records.map((d) => h("div", { class: "mono small" }, d)) : h("span", { class: "muted" }, "–"),
|
parts.push(h("p", { class: "muted" }, r.linked
|
||||||
vs.linked === "local" ? [badge("only " + vs.view, "accent"), " "] : null,
|
? "The zones are linked: shared records are kept in step, so a difference is a view's own records, there on purpose."
|
||||||
r.linked && !row.same && vs.records.length ? h("button", { class: "small", title: "Make view " + vs.view + "'s version every view's: the others' records of this name and type are replaced",
|
: "The zones aren't linked: nothing keeps them in step, so a difference may be one that crept in. Link them (Link… above) to keep what is the same in step."));
|
||||||
onclick: () => share(vs, row) }, "Use this version") : null))));
|
if (!oneName) parts.push(h("ul", { class: "compare-summary" }, r.summary.map((s) => h("li", null,
|
||||||
mount(out,
|
h("strong", null, s.view), ": ",
|
||||||
h("p", { class: "muted" }, r.same + " names and types are the same in " + r.views.join(", ") + (r.rows.length && !all ? "; these differ." : ".") + " ",
|
[[s.different, "answered differently"], [s.only_base, "only in " + r.base], [s.only_other, "only in " + s.view], [s.same, "the same"]]
|
||||||
r.linked ? "The zones are linked: shared records are kept in step, and a view's own records override them there." : "The zones aren't linked: a change in one doesn't reach the others. Link them (Link… above) to keep what is the same in step; linking changes no answer."),
|
.map(([n, what], i) => [i ? " · " : "", h("span", { class: n && i < 3 ? "" : "muted" }, n + " " + what)])))));
|
||||||
r.rows.length ? h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("thead", null, head), h("tbody", null, rows))) : h("div", { class: "empty" }, "Nothing differs."),
|
|
||||||
h("label", { class: "check" }, h("input", { type: "checkbox", checked: all, onchange: (e) => { all = e.target.checked; draw(); } }), "Show what is the same too"));
|
if (r.different.length) {
|
||||||
|
parts.push(h("h3", null, "Answered differently (" + r.different.length + ")"),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "compare" },
|
||||||
|
h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)))),
|
||||||
|
h("tbody", null, r.different.map((row) => h("tr", null,
|
||||||
|
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||||
|
row.views.map((vs) => h("td", { class: "compare-cell" },
|
||||||
|
vs.records.length ? vs.records.map(one) : h("span", { class: "muted" }, "–"),
|
||||||
|
vs.records.length ? h("div", { class: "compare-actions" },
|
||||||
|
vs.linked === "local" ? [badge("own records", "accent"), " "] : null,
|
||||||
|
h("button", { class: "small", title: "Every view gets " + vs.view + "'s records for " + row.name + " " + row.type, onclick: () => useEverywhere(r, row, vs) }, "Use this everywhere")) : null))))))));
|
||||||
|
}
|
||||||
|
for (const o of r.only) {
|
||||||
|
const missing = r.views.filter((v) => !o.views.includes(v));
|
||||||
|
parts.push(h("details", { class: "compare-only", open: oneName || o.rows.length <= 8 },
|
||||||
|
h("summary", null, h("strong", null, "Only in " + o.views.join(" and ")), " (" + o.rows.length + ")",
|
||||||
|
h("span", { class: "muted small" }, " · " + missing.join(", ") + (missing.length > 1 ? " have" : " has") + " no records of these names and types")),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, o.rows.map((row) => {
|
||||||
|
const src = row.views.find((vs) => vs.records.length);
|
||||||
|
return h("tr", null, h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||||
|
h("td", { class: "compare-cell" }, src.records.map(one)),
|
||||||
|
h("td", { class: "actions-cell" }, missing.map((v) => h("button", { class: "small", title: "Give view " + v + " the same records (its own, local to it)", onclick: () => copyTo(row, src, row.views.find((x) => x.view === v)) }, "Add to " + v))));
|
||||||
|
}))))));
|
||||||
|
}
|
||||||
|
if (r.same_rows && r.same_rows.length) {
|
||||||
|
parts.push(h("h3", null, "The same in " + r.views.join(", ") + " (" + r.same_rows.length + ")"),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, r.same_rows.map((row) => h("tr", { class: "muted" },
|
||||||
|
h("td", { class: "mono" }, row.name), h("td", null, row.type), h("td", { class: "compare-cell" }, row.views[0].records.map(one))))))));
|
||||||
|
} else if (!oneName && r.same) {
|
||||||
|
parts.push(h("p", { class: "muted" }, r.same + " names and types are the same. ", h("a", { href: "#", onclick: (e) => { e.preventDefault(); showSame = true; draw(); } }, "List them")));
|
||||||
|
}
|
||||||
|
if (oneName && !r.different.length && !r.only.length && !(r.same_rows || []).length) parts.push(h("div", { class: "empty" }, "No records of that name in these views."));
|
||||||
|
else if (!r.different.length && !r.only.length && !oneName) parts.push(h("div", { class: "empty" }, "Nothing differs."));
|
||||||
|
mount(out, parts);
|
||||||
};
|
};
|
||||||
const share = async (vs, row) => {
|
// useEverywhere gives every view one view's records of a name and type:
|
||||||
if (!await confirmDialog("Use view " + vs.view + "'s " + row.name + " " + row.type, "Every view gets " + vs.records.join(", ") + " for " + row.name + " " + row.type + ", shared from now on; the other views' records of that name and type are replaced.", "Use this version")) return;
|
// in a linked zone by sharing them, otherwise by setting each zone's.
|
||||||
|
const useEverywhere = async (r, row, vs) => {
|
||||||
|
const rest = row.views.filter((x) => x !== vs);
|
||||||
|
const linkedAll = r.linked && zone.linked_with;
|
||||||
|
const msg = linkedAll
|
||||||
|
? "View " + vs.view + "'s " + row.name + " " + row.type + " records become shared: every linked view (" + [zone.view, ...zone.linked_with].join(", ") + ") answers " + vs.data.join(", ") + ", and their own records of that name and type are replaced."
|
||||||
|
: rest.map((x) => x.view).join(" and ") + " get" + (rest.length > 1 ? "" : "s") + " " + vs.data.join(", ") + " for " + row.name + " " + row.type + ", replacing what " + (rest.length > 1 ? "they have" : "it has") + ".";
|
||||||
|
if (!await confirmDialog("Use " + vs.view + "'s " + row.name + " " + row.type, msg, "Use this everywhere")) return;
|
||||||
try {
|
try {
|
||||||
toast(describeResult(await change("PATCH", "/records/" + vs.ids[0], { local: false })));
|
const res = linkedAll
|
||||||
|
? await change("PATCH", "/records/" + vs.ids[0], { local: false })
|
||||||
|
: await changeMany(rest.map((x) => ({ op: "rrset.replace", data: rrsetFrom(row, vs, x) })), "use " + vs.view + "'s " + row.name + " " + row.type);
|
||||||
|
toast(describeResult(res));
|
||||||
await draw();
|
await draw();
|
||||||
} catch (e) { toast(e.message, "err", 8000); }
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
};
|
};
|
||||||
|
const copyTo = async (row, src, dst) => {
|
||||||
|
try {
|
||||||
|
toast(describeResult(await changeMany([{ op: "rrset.replace", data: rrsetFrom(row, src, dst) }], "add " + row.name + " " + row.type + " to view " + dst.view)));
|
||||||
|
await draw();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const go = () => draw();
|
||||||
|
withSel.addEventListener("change", go);
|
||||||
|
nameIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
|
||||||
|
typeIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
|
||||||
|
mount(el, h("div", { class: "panel" },
|
||||||
|
h("div", { class: "compare-controls" },
|
||||||
|
h("label", null, "Compare " + zone.view + " with "), withSel, " ",
|
||||||
|
h("label", null, " Name "), nameIn, typeIn, " ",
|
||||||
|
h("button", { class: "small", onclick: go }, "Compare"),
|
||||||
|
nameIn.value ? [" ", h("button", { class: "small", onclick: () => { nameIn.value = ""; typeIn.value = ""; go(); } }, "All names")] : null),
|
||||||
|
out));
|
||||||
await draw();
|
await draw();
|
||||||
mount(el, out);
|
}
|
||||||
|
|
||||||
|
// rrsetFrom is the request giving zone dst a name and type's records from
|
||||||
|
// src: its own (local) records, in a linked zone.
|
||||||
|
function rrsetFrom(row, src, dst) {
|
||||||
|
const d = { zone_id: dst.zone_id, name: row.fqdn, type: row.type, data: src.data, local: true };
|
||||||
|
if (src.ttl !== undefined && src.ttl !== null) d.ttl = src.ttl;
|
||||||
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- linked zones (docs/views.md#linked-zones)
|
// ---- linked zones (docs/views.md#linked-zones)
|
||||||
@@ -442,7 +523,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();
|
||||||
@@ -605,6 +686,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
: h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null,
|
: h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null,
|
||||||
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
||||||
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
||||||
|
zone.other_views && !(r.type === "NS" && r.relative_name === "@") ? [h("a", { class: "button small reveal", href: "#/zones/" + zone.id + "/compare?name=" + encodeURIComponent(r.relative_name) + "&type=" + r.type, title: "Compare " + r.relative_name + " " + r.type + " with view" + (zone.other_views.length > 1 ? "s " : " ") + zone.other_views.join(", ") }, "Compare"), " "] : null,
|
||||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||||
r.disabled
|
r.disabled
|
||||||
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
||||||
@@ -622,7 +704,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 +777,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 +817,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 +828,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 +841,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 +912,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); }
|
||||||
|
}
|
||||||
@@ -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, confirmDialog } 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, maskNetwork } 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" },
|
||||||
@@ -38,8 +39,10 @@ export async function render(el, params, query) {
|
|||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
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", { title: "A zone for the PTR records of a network: Jarl lists the networks your records use", onclick: () => newZoneDialog(views, zones, settings, { kind: "reverse" }) }, "New reverse zone"), " ",
|
||||||
h("div", { class: "panel" }, list));
|
h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone")] : null)),
|
||||||
|
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
|
||||||
@@ -49,16 +52,38 @@ export async function render(el, params, query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// preset.view picks the view (opened from a view's page).
|
// preset.view picks the view (opened from a view's page), preset.kind
|
||||||
|
// "reverse" starts with a reverse zone.
|
||||||
export async function newZoneDialog(views, zones, settings, preset = {}) {
|
export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
// The zone's name matters most and is easy to get wrong, so the dialog
|
// The zone's name matters most and is easy to get wrong, so the dialog
|
||||||
// asks for a domain or a network and shows the resulting zone name.
|
// asks for a domain or a network and shows the resulting zone name.
|
||||||
let kind = "forward";
|
let kind = preset.kind === "reverse" ? "reverse" : "forward";
|
||||||
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
|
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
|
||||||
const nameLabel = h("label", { for: "zone-name" }, "Domain");
|
const nameLabel = h("label", { for: "zone-name" }, "Domain");
|
||||||
name.id = "zone-name";
|
name.id = "zone-name";
|
||||||
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
|
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
|
||||||
|
// For a reverse zone: sizes to pick from, and the networks the records
|
||||||
|
// use without a reverse zone, in every view (docs/reverse.md).
|
||||||
|
const helper = h("div", { class: "reverse-helper" });
|
||||||
|
// The networks per view picked ("" for all views): a chosen view's list
|
||||||
|
// also has the addresses only other views' records use.
|
||||||
|
const candsBy = {};
|
||||||
|
let cands = null;
|
||||||
|
const candQ = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { marginBottom: "6px" } });
|
||||||
|
candQ.addEventListener("input", () => drawHelper());
|
||||||
|
const loadCands = () => {
|
||||||
|
const k = view.value || "";
|
||||||
|
if (candsBy[k]) { cands = candsBy[k]; return drawHelper(); }
|
||||||
|
get("/reverse-zones/suggestions", draftQuery({ all: 1, view: k || undefined })).catch(() => []).then((c) => {
|
||||||
|
candsBy[k] = c;
|
||||||
|
if ((view.value || "") === k) { cands = c; drawHelper(); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// sizeFrom is the address the size buttons work from: a record's, once a
|
||||||
|
// network was picked, so a /48 made smaller becomes that record's /64,
|
||||||
|
// not the /48's first one.
|
||||||
|
let sizeFrom = null;
|
||||||
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
|
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
|
||||||
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
||||||
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
|
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
|
||||||
@@ -77,6 +102,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,20 +171,61 @@ 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 (k === "reverse") loadCands();
|
||||||
|
drawHelper();
|
||||||
|
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();
|
||||||
};
|
};
|
||||||
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
|
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
|
||||||
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
|
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
|
||||||
}
|
}
|
||||||
name.addEventListener("input", () => { mount(err); updateClones(); check(); });
|
const changed = () => { mount(err); updateClones(); check(); drawHelper(); };
|
||||||
view.addEventListener("change", check);
|
const typed = () => { sizeFrom = null; changed(); };
|
||||||
|
const drawHelper = () => {
|
||||||
|
if (kind !== "reverse") return mount(helper);
|
||||||
|
const v = name.value.trim();
|
||||||
|
const isNet = v && maskNetwork(v, 32) !== null;
|
||||||
|
const v6 = v.includes(":");
|
||||||
|
const bits = Number(v.split("/")[1]);
|
||||||
|
const sizes = isNet ? h("div", { class: "size-chips" }, h("span", { class: "muted small" }, "Size: "),
|
||||||
|
(v6 ? [[48, "a whole site (all its /64s)"], [56, "a smaller delegation"], [64, "one subnet"], [128, "one address"]] : [[16, "a /16"], [24, "one network"], [32, "one address"]])
|
||||||
|
.map(([b, title]) => h("button", { type: "button", class: "small" + (bits === b ? " active" : ""), title, onclick: () => { name.value = maskNetwork(sizeFrom || name.value, b); changed(); } }, "/" + b)),
|
||||||
|
sizeFrom ? h("span", { class: "muted small" }, " around " + sizeFrom) : null) : null;
|
||||||
|
const q = candQ.value.trim().toLowerCase();
|
||||||
|
// Yours first, then the networks with the most records.
|
||||||
|
const list = (cands || []).filter((c) => !q || [c.prefix, c.zone, c.view, c.hidden, ...c.samples].join(" ").toLowerCase().includes(q))
|
||||||
|
.sort((a, b) => (a.group === "yours" ? 0 : 1) - (b.group === "yours" ? 0 : 1) || b.fill - a.fill);
|
||||||
|
const shown = list.slice(0, 15);
|
||||||
|
mount(helper, sizes,
|
||||||
|
(cands || []).length > 8 ? candQ : null,
|
||||||
|
shown.length ? h("div", { class: "cands" },
|
||||||
|
h("div", { class: "muted small" }, "Networks your records use without a reverse zone" + (view.value ? " in view " + view.value : "") + ": pick one, then change its size if you need"),
|
||||||
|
shown.map((c) => h("button", { type: "button", class: "cand", title: c.why, onclick: () => {
|
||||||
|
name.value = c.prefix;
|
||||||
|
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
|
||||||
|
if (views.length && c.view) view.value = c.view;
|
||||||
|
changed();
|
||||||
|
} },
|
||||||
|
h("span", { class: "mono" }, c.prefix),
|
||||||
|
views.length && !view.value ? h("span", { class: "muted" }, " " + c.view) : null,
|
||||||
|
h("span", { class: "muted small" }, " " + c.fill + " PTR" + (c.samples.length ? " · " + c.samples.slice(0, 2).join(", ") : "")),
|
||||||
|
c.hidden ? [" ", badge(c.hidden, "off")] : null)),
|
||||||
|
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more: search to find one") : null)
|
||||||
|
: q && cands && cands.length ? h("div", { class: "muted small" }, "No network matches " + JSON.stringify(candQ.value.trim()) + ".")
|
||||||
|
: cands && !list.length ? h("div", { class: "muted small" }, "Every address your records use" + (view.value ? " in view " + view.value : "") + " has a reverse zone.") : null);
|
||||||
|
};
|
||||||
|
name.addEventListener("input", typed);
|
||||||
|
view.addEventListener("change", () => { check(); if (kind === "reverse") loadCands(); else drawHelper(); });
|
||||||
|
const glueRow = field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue).");
|
||||||
|
const cloneRow = field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).");
|
||||||
const rows = {
|
const rows = {
|
||||||
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
|
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
|
||||||
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
glueRow,
|
||||||
field("SOA contact", contact, "Email address of the zone's administrator."),
|
field("SOA contact", contact, "Email address of the zone's administrator."),
|
||||||
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
|
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
|
||||||
field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).")],
|
cloneRow],
|
||||||
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
|
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
|
||||||
stub: [field("Primary servers", primaries, "One per line.")],
|
stub: [field("Primary servers", primaries, "One per line.")],
|
||||||
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
|
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
|
||||||
@@ -167,7 +235,13 @@ 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";
|
||||||
|
// Glue and copying a zone from another view are for domains.
|
||||||
|
for (const n of [...glueRow, ...cloneRow]) if (n) n.style.display = kind === "reverse" ? "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();
|
||||||
@@ -177,13 +251,15 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
const body = h("div", null,
|
const body = h("div", null,
|
||||||
h("div", { class: "form-grid" },
|
h("div", { class: "form-grid" },
|
||||||
h("label", null, "Kind"), h("div", null, kindButtons),
|
h("label", null, "Kind"), h("div", null, kindButtons),
|
||||||
nameLabel, h("div", null, name, preview),
|
nameLabel, h("div", { class: "grid-cell" }, name, preview, helper),
|
||||||
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);
|
||||||
await modal("New zone", body, [
|
if (kind === "reverse") setKind("reverse");
|
||||||
|
await modal(kind === "reverse" ? "New reverse zone" : "New zone", body, [
|
||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: "Create zone", kind: "primary", onclick: async () => {
|
{ label: "Create zone", kind: "primary", onclick: async () => {
|
||||||
const t = type.value;
|
const t = type.value;
|
||||||
@@ -197,6 +273,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 +306,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 +346,84 @@ 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 ? publicGroup(pub, cols(true), create) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicGroup lists the public addresses, collapsed: there can be hundreds
|
||||||
|
// (a web host's), so it has a search, shows 25 at a time, and dismisses
|
||||||
|
// what the search found at once.
|
||||||
|
function publicGroup(pub, cols, create) {
|
||||||
|
const PAGE = 25;
|
||||||
|
let limit = PAGE;
|
||||||
|
const q = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { width: "300px" } });
|
||||||
|
const body = h("div");
|
||||||
|
const match = () => {
|
||||||
|
const t = q.value.trim().toLowerCase();
|
||||||
|
return pub.filter((s) => !t || [s.prefix, s.zone, s.view, ...s.samples].join(" ").toLowerCase().includes(t));
|
||||||
|
};
|
||||||
|
const dismissAll = async (list) => {
|
||||||
|
const byView = new Map();
|
||||||
|
for (const s of list) {
|
||||||
|
const k = s.view_id || 0;
|
||||||
|
if (!byView.has(k)) byView.set(k, []);
|
||||||
|
byView.get(k).push(s.prefix);
|
||||||
|
}
|
||||||
|
if (!await confirmDialog("Dismiss " + plural(list.length, "address", "addresses"), "Jarl stops suggesting reverse zones for " + (list.length === 1 ? list[0].prefix : list.length + " public addresses" + (q.value.trim() ? " matching " + JSON.stringify(q.value.trim()) : "")) + ". Bring one back with: jarl zone suggest-reverse --undismiss NETWORK --view VIEW.", "Dismiss")) return;
|
||||||
|
try {
|
||||||
|
const ops = [...byView].map(([vid, prefixes]) => ({ op: "reverse.dismiss", data: { view_id: vid || undefined, prefixes } }));
|
||||||
|
toast(describeResult(await changeMany(ops, "dismiss " + plural(list.length, "reverse zone suggestion"))));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const draw = () => {
|
||||||
|
const list = match();
|
||||||
|
const shown = list.slice(0, limit);
|
||||||
|
mount(body,
|
||||||
|
h("div", { class: "compare-controls" }, q,
|
||||||
|
list.length ? h("button", { class: "small", title: "Not yours: stop suggesting all of these", onclick: () => dismissAll(list) }, list.length === pub.length ? "Dismiss all " + list.length : "Dismiss these " + list.length) : null),
|
||||||
|
shown.length ? table(cols, shown) : h("div", { class: "muted small" }, "No address matches."),
|
||||||
|
list.length > shown.length ? h("button", { class: "small", onclick: () => { limit += PAGE; draw(); } }, "Show " + Math.min(PAGE, list.length - shown.length) + " more (of " + (list.length - shown.length) + ")") : null);
|
||||||
|
};
|
||||||
|
q.addEventListener("input", () => { limit = PAGE; draw(); });
|
||||||
|
draw();
|
||||||
|
return h("details", { class: "compare-only", open: pub.length <= 10 },
|
||||||
|
h("summary", null, h("strong", null, "Public addresses: " + pub.length), h("span", { class: "muted small" }, " · create a zone only for an address that is yours; dismiss the others (a web host's)")),
|
||||||
|
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's. Each gets a zone of its own: the rest of its network belongs to others."),
|
||||||
|
body);
|
||||||
|
}
|
||||||
@@ -564,6 +564,29 @@ ul.probes > li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
|||||||
ul.probes > li .msg { margin: 6px 0 0; }
|
ul.probes > li .msg { margin: 6px 0 0; }
|
||||||
|
|
||||||
/* Comparing a zone across views. */
|
/* Comparing a zone across views. */
|
||||||
table.compare td.compare-cell { vertical-align: top; }
|
table.compare td { vertical-align: top; }
|
||||||
|
/* The same name and type columns in every section, so they line up. */
|
||||||
|
table.compare { table-layout: auto; width: 100%; }
|
||||||
|
table.compare tr > :nth-child(1) { width: 18ch; }
|
||||||
|
table.compare tr > :nth-child(2) { width: 8ch; }
|
||||||
table.compare td.compare-cell button { margin-top: 4px; }
|
table.compare td.compare-cell button { margin-top: 4px; }
|
||||||
|
/* One line per record: a long TXT record is cut, with the whole of it on hover. */
|
||||||
|
table.compare .oneline { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46ch; }
|
||||||
|
.compare-actions { margin-top: 4px; }
|
||||||
|
.compare-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 12px; }
|
||||||
|
.compare-controls label { color: var(--muted); }
|
||||||
|
ul.compare-summary { margin: 0 0 12px; padding-left: 18px; }
|
||||||
|
details.compare-only { margin: 12px 0; }
|
||||||
|
details.compare-only summary { cursor: pointer; padding: 4px 0; }
|
||||||
table.compare tr.muted td { color: var(--muted); }
|
table.compare tr.muted td { color: var(--muted); }
|
||||||
|
|
||||||
|
/* Text that wraps anywhere, e.g. long statements in the Added by Jarl table. */
|
||||||
|
pre.wrap { white-space: pre-wrap; word-break: break-all; }
|
||||||
|
|
||||||
|
/* New reverse zone: sizes and the networks the records use. */
|
||||||
|
.reverse-helper { margin-top: 6px; }
|
||||||
|
.form-grid > .grid-cell { min-width: 0; }
|
||||||
|
.size-chips { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
|
||||||
|
.size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||||
|
.cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; }
|
||||||
|
.cands button.cand { flex-shrink: 0; text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
+90
-4
@@ -418,11 +418,28 @@ expect_dig "now every view's" "10.1.1.1" -k $E/external.key linked.adbraun.test
|
|||||||
run "--view all changes a link once" 0 $J record set adbraun.test linked A 10.1.1.2 --view all
|
run "--view all changes a link once" 0 $J record set adbraun.test linked A 10.1.1.2 --view all
|
||||||
contains "linked with internal"
|
contains "linked with internal"
|
||||||
expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A
|
expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A
|
||||||
run "zone diff: what differs between the views" 0 $J zone diff adbraun.test
|
run "zone diff: internal compared with external" 0 $J zone diff adbraun.test
|
||||||
contains "ns1 *A *127.0.0.1 \\[local, id [0-9]*\\] *203.0.113.1 \\[local, id [0-9]*\\]"
|
contains "^adbraun.test in internal, compared with external$"
|
||||||
contains "names and types differ, [0-9]* are the same; linked"
|
contains "^ linked: a difference is a view's own records"
|
||||||
run "the compare API" 0 curl -sf -H "Authorization: Bearer $(cat $E/admin.token)" "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test/compare?view=internal&all=1"
|
contains "^ external: [1-9][0-9]* answered differently, [0-9]* only in internal, [0-9]* only in external, [0-9]* the same"
|
||||||
|
contains "^ANSWERED DIFFERENTLY"
|
||||||
|
contains "^ ns1 A$"
|
||||||
|
contains "^ internal 127.0.0.1 \\[local, id [0-9]*\\]"
|
||||||
|
contains "^ external 203.0.113.1 \\[local, id [0-9]*\\]"
|
||||||
|
contains "the same (--all lists them)"
|
||||||
|
run "zone diff from external" 0 $J zone diff adbraun.test --view external
|
||||||
|
contains "^adbraun.test in external, compared with internal"
|
||||||
|
run "zone diff for one name" 0 $J zone diff adbraun.test ns1 A
|
||||||
|
contains "^ ns1 A$"
|
||||||
|
not_contains "the same (--all"
|
||||||
|
run "a long TXT record" 0 $J record add adbraun.test longtxt TXT "$(printf 'x%.0s' $(seq 300))" --view internal --local
|
||||||
|
run "zone diff --wide shows it whole" 0 $J zone diff adbraun.test longtxt --wide
|
||||||
|
contains "x\\{250\\}"
|
||||||
|
run "the compare API" 0 curl -sf -H "Authorization: Bearer $(cat $E/admin.token)" "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test/compare?view=internal&with=external&all=1"
|
||||||
contains '"linked":true'
|
contains '"linked":true'
|
||||||
|
contains '"base":"internal"'
|
||||||
|
contains '"same_rows":\['
|
||||||
|
run "remove the long TXT" 0 $J record delete adbraun.test longtxt TXT --view internal -y
|
||||||
run "a shared delete" 0 $J record delete adbraun.test linked A --view external -y
|
run "a shared delete" 0 $J record delete adbraun.test linked A --view external -y
|
||||||
expect_dig "gone from internal too" "" linked.adbraun.test A
|
expect_dig "gone from internal too" "" linked.adbraun.test A
|
||||||
run "zone unlink" 0 $J zone unlink adbraun.test --view external
|
run "zone unlink" 0 $J zone unlink adbraun.test --view external
|
||||||
@@ -640,6 +657,75 @@ 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 "two more web host addresses" 0 $J record add revtest.test docs A 185.199.109.153 --view internal
|
||||||
|
run "and" 0 $J record add revtest.test pages A 185.199.110.153 --view internal
|
||||||
|
run "dismiss three at once" 0 $J zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --dismiss 185.199.110.153/32 --view internal
|
||||||
|
run "gone from the suggestions" 0 $J zone suggest-reverse
|
||||||
|
not_contains "185.199."
|
||||||
|
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 "but --all still lists its networks" 0 $J zone suggest-reverse --all --view internal
|
||||||
|
contains "^public *internal *185.199.108.153/32 .* suggestions off"
|
||||||
|
run "and back on" 0 $J view update internal --reverse-suggestions
|
||||||
|
# An address only the external view has a record for: a reverse zone in
|
||||||
|
# the internal view can still name it.
|
||||||
|
run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external
|
||||||
|
run "offered for internal" 0 $J zone suggest-reverse --all --view internal
|
||||||
|
contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external"
|
||||||
|
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill
|
||||||
|
expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1
|
||||||
|
# A zone created inside another takes the parent's records under it.
|
||||||
|
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal
|
||||||
|
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.
|
||||||
|
|||||||
Reference in new issue
Block a user