- 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>
57 lines
3.5 KiB
JavaScript
57 lines
3.5 KiB
JavaScript
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";
|
|
|
|
export async function render(el) {
|
|
const acls = await get("/acls");
|
|
mount(el,
|
|
h("div", { class: "page-head" },
|
|
h("div", { class: "grow" }, h("h1", null, "ACLs"),
|
|
h("div", { class: "muted" }, "Named address match lists, used in options such as allow-query, allow-recursion, allow-transfer and in views' match-clients.")),
|
|
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => aclDialog(null) }, "New ACL"))),
|
|
h("div", { class: "msg info small" }, "Built-in ACLs: any, none, localhost (this server's addresses), localnets (networks this server is directly attached to). Elements are checked in order and the first match wins, so put exceptions (!addr) before broader prefixes."),
|
|
h("div", { class: "panel" }, table([
|
|
{ label: "Name", render: (a) => h("strong", { class: "mono" }, a.name) },
|
|
{ label: "Elements", render: (a) => a.elements.join("; ") || h("span", { class: "muted" }, "(empty: matches nothing)"), tdClass: "data" },
|
|
{ label: "Used by", render: (a) => a.used_by.length ? a.used_by.join(", ") : h("span", { class: "muted" }, "unused"), tdClass: "small" },
|
|
{ label: "Comment", render: (a) => h("span", { class: "muted" }, a.comment || "") },
|
|
{ label: "", render: (a) => h("span", null,
|
|
h("button", { class: "small", onclick: () => aclDialog(a) }, "Edit"), " ",
|
|
h("button", { class: "small danger", disabled: a.used_by.length > 0, title: a.used_by.length ? "Still in use" : "", onclick: () => del(a) }, "Delete")), tdClass: "actions-cell" },
|
|
], acls, { empty: "No ACLs yet." })));
|
|
}
|
|
|
|
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", 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 () => {
|
|
const data = { name: name.value.trim(), elements: lines(elems.value), comment: comment.value };
|
|
try {
|
|
const r = a ? await change("PATCH", "/acls/" + a.id, data) : await change("POST", "/acls", data);
|
|
toast(describeResult(r));
|
|
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
|
rerender();
|
|
return true;
|
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
|
} },
|
|
], { sticky: true });
|
|
}
|
|
|
|
async function del(a) {
|
|
if (!(await confirmDialog("Delete ACL", "Delete ACL " + a.name + "?", "Delete", "danger"))) return;
|
|
try {
|
|
const r = await change("DELETE", "/acls/" + a.id);
|
|
toast(describeResult(r));
|
|
rerender();
|
|
} catch (e) { toast(e.message, "err", 8000); }
|
|
}
|