diff --git a/CHANGELOG.md b/CHANGELOG.md index 91104e9..6317050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ The first version, to be released as 0.1.0. ### 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). When enabled, Jarl adds a loopback statistics channel and a response log to named's configuration (merged into existing `logging` and @@ -101,12 +106,38 @@ The first version, to be released as 0.1.0. ### 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 outside Jarl unless `--force` is given, as documented. Before, they always overwrote them. ### 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 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 diff --git a/README.md b/README.md index afda6d8..cc271ce 100644 --- a/README.md +++ b/README.md @@ -103,11 +103,16 @@ these pages: * **Statistics** (when enabled): queries over time, failed answers, a weekday x hour heatmap, top names and clients, and breakdowns. Every chart has a table view. -* **Zones**: list, create (fields adapt to the zone type), per-zone records - with inline editing and type-specific hints, settings (SOA, TTL, serial +* **Zones**: list, create (choose a domain or a network, and the dialog shows + 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 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 a value field that fits it. * **Changes**: pending edits with the diff, named-checkconf output and diff --git a/docs/cli.md b/docs/cli.md index 3b6534b..a8a3ba7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 192.168.1.10 # just this one 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 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 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 diff --git a/docs/concepts.md b/docs/concepts.md index f801464..07adced 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -147,7 +147,9 @@ RFC 1982 arithmetic. 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.` 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 { }`, a view, a zone, or the top level (`logging`, `controls`, `statistics-channels`, `include`, ...). The UI and CLI know every BIND 9.20 diff --git a/internal/dnsname/dnsname.go b/internal/dnsname/dnsname.go index ce60331..a73f4cf 100644 --- a/internal/dnsname/dnsname.go +++ b/internal/dnsname/dnsname.go @@ -116,6 +116,12 @@ func Owner(input, zone string) (string, error) { if in == "" || in == "@" { 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) if err != nil { return "", err diff --git a/internal/dnsname/dnsname_test.go b/internal/dnsname/dnsname_test.go index 71d5af6..6d7dae6 100644 --- a/internal/dnsname/dnsname_test.go +++ b/internal/dnsname/dnsname_test.go @@ -172,3 +172,16 @@ func TestMisc(t *testing.T) { 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) + } + } +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index a25dd40..cc7f1c3 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -450,3 +450,15 @@ func TestZoneCreateWithOptions(t *testing.T) { 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) + } +} diff --git a/internal/engine/expand.go b/internal/engine/expand.go index 382ec3d..037d4c7 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -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))} } +// 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) { var in RecordInput if err := decode(req.Data, &in); err != nil { @@ -544,6 +553,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) { if err != nil { return nil, err } + x.parseWarnings(owner, p) ptr := in.PTR in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false 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 { return nil, err } + x.parseWarnings(owner, p) if in.Name != nil { in.Name = &owner } @@ -737,6 +748,7 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) { if err != nil { return nil, err } + x.parseWarnings(owner, p) canon = append(canon, p.Data) } in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon diff --git a/internal/rr/rr.go b/internal/rr/rr.go index 5720b06..5f53093 100644 --- a/internal/rr/rr.go +++ b/internal/rr/rr.go @@ -202,6 +202,16 @@ func Parse(zone, owner, typ, data string) (*Parsed, error) { if IsTextType(t) && !strings.HasPrefix(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 if _, known := dns.TypeToString[code]; !known { 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) } p := &Parsed{RR: parsed, Type: t} + if ptrWarning != "" { + p.Warnings = append(p.Warnings, ptrWarning) + } if err := p.fixNames(zone, data); err != nil { return nil, err } diff --git a/internal/rr/rr_test.go b/internal/rr/rr_test.go index a4a0078..aa0a0ac 100644 --- a/internal/rr/rr_test.go +++ b/internal/rr/rr_test.go @@ -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) + } +} diff --git a/internal/web/static/assets/aml.js b/internal/web/static/assets/aml.js new file mode 100644 index 0000000..8b651fd --- /dev/null +++ b/internal/web/static/assets/aml.js @@ -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); +} diff --git a/internal/web/static/assets/names.js b/internal/web/static/assets/names.js new file mode 100644 index 0000000..12a7c13 --- /dev/null +++ b/internal/web/static/assets/names.js @@ -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 }; +} diff --git a/internal/web/static/assets/pages/acls.js b/internal/web/static/assets/pages/acls.js index 2b46ca9..b688b83 100644 --- a/internal/web/static/assets/pages/acls.js +++ b/internal/web/static/assets/pages/acls.js @@ -1,4 +1,5 @@ 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 { rerender } from "../app.js"; @@ -23,12 +24,13 @@ export async function render(el) { async function aclDialog(a) { const err = h("div"); + const ctx = await amlContext(); 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 comment = input({ value: a ? a.comment : "" }); 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("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), [ { label: "Cancel", value: null }, { label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => { diff --git a/internal/web/static/assets/pages/options.js b/internal/web/static/assets/pages/options.js index 589fcb1..7fb47d7 100644 --- a/internal/web/static/assets/pages/options.js +++ b/internal/web/static/assets/pages/options.js @@ -2,6 +2,7 @@ import { h, mount, table, field, input, select, textarea, toast, errorBox, badge import { get, change, describeResult } from "../api.js"; import { rerender } from "../app.js"; import { emailOf } from "./zones.js"; +import { amlContext, suggestions } from "../aml.js"; const catalogCache = {}; async function catalog(scope) { @@ -10,7 +11,8 @@ async function catalog(scope) { } // 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"; if (kind === "bool") { const s = select(["yes", "no"], value || "yes"); @@ -23,11 +25,15 @@ function valueWidget(stmt, value) { if (kind === "address_match_list") { 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" }); - 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") { 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" }); return { el: i, get: () => i.value.trim() }; @@ -35,7 +41,7 @@ function valueWidget(stmt, value) { // optionsEditor renders the options of one scope. 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 err = 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." })); }; const editRow = (o) => { - const w = valueWidget(byName[o.name], o.value); + const w = valueWidget(byName[o.name], o.value, ctx); const e2 = h("div"); return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null, 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 info = h("div", { class: "hint" }); const valueBox = h("div"); - let widget = valueWidget(null, ""); + let widget = valueWidget(null, "", ctx); const showInfo = () => { 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); 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)); diff --git a/internal/web/static/assets/pages/views.js b/internal/web/static/assets/pages/views.js index c55d734..ea784ae 100644 --- a/internal/web/static/assets/pages/views.js +++ b/internal/web/static/assets/pages/views.js @@ -1,4 +1,5 @@ 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 { rerender } from "../app.js"; import { optionsEditor } from "./options.js"; @@ -44,6 +45,7 @@ export async function render(el, params, query) { async function viewDialog(v, viewless) { const err = h("div"); + const ctx = await amlContext(); 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 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 body = h("div", null, h("div", { class: "form-grid" }, 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 destinations", md, "Server addresses this view answers on (optional)."), + 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", 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("Comment", comment)), !v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null, diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index b1e5d35..7c3188e 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -3,6 +3,7 @@ import { api, get, change, describeResult, rrtypes, state, draftQuery } from ".. import { rerender } from "../app.js"; import { optionsEditor } from "./options.js"; import { emailOf, pendingBadge } from "./zones.js"; +import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js"; export async function render(el, params, query) { 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 typeInfo = Object.fromEntries(types.map((t) => [t.type, t])); const err = h("div"); - // Add form - const fName = input({ placeholder: "@ or www", style: { width: "180px" }, class: "mono" }); - const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), "A", { style: { width: "110px" } }); + const zoneName = zone.name.replace(/\.$/, ""); + const reverse = isReverseZone(zone.name); + 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 fData = input({ class: "mono", placeholder: typeInfo.A.example }); 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 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 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); fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA")); }; - fType.addEventListener("change", showHint); + fType.addEventListener("change", () => { showHint(); preview(); }); 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) => { e.preventDefault(); 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 (fComment.value.trim()) body.comment = fComment.value.trim(); 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); toast(describeResult(r)); (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 = ""; + fTTL.value = ""; fComment.value = ""; + lookupFor = ""; + preview(); await redraw(); - fData.focus(); + fName.focus(); } catch (ex) { mount(err, errorBox(ex)); } }; 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, "TTL"), fTTL), 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)); let prevName = null; 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: "Type", render: (r) => h("strong", null, r.type) }, { label: "Data", render: (r) => r.data, tdClass: "data" }, @@ -186,10 +271,13 @@ async function recordsTab(el, zone, query) { filter.addEventListener("input", draw); typeFilter.addEventListener("change", draw); 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: "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)); draw(); fName.focus(); diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index b929bc3..cac2b5f 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -1,6 +1,7 @@ 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 { rerender } from "../app.js"; +import { checkDomain, reverseZoneFor } from "../names.js"; export async function render(el, params, query) { 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)), h("div", { class: "panel" }, list)); 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) { 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 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." }); @@ -50,11 +63,69 @@ export async function newZoneDialog(views, zones, settings) { const cloneSel = select([["", "– don't copy –"]], ""); const comment = input(); 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 –"), 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..")); 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 = { 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)."), @@ -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.")], }; 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); drawType(); const body = h("div", null, 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, field("Type", type), field("Comment", comment)), @@ -85,7 +161,13 @@ export async function newZoneDialog(views, zones, settings) { { label: "Cancel", value: null }, { label: "Create zone", kind: "primary", onclick: async () => { 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; const opts = []; const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" "); diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 6fd502e..c442d39 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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 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 a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); } 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; } .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; } diff --git a/internal/web/static/assets/ui.js b/internal/web/static/assets/ui.js index b186964..7cb2d5d 100644 --- a/internal/web/static/assets/ui.js +++ b/internal/web/static/assets/ui.js @@ -131,8 +131,10 @@ export async function confirmDialog(title, message, okLabel = "OK", kind = "prim // field returns a label + control pair for .form-grid layouts. export function field(label, control, help) { - const id = control.id || ("f" + Math.random().toString(36).slice(2, 9)); - control.id = id; + // The label points at the control itself, also when it is wrapped. + 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]; }