Files
housecarl/internal/web/static/assets/pages/settings.js
T
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
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>
2026-10-19 23:16:46 +01:00

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"));
}