Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.
A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.
Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
7.9 KiB
JavaScript
136 lines
7.9 KiB
JavaScript
import { h, mount, table, input, toast, errorBox, confirmDialog, diffView, problemsList, checkList, time, plural, badge } from "../ui.js";
|
|
import { api, get, state, useDraft, refreshDraft } from "../api.js";
|
|
import { rerender } from "../app.js";
|
|
|
|
export async function render(el) {
|
|
await refreshDraft();
|
|
const open = await get("/changesets", { status: "open", mine: 1, limit: 50 });
|
|
const d = state.draft;
|
|
const parts = [h("div", { class: "page-head" },
|
|
h("div", { class: "grow" }, h("h1", null, "Pending changes"),
|
|
h("div", { class: "muted" }, "Edits are collected in a changeset. Nothing reaches named until you apply it; then the whole changeset is checked by Jarl and named-checkconf, written, and loaded together.")))];
|
|
if (!d || !(d.ops || []).length) {
|
|
parts.push(h("div", { class: "panel" }, h("div", { class: "empty" }, "No pending changes. Edit zones, records or options and they will show up here.")));
|
|
} else {
|
|
parts.push(await draftPanel(d));
|
|
}
|
|
const others = open.filter((c) => !d || c.id !== d.id);
|
|
if (others.length) {
|
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Other open changesets"),
|
|
h("p", { class: "muted small" }, "Created through the API or CLI, or earlier in the web UI. Switch to one to review and apply it here."),
|
|
table([
|
|
{ label: "#", render: (c) => String(c.id) },
|
|
{ label: "Description", render: (c) => c.description || h("span", { class: "muted" }, "(none)") },
|
|
{ label: "Operations", render: (c) => String((c.ops || []).length) },
|
|
{ label: "Via", render: (c) => c.via },
|
|
{ label: "Created", render: (c) => time(c.created_at) },
|
|
{ label: "", render: (c) => h("span", null,
|
|
h("button", { class: "small", onclick: () => { useDraft(c); rerender(); } }, "Switch to"), " ",
|
|
h("button", { class: "small danger", onclick: async () => {
|
|
if (!(await confirmDialog("Discard", "Discard changeset #" + c.id + "?", "Discard", "danger"))) return;
|
|
await api("POST", "/changesets/" + c.id + "/discard");
|
|
rerender();
|
|
} }, "Discard")), tdClass: "actions-cell" },
|
|
], others)));
|
|
}
|
|
mount(el, parts);
|
|
}
|
|
|
|
async function draftPanel(d) {
|
|
const desc = input({ value: d.description || "", placeholder: "Describe this change (shown in the history)", style: { width: "100%" } });
|
|
const preview = h("div", { class: "muted" }, "Checking…");
|
|
const result = h("div");
|
|
let valid = false;
|
|
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
|
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
|
|
// Changes that open a public view up must be confirmed (docs/exposure.md).
|
|
const riskBox = h("label", { class: "check risk-confirm hidden" }, h("input", { type: "checkbox" }), "");
|
|
let risky = false;
|
|
riskBox.querySelector("input").addEventListener("change", () => { applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked); });
|
|
const ops = d.ops || [];
|
|
const removeOp = async (seq) => {
|
|
try {
|
|
const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + seq);
|
|
useDraft(cs);
|
|
rerender();
|
|
} catch (e) { toast(e.message, "err", 8000); }
|
|
};
|
|
const opsTable = table([
|
|
{ label: "#", render: (o) => String(o.seq) },
|
|
{ label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" },
|
|
{ label: "Added", render: (o) => time(o.added_at) },
|
|
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: () => removeOp(o.seq) }, "Remove"), tdClass: "actions-cell" },
|
|
], ops);
|
|
// failedOp marks the operation that no longer applies and says how to
|
|
// get past it.
|
|
const failedOp = (err) => {
|
|
const i = ops.findIndex((o) => o.seq === err.op);
|
|
const rows = opsTable.querySelectorAll("tbody tr");
|
|
if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err });
|
|
rows[i].classList.add("op-failed");
|
|
return h("div", { class: "msg err" },
|
|
h("div", null, h("strong", null, "Operation " + err.op + " can't be applied: "), err.message),
|
|
h("div", { class: "mono small" }, ops[i].summary || ops[i].op),
|
|
h("div", { style: { marginTop: "8px" } },
|
|
h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op),
|
|
" ", h("span", { class: "muted small" }, "It is marked in the list above. Then check the rest again.")));
|
|
};
|
|
const runPreview = async () => {
|
|
try {
|
|
const p = await get("/changesets/" + d.id + "/preview");
|
|
valid = p.valid && !p.error;
|
|
risky = (p.risks || []).length > 0;
|
|
riskBox.classList.toggle("hidden", !risky);
|
|
if (risky) riskBox.lastChild.textContent = " I understand: " + p.risks.map((x) => x.message).join("; ");
|
|
applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked);
|
|
const drift = (p.drift || []);
|
|
forceBox.classList.toggle("hidden", drift.length === 0);
|
|
mount(preview,
|
|
p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null,
|
|
valid ? h("div", { class: "msg ok" }, "Valid. named-checkconf accepts the new configuration.", (p.serials || []).length ? " Serials: " + p.serials.map((s) => s.zone.replace(/\.$/, "") + " " + s.old + "→" + s.new).join(", ") : "") : null,
|
|
!p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null,
|
|
problemsList(p.problems),
|
|
checkList(p.check),
|
|
drift.length ? h("div", { class: "msg warn" }, "Files changed outside Jarl: ", drift.map((x) => x.path + " (" + x.reason + ")").join(", ")) : null,
|
|
(p.warnings || []).length ? h("div", { class: "msg warn" }, h("ul", null, p.warnings.map((w) => h("li", null, w)))) : null,
|
|
(p.existing_problems || []).filter((x) => x.severity !== "info").length ? h("details", null, h("summary", { class: "muted" }, "Existing problems (not caused by this changeset)"), problemsList(p.existing_problems.filter((x) => x.severity !== "info"))) : null,
|
|
h("h3", null, "Changes to BIND's files" + ((p.files_changed || []).length ? " (" + plural(p.files_changed.length, "file") + ")" : "")),
|
|
diffView(p.diff));
|
|
} catch (e) { mount(preview, errorBox(e)); }
|
|
};
|
|
applyBtn.addEventListener("click", async () => {
|
|
applyBtn.disabled = true;
|
|
mount(result, h("div", { class: "muted" }, "Applying…"));
|
|
try {
|
|
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
|
|
const force = forceBox.querySelector("input").checked;
|
|
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force, accept_risk: riskBox.querySelector("input").checked } });
|
|
const res = r.result || {};
|
|
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
|
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
|
useDraft(null);
|
|
location.hash = "#/history/" + d.id;
|
|
} catch (e) {
|
|
mount(result, errorBox(e));
|
|
applyBtn.disabled = false;
|
|
}
|
|
});
|
|
const discard = h("button", { class: "danger", onclick: async () => {
|
|
if (!(await confirmDialog("Discard changes", "Throw away all " + ops.length + " pending changes?", "Discard", "danger"))) return;
|
|
await api("POST", "/changesets/" + d.id + "/discard");
|
|
useDraft(null);
|
|
rerender();
|
|
} }, "Discard all");
|
|
const panel = h("div", null,
|
|
h("div", { class: "panel" },
|
|
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
|
desc,
|
|
h("div", { style: { marginTop: "10px" } }, opsTable),
|
|
riskBox,
|
|
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
|
|
result),
|
|
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
|
runPreview();
|
|
return panel;
|
|
}
|