Sets from Jarl: sets holding the addresses of names in Jarl
A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
50dce02746
commit
68ba2d06c5
37 files changed
+2315
-59
No files matched your search
@@ -1,5 +1,5 @@
|
||||
// Address sets, and dynamic sets' entries.
|
||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
|
||||
// Address sets, dynamic sets' entries, and what Jarl sets hold.
|
||||
import { h, mount, table, modal, errorBox, field, input, select, confirmDialog, badge, toast, fmtTime } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js";
|
||||
@@ -9,11 +9,14 @@ export async function render(el) {
|
||||
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).")),
|
||||
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). A Jarl set holds the addresses of names in Jarl, kept up to date.")),
|
||||
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) : "", " ",
|
||||
{ label: "Entries", render: (s) => s.jarl ? [badge("Jarl", "accent"), " ", h("span", { class: "mono small" }, jarlNames(s).join(", ")),
|
||||
h("span", { class: "muted small" }, " within " + s.jarl.within.join(", ")), " ",
|
||||
s.pending ? null : h("button", { class: "small", onclick: () => jarlStatus(s) }, "Addresses…")]
|
||||
: 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") : [
|
||||
@@ -27,33 +30,52 @@ async function del(s) {
|
||||
try { await change("DELETE", "/sets/" + s.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
|
||||
}
|
||||
|
||||
const nameOf = (n) => n.view ? n.name + "@" + n.view : n.name;
|
||||
const jarlNames = (s) => (s.jarl.names || []).map(nameOf);
|
||||
|
||||
// parseJarlName reads "name" or "name@view".
|
||||
function parseJarlName(v) {
|
||||
const i = v.indexOf("@");
|
||||
return i < 0 ? { name: v } : { name: v.slice(0, i), view: v.slice(i + 1) };
|
||||
}
|
||||
|
||||
async function setDialog(s) {
|
||||
s = s || { entries: [] };
|
||||
const zones = (await get("/zones")).map((z) => z.name);
|
||||
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 kind = select([["list", "addresses listed here"], ["dynamic", "dynamic: entries added at run time, each for a time"], ["jarl", "from Jarl: the addresses of names in Jarl"]],
|
||||
s.jarl ? "jarl" : s.dynamic ? "dynamic" : "list");
|
||||
const names = listEditor(s.jarl ? jarlNames(s) : [], { placeholder: "nas.example.com@internal", addLabel: "Add name" });
|
||||
const within = listEditor(s.jarl ? s.jarl.within : [], { placeholder: "lan, or 10.10.0.0/24", addLabel: "Add zone or network", suggestions: zones });
|
||||
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
|
||||
// Each kind shows its own fields.
|
||||
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 jarlFields = [
|
||||
...field("Names in Jarl", names, "The set holds their A and AAAA records' addresses (all of them), CNAMEs followed. Write NAME@VIEW, or just NAME if only one of Jarl's views has it. Read again every minute.").filter(Boolean),
|
||||
...field("Within", within, "Where its addresses may be: zones (the networks connected to their interfaces) or networks. An address elsewhere is left out, so a changed record in Jarl can't let anything else in.").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);
|
||||
for (const n of entryFields) n.classList.toggle("hidden", kind.value !== "list");
|
||||
for (const n of timeoutFields) n.classList.toggle("hidden", kind.value !== "dynamic");
|
||||
for (const n of jarlFields) n.classList.toggle("hidden", kind.value !== "jarl");
|
||||
};
|
||||
dynamic.input.addEventListener("change", showKind);
|
||||
kind.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), [
|
||||
field("Name", name), ...field("Kind", kind), ...entryFields, ...timeoutFields, ...jarlFields, 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; }
|
||||
const k = kind.value;
|
||||
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() });
|
||||
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: k === "list" ? list.value() : [], dynamic: k === "dynamic",
|
||||
timeout: k === "dynamic" ? t : 0, jarl: k === "jarl" ? { names: names.value().map(parseJarlName), within: within.value() } : null, comment: comment.value.trim() });
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
@@ -61,6 +83,44 @@ async function setDialog(s) {
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
|
||||
// jarlStatus shows what a Jarl set holds: each name's addresses in Jarl,
|
||||
// used or left out (and why).
|
||||
async function jarlStatus(s) {
|
||||
const body = h("div");
|
||||
const path = "/sets/" + encodeURIComponent(s.name) + "/jarl";
|
||||
const draw = (js) => {
|
||||
const rows = [];
|
||||
for (const n of js.names) {
|
||||
// A new node for each row: a node can only be in one place.
|
||||
const label = () => [h("span", { class: "mono" }, n.name), n.chain && n.chain.length ? h("span", { class: "muted small" }, " → " + n.chain.join(" → ")) : null];
|
||||
const view = n.view || n.found_view || "";
|
||||
if (n.problem) rows.push({ label, view, address: "", used: h("span", null, badge("no addresses", "err"), " " + n.problem) });
|
||||
else if (!n.addresses.length) rows.push({ label, view, address: "", used: n.error ? "not read: " + n.error : "not read yet" });
|
||||
for (const a of n.addresses) rows.push({ label, view, address: a.address, used: a.used ? badge("used", "ok") : h("span", null, badge("left out", "warn"), " " + a.why) });
|
||||
if (n.error && n.addresses.length) rows.push({ label, view, address: "", used: h("span", { class: "muted" }, "The last read failed (" + n.error + "): these are from " + (n.checked ? fmtTime(n.checked) : "before") + ".") });
|
||||
}
|
||||
mount(body,
|
||||
!js.configured ? h("div", { class: "msg warn" }, "Jarl isn't configured (jarl.server in housecarl.yaml): the set keeps the addresses it last had.") : null,
|
||||
js.error ? h("div", { class: "msg warn" }, "Reading Jarl failed: " + js.error + ". The set keeps the addresses it had.") : null,
|
||||
h("p", null, "Holds ", h("span", { class: "mono" }, js.entries.length ? js.entries.join(", ") : "nothing"), " · within " + js.within.join(", "),
|
||||
js.read ? h("span", { class: "muted" }, " · Jarl read " + fmtTime(js.read)) : null),
|
||||
table([
|
||||
{ label: "Name", render: (r) => r.label() },
|
||||
{ label: "View", render: (r) => r.view },
|
||||
{ label: "Address", render: (r) => h("span", { class: "mono" }, r.address) },
|
||||
{ label: "", render: (r) => r.used },
|
||||
], rows, { empty: "No names." }));
|
||||
};
|
||||
draw(await get(path));
|
||||
await modal("Addresses of " + s.name, body, [
|
||||
{ label: "Read Jarl now", onclick: async () => {
|
||||
try { draw(await api("POST", path + "/refresh")); } catch (x) { toast(x.message, "err"); }
|
||||
return false;
|
||||
} },
|
||||
{ label: "Close", value: null },
|
||||
], { wide: true });
|
||||
}
|
||||
|
||||
// entries shows and changes a dynamic set's entries.
|
||||
async function entries(s) {
|
||||
const body = h("div");
|
||||
|
||||
Reference in new issue
Block a user