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:
1 parent
a7d389be27
commit
785b0006f9
22 files changed
+1375
-185
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(/\.$/, "") };
|
||||
|
||||
@@ -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), [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user