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. 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(), 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", style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, 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"); 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); 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." }); } 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")))); }