Files
housecarl/internal/web/static/assets/pages/settings.js
T
abrown38andClaude Opus 5.5 e4efdf9c78 Confirm changes automatically from the client that made them
A change made over the network answers with a one-time confirm_token
and closes its connection (HTTP/2: a GOAWAY). The web UI and hc send the
token straight back to /confirm, which accepts it only for the newest
change, from the same user and API token, the same address, and over a
new connection: proof that the client can still reach the API through
the new ruleset (an established connection would get through anyway). A
change that cuts its client off can't be confirmed so, and rolls back by
itself. The setting manual_confirm (hc settings set --auto-confirm off)
and hc --no-confirm leave confirming to a person. Changes through the
local socket still need no confirmation. e2e: 136 checks, among them a
lockout change rolled back without anyone confirming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:23:28 +01:00

66 lines
4.3 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 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 }, { 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). Exact names; 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),
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"));
}