Multi-server CLI, more reverse zones, and a fix for new view directories

- CLI: name several servers and groups in the config file; select them
  with -s/--server NAME|GROUP|all|URL or JARL_SERVER. A command aimed at
  several servers is dry-run on all of them first and only runs if every
  one accepts it. Named servers always use their own token.
- Reverse zones: /32 and /128 get single-address zones; networks between
  octet or nibble boundaries (e.g. a /22) are covered by several zones
  created together, from the CLI and the web UI.
- Fix: under jarl.service's RestrictSUIDSGID, creating a view directory
  failed on chmod with the setgid bit; fall back to the permission bits.
- Web UI: the review/live switch is a small header button, and the
  pending-changes bar only shows when there are pending changes.
- TSIG key wording; the keys page points to restricted API tokens.
- Docs: several servers (cli.md), a secondary server (operations.md),
  tokens vs TSIG keys (permissions.md); CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 08:12:03 +00:00
1 parent a7d389be27
commit 785b0006f9
22 files changed
+1375 -185

No files matched your search

+62 -17
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/netip"
"strconv"
"strings"
"github.com/miekg/dns"
@@ -330,31 +331,75 @@ func ReverseName(addr netip.Addr) string {
return sb.String()
}
// ReverseZone returns the name of the reverse zone covering prefix. IPv4
// prefixes must be /8, /16 or /24 and IPv6 prefixes a multiple of /4; other
// lengths need RFC 2317 style delegation, which must be set up by hand.
func ReverseZone(p netip.Prefix) (string, error) {
// MaxReverseZones is the most zones ReverseZones splits a network into.
const MaxReverseZones = 16
// ReverseZones returns the reverse zones that together cover prefix.
// Reverse names follow octet (IPv4) or nibble (IPv6) boundaries, so a
// prefix in between is covered by several zones: a /22 by four /24 zones.
// A single address (/32, /128) gets a zone of its own, which answers for
// just that address. IPv4 networks smaller than a /24 (/25 to /31) are
// refused: their names live in the enclosing /24 zone.
func ReverseZones(p netip.Prefix) ([]string, error) {
p = p.Masked()
addr := p.Addr()
bits := p.Bits()
step := 4
if addr.Is4() {
if bits != 8 && bits != 16 && bits != 24 {
return "", fmt.Errorf("IPv4 reverse zones must be /8, /16 or /24 (got /%d); use the enclosing /24 zone instead", bits)
step = 8
switch {
case bits < 8:
return nil, fmt.Errorf("/%d is too large for a reverse zone; the largest IPv4 reverse zone is a /8", bits)
case bits > 24 && bits < 32:
enclosing, _ := addr.Prefix(24)
return nil, fmt.Errorf("a /%d is part of %s, and its reverse names live in that /24's zone; create %s, or a /32 zone per address to answer for single addresses only", bits, enclosing, enclosing)
}
b := addr.As4()
parts := []string{}
for i := bits/8 - 1; i >= 0; i-- {
parts = append(parts, fmt.Sprint(b[i]))
} else if bits < 4 {
return nil, fmt.Errorf("/%d is too large for a reverse zone", bits)
}
zoneBits := (bits + step - 1) / step * step
n := 1 << (zoneBits - bits)
if n > MaxReverseZones {
return nil, fmt.Errorf("a /%d spans %d /%d reverse zones; use a /%d or create the zones you need", bits, n, zoneBits, zoneBits-step)
}
full := ReverseName(addr)
labels := strings.Split(strings.TrimSuffix(strings.TrimSuffix(full, ".in-addr.arpa."), ".ip6.arpa."), ".")
suffix := ".ip6.arpa."
if addr.Is4() {
suffix = ".in-addr.arpa."
}
// labels are least significant first; the zone keeps zoneBits/step of
// the most significant ones.
keep := zoneBits / step
zones := make([]string, 0, n)
for i := 0; i < n; i++ {
zl := append([]string(nil), labels[len(labels)-keep:]...)
if n > 1 {
// The last zone label counts through the network's range.
if addr.Is4() {
v, _ := strconv.Atoi(zl[0])
zl[0] = strconv.Itoa(v + i)
} else {
v := strings.IndexByte("0123456789abcdef", zl[0][0])
zl[0] = string("0123456789abcdef"[v+i])
}
}
return strings.Join(parts, ".") + ".in-addr.arpa.", nil
zones = append(zones, strings.Join(zl, ".")+suffix)
}
if bits%4 != 0 || bits == 0 {
return "", fmt.Errorf("IPv6 reverse zones must be a multiple of /4 (got /%d)", bits)
return zones, nil
}
// ReverseZone returns the name of the reverse zone covering prefix, or an
// error when the prefix needs several zones (see ReverseZones).
func ReverseZone(p netip.Prefix) (string, error) {
zones, err := ReverseZones(p)
if err != nil {
return "", err
}
full := ReverseName(addr) // 32 nibbles
nibbles := strings.Split(strings.TrimSuffix(full, ".ip6.arpa."), ".")
keep := nibbles[len(nibbles)-bits/4:]
return strings.Join(keep, ".") + ".ip6.arpa.", nil
if len(zones) > 1 {
return "", fmt.Errorf("%s spans %d reverse zones (%s ... %s); create each of them, or use `jarl zone create %s`, which does", p.Masked(), len(zones), Display(zones[0]), Display(zones[len(zones)-1]), p.Masked())
}
return zones[0], nil
}
// AddrFromReverse parses a full reverse-lookup owner name back into an
+32
View File
@@ -2,6 +2,7 @@ package dnsname
import (
"net/netip"
"strings"
"testing"
)
@@ -185,3 +186,34 @@ func TestOwnerAddressInReverseZone(t *testing.T) {
}
}
}
func TestReverseZones(t *testing.T) {
cases := []struct {
prefix string
want string // zones joined by spaces, or "error"
}{
{"10.0.10.0/24", "10.0.10.in-addr.arpa."},
{"10.0.10.100/32", "100.10.0.10.in-addr.arpa."},
{"10.0.8.0/22", "8.0.10.in-addr.arpa. 9.0.10.in-addr.arpa. 10.0.10.in-addr.arpa. 11.0.10.in-addr.arpa."},
{"10.0.9.0/22", "8.0.10.in-addr.arpa. 9.0.10.in-addr.arpa. 10.0.10.in-addr.arpa. 11.0.10.in-addr.arpa."},
{"172.16.0.0/12", "16.172.in-addr.arpa. 17.172.in-addr.arpa. 18.172.in-addr.arpa. 19.172.in-addr.arpa. 20.172.in-addr.arpa. 21.172.in-addr.arpa. 22.172.in-addr.arpa. 23.172.in-addr.arpa. 24.172.in-addr.arpa. 25.172.in-addr.arpa. 26.172.in-addr.arpa. 27.172.in-addr.arpa. 28.172.in-addr.arpa. 29.172.in-addr.arpa. 30.172.in-addr.arpa. 31.172.in-addr.arpa."},
{"10.0.0.0/17", "error"}, // 128 zones
{"10.0.10.64/26", "error"},
{"10.0.0.0/7", "error"},
{"2001:db8::/47", "0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa. 1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."},
{"2001:db8::1/128", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."},
}
for _, c := range cases {
zones, err := ReverseZones(netip.MustParsePrefix(c.prefix))
got := strings.Join(zones, " ")
if err != nil {
got = "error"
}
if got != c.want {
t.Errorf("ReverseZones(%s) = %s (%v), want %s", c.prefix, got, err, c.want)
}
}
if _, err := ReverseZone(netip.MustParsePrefix("10.0.8.0/22")); err == nil || !strings.Contains(err.Error(), "spans 4 reverse zones") {
t.Errorf("ReverseZone /22: %v", err)
}
}