The page's Content Security Policy (style-src 'self') blocked the inline style attribute, so long statements didn't wrap and the browser console reported the violation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
12 KiB
JavaScript
218 lines
12 KiB
JavaScript
import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js";
|
|
import { get, change, describeResult, draftQuery } 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) {
|
|
if (!catalogCache[scope]) catalogCache[scope] = await get("/catalog/options", { scope });
|
|
return catalogCache[scope];
|
|
}
|
|
|
|
// valueWidget returns {el, get()} for editing a value of the given kind.
|
|
// 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");
|
|
return { el: s, get: () => s.value };
|
|
}
|
|
if (kind === "enum") {
|
|
const s = select(stmt.choices, value || stmt.choices[0]);
|
|
return { el: s, get: () => s.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: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address, prefix or range (10.0.10.101-10.0.10.199), !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
|
|
}
|
|
if (kind === "raw") {
|
|
const t = codeArea(value || "");
|
|
// Statements such as allow-transfer and listen-on take an address
|
|
// match list after optional parameters: suggest names at the cursor.
|
|
// Names are suggested wherever the grammar has an address match list
|
|
// (rate-limit's exempt-clients too); the braces are only optional for
|
|
// statements that end in the list (catalog WrapList).
|
|
const hasAML = stmt && /address_match_element/.test(stmt.grammar || "");
|
|
const takesList = stmt && /\{ <address_match_element>; \.\.\. \};$/.test((stmt.grammar || "").split("\n")[0]);
|
|
if (takesList && !value) t.placeholder = "homelab; key xfer-key";
|
|
return { el: h("div", null, t, hasAML && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
|
|
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
|
|
}
|
|
const i = input({ value: value || "", class: "mono" });
|
|
return { el: i, get: () => i.value.trim() };
|
|
}
|
|
|
|
// codeArea is a textarea for named.conf text: as tall as its content
|
|
// (up to 30 lines), Tab indents, Enter keeps the line's indentation. After
|
|
// Esc, Tab moves on to the next field again.
|
|
function codeArea(value) {
|
|
const t = textarea(value, { class: "mono code", spellcheck: "false" });
|
|
const fit = () => { t.rows = Math.min(30, Math.max(3, t.value.split("\n").length + 1)); };
|
|
fit();
|
|
let tabMoves = false;
|
|
const insert = (text) => {
|
|
const { selectionStart: a, selectionEnd: b } = t;
|
|
t.setRangeText(text, a, b, "end");
|
|
fit();
|
|
};
|
|
t.addEventListener("input", fit);
|
|
t.addEventListener("focus", () => { tabMoves = false; });
|
|
t.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Escape") { tabMoves = true; return; }
|
|
if (ev.key === "Tab" && !tabMoves && !ev.shiftKey) {
|
|
ev.preventDefault();
|
|
insert("\t");
|
|
} else if (ev.key === "Enter" && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
|
|
const line = t.value.slice(0, t.selectionStart).split("\n").pop();
|
|
let indent = line.match(/^\t*/)[0];
|
|
if (line.trimEnd().endsWith("{")) indent += "\t";
|
|
ev.preventDefault();
|
|
insert("\n" + indent);
|
|
}
|
|
});
|
|
return t;
|
|
}
|
|
|
|
// optionsEditor renders the options of one scope.
|
|
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
|
|
// With a draft open, show the options as they will be: a staged edit
|
|
// stays visible after Save.
|
|
const [opts, cat, ctx] = await Promise.all([get("/options", draftQuery({ scope, scope_id })), catalog(catalogScope), amlContext()]);
|
|
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
|
|
const err = h("div");
|
|
const listEl = h("div");
|
|
let editing = null;
|
|
const draw = () => {
|
|
mount(listEl, table([
|
|
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
|
|
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code optval" }, o.formatted || o.value || "(no value)") },
|
|
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
|
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
|
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
|
h("button", { class: "small danger", onclick: () => del(o) }, "Delete")), tdClass: "actions-cell" },
|
|
], opts, { empty: "No options set; BIND's defaults apply." }));
|
|
};
|
|
const editRow = (o) => {
|
|
const w = valueWidget(byName[o.name], o.formatted || 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" } },
|
|
h("button", { class: "primary small", onclick: async () => {
|
|
try {
|
|
const r = await change("PATCH", "/options/" + o.id, { value: w.get() });
|
|
toast(describeResult(r));
|
|
editing = null;
|
|
rerender();
|
|
} catch (ex) { mount(e2, errorBox(ex)); }
|
|
} }, "Save"),
|
|
h("button", { class: "small", onclick: () => { editing = null; draw(); } }, "Cancel")), e2);
|
|
};
|
|
const del = async (o) => {
|
|
if (!(await confirmDialog("Remove option", "Remove " + o.name + " (" + o.where + ")? BIND's default applies afterwards.", "Remove", "danger"))) return;
|
|
try {
|
|
const r = await change("DELETE", "/options/" + o.id);
|
|
toast(describeResult(r));
|
|
rerender();
|
|
} catch (ex) { toast(ex.message, "err", 8000); }
|
|
};
|
|
// Add form
|
|
const listId = "opt-" + Math.random().toString(36).slice(2);
|
|
const nameIn = input({ list: listId, placeholder: "Start typing an option name…", class: "mono", style: { width: "280px" } });
|
|
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, "", ctx);
|
|
const showInfo = () => {
|
|
const st = byName[nameIn.value.trim()];
|
|
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));
|
|
};
|
|
nameIn.addEventListener("input", showInfo);
|
|
showInfo();
|
|
const add = async () => {
|
|
mount(err);
|
|
try {
|
|
const r = await change("POST", "/options", { scope, scope_id: scope_id || undefined, name: nameIn.value.trim(), value: widget.get() });
|
|
toast(describeResult(r));
|
|
rerender();
|
|
} catch (ex) { mount(err, errorBox(ex)); }
|
|
};
|
|
draw();
|
|
mount(el, h("div", { class: "panel" },
|
|
h("h2", null, title),
|
|
intro ? h("p", { class: "muted small" }, intro) : null,
|
|
listEl,
|
|
h("h3", null, "Add an option"),
|
|
h("div", { class: "form-grid" },
|
|
h("label", null, "Name"), h("div", null, nameIn, dl, info),
|
|
h("label", null, "Value"), valueBox),
|
|
err,
|
|
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: add }, "Add option"))));
|
|
}
|
|
|
|
export async function render(el) {
|
|
const settings = await get("/settings");
|
|
const settingsEl = h("div");
|
|
const serverEl = h("div");
|
|
const topEl = h("div");
|
|
const genEl = h("div");
|
|
mount(el,
|
|
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"),
|
|
h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))),
|
|
settingsEl, serverEl, topEl, genEl);
|
|
settingsForm(settingsEl, settings);
|
|
await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." });
|
|
await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements",
|
|
intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." });
|
|
generatedPanel(genEl, await get("/options/generated").catch(() => []));
|
|
}
|
|
|
|
// generatedPanel lists what Jarl adds to named.conf by itself: read-only,
|
|
// with why and what turns it on or off.
|
|
function generatedPanel(el, list) {
|
|
if (!list.length) return;
|
|
mount(el, h("div", { class: "panel" },
|
|
h("h2", null, "Added by Jarl"),
|
|
h("p", { class: "muted" }, "Jarl writes these into named.conf itself, for its own features. They aren't options you can edit here: each is controlled by the setting shown."),
|
|
h("div", { class: "table-wrap" }, h("table", null,
|
|
h("thead", null, h("tr", null, h("th", null, "Where"), h("th", null, "Statement"), h("th", null, "Why"), h("th", null, "Controlled by"))),
|
|
h("tbody", null, list.map((g) => h("tr", null,
|
|
h("td", { class: "small" }, g.where),
|
|
h("td", null, h("pre", { class: "code optval wrap" }, g.text)),
|
|
h("td", { class: "small" }, g.why),
|
|
h("td", { class: "small mono" }, g.setting))))))));
|
|
}
|
|
|
|
function settingsForm(el, s) {
|
|
const ns = textarea((s.nameservers || []).join("\n"), { class: "mono", rows: 2 });
|
|
const rname = input({ value: s.rname ? emailOf(s.rname) : "", placeholder: "hostmaster@example.com" });
|
|
const ttl = input({ type: "number", value: s.default_ttl });
|
|
const refresh = input({ type: "number", value: s.refresh });
|
|
const retry = input({ type: "number", value: s.retry });
|
|
const expire = input({ type: "number", value: s.expire });
|
|
const neg = input({ type: "number", value: s.negative_ttl });
|
|
const policy = select(["date", "increment", "unixtime"], s.serial_policy);
|
|
const err = h("div");
|
|
mount(el, h("div", { class: "panel" },
|
|
h("h2", null, "Defaults for new zones"),
|
|
h("div", { class: "form-grid" },
|
|
field("Name servers", ns, "One per line; used as NS records for new primary zones."),
|
|
field("SOA contact", rname, "Email address (or mailbox name) for new zones."),
|
|
field("Default TTL", ttl),
|
|
field("Refresh", refresh), field("Retry", retry), field("Expire", expire),
|
|
field("Negative TTL", neg, "SOA minimum."),
|
|
field("Serial policy", policy, "date: YYYYMMDDnn, increment: +1, unixtime: seconds since 1970.")),
|
|
err,
|
|
h("div", { class: "actions", style: { marginTop: "10px" } }, h("button", { class: "primary", onclick: async () => {
|
|
mount(err);
|
|
try {
|
|
const r = await change("PATCH", "/settings", { nameservers: lines(ns.value), rname: rname.value.trim(), default_ttl: +ttl.value, refresh: +refresh.value, retry: +retry.value, expire: +expire.value, negative_ttl: +neg.value, serial_policy: policy.value });
|
|
toast(describeResult(r));
|
|
} catch (e) { mount(err, errorBox(e)); }
|
|
} }, "Save defaults"))));
|
|
}
|