// Import: an existing nftables ruleset as Housecarl's configuration. import { h, mount, field, textarea, input, checkbox, errorBox, copyBox } from "../ui.js"; import { api } from "../api.js"; import { refreshProbation, autoConfirm } from "../app.js"; export async function render(el) { const json = textarea("", { rows: 6, class: "mono", placeholder: "the output of: sudo nft -j list ruleset" }); const text = textarea("", { rows: 4, class: "mono", placeholder: "optional, the output of: sudo nft -a list ruleset (keeps what Housecarl can't express as raw rules)" }); const defines = textarea("", { rows: 4, class: "mono", placeholder: "optional, your /etc/nftables.conf: its defines name the sets" }); const table = input({ placeholder: "inet main (when there are several)", class: "mono" }); const zones = input({ placeholder: "enp2s0=lan, wg0=vpn (optional)", class: "mono" }); const replace = checkbox("replace Housecarl's configuration", false); const out = h("div"); const run = async (dry) => { mount(out); const zoneNames = {}; for (const part of zones.value.split(",").map((s) => s.trim()).filter(Boolean)) { const [iface, name] = part.split("=").map((s) => (s || "").trim()); if (!iface || !name) { mount(out, h("div", { class: "msg err" }, "Zone names: write each as interface=name (enp2s0=lan).")); return; } zoneNames[iface] = name; } let parsed; try { parsed = JSON.parse(json.value); } catch { mount(out, h("div", { class: "msg err" }, "The ruleset isn't JSON: paste the output of nft -j list ruleset.")); return; } try { const r = await api("POST", "/import/nft", { body: { json: parsed, text: text.value, defines: defines.value, table: table.value.trim(), zones: zoneNames, replace: replace.input.checked }, query: dry ? { dry_run: 1 } : undefined }); const st = r.state; mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") }, dry ? "Would import: " : "Imported: ", `${st.zones.length} zones, ${st.sets.length} sets, ${st.services.length} services, ${st.rules.length} rules, ${st.forwards.length} port forwards, ${st.snats.length} source NAT rules, ${st.redirects.length} redirects.`, r.confirm_by && !r.confirm_token ? " It is on probation: confirm it in the banner." : ""), r.notes.length ? h("div", { class: "panel" }, h("h2", null, "Notes"), h("ul", null, r.notes.map((n) => h("li", null, n)))) : null, r.raw.length ? h("div", { class: "panel" }, h("h2", null, "Kept as raw rules"), h("ul", { class: "mono small" }, r.raw.map((n) => h("li", null, n)))) : null, !dry ? h("p", null, "Check the ", h("a", { href: "#/rules" }, "rules"), " and ", h("a", { href: "#/nat" }, "NAT"), "; both rulesets apply until the old table goes: ", copyBox("sudo hc takeover \"inet main\"")) : null); if (!dry && r.confirm_token) autoConfirm(r.confirm_token); else if (!dry) refreshProbation(); } catch (e) { mount(out, errorBox(e)); } }; mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Import a ruleset"), h("div", { class: "muted" }, "Turns an nftables ruleset into zones, sets, services, rules, port forwards and NAT. On the firewall, 'sudo hc import --defines /etc/nftables.conf -n' does the same from the command line."))), h("div", { class: "panel" }, h("div", { class: "form-grid" }, field("Ruleset (JSON)", json), field("Ruleset (text)", text), field("Ruleset file", defines), field("Table", table), field("Zone names", zones, "Name zones by what the network is for; interfaces given one name share a zone. Otherwise a define holding the interface names it, or the interface's own name."), h("label"), replace), h("div", { class: "actions form-actions" }, h("button", { onclick: () => run(true) }, "Preview"), h("button", { class: "primary", onclick: () => run(false) }, "Import"))), out); }