import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js"; 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) { 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 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 }); // 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() }; } // optionsEditor renders the options of one scope. export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) { 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"); 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", { style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, 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.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")))); }