- Undo is staged like any edit: in review mode the web UI's Undo, and hc history undo with -C or HOUSECARL_CHANGESET, add a revert operation that checks, when replayed, that nothing it touches changed since; applied, History links the entries. Imports and takeovers still can't be staged. - The rules page sums up a chain's built-ins in one line; a window lists them with what they do and their hits, and switches them. - Warnings about the machine (dashboard, hc status): nftables.service enabled (it loads /etc/nftables.conf at boot and flushes every table when stopped), and the imported table still in force beside Housecarl's. The docs say to disable nftables.service but not stop it. - A raw rule with other fields set is refused (raw_conflict) instead of silently ignoring them; the rule dialog's nftables section hides what it replaces, and the importer's raw rules carry only their text. - A dynamic set with configured entries is refused (set_dynamic_entries): they were silently ignored. The set dialog shows entries or the entry timeout, as fits. - The rule dialog's chain choices are just the chains' names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
5.6 KiB
JavaScript
89 lines
5.6 KiB
JavaScript
// Address sets, and dynamic sets' entries.
|
|
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
|
|
import { get, api, change, can } from "../api.js";
|
|
import { rerender } from "../app.js";
|
|
import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js";
|
|
|
|
export async function render(el) {
|
|
const sets = await get("/sets");
|
|
const write = can("config", true);
|
|
mount(el,
|
|
h("div", { class: "page-head" },
|
|
h("div", { class: "grow" }, h("h1", null, "Address sets"), h("div", { class: "muted" }, "Named lists of IPv4 and IPv6 addresses, prefixes and ranges, mixed. A dynamic set takes entries at run time, each with a timeout (a captive portal's signed-in clients).")),
|
|
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => setDialog(null) }, "Add set")) : null),
|
|
h("div", { class: "panel" }, table([
|
|
{ label: "Name", render: (s) => h("strong", null, s.name) },
|
|
{ label: "Entries", render: (s) => s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ",
|
|
s.pending ? null : h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
|
|
{ label: "Comment", render: (s) => s.comment },
|
|
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : [
|
|
h("button", { class: "small reveal", onclick: () => setDialog(s) }, "Edit"), " ",
|
|
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
|
|
].filter(Boolean), sets, { empty: "No sets yet.", rowClass })));
|
|
}
|
|
|
|
async function del(s) {
|
|
if (!await confirmDialog("Delete set", "Delete set " + s.name + "?", "Delete", "danger")) return;
|
|
try { await change("DELETE", "/sets/" + s.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
|
|
}
|
|
|
|
async function setDialog(s) {
|
|
s = s || { entries: [] };
|
|
const name = input({ value: s.name || "", placeholder: "trusted" });
|
|
const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" });
|
|
const dynamic = checkbox("dynamic: entries are added at run time (the Entries button, hc set entries, a program with a token), each for a time", s.dynamic);
|
|
// A dynamic set has no configured entries; only it has a timeout.
|
|
const entryFields = field("Entries", list).filter(Boolean);
|
|
const timeoutFields = field("Entry timeout", timeout, "How long an entry stays unless it is added with its own. Empty: until removed (or a reboot).").filter(Boolean);
|
|
const showKind = () => {
|
|
for (const n of entryFields) n.classList.toggle("hidden", dynamic.input.checked);
|
|
for (const n of timeoutFields) n.classList.toggle("hidden", !dynamic.input.checked);
|
|
};
|
|
dynamic.input.addEventListener("change", showKind);
|
|
showKind();
|
|
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
|
|
const comment = input({ value: s.comment || "" });
|
|
const err = h("div");
|
|
await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" },
|
|
field("Name", name), ...entryFields, h("label"), dynamic, ...timeoutFields, field("Comment", comment)), err), [
|
|
{ label: "Cancel", value: null },
|
|
{ label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
|
|
mount(err);
|
|
const t = parseSeconds(timeout.value);
|
|
if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; }
|
|
try {
|
|
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: dynamic.input.checked ? [] : list.value(), dynamic: dynamic.input.checked,
|
|
timeout: dynamic.input.checked ? t : 0, comment: comment.value.trim() });
|
|
rerender();
|
|
return true;
|
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
|
} },
|
|
], { wide: true, sticky: true });
|
|
}
|
|
|
|
// entries shows and changes a dynamic set's entries.
|
|
async function entries(s) {
|
|
const body = h("div");
|
|
const write = can("sets", true);
|
|
const draw = async () => {
|
|
const es = await get("/sets/" + encodeURIComponent(s.name) + "/elements");
|
|
const addr = input({ placeholder: "192.168.1.50 or 2001:db8:1::50", class: "mono" });
|
|
const t = input({ placeholder: s.timeout ? fmtSeconds(s.timeout) : "none", class: "short" });
|
|
mount(body,
|
|
table([
|
|
{ label: "Address", render: (e) => h("span", { class: "mono" }, e.address) },
|
|
{ label: "Expires in", render: (e) => e.timeout ? fmtSeconds(e.timeout) : "never" },
|
|
write ? { label: "", render: (e) => h("button", { class: "small", onclick: async () => {
|
|
try { await api("DELETE", "/sets/" + encodeURIComponent(s.name) + "/elements/" + encodeURIComponent(e.address)); draw(); } catch (x) { toast(x.message, "err"); }
|
|
} }, "Remove") } : null,
|
|
].filter(Boolean), es, { empty: "No entries." }),
|
|
write ? h("div", { class: "filter-row" }, addr, t, h("button", { class: "primary small", onclick: async () => {
|
|
const secs = parseSeconds(t.value);
|
|
if (secs === null) { toast("Write the timeout as 1h, 30m or 1d", "err"); return; }
|
|
try { await api("POST", "/sets/" + encodeURIComponent(s.name) + "/elements", { body: { address: addr.value.trim(), timeout: secs } }); draw(); } catch (x) { toast(x.message, "err"); }
|
|
} }, "Add")) : null);
|
|
};
|
|
await draw();
|
|
await modal("Entries of " + s.name, body, [{ label: "Close", value: null }], { wide: true });
|
|
}
|