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>
66 lines
4.3 KiB
JavaScript
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"));
|
|
}
|