Web UI: zone naming and reverse zones, ACL suggestions, record form

- New zone dialog: choose Domain or Reverse zone. Reverse zones are
  created from a network and their name is generated; both kinds show the
  exact zone name and flag likely mistakes (addresses or URLs as names,
  host-like names, duplicates in the view, parent/child zones, networks
  off the octet/nibble boundaries) before anything is submitted.
- Record form: clears every field but the type after an add; shows the
  record exactly as it will be stored and warns about unqualified names.
  In reverse zones it takes an address, defaults to PTR, suggests host
  names of A/AAAA records with that address and warns about a second PTR;
  the record list shows addresses.
- Address match lists (options, view match lists, ACL elements) offer the
  existing ACLs, TSIG keys and built-ins as one-click suggestions and
  flag unknown names.
- Server: in reverse zones a record can be named by its address, and a
  PTR target like nas.example.com without the final dot is read as the
  full host name (a bare label is refused). Warnings noticed while parsing
  record data reach the result again.
- Fixes: brand link hover underline, the dashboard's "New zone" link
  opening the dialog twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 20:49:43 +00:00
1 parent 3b7bee67f8
commit bf7f99874b
19 files changed
+577 -35

No files matched your search

+31
View File
@@ -10,6 +10,11 @@ The first version, to be released as 0.1.0.
### Added ### Added
- Web UI: wherever an address match list is entered (server, view and zone
options such as allow-query or allow-transfer, view match lists, ACL
elements) the existing ACLs, TSIG keys and built-in names are offered as
one-click suggestions, and names that don't exist are flagged as you type.
- **Query statistics** (off by default; `stats` in jarl.yaml, docs/stats.md). - **Query statistics** (off by default; `stats` in jarl.yaml, docs/stats.md).
When enabled, Jarl adds a loopback statistics channel and a response log to When enabled, Jarl adds a loopback statistics channel and a response log to
named's configuration (merged into existing `logging` and named's configuration (merged into existing `logging` and
@@ -101,12 +106,38 @@ The first version, to be released as 0.1.0.
### Changed ### Changed
- Web UI, new zone dialog: choose **Domain** or **Reverse zone**. For a
reverse zone you give the network (10.10.0.0/24, 2001:db8:1::/48) and the
zone name is generated. Either way the dialog shows the exact zone name it
will create and explains problems before submitting: an address or URL
instead of a name, a host-like name such as www.example.com, a zone that
already exists in the view, a child or parent of an existing zone, and
networks that don't follow the octet or nibble boundaries.
- Web UI, record form: after adding a record every field is cleared except
the type, and the cursor returns to the name. A "Will add:" line shows the
record exactly as it will be stored, with warnings for unqualified names
(e.g. a CNAME to readthedocs.io without the final dot). In reverse zones
the form asks for the address, defaults to PTR, offers the host names of
A/AAAA records with that address, warns about a second PTR for an
address, and the record list shows each record's address.
- Reverse zones: a record name can be given as an address (10.10.0.20) in
the API and CLI too, and a PTR target without a final dot that contains a
dot (nas.adbraun.lol) is read as the full host name (with a warning); a
bare label is refused.
- `jarl resync` / `jarld resync` refuse to overwrite files that were changed - `jarl resync` / `jarld resync` refuse to overwrite files that were changed
outside Jarl unless `--force` is given, as documented. Before, they always outside Jarl unless `--force` is given, as documented. Before, they always
overwrote them. overwrote them.
### Fixed ### Fixed
- Web UI: the "Jarl 0.1.0" brand link no longer underlines the gap between
the name and the version on hover.
- Web UI: creating a zone from the dashboard's "New zone" link no longer
opens the dialog a second time.
- Warnings noticed while parsing record data (a name read as absolute) were
lost before reaching the result; they are shown again.
- The negative-caching warning now uses the effective negative TTL, which - The negative-caching warning now uses the effective negative TTL, which
is the smaller of the SOA minimum and the zone's default TTL (RFC 2308). is the smaller of the SOA minimum and the zone's default TTL (RFC 2308).
It explains the real effect (new names can stay invisible to resolvers It explains the real effect (new names can stay invisible to resolvers
+8 -3
View File
@@ -103,11 +103,16 @@ these pages:
* **Statistics** (when enabled): queries over time, failed answers, a * **Statistics** (when enabled): queries over time, failed answers, a
weekday x hour heatmap, top names and clients, and breakdowns. Every chart weekday x hour heatmap, top names and clients, and breakdowns. Every chart
has a table view. has a table view.
* **Zones**: list, create (fields adapt to the zone type), per-zone records * **Zones**: list, create (choose a domain or a network, and the dialog shows
with inline editing and type-specific hints, settings (SOA, TTL, serial the exact zone name it will create and warns about likely mistakes;
fields adapt to the zone type), per-zone records
with inline editing, type-specific hints and a preview of the exact record
that will be added (reverse zones take addresses and suggest host names), settings (SOA, TTL, serial
policy, view), options, the generated zone file, and pasting a zone file policy, view), options, the generated zone file, and pasting a zone file
to merge or replace records. to merge or replace records.
* **Views, ACLs, TSIG keys, Server options**: the options editor knows every * **Views, ACLs, TSIG keys, Server options**: wherever an address match list
is entered, the existing ACLs and keys are offered and unknown names are
flagged. The options editor knows every
BIND 9.20 statement for the scope and shows its description, grammar and BIND 9.20 statement for the scope and shows its description, grammar and
a value field that fits it. a value field that fits it.
* **Changes**: pending edits with the diff, named-checkconf output and * **Changes**: pending edits with the diff, named-checkconf output and
+7 -1
View File
@@ -98,11 +98,17 @@ jarl record update 1234 --ttl default
jarl record delete adbraun.lol www A # every A record of www jarl record delete adbraun.lol www A # every A record of www
jarl record delete adbraun.lol www A 192.168.1.10 # just this one jarl record delete adbraun.lol www A 192.168.1.10 # just this one
jarl record delete --id 1234 [--ptr] jarl record delete --id 1234 [--ptr]
# reverse zones: name records by address; the host name needs no final dot
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
``` ```
Names are relative to the zone (`@` is the apex) unless they end with a Names are relative to the zone (`@` is the apex) unless they end with a
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
adds the quotes and splits long values into 255-byte strings. `--ptr` adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
(`10.10.0.20`), and a PTR target such as `nas.adbraun.lol` is read as the full
host name (a bare `nas` is refused, since it would mean
`nas.0.10.10.in-addr.arpa.`). `--ptr`
keeps the PTR record in the reverse zone in step (when that zone is managed keeps the PTR record in the reverse zone in step (when that zone is managed
by Jarl). Every record type BIND supports can be used. Jarl knows the by Jarl). Every record type BIND supports can be used. Jarl knows the
syntax of the common ones and checks them strictly, and the rest are syntax of the common ones and checks them strictly, and the rest are
+3 -1
View File
@@ -147,7 +147,9 @@ RFC 1982 arithmetic.
resolved against the zone, and `www.example.com` without a final dot in a resolved against the zone, and `www.example.com` without a final dot in a
zone `example.com` is caught as the classic `www.example.com.example.com.` zone `example.com` is caught as the classic `www.example.com.example.com.`
mistake. Adding an A/AAAA record with `ptr` also maintains the PTR record in mistake. Adding an A/AAAA record with `ptr` also maintains the PTR record in
the matching reverse zone. the matching reverse zone. In reverse zones a record can be named by its
address (`10.10.0.20`), and a PTR target without a final dot is read as
the full host name, because a PTR never points into the reverse zone.
* **Options** are named.conf statements attached to a scope: server * **Options** are named.conf statements attached to a scope: server
`options { }`, a view, a zone, or the top level (`logging`, `controls`, `options { }`, a view, a zone, or the top level (`logging`, `controls`,
`statistics-channels`, `include`, ...). The UI and CLI know every BIND 9.20 `statistics-channels`, `include`, ...). The UI and CLI know every BIND 9.20
+6
View File
@@ -116,6 +116,12 @@ func Owner(input, zone string) (string, error) {
if in == "" || in == "@" { if in == "" || in == "@" {
return zone, nil return zone, nil
} }
// In reverse zones an address stands for its PTR name.
if IsReverse(zone) {
if addr, err := netip.ParseAddr(strings.TrimSuffix(in, ".")); err == nil {
return ReverseName(addr), nil
}
}
a, err := ToASCII(in) a, err := ToASCII(in)
if err != nil { if err != nil {
return "", err return "", err
+13
View File
@@ -172,3 +172,16 @@ func TestMisc(t *testing.T) {
t.Error("IsSubdomain") t.Error("IsSubdomain")
} }
} }
func TestOwnerAddressInReverseZone(t *testing.T) {
for _, tc := range []struct{ in, zone, want string }{
{"10.10.0.20", "0.10.10.in-addr.arpa.", "20.0.10.10.in-addr.arpa."},
{"20", "0.10.10.in-addr.arpa.", "20.0.10.10.in-addr.arpa."},
{"2001:db8::1", "8.b.d.0.1.0.0.2.ip6.arpa.", "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."},
{"10.10.0.20", "adbraun.lol.", "10.10.0.20.adbraun.lol."}, // not special in forward zones
} {
if got, err := Owner(tc.in, tc.zone); err != nil || got != tc.want {
t.Errorf("Owner(%q, %q) = %q, %v; want %q", tc.in, tc.zone, got, err, tc.want)
}
}
}
+12
View File
@@ -450,3 +450,15 @@ func TestZoneCreateWithOptions(t *testing.T) {
t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o) t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o)
} }
} }
func TestParseWarningsReachTheResult(t *testing.T) {
env := newEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.10.0.0/24")}))
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "0.10.10.in-addr.arpa", Name: model.Ptr("10.10.0.20"), Type: model.Ptr("PTR"), Data: model.Ptr("nas.example.com")}))
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "read as the absolute name") {
t.Errorf("warnings = %q", res.Warnings)
}
if len(res.Ops) != 1 || !strings.Contains(res.Ops[0].Summary, "20.0.10.10.in-addr.arpa. IN PTR nas.example.com.") {
t.Errorf("ops = %+v", res.Ops)
}
}
+12
View File
@@ -528,6 +528,15 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))} return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
} }
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
// The stored operation holds the canonical data, so applying it doesn't
// notice again.
func (x *expander) parseWarnings(owner string, p *rr.Parsed) {
for _, w := range p.Warnings {
x.warnings = append(x.warnings, fmt.Sprintf("%s %s: %s", dnsname.Display(owner), p.Type, w))
}
}
func (x *expander) recordCreate(req Request) ([]model.Op, error) { func (x *expander) recordCreate(req Request) ([]model.Op, error) {
var in RecordInput var in RecordInput
if err := decode(req.Data, &in); err != nil { if err := decode(req.Data, &in); err != nil {
@@ -544,6 +553,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
x.parseWarnings(owner, p)
ptr := in.PTR ptr := in.PTR
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
@@ -589,6 +599,7 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
x.parseWarnings(owner, p)
if in.Name != nil { if in.Name != nil {
in.Name = &owner in.Name = &owner
} }
@@ -737,6 +748,7 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
x.parseWarnings(owner, p)
canon = append(canon, p.Data) canon = append(canon, p.Data)
} }
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
+13
View File
@@ -202,6 +202,16 @@ func Parse(zone, owner, typ, data string) (*Parsed, error) {
if IsTextType(t) && !strings.HasPrefix(d, `"`) { if IsTextType(t) && !strings.HasPrefix(d, `"`) {
d = QuoteText(d) d = QuoteText(d)
} }
// A PTR record names a host, which is never inside the reverse zone
// itself: "nas.adbraun.lol" means the absolute name.
var ptrWarning string
if t == "PTR" && dnsname.IsReverse(zone) && !strings.HasSuffix(d, ".") && !strings.ContainsAny(d, " \t") {
if !strings.Contains(d, ".") {
return nil, fmt.Errorf("PTR data %q would become %s%s; give the host's full name, e.g. %s.example.com", d, d+".", zone, d)
}
ptrWarning = fmt.Sprintf("%q was read as the absolute name %q (add a trailing dot to be explicit)", d, d+".")
d += "."
}
typeText := t typeText := t
if _, known := dns.TypeToString[code]; !known { if _, known := dns.TypeToString[code]; !known {
typeText = fmt.Sprintf("TYPE%d", code) typeText = fmt.Sprintf("TYPE%d", code)
@@ -226,6 +236,9 @@ func Parse(zone, owner, typ, data string) (*Parsed, error) {
return nil, fmt.Errorf("data does not form a %s record", t) return nil, fmt.Errorf("data does not form a %s record", t)
} }
p := &Parsed{RR: parsed, Type: t} p := &Parsed{RR: parsed, Type: t}
if ptrWarning != "" {
p.Warnings = append(p.Warnings, ptrWarning)
}
if err := p.fixNames(zone, data); err != nil { if err := p.fixNames(zone, data); err != nil {
return nil, err return nil, err
} }
+18
View File
@@ -157,3 +157,21 @@ func TestParseErrorText(t *testing.T) {
} }
} }
} }
func TestPTRTargetsInReverseZones(t *testing.T) {
zone := "0.10.10.in-addr.arpa."
p, err := Parse(zone, "20."+zone, "PTR", "nas.adbraun.lol")
if err != nil || p.Data != "nas.adbraun.lol." || len(p.Warnings) != 1 {
t.Errorf("unqualified PTR target: %v %+v", err, p)
}
if p, err := Parse(zone, "20."+zone, "PTR", "nas.adbraun.lol."); err != nil || p.Data != "nas.adbraun.lol." || len(p.Warnings) != 0 {
t.Errorf("qualified PTR target: %v %+v", err, p)
}
if _, err := Parse(zone, "20."+zone, "PTR", "nas"); err == nil {
t.Error("a single-label PTR target inside the reverse zone was accepted")
}
// Forward zones keep zone-file semantics.
if p, err := Parse("adbraun.lol.", "x.adbraun.lol.", "CNAME", "nas"); err != nil || p.Data != "nas.adbraun.lol." {
t.Errorf("forward CNAME: %v %+v", err, p)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Address match list helpers: suggestions for the ACLs, TSIG keys and
// built-in names that can be used where named expects an address match
// list (options such as allow-query, view match-clients, ACL elements),
// and a check for names that don't exist, so nobody has to switch pages
// to look up an ACL's exact name.
import { h, mount } from "./ui.js";
import { get } from "./api.js";
export const builtins = ["any", "none", "localhost", "localnets"];
// amlContext loads the names that can be referred to. Users who may not
// read ACLs or keys simply get fewer suggestions.
export async function amlContext() {
const [acls, keys] = await Promise.all([get("/acls").catch(() => []), get("/tsig-keys").catch(() => [])]);
return { acls: acls.map((a) => ({ name: a.name, detail: a.elements.join("; ") })), keys: keys.map((k) => k.name) };
}
const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i;
// checkElement returns a problem with one element, or "".
function checkElement(line, ctx, exclude) {
let e = line.trim().replace(/;$/, "").trim();
if (!e) return "";
if (e.startsWith("!")) e = e.slice(1).trim();
const lower = e.toLowerCase();
if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return "";
const key = e.match(/^key\s+"?([^"\s]+)"?$/i);
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
if (/\s/.test(e)) return "";
const name = e.replace(/^"|"$/g, "");
if (exclude && name.toLowerCase() === exclude.toLowerCase()) return "an ACL can't include itself";
return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)");
}
// suggestions adds clickable names below a text field. mode "lines" adds
// one element per line (and checks the names); "inline" inserts
// "name; " at the cursor in named.conf syntax.
export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
const problems = h("div", { class: "aml-problems" });
const insert = (text) => {
if (mode === "lines") {
const lines = field.value.split("\n").map((l) => l.trim()).filter(Boolean);
if (!lines.includes(text)) lines.push(text);
field.value = lines.join("\n");
} else {
const at = field.selectionStart ?? field.value.length;
const before = field.value.slice(0, at), after = field.value.slice(at);
const sep = before && !/[\s{]$/.test(before) ? " " : "";
field.value = before + sep + text + "; " + after;
field.selectionStart = field.selectionEnd = (before + sep + text + "; ").length;
}
field.focus();
field.dispatchEvent(new Event("input"));
};
const chip = (label, text, title) => h("button", { type: "button", class: "chip", title: title || "", onclick: () => insert(text) }, label);
const acls = ctx.acls.filter((a) => a.name.toLowerCase() !== exclude.toLowerCase());
const row = h("div", { class: "aml-suggest" },
acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null,
ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null,
h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])),
mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it.") : null);
if (mode === "lines") {
const check = () => {
const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m);
mount(problems, msgs.map(([l, m]) => h("div", { class: "msg warn small" }, l + ": " + m)));
};
field.addEventListener("input", check);
check();
}
return h("div", { class: "aml" }, row, problems);
}
+152
View File
@@ -0,0 +1,152 @@
// DNS name helpers for forms: reverse zones from networks, PTR names from
// addresses, and plain checks of domain names, so the UI can show what a
// name will become before anything is submitted. The server checks
// everything again; these only make mistakes visible early.
const v4Re = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
export function parseIPv4(s) {
const m = s.trim().match(v4Re);
if (!m) return null;
const b = m.slice(1, 5).map(Number);
return b.every((x) => x <= 255) ? b : null;
}
// parseIPv6 returns the 32 hex nibbles of an address, or null.
export function parseIPv6(s) {
s = s.trim().toLowerCase();
if (!s.includes(":") || !/^[0-9a-f:.]+$/.test(s)) return null;
let tail = [];
const v4 = s.match(/:(\d+\.\d+\.\d+\.\d+)$/);
if (v4) {
const b = parseIPv4(v4[1]);
if (!b) return null;
tail = [((b[0] << 8) | b[1]).toString(16), ((b[2] << 8) | b[3]).toString(16)];
s = s.slice(0, -v4[1].length) + "0:0";
}
const halves = s.split("::");
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(":") : [];
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
let groups;
if (halves.length === 2) {
const fill = 8 - head.length - rest.length;
if (fill < 1) return null;
groups = [...head, ...Array(fill).fill("0"), ...rest];
} else groups = head;
if (tail.length) groups.splice(-2, 2, ...tail);
if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
return groups.map((g) => g.padStart(4, "0")).join("").split("");
}
// reverseZoneFor turns a network such as 10.10.0.0/24 into its reverse
// zone. It returns {name} 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" };
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 = 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() };
}
return { error: "not an IPv4 or IPv6 network" };
}
export function isReverseZone(name) {
const n = name.replace(/\.$/, "").toLowerCase();
return n.endsWith(".in-addr.arpa") || n.endsWith(".ip6.arpa");
}
// reverseInfo describes the network a reverse zone covers.
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;
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(".") };
}
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" };
}
return null;
}
// ptrOwner turns an address (or a relative label) into the record name
// inside a reverse zone: {relative, fqdn} or {error}.
export function ptrOwner(input, zoneName) {
const zone = zoneName.replace(/\.$/, "");
const info = reverseInfo(zone);
const s = input.trim();
if (!info || !s) return {};
const b4 = info.family === 4 ? parseIPv4(s) : null;
const n6 = info.family === 6 ? parseIPv6(s) : null;
if (b4 || n6) {
const parts = b4 ? b4.map(String) : n6;
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(".");
return { relative: rel, fqdn: rel + "." + zone };
}
if (s.endsWith(".")) return { relative: s, fqdn: s.replace(/\.$/, "") };
return { relative: s, fqdn: s + "." + zone };
}
// addressOf rebuilds the address a PTR name in a reverse zone stands for.
export function addressOf(fqdn) {
const n = fqdn.replace(/\.$/, "").toLowerCase();
if (n.endsWith(".in-addr.arpa")) {
const l = n.slice(0, -".in-addr.arpa".length).split(".");
return l.length === 4 && l.every((x) => /^\d+$/.test(x)) ? l.reverse().join(".") : "";
}
if (n.endsWith(".ip6.arpa")) {
const l = n.slice(0, -".ip6.arpa".length).split(".");
if (l.length !== 32) return "";
const hex = l.reverse().join("");
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(hex.slice(i, i + 4).replace(/^0+(?=.)/, ""));
return groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::");
}
return "";
}
// checkDomain checks a zone name as typed and returns {name, problems,
// notes}: name is the normalized form (lowercase, no final dot).
export function checkDomain(input) {
const raw = input.trim();
const name = raw.toLowerCase().replace(/\.$/, "");
const problems = [], notes = [];
if (!name) return { name, problems, notes };
if (/\s/.test(name)) problems.push("names can't contain spaces");
if (/^[a-z]+:\/\//.test(name) || name.includes("/")) problems.push("give just the domain name, not a URL or path");
if (name.includes("@")) problems.push("that looks like an email address");
if (parseIPv4(name) || parseIPv6(name)) problems.push("that's an address: for a reverse zone choose \"Reverse zone\" and give the network");
const labels = name.split(".");
if (labels.some((l) => l === "")) problems.push("empty label (two dots in a row)");
if (labels.some((l) => l.length > 63)) problems.push("each part can be at most 63 characters");
if (name.length > 253) problems.push("the name is too long");
if (!problems.length && labels.some((l) => !/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?$/.test(l) && !/^xn--/.test(l))) {
if (/[^\x00-\x7f]/.test(name)) notes.push("international name: it will be stored in its xn-- (punycode) form");
else problems.push("use letters, digits and hyphens (no hyphen at the start or end of a part)");
}
if (raw !== name && raw.replace(/\.$/, "") !== raw.replace(/\.$/, "").toLowerCase()) notes.push("stored in lowercase");
if (!problems.length && labels.length === 1 && name !== "") notes.push("a single label: that's a top-level domain; usually you want your domain, e.g. " + name + "." + "example");
if (!problems.length && /^(www|mail|ns\d*|smtp|vpn)\./.test(name) && labels.length > 2) notes.push("\"" + labels[0] + "\" usually names a host inside a zone; is the zone " + labels.slice(1).join(".") + "?");
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
return { name, problems, notes };
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { h, mount, table, field, input, textarea, toast, errorBox, confirmDialog, modal, lines } from "../ui.js"; import { h, mount, table, field, input, textarea, toast, errorBox, confirmDialog, modal, lines } from "../ui.js";
import { amlContext, suggestions } from "../aml.js";
import { get, change, describeResult } from "../api.js"; import { get, change, describeResult } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
@@ -23,12 +24,13 @@ export async function render(el) {
async function aclDialog(a) { async function aclDialog(a) {
const err = h("div"); const err = h("div");
const ctx = await amlContext();
const name = input({ value: a ? a.name : "", placeholder: "homelab", class: "mono" }); const name = input({ value: a ? a.name : "", placeholder: "homelab", class: "mono" });
const elems = textarea(a ? a.elements.join("\n") : "", { class: "mono", rows: 8, placeholder: "192.168.1.0/24\n10.0.0.0/8\n!10.0.0.66\nkey xfer-key\nlocalhost" }); const elems = textarea(a ? a.elements.join("\n") : "", { class: "mono", rows: 8, placeholder: "192.168.1.0/24\n10.0.0.0/8\n!10.0.0.66\nkey xfer-key\nlocalhost" });
const comment = input({ value: a ? a.comment : "" }); const comment = input({ value: a ? a.comment : "" });
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" }, await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
field("Name", name, a ? "Renaming updates every reference automatically." : ""), field("Name", name, a ? "Renaming updates every reference automatically." : ""),
field("Elements", elems, "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."), field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
field("Comment", comment)), err), [ field("Comment", comment)), err), [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => { { label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
+13 -7
View File
@@ -2,6 +2,7 @@ import { h, mount, table, field, input, select, textarea, toast, errorBox, badge
import { get, change, describeResult } from "../api.js"; import { get, change, describeResult } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { emailOf } from "./zones.js"; import { emailOf } from "./zones.js";
import { amlContext, suggestions } from "../aml.js";
const catalogCache = {}; const catalogCache = {};
async function catalog(scope) { async function catalog(scope) {
@@ -10,7 +11,8 @@ async function catalog(scope) {
} }
// valueWidget returns {el, get()} for editing a value of the given kind. // valueWidget returns {el, get()} for editing a value of the given kind.
function valueWidget(stmt, value) { // ctx holds the ACL and key names to suggest for address match lists.
function valueWidget(stmt, value, ctx) {
const kind = stmt ? stmt.kind : "raw"; const kind = stmt ? stmt.kind : "raw";
if (kind === "bool") { if (kind === "bool") {
const s = select(["yes", "no"], value || "yes"); const s = select(["yes", "no"], value || "yes");
@@ -23,11 +25,15 @@ function valueWidget(stmt, value) {
if (kind === "address_match_list") { if (kind === "address_match_list") {
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, ""); const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" }); const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
return { el: t, get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." }; return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
} }
if (kind === "raw") { if (kind === "raw") {
const t = textarea(value || "", { class: "mono", rows: 3 }); const t = textarea(value || "", { class: "mono", rows: 3 });
return { el: t, get: () => t.value.trim() }; // Statements such as allow-transfer and listen-on take an address
// match list after optional parameters: suggest names at the cursor.
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
if (takesList && !value) t.placeholder = "{ homelab; key xfer-key; }";
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim() };
} }
const i = input({ value: value || "", class: "mono" }); const i = input({ value: value || "", class: "mono" });
return { el: i, get: () => i.value.trim() }; return { el: i, get: () => i.value.trim() };
@@ -35,7 +41,7 @@ function valueWidget(stmt, value) {
// optionsEditor renders the options of one scope. // optionsEditor renders the options of one scope.
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) { export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
const [opts, cat] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope)]); const [opts, cat, ctx] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope), amlContext()]);
const byName = Object.fromEntries(cat.map((s) => [s.name, s])); const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
const err = h("div"); const err = h("div");
const listEl = h("div"); const listEl = h("div");
@@ -51,7 +57,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
], opts, { empty: "No options set; BIND's defaults apply." })); ], opts, { empty: "No options set; BIND's defaults apply." }));
}; };
const editRow = (o) => { const editRow = (o) => {
const w = valueWidget(byName[o.name], o.value); const w = valueWidget(byName[o.name], o.value, ctx);
const e2 = h("div"); const e2 = h("div");
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null, return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
h("div", { class: "actions", style: { marginTop: "6px" } }, h("div", { class: "actions", style: { marginTop: "6px" } },
@@ -79,10 +85,10 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
const dl = h("datalist", { id: listId }, cat.filter((s) => !s.managed).map((s) => h("option", { value: s.name }, s.description || ""))); const dl = h("datalist", { id: listId }, cat.filter((s) => !s.managed).map((s) => h("option", { value: s.name }, s.description || "")));
const info = h("div", { class: "hint" }); const info = h("div", { class: "hint" });
const valueBox = h("div"); const valueBox = h("div");
let widget = valueWidget(null, ""); let widget = valueWidget(null, "", ctx);
const showInfo = () => { const showInfo = () => {
const st = byName[nameIn.value.trim()]; const st = byName[nameIn.value.trim()];
widget = valueWidget(st, ""); widget = valueWidget(st, "", ctx);
mount(valueBox, widget.el, widget.hint ? h("div", { class: "hint" }, widget.hint) : null); mount(valueBox, widget.el, widget.hint ? h("div", { class: "hint" }, widget.hint) : null);
if (!st) { mount(info, nameIn.value ? "Not a known option here (see the BIND 9.20 reference)." : ""); return; } if (!st) { mount(info, nameIn.value ? "Not a known option here (see the BIND 9.20 reference)." : ""); return; }
mount(info, st.description || "", st.status ? [" ", badge(st.status, "warn")] : null, h("pre", { style: { marginTop: "4px", whiteSpace: "pre-wrap" } }, st.grammar)); mount(info, st.description || "", st.status ? [" ", badge(st.status, "warn")] : null, h("pre", { style: { marginTop: "4px", whiteSpace: "pre-wrap" } }, st.grammar));
+4 -2
View File
@@ -1,4 +1,5 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js"; import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js";
import { amlContext, suggestions } from "../aml.js";
import { get, change, describeResult } from "../api.js"; import { get, change, describeResult } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { optionsEditor } from "./options.js"; import { optionsEditor } from "./options.js";
@@ -44,6 +45,7 @@ export async function render(el, params, query) {
async function viewDialog(v, viewless) { async function viewDialog(v, viewless) {
const err = h("div"); const err = h("div");
const ctx = await amlContext();
const name = input({ value: v ? v.name : "", placeholder: "internal" }); const name = input({ value: v ? v.name : "", placeholder: "internal" });
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" }); const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" }); const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
@@ -52,8 +54,8 @@ async function viewDialog(v, viewless) {
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),
field("Match clients", mc, "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."), field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
field("Match destinations", md, "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)),
!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,
+99 -11
View File
@@ -3,6 +3,7 @@ import { api, get, change, describeResult, rrtypes, state, draftQuery } from "..
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";
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
export async function render(el, params, query) { export async function render(el, params, query) {
const id = params.id; const id = params.id;
@@ -53,26 +54,104 @@ async function recordsTab(el, zone, query) {
const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]); const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]);
const typeInfo = Object.fromEntries(types.map((t) => [t.type, t])); const typeInfo = Object.fromEntries(types.map((t) => [t.type, t]));
const err = h("div"); const err = h("div");
// Add form const zoneName = zone.name.replace(/\.$/, "");
const fName = input({ placeholder: "@ or www", style: { width: "180px" }, class: "mono" }); const reverse = isReverseZone(zone.name);
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), "A", { style: { width: "110px" } }); const rinfo = reverse ? reverseInfo(zone.name) : null;
// Add form. In reverse zones records are named by address.
const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false" });
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), reverse ? "PTR" : "A", { style: { width: "110px" } });
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } }); const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
const fData = input({ class: "mono", placeholder: typeInfo.A.example }); const fData = input({ class: "mono", placeholder: typeInfo.A.example });
const fComment = input({ placeholder: "optional", style: { width: "160px" } }); const fComment = input({ placeholder: "optional", style: { width: "160px" } });
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" }); const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
const hint = h("div", { class: "hint" }); const hint = h("div", { class: "hint" });
const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
const suggest = h("div", { class: "aml-suggest" });
const showHint = () => { const showHint = () => {
const t = typeInfo[fType.value] || {}; const t = typeInfo[fType.value] || {};
fData.placeholder = 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", !(fType.value === "A" || fType.value === "AAAA"));
}; };
fType.addEventListener("change", showHint); fType.addEventListener("change", () => { showHint(); preview(); });
showHint(); showHint();
// ownerOf shows what the Name field becomes.
const ownerOf = (v) => {
v = v.trim();
if (reverse) {
const r = ptrOwner(v || "@", zone.name);
if (!v || v === "@") return { fqdn: zoneName };
return r;
}
if (!v || v === "@") return { fqdn: zoneName };
if (v.endsWith(".")) return { fqdn: v.replace(/\.$/, "").toLowerCase() };
const low = v.toLowerCase();
if (low === zoneName || low.endsWith("." + zoneName)) return { fqdn: low, note: "\"" + v + "\" already ends with the zone name, so it is read as " + low + " (end it with a dot to be explicit)" };
return { fqdn: low + "." + zoneName };
};
// Which word of the data is a domain name, per type.
const targetIndex = { CNAME: 0, DNAME: 0, NS: 0, PTR: 0, MX: 1, SRV: 3, KX: 1, AFSDB: 1 };
const dataOf = (type, data) => {
const words = data.trim().split(/\s+/);
const i = targetIndex[type];
if (i === undefined || words.length <= i || !words[i]) return { text: data.trim() };
const w = words[i];
let target, note, error;
if (w === "@") target = zoneName + ".";
else if (w.endsWith(".")) target = w;
else if (reverse && type === "PTR") {
if (w.includes(".")) target = w + ".";
else error = "give the host's full name, e.g. " + w + ".adbraun.lol (" + w + " alone would mean " + w + "." + zoneName + ")";
} else {
target = w + "." + zoneName + ".";
if (w.includes(".")) note = "\"" + w + "\" has no final dot, so it means " + target + "; write " + w + ". if you meant the name outside this zone";
}
if (error) return { error };
words[i] = target;
return { text: words.join(" "), note };
};
let lookupTimer = null, lookupFor = "";
const preview = () => {
const n = fName.value, d = fData.value;
if (!n.trim() && !d.trim()) { mount(willAdd); mount(suggest); return; }
const o = ownerOf(n);
const dd = d.trim() ? dataOf(fType.value, d) : { text: "…" };
const lines = [];
if (o.error) lines.push(h("div", { class: "msg err small" }, o.error));
if (dd.error) lines.push(h("div", { class: "msg err small" }, dd.error));
if (o.note) lines.push(h("div", { class: "msg warn small" }, o.note));
if (dd.note) lines.push(h("div", { class: "msg warn small" }, dd.note));
if (reverse && o.fqdn && fType.value === "PTR") {
const existing = current.filter((r) => r.type === "PTR" && r.name.replace(/\.$/, "") === o.fqdn && r.pending !== "delete");
if (existing.length) lines.push(h("div", { class: "msg warn small" }, "this address already has a PTR record (" + existing.map((r) => r.data).join(", ") + "); a second one gives it two names"));
}
mount(willAdd, o.fqdn && !o.error ? h("div", null, h("span", { class: "muted" }, "Will add: "),
h("code", null, o.fqdn + ". " + (fTTL.value !== "" ? fTTL.value + " " : "") + fType.value + " " + (dd.text || "")),
reverse && addressOf(o.fqdn) ? h("span", { class: "muted" }, " (address " + addressOf(o.fqdn) + ")") : null) : null, lines);
// In reverse zones, offer the names that already point to the address.
const addr = reverse && o.fqdn ? addressOf(o.fqdn) : "";
if (addr !== lookupFor) {
lookupFor = addr;
mount(suggest);
clearTimeout(lookupTimer);
if (addr && fType.value === "PTR") lookupTimer = setTimeout(async () => {
const hits = await get("/search", { q: addr }).catch(() => []);
if (lookupFor !== addr) return;
const names = [...new Set(hits.filter((x) => x.kind === "record").map((x) => x.label.split(" ")).filter((l) => (l[1] === "A" || l[1] === "AAAA") && l[2] === addr).map((l) => l[0]))];
mount(suggest, names.length ? [h("span", { class: "muted small" }, "Hosts with this address:"),
names.map((nm) => h("button", { type: "button", class: "chip", onclick: () => { fData.value = nm.replace(/\.$/, "") + "."; preview(); fData.focus(); } }, nm))]
: h("span", { class: "muted small" }, "No A/AAAA record points to " + addr + " yet. Tip: adding the A record with \"PTR\" ticked creates this PTR for you."));
}, 250);
}
};
for (const f of [fName, fData, fTTL]) f.addEventListener("input", preview);
const add = async (e) => { const add = async (e) => {
e.preventDefault(); e.preventDefault();
mount(err); mount(err);
const body = { name: fName.value.trim() || "@", type: fType.value, data: fData.value.trim() }; const o = ownerOf(fName.value);
const dd = dataOf(fType.value, fData.value);
if (o.error || dd.error) { mount(err, h("div", { class: "msg err" }, o.error || dd.error)); (o.error ? fName : fData).focus(); return; }
const body = { name: reverse && o.relative ? o.relative : fName.value.trim() || "@", type: fType.value, data: fData.value.trim() };
if (fTTL.value !== "") body.ttl = Number(fTTL.value); if (fTTL.value !== "") body.ttl = Number(fTTL.value);
if (fComment.value.trim()) body.comment = fComment.value.trim(); if (fComment.value.trim()) body.comment = fComment.value.trim();
if (fPTR.input.checked) body.ptr = true; if (fPTR.input.checked) body.ptr = true;
@@ -80,16 +159,22 @@ async function recordsTab(el, zone, query) {
const r = await change("POST", "/zones/" + zone.id + "/records", body); const r = await change("POST", "/zones/" + zone.id + "/records", body);
toast(describeResult(r)); toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 8000)); (r.warnings || []).forEach((w) => toast(w, "warn", 8000));
// Start the next record from scratch (the type stays, as records
// are often added several of a kind at a time).
fName.value = "";
fData.value = ""; fData.value = "";
fTTL.value = "";
fComment.value = ""; fComment.value = "";
lookupFor = "";
preview();
await redraw(); await redraw();
fData.focus(); fName.focus();
} catch (ex) { } catch (ex) {
mount(err, errorBox(ex)); mount(err, errorBox(ex));
} }
}; };
const form = h("form", { class: "inline-form", onsubmit: add }, const form = h("form", { class: "inline-form", onsubmit: add },
h("div", { class: "f" }, h("label", null, "Name"), fName), h("div", { class: "f" }, h("label", null, reverse ? "Address" : "Name"), fName),
h("div", { class: "f" }, h("label", null, "Type"), fType), h("div", { class: "f" }, h("label", null, "Type"), fType),
h("div", { class: "f" }, h("label", null, "TTL"), fTTL), h("div", { class: "f" }, h("label", null, "TTL"), fTTL),
h("div", { class: "f grow" }, h("label", null, "Data"), fData), h("div", { class: "f grow" }, h("label", null, "Data"), fData),
@@ -112,7 +197,7 @@ async function recordsTab(el, zone, query) {
const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value)); const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value));
let prevName = null; let prevName = null;
mount(list, table([ mount(list, table([
{ label: "Name", render: (r) => r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } }, { label: "Name", render: (r) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" }, { label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
{ label: "Type", render: (r) => h("strong", null, r.type) }, { label: "Type", render: (r) => h("strong", null, r.type) },
{ label: "Data", render: (r) => r.data, tdClass: "data" }, { label: "Data", render: (r) => r.data, tdClass: "data" },
@@ -186,10 +271,13 @@ async function recordsTab(el, zone, query) {
filter.addEventListener("input", draw); filter.addEventListener("input", draw);
typeFilter.addEventListener("change", draw); typeFilter.addEventListener("change", draw);
mount(el, mount(el,
h("div", { class: "panel" }, form, hint, err), h("div", { class: "panel" },
reverse && rinfo ? h("div", { class: "muted small", style: { marginBottom: "6px" } }, "Reverse zone for " + rinfo.network + ": name records by address (e.g. " + rinfo.example + ") and give the host name as data.") : null,
form, willAdd, suggest, hint, err),
h("div", { class: "panel" }, h("div", { class: "panel" },
h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter, h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter,
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute.")), h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). " +
(reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))),
list)); list));
draw(); draw();
fName.focus(); fName.focus();
+89 -7
View File
@@ -1,6 +1,7 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js"; 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, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js";
export async function render(el, params, query) { export async function render(el, params, query) {
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]); const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]);
@@ -30,12 +31,24 @@ export async function render(el, params, query) {
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)), can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
h("div", { class: "panel" }, list)); h("div", { class: "panel" }, list));
draw(); draw();
if (query.get("new")) newZoneDialog(views, zones, settings); if (query.get("new")) {
// Drop ?new=1 so re-rendering after the zone is created doesn't open
// the dialog again.
history.replaceState(null, "", "#/zones");
newZoneDialog(views, zones, settings);
}
} }
export async function newZoneDialog(views, zones, settings) { export async function newZoneDialog(views, zones, settings) {
const err = h("div"); const err = h("div");
const name = input({ placeholder: "example.com or 192.168.1.0/24" }); // 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.
let kind = "forward";
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
const nameLabel = h("label", { for: "zone-name" }, "Domain");
name.id = "zone-name";
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
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])], views.length === 1 ? views[0].name : ""); const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], views.length === 1 ? views[0].name : "");
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." }); const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
@@ -50,11 +63,69 @@ export async function newZoneDialog(views, zones, settings) {
const cloneSel = select([["", "– don't copy –"]], ""); const cloneSel = select([["", "– don't copy –"]], "");
const comment = input(); const comment = input();
const updateClones = () => { const updateClones = () => {
const n = name.value.trim().toLowerCase().replace(/\.$/, "") + "."; const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
mount(cloneSel, h("option", { value: "" }, "– don't copy –"), 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") + "]"))); zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
}; };
name.addEventListener("input", updateClones); // check validates the name as typed and returns the zone name (or null).
const check = () => {
const v = name.value.trim();
const msgs = [];
let zoneName = null;
if (type.value === "hint" && (v === "." || v === "")) {
mount(preview, h("div", null, "Zone name: ", h("strong", { class: "mono" }, "."), " (the root, for root hints)"));
return ".";
}
if (!v) { mount(preview, h("div", { class: "muted" }, kind === "reverse" ? "The zone name is generated from the network." : "Records in the zone are named relative to it: www becomes www.<zone>.")); return null; }
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,
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
} else {
const c = checkDomain(v);
c.problems.forEach((m) => msgs.push(["err", m]));
c.notes.forEach((m) => msgs.push(["warn", m]));
if (!c.problems.length) zoneName = c.name;
}
if (zoneName && kind === "forward") {
const fq = zoneName + ".";
const inView = (z) => (z.view || "") === (view.value || "");
const same = zones.find((z) => z.name === fq && inView(z));
if (same) { msgs.unshift(["err", "zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "") + ": open it from the zone list"]); zoneName = null; }
else {
const parent = zones.filter((z) => inView(z) && z.name !== "." && fq.endsWith("." + z.name)).sort((a, b) => b.name.length - a.name.length)[0];
if (parent) msgs.push(["info", "a child of zone " + parent.display_name + ": add NS records for " + fq.replace(/\.$/, "") + " there so other servers can find it"]);
const kids = zones.filter((z) => inView(z) && z.name.endsWith("." + fq));
if (kids.length) msgs.push(["info", "contains the existing zone" + (kids.length > 1 ? "s " : " ") + kids.map((z) => z.display_name).join(", ")]);
}
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · a record named www becomes www." + zoneName)) : null,
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 : "")));
return null;
}
}
return zoneName;
};
const setKind = (k) => {
kind = k;
mount(err);
nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
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);
check();
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"]]) {
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(); });
view.addEventListener("change", check);
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)."), field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
@@ -70,12 +141,17 @@ export async function newZoneDialog(views, zones, settings) {
"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 drawType = () => mount(typeRows, rows[type.value] || []); const drawType = () => {
mount(typeRows, rows[type.value] || []);
if (type.value === "hint" && !name.value.trim()) name.value = ".";
check();
};
type.addEventListener("change", drawType); type.addEventListener("change", drawType);
drawType(); drawType();
const body = h("div", null, const body = h("div", null,
h("div", { class: "form-grid" }, h("div", { class: "form-grid" },
field("Name", name, "A domain name, or a network such as 192.168.1.0/24 to create its reverse zone."), h("label", null, "Kind"), h("div", null, kindButtons),
nameLabel, h("div", null, name, preview),
views.length ? field("View", view) : null, views.length ? field("View", view) : null,
field("Type", type), field("Type", type),
field("Comment", comment)), field("Comment", comment)),
@@ -85,7 +161,13 @@ export async function newZoneDialog(views, zones, settings) {
{ 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;
const data = { name: name.value.trim(), type: t, comment: comment.value || undefined }; const zoneName = check();
if (!zoneName) {
mount(err, h("div", { class: "msg err" }, name.value.trim() ? "Fix the " + (kind === "reverse" ? "network" : "zone name") + " first." : "Give the " + (kind === "reverse" ? "network" : "domain") + " first."));
name.focus();
return false;
}
const data = { name: zoneName, type: t, comment: comment.value || undefined };
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(" ");
+19
View File
@@ -89,6 +89,7 @@ header.top {
} }
header.top .brand { font-weight: 700; font-size: 17px; letter-spacing: 0.5px; color: var(--text); } header.top .brand { font-weight: 700; font-size: 17px; letter-spacing: 0.5px; color: var(--text); }
header.top .brand span { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 6px; } header.top .brand span { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 6px; }
header.top .brand:hover { text-decoration: none; color: var(--accent); }
nav.main { display: flex; gap: 2px; flex-wrap: wrap; } nav.main { display: flex; gap: 2px; flex-wrap: wrap; }
nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); } nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); }
nav.main a:hover { background: var(--info-soft); text-decoration: none; } nav.main a:hover { background: var(--info-soft); text-decoration: none; }
@@ -343,3 +344,21 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.hm-cell { height: 16px; } .hm-cell { height: 16px; }
.chart-grid { grid-template-columns: 1fr; } .chart-grid { grid-template-columns: 1fr; }
} }
/* Address match list suggestions. */
.aml-suggest { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin-top: 6px; }
.aml-suggest .muted { margin-left: 4px; }
.aml-suggest .muted:first-child { margin-left: 0; }
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
.aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; }
/* Zone name and record previews. */
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
.segmented button:last-child { border-right: none; }
.segmented button:hover { background: var(--info-soft); }
.segmented button.active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
.name-preview { margin-top: 6px; font-size: 13px; }
.name-preview .msg, .will-add .msg { margin: 4px 0 0; padding: 3px 8px; }
.will-add { margin-top: 8px; font-size: 13px; }
.will-add code { font-family: var(--mono); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; word-break: break-all; }
+4 -2
View File
@@ -131,8 +131,10 @@ export async function confirmDialog(title, message, okLabel = "OK", kind = "prim
// field returns a label + control pair for .form-grid layouts. // field returns a label + control pair for .form-grid layouts.
export function field(label, control, help) { export function field(label, control, help) {
const id = control.id || ("f" + Math.random().toString(36).slice(2, 9)); // The label points at the control itself, also when it is wrapped.
control.id = id; const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control;
const id = target.id || ("f" + Math.random().toString(36).slice(2, 9));
target.id = id;
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null]; return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
} }