Fixes from an audit of the importer, each of which could change what an imported ruleset does: two base chains on one hook imported their rules twice (and hoisted the second's built-ins), now in priority order with the stricter policy; raw rules from a jumped-to chain lost the jump's condition, now put in front; a jump and a rule matching one field are combined only when one's addresses lie within the other's; a second match on a field replaced the first, and != on hop limit and TCP flags read as ==, now kept raw; a forward accept without ct status dnat no longer folds into the port forward, nor an interface-limited IPv6 accept into an open pinhole; raw rules' set references are renamed (NAME_v4/_v6) or written out, so nftables takes them; DNATs with matches a forward can't hold are left out with a note. Sets with flags timeout become dynamic sets; a DNAT of several ports one forward per port. Zone names: --zone IFACE=NAME (and on the Import page), VLAN defines, more suffixes, and a note on where each name came from. testdata/audit.nft covers the cases, checked with nft 1.1.3. make test and make e2e (246 checks) pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
51 lines
3.9 KiB
JavaScript
51 lines
3.9 KiB
JavaScript
// 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);
|
|
}
|