The boot unit fails closed: if ruleset.nft doesn't load, it loads ruleset-fallback.nft (render.Fallback), saved beside it from the same configuration: nothing new in from external zones, established, the ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and masqueraded, and the unit marked failed with a critical message. housecarld replaces it when it applies; status, the dashboard and /healthz say while it is in force. no_external_zone now also says the fallback closes everything without an external zone. The boot ruleset has no flowtable: nftables on Debian 13's kernel refuses one naming a device that doesn't exist yet (ppp0 before PPPoE), and with it the whole file. internal/linkwatch listens to rtnetlink link messages; housecarld applies again when a flowtable device appears or comes back as a new interface (by ifindex), under the engine's lock. docs/flowtable.md: what it does, when to use it and when not, how. GET /healthz reports firewall (applied, unconfigured, fallback, missing), changes on probation and the machine's warnings, with 503 unless in force, read at most every five seconds. Renaming a zone, set or service changes what names it in the same change (one history entry, one undo); dynamic sets keep their names. The importer keeps a chain's head in order: once a would-be built-in stays a rule, the ones after it do too, so the maintainer's ICMPv6 accepts stay behind the invalid drop as written. hc settings set --flowtable none empties the list. make test (nft 1.1.3) and make e2e (242 checks, with VLAN sub-interfaces, the fallback and the flowtable) pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
4.7 KiB
JavaScript
68 lines
4.7 KiB
JavaScript
// Settings: policies, built-ins, antispoofing, flowtable, probation.
|
|
import { h, mount, field, select, checkbox, input, errorBox, readOnly } from "../ui.js";
|
|
import { get, change, can } from "../api.js";
|
|
import { rerender } from "../app.js";
|
|
import { listEditor, fmtSeconds, parseSeconds } from "../widgets.js";
|
|
|
|
const builtinText = [
|
|
["loopback", "Accept everything on lo"],
|
|
["established", "Accept established and related connections (input and forward)"],
|
|
["drop_invalid", "Drop invalid packets (input and forward)"],
|
|
["icmpv6", "Accept the ICMPv6 IPv6 needs: errors, echo, neighbour discovery, MLD (RFC 4890)"],
|
|
["ping_internal", "Accept ping to the firewall from internal zones"],
|
|
["dhcpv6_client", "Accept DHCPv6 replies on external zones (your ISP's prefix delegation)"],
|
|
["antispoof", "Drop impossible sources arriving on external zones (below)"],
|
|
["mss_clamp", "Clamp TCP MSS to the route MTU on external zones (needed with PPPoE)"],
|
|
];
|
|
|
|
export async function render(el) {
|
|
const [s, ifs] = await Promise.all([get("/settings"), get("/interfaces")]);
|
|
const write = can("config", true);
|
|
const pol = (v) => select([["drop", "drop"], ["accept", "accept"]], v);
|
|
const input_ = pol(s.input_policy), forward = pol(s.forward_policy), output = pol(s.output_policy);
|
|
const logDrops = checkbox("log what the input and forward policies drop (rate-limited; the Log page)", s.log_policy_drops);
|
|
const bis = builtinText.map(([k, text]) => [k, checkbox(text, s.builtins[k])]);
|
|
const own = listEditor(s.own_prefixes, { placeholder: "2001:db8::/48", addLabel: "Add prefix" });
|
|
const v4 = listEditor(s.antispoof_v4, { addLabel: "Add" });
|
|
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
|
|
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
|
|
const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" });
|
|
const perDevice = checkbox("count traffic per inside device (IPv6 per /64), for the Statistics page's top devices: a counter for each address talking to an external zone", s.stats_per_device);
|
|
const manual = checkbox("a person confirms each change (otherwise the browser or hc that made it confirms it by itself, once it reaches Housecarl again over a new connection)", s.manual_confirm);
|
|
const err = h("div");
|
|
const save = async (dry) => {
|
|
mount(err);
|
|
const p = parseSeconds(probation.value);
|
|
if (p === null) { mount(err, h("div", { class: "msg err" }, "Write the probation as 2m or 90s.")); return; }
|
|
const builtins = {};
|
|
for (const [k, c] of bis) builtins[k] = c.input.checked;
|
|
try {
|
|
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
|
|
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
|
|
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p, manual_confirm: manual.input.checked, stats_per_device: perDevice.input.checked }, { query: dry ? { dry_run: 1 } : undefined });
|
|
if (!dry && (r.applied || r.staged)) rerender();
|
|
} catch (e) { mount(err, errorBox(e)); }
|
|
};
|
|
mount(el,
|
|
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and built-ins"),
|
|
h("div", { class: "muted" }, "What happens to packets no rule decided, and the rules Housecarl adds before yours."))),
|
|
h("div", { class: "panel" }, h("div", { class: "form-grid" },
|
|
field("Input policy", input_, "Traffic to the firewall itself."),
|
|
field("Forward policy", forward, "Traffic through it."),
|
|
field("Output policy", output, "Traffic from it."),
|
|
h("label"), logDrops,
|
|
field("Built-ins", h("div", { class: "checks" }, bis.map(([, c]) => c))),
|
|
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
|
|
field("Antispoof IPv4", v4),
|
|
field("Antispoof IPv6", v6),
|
|
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."),
|
|
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
|
|
h("label"), manual,
|
|
field("Statistics", perDevice)),
|
|
err,
|
|
write ? h("div", { class: "actions form-actions" },
|
|
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
|
h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null));
|
|
if (!write) readOnly(el.querySelector(".panel"));
|
|
}
|