// 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"; 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). 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.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") : [ 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)); } } 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 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", 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"); }; kind.addEventListener("change", showKind); showKind(); 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), ...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: 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; } } }, ], { 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"); 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 }); }