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

+37
View File
@@ -8,6 +8,7 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -30,8 +31,18 @@ type Client struct {
Changeset int64
DryRun bool
Force bool
// Preflight lets only requests through that can't change anything:
// reads and dry runs. Running a command on several servers uses it to
// check the command everywhere before running it anywhere.
Preflight bool
// Mutated records that a preflight run sent a (dry-run) change.
Mutated bool
}
// ErrNoPreflight is returned in Preflight mode for a request that would
// change something and has no dry run.
var ErrNoPreflight = errors.New("this command can't be checked without running it, so it can't run on several servers at once; run it on one server at a time with --server NAME")
// Options configure New.
type Options struct {
Server string
@@ -124,6 +135,12 @@ func (e *Error) Check() []CheckMessage {
// Do sends a request. body is marshaled as JSON; out (if not nil) receives
// the JSON response.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
if c.Preflight && method != http.MethodGet && path != "/system/check" {
if !isDryRun(query, body) {
return ErrNoPreflight
}
c.Mutated = true
}
u := c.BaseURL + api.Prefix + path
if len(query) > 0 {
u += "?" + query.Encode()
@@ -174,6 +191,26 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
return json.Unmarshal(data, out)
}
// isDryRun reports whether a request asks for a dry run, in its query or
// its body.
func isDryRun(query url.Values, body any) bool {
if query.Get("dry_run") == "1" {
return true
}
switch b := body.(type) {
case api.BatchRequest:
return b.DryRun
case *api.BatchRequest:
return b != nil && b.DryRun
case map[string]any:
v, _ := b["dry_run"].(bool)
return v
case map[string]bool:
return b["dry_run"]
}
return false
}
// Get is Do with GET.
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
return c.Do(ctx, http.MethodGet, path, query, nil, out)
+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)
}
}
+24 -1
View File
@@ -108,9 +108,32 @@ func (d *Deployer) EnsureDir(dir string, mode fs.FileMode) error {
return fmt.Errorf("chgrp %s: %w", dir, err)
}
}
return os.Chmod(dir, mode)
fi, err := os.Stat(dir)
if err != nil {
return err
}
// mkdir applies the umask, and a setgid parent passes its bit on, so
// the mode usually needs fixing. Under systemd's RestrictSUIDSGID (as in
// jarl.service) any chmod that includes the setgid bit fails with
// EPERM; the permission bits matter more (named writes journals here),
// and every file Jarl writes gets the group explicitly, so fall back to
// them alone.
if fi.Mode()&(fs.ModePerm|fs.ModeSetgid) == mode&(fs.ModePerm|fs.ModeSetgid) {
return nil
}
err = chmod(dir, mode)
if errors.Is(err, fs.ErrPermission) && mode&fs.ModeSetgid != 0 {
err = chmod(dir, mode.Perm())
}
if err != nil {
return fmt.Errorf("chmod %s: %w", dir, err)
}
return nil
}
// chmod is os.Chmod; tests replace it to simulate a sandbox.
var chmod = os.Chmod
func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) error {
dir := filepath.Dir(path)
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
+47
View File
@@ -0,0 +1,47 @@
package engine
import (
"io/fs"
"os"
"path/filepath"
"syscall"
"testing"
)
// Under systemd's RestrictSUIDSGID a chmod that sets the setgid bit fails
// with EPERM; EnsureDir must still create a group-writable directory.
func TestEnsureDirSetgidRestricted(t *testing.T) {
old := chmod
defer func() { chmod = old }()
chmod = func(name string, mode fs.FileMode) error {
if mode&fs.ModeSetgid != 0 {
return &fs.PathError{Op: "chmod", Path: name, Err: syscall.EPERM}
}
return os.Chmod(name, mode)
}
oldMask := syscall.Umask(0o027)
defer syscall.Umask(oldMask)
root := t.TempDir()
d := &Deployer{NamedWritable: []string{root}}
dir := filepath.Join(root, "internal")
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o770 {
t.Errorf("mode = %v, want group-writable 0770", fi.Mode())
}
// Unrestricted, the setgid bit is set too.
chmod = os.Chmod
dir2 := filepath.Join(root, "external")
if err := d.EnsureDir(dir2, d.dirMode(dir2)); err != nil {
t.Fatal(err)
}
if fi, _ := os.Stat(dir2); fi.Mode()&(fs.ModePerm|fs.ModeSetgid) != 0o770|fs.ModeSetgid {
t.Errorf("mode = %v, want 2770", fi.Mode())
}
}
+1 -1
View File
@@ -966,7 +966,7 @@ func applyTSIGPut(st *model.State, op model.Op, c *applyCtx) error {
if in.Name != nil {
name := model.NormalizeKeyName(*in.Name)
if err := dnsname.CheckZoneName(dnsname.Fqdn(name)); err != nil || name == "" || name == "." {
return errf(KindBadRequest, "invalid key name %q (key names are domain names, e.g. xfer-key or acme.example.com)", *in.Name)
return errf(KindBadRequest, "invalid key name %q (use letters, digits, hyphens and dots, e.g. xfer-key or ns2-xfer.example.com)", *in.Name)
}
if other := st.KeyByName(name); other != nil && other.ID != k.ID {
return errf(KindConflict, "TSIG key %s already exists", name)
+19 -14
View File
@@ -85,6 +85,7 @@ function renderLogin(message) {
function layout() {
navEl = h("nav", { class: "main" });
let modeEl;
barEl = h("div");
mainEl = h("main");
const q = h("input", { type: "search", placeholder: "Search names, data…", "aria-label": "Search" });
@@ -96,38 +97,42 @@ function layout() {
h("span", { class: "brand-version" }, me.version)),
navEl,
h("div", { class: "spacer" }),
modeEl = h("span"),
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { class: "small", onclick: logout }, "Log out"));
mount(app, header, barEl, mainEl);
modeHolder = modeEl;
renderNav();
renderBar();
}
let modeHolder;
function renderNav() {
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
}
// renderBar shows the pending-changes bar (only while there are pending
// changes) and the small review/live switch in the header.
function renderBar() {
if (!barEl) return;
const d = state.draft;
const n = d ? (d.ops || []).length : 0;
const mode = h("label", { class: "check small", title: "When on, every edit is applied to named right away instead of being collected for review." },
h("input", { type: "checkbox", checked: state.immediate, onchange: (e) => setImmediate(e.target.checked) }), "Apply edits immediately");
if (n > 0) {
mount(barEl, h("div", { class: "pending-bar" },
h("strong", null, plural(n, "pending change")),
h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."),
h("a", { class: "button primary small", href: "#/changes" }, "Review & apply"),
h("div", { class: "spacer", style: { flex: 1 } }),
mode));
} else {
mount(barEl, h("div", { class: "pending-bar", style: { background: "var(--panel)" } },
h("span", { class: "muted small" }, state.immediate ? "Edits are applied to named immediately." : "Edits are collected as pending changes; review and apply them under Changes."),
h("div", { style: { flex: 1 } }),
mode));
if (modeHolder) {
const live = state.immediate;
mount(modeHolder, h("button", {
type: "button", class: "mode-toggle" + (live ? " live" : ""), "aria-pressed": live ? "true" : "false",
title: live ? "Edits are applied to named right away. Click to collect them for review first."
: "Edits are collected as pending changes and applied from Changes. Click to apply edits right away.",
onclick: () => setImmediate(!live),
}, h("span", { class: "dot" }), live ? "Live edits" : "Review edits"));
}
mount(barEl, n > 0 ? h("div", { class: "pending-bar" },
h("strong", null, plural(n, "pending change")),
h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."),
h("a", { class: "button primary small", href: "#/changes" }, "Review & apply")) : null);
}
async function logout() {
+50 -18
View File
@@ -39,28 +39,57 @@ export function parseIPv6(s) {
return groups.map((g) => g.padStart(4, "0")).join("").split("");
}
// maxReverseZones matches the server's limit for splitting a network.
const maxReverseZones = 16;
// reverseZoneFor turns a network such as 10.10.0.0/24 into its reverse
// zone. It returns {name} or {error}.
// zones. Reverse names follow octets (IPv4) or nibbles (IPv6), so a /22 is
// covered by four /24 zones; a /32 gets a zone for just that address. It
// returns {names, network} (with name when there is a single zone) or
// {error}.
export function reverseZoneFor(cidr) {
const [addr, bitsText] = cidr.trim().split("/");
if (bitsText === undefined) return { error: "give the network with its prefix length, e.g. 10.10.0.0/24" };
if (bitsText === undefined) return { error: "give the network with its prefix length, e.g. 10.10.0.0/24 (or 10.10.0.5/32 for one address)" };
const bits = Number(bitsText);
const b4 = parseIPv4(addr);
if (b4) {
if (![8, 16, 24].includes(bits)) return { error: "IPv4 reverse zones follow the dots: use /8, /16 or /24 (a /" + bits + " network goes in the enclosing /" + (bits > 24 ? 24 : bits > 16 ? 16 : 8) + " zone)" };
const n = bits / 8;
const host = b4.slice(n);
if (host.some((x) => x !== 0)) return { error: "that's an address inside the network; the network is " + b4.slice(0, n).concat(host.map(() => 0)).join(".") + "/" + bits };
return { name: b4.slice(0, n).reverse().join(".") + ".in-addr.arpa", network: b4.slice(0, n).join(".") + (n < 4 ? "." + Array(4 - n).fill(0).join(".") : "") + "/" + bits };
const n6 = b4 ? null : parseIPv6(addr);
if (!b4 && !n6) return { error: "not an IPv4 or IPv6 network" };
// Work on digits: octets for IPv4, nibbles for IPv6.
const digits = b4 || n6.map((x) => parseInt(x, 16));
const step = b4 ? 8 : 4, total = b4 ? 32 : 128;
if (!Number.isInteger(bits) || bits < 0 || bits > total) return { error: "the prefix length must be 0 to " + total };
if (bits < step) return { error: "/" + bits + " is too large for a reverse zone" };
if (b4 && bits > 24 && bits < 32) {
const enclosing = b4.slice(0, 3).join(".") + ".0/24";
return { error: "a /" + bits + " is part of " + enclosing + ", and all its reverse names live in that /24's zone: create " + enclosing + ", or a /32 zone per address to answer for single addresses only" };
}
const n6 = parseIPv6(addr);
if (n6) {
if (!(bits > 0 && bits <= 124 && bits % 4 === 0)) return { error: "IPv6 reverse zones need a prefix that is a multiple of 4, e.g. /48 or /64" };
const keep = n6.slice(0, bits / 4);
if (n6.slice(bits / 4).some((x) => x !== "0")) return { error: "the address has bits set after /" + bits + "; use the network address" };
return { name: keep.slice().reverse().join(".") + ".ip6.arpa", network: cidr.trim() };
// The host part must be zero.
for (let i = 0; i < digits.length; i++) {
const keepBits = Math.max(0, Math.min(step, bits - i * step));
if (digits[i] & ((1 << (step - keepBits)) - 1)) {
const net = digits.map((d, j) => { const k = Math.max(0, Math.min(step, bits - j * step)); return d & ~((1 << (step - k)) - 1); });
return { error: "the address has bits set after /" + bits + "; the network is " + (b4 ? net.join(".") : sixOf(net.map((d) => d.toString(16)))) + "/" + bits };
}
}
return { error: "not an IPv4 or IPv6 network" };
const zoneBits = Math.ceil(bits / step) * step;
const n = 2 ** (zoneBits - bits);
if (n > maxReverseZones) return { error: "a /" + bits + " spans " + n + " /" + zoneBits + " reverse zones; use a /" + (zoneBits - step) + " or create the zones you need" };
const keep = zoneBits / step;
const names = [];
for (let i = 0; i < n; i++) {
const d = digits.slice(0, keep);
d[keep - 1] += i;
names.push(d.map((x) => (b4 ? String(x) : x.toString(16))).reverse().join(".") + (b4 ? ".in-addr.arpa" : ".ip6.arpa"));
}
const network = (b4 ? b4.join(".") : sixOf(n6)) + "/" + bits;
return n === 1 ? { name: names[0], names, network } : { names, network };
}
// sixOf formats 32 nibbles as a compressed IPv6 address.
function sixOf(nib) {
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(nib.slice(i, i + 4).join("").replace(/^0+(?=.)/, ""));
return groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::");
}
export function isReverseZone(name) {
@@ -73,16 +102,17 @@ export function reverseInfo(zoneName) {
const n = zoneName.replace(/\.$/, "").toLowerCase();
if (n.endsWith(".in-addr.arpa")) {
const labels = n.slice(0, -".in-addr.arpa".length).split(".").reverse();
if (labels.length > 3 || labels.some((l) => !/^\d+$/.test(l))) return null;
if (labels.length > 4 || labels.some((l) => !/^\d+$/.test(l))) return null;
const pad = Array(4 - labels.length).fill("0");
return { family: 4, prefix: labels, network: [...labels, ...pad].join(".") + "/" + labels.length * 8, example: [...labels, ...pad.map((_, i) => (i === pad.length - 1 ? "20" : "0"))].join(".") };
return { family: 4, prefix: labels, network: [...labels, ...pad].join(".") + "/" + labels.length * 8, example: [...labels, ...pad.map((_, i) => (i === pad.length - 1 ? "20" : "0"))].join("."), single: labels.length === 4 };
}
if (n.endsWith(".ip6.arpa")) {
const nib = n.slice(0, -".ip6.arpa".length).split(".").reverse();
const full = [...nib, ...Array(32 - nib.length).fill("0")];
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(full.slice(i, i + 4).join("").replace(/^0+(?=.)/, ""));
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: groups.slice(0, 4).join(":") + "::20" };
const single = nib.length === 32;
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: single ? sixOf(full) : groups.slice(0, 4).join(":") + "::20", single };
}
return null;
}
@@ -101,6 +131,8 @@ export function ptrOwner(input, zoneName) {
const inZone = info.prefix.every((p, i) => parts[i] === p);
if (!inZone) return { error: s + " is not in " + info.network + ", the network this zone covers" };
const rel = parts.slice(info.prefix.length).reverse().join(".");
// In a single-address zone the address is the zone's apex.
if (!rel) return { relative: "@", fqdn: zone };
return { relative: rel, fqdn: rel + "." + zone };
}
if (s.endsWith(".")) return { relative: s, fqdn: s.replace(/\.$/, "") };
+3 -2
View File
@@ -17,7 +17,8 @@ export async function render(el) {
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "TSIG keys"),
h("div", { class: "muted" }, "Shared secrets that authenticate zone transfers, NOTIFY and other server-to-server traffic. Use them in ACLs and options as 'key NAME'.")),
h("div", { class: "muted" }, "Shared secrets that authenticate zone transfers, NOTIFY and other server-to-server traffic. Use them in ACLs and options as 'key NAME'."),
h("div", { class: "muted small" }, "To let a script, tool or person change only some zones or records through Jarl, create an ", h("a", { href: "#/account" }, "API token"), " restricted to them instead.")),
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => keyDialog() }, "New key"))),
h("div", { class: "panel" }, table([
{ label: "Name", render: (k) => h("strong", { class: "mono" }, k.name) },
@@ -52,7 +53,7 @@ async function keyDialog() {
const secret = input({ placeholder: "leave empty to generate a random secret", class: "mono" });
const comment = input();
await modal("New TSIG key", h("div", null, h("div", { class: "form-grid" },
field("Name", name, "Key names are domain names, e.g. xfer-key or secondary.example.com."),
field("Name", name, "Letters, digits, hyphens and dots, e.g. xfer-key or ns2-xfer.adbraun.lol. It's only a label (it doesn't have to exist in DNS), but both servers must use exactly the same name."),
field("Algorithm", alg),
field("Secret", secret, "Base64. Must be the same on both servers."),
field("Comment", comment)), err), [
+21 -9
View File
@@ -1,5 +1,5 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
import { get, change, describeResult, can, draftQuery } from "../api.js";
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js";
@@ -63,11 +63,13 @@ export async function newZoneDialog(views, zones, settings) {
const cloneSel = select([["", "– don't copy –"]], "");
const comment = input();
const updateClones = () => {
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
// Records can only be copied into a single zone.
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
};
// check validates the name as typed and returns the zone name (or null).
// check validates the name as typed and returns the zone name (or null);
// a network that needs several reverse zones gives a list of names.
const check = () => {
const v = name.value.trim();
const msgs = [];
@@ -80,8 +82,16 @@ export async function newZoneDialog(views, zones, settings) {
if (kind === "reverse") {
const r = reverseZoneFor(v);
if (r.error) msgs.push(["err", r.error]);
else zoneName = r.name;
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · answers \"which host has address …?\" for " + r.network)) : null,
else zoneName = r.name || r.names;
const single = /\/(32|128)$/.test(r.network || "");
const what = single ? " · answers \"which host has address " + r.network.replace(/\/\d+$/, "") + "?\" and nothing else in its network"
: " · answers \"which host has address …?\" for " + r.network;
mount(preview,
typeof zoneName === "string" ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, what)) : null,
Array.isArray(zoneName) ? h("div", null,
h("div", null, "Reverse names follow the dots, so " + r.network + " is covered by " + zoneName.length + " zones, created together:"),
h("div", { class: "mono small" }, zoneName.join(", ")),
h("div", { class: "muted small" }, "Answers \"which host has address …?\" for " + r.network + ".")) : null,
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
} else {
const c = checkDomain(v);
@@ -104,9 +114,9 @@ export async function newZoneDialog(views, zones, settings) {
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
}
if (zoneName && kind === "reverse") {
const fq = zoneName + ".";
if (zones.find((z) => z.name === fq && (z.view || "") === (view.value || ""))) {
mount(preview, h("div", { class: "msg err small" }, "the reverse zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "")));
const existing = [zoneName].flat().filter((n) => zones.find((z) => z.name === n + "." && (z.view || "") === (view.value || "")));
if (existing.length) {
mount(preview, h("div", { class: "msg err small" }, "the reverse zone" + (existing.length > 1 ? "s " : " ") + existing.join(", ") + (existing.length > 1 ? " already exist" : " already exists") + (view.value ? " in view " + view.value : "")));
return null;
}
}
@@ -194,7 +204,9 @@ export async function newZoneDialog(views, zones, settings) {
}
if (opts.length) data.options = opts;
try {
const r = await change("POST", "/zones", data);
const r = Array.isArray(zoneName)
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
: await change("POST", "/zones", data);
toast(describeResult(r));
rerender();
return true;
+6
View File
@@ -118,6 +118,12 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
padding: 8px 16px;
}
.pending-bar strong { color: var(--warn); }
/* The review/live switch in the header. */
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--border); }
.mode-toggle:hover { color: var(--text); border-color: var(--accent); }
.mode-toggle .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.mode-toggle.live { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); }
.mode-toggle.live .dot { background: var(--warn); }
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.page-head .grow { flex: 1; min-width: 200px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }