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:
1 parent
3b7bee67f8
commit
bf7f99874b
19 files changed
+577
-35
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.<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 = {
|
||||
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(" ");
|
||||
|
||||
Reference in new issue
Block a user