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>
This commit is contained in:
1 parent
fc390eb57c
commit
e4efdf9c78
19 files changed
+384
-50
No files matched your search
@@ -22,11 +22,13 @@ export class APIError extends Error {
|
||||
let onUnauthorized = () => {};
|
||||
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||
|
||||
// onChange is told about every applied change (the probation banner).
|
||||
// onChange is told about every applied change (the probation banner), with
|
||||
// the answer (its confirm_token, to confirm it by itself).
|
||||
let onChange = () => {};
|
||||
export function setChangeHandler(fn) { onChange = fn; }
|
||||
|
||||
// api calls the REST API. opts: {body, query, stage (a changeset ID)}.
|
||||
// api calls the REST API. opts: {body, query, stage (a changeset ID),
|
||||
// timeout (ms)}.
|
||||
export async function api(method, path, opts = {}) {
|
||||
const headers = { "Accept": "application/json" };
|
||||
if (opts.stage) headers["X-Housecarl-Changeset"] = String(opts.stage);
|
||||
@@ -43,7 +45,13 @@ export async function api(method, path, opts = {}) {
|
||||
const s = q.toString();
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
|
||||
let signal;
|
||||
if (opts.timeout) {
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), opts.timeout);
|
||||
signal = ac.signal;
|
||||
}
|
||||
const resp = await fetch(url, { method, headers, body, credentials: "same-origin", signal });
|
||||
if (resp.status === 204) return null;
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
||||
@@ -173,11 +181,12 @@ export function report(r) {
|
||||
const n = ((r.changeset || {}).ops || []).length;
|
||||
toast("Staged: " + ((r.changeset.ops || [])[n - 1] || {}).summary + " — " + (n === 1 ? "1 pending change" : n + " pending changes") + " to review and apply");
|
||||
} else if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
|
||||
else if (r.applied && r.confirm_by) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
|
||||
else if (r.applied && r.confirm_by && !r.confirm_token) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
|
||||
else if (r.applied && r.confirm_by) toast("Applied (history #" + r.history_id + ")");
|
||||
else if (r.applied) toast("Applied and saved (history #" + r.history_id + ")");
|
||||
else if (!(r.changes || []).length) toast("Nothing to change");
|
||||
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
|
||||
if (r.applied) onChange();
|
||||
if (r.applied) onChange(r);
|
||||
}
|
||||
|
||||
// can reports whether the user may read (or write) a resource.
|
||||
|
||||
@@ -158,10 +158,11 @@ export async function applyDraft(btn, d, description) {
|
||||
try {
|
||||
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { description: description || "" } });
|
||||
useDraft(null);
|
||||
if (r.confirm_by) toast("Applied changeset #" + d.id + " on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 8000);
|
||||
if (r.confirm_by && !r.confirm_token) toast("Applied changeset #" + d.id + " on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 8000);
|
||||
else toast("Applied changeset #" + d.id + " (history #" + r.history_id + ")");
|
||||
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
|
||||
await refreshProbation();
|
||||
if (r.confirm_token) await autoConfirm(r.confirm_token);
|
||||
else await refreshProbation();
|
||||
return r;
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
@@ -176,7 +177,25 @@ export async function applyDraft(btn, d, description) {
|
||||
|
||||
// ---- probation: changes made here roll back unless confirmed
|
||||
|
||||
let probationTimer = null, deadline = 0, pending = 0;
|
||||
let probationTimer = null, deadline = 0, pending = 0, checking = false;
|
||||
|
||||
// autoConfirm confirms a change with its token. The server closed the
|
||||
// change's connection, so this request opens a new one through the new
|
||||
// ruleset: it only gets through if this browser can still reach
|
||||
// Housecarl. If it can't, the change rolls back by itself.
|
||||
export async function autoConfirm(token) {
|
||||
checking = true;
|
||||
await refreshProbation();
|
||||
try {
|
||||
await api("POST", "/confirm", { body: { token }, timeout: 10000 });
|
||||
checking = false;
|
||||
toast("Confirmed automatically: this browser reached Housecarl again over a new connection");
|
||||
} catch (e) {
|
||||
checking = false;
|
||||
toast("Couldn't confirm the change (" + (e.name === "AbortError" ? "no answer over a new connection" : e.message) + "): unless it is confirmed, it rolls back by itself", "warn", 10000);
|
||||
}
|
||||
await refreshProbation();
|
||||
}
|
||||
|
||||
// refreshProbation asks whether changes wait for confirmation, and shows
|
||||
// the banner with a countdown while they do.
|
||||
@@ -204,6 +223,10 @@ function drawBanner() {
|
||||
}
|
||||
bannerEl.classList.remove("hidden");
|
||||
const write = can("config", true);
|
||||
if (checking) {
|
||||
mount(bannerEl, h("strong", null, "Checking you can still reach Housecarl…"), " The change is confirmed once this browser gets through over a new connection.");
|
||||
return;
|
||||
}
|
||||
mount(bannerEl,
|
||||
h("strong", null, pending === 1 ? "1 change is on probation" : pending + " changes are on probation"),
|
||||
" — rolled back in ", h("span", { class: "mono" }, left + "s"), " unless you confirm them. If this page stops answering, wait: the firewall puts itself back.",
|
||||
@@ -303,7 +326,7 @@ export function rerender() {
|
||||
if (current.page) route();
|
||||
}
|
||||
|
||||
setChangeHandler(() => refreshProbation());
|
||||
setChangeHandler((r) => r && r.confirm_token ? autoConfirm(r.confirm_token) : refreshProbation());
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
if (state.me) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 } from "../app.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" });
|
||||
@@ -21,12 +21,13 @@ export async function render(el) {
|
||||
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 ? " It is on probation: confirm it in the banner." : ""),
|
||||
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) refreshProbation();
|
||||
if (!dry && r.confirm_token) autoConfirm(r.confirm_token);
|
||||
else if (!dry) refreshProbation();
|
||||
} catch (e) { mount(out, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
|
||||
@@ -27,6 +27,7 @@ export async function render(el) {
|
||||
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);
|
||||
@@ -37,7 +38,7 @@ export async function render(el) {
|
||||
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 }, { query: dry ? { dry_run: 1 } : undefined });
|
||||
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)); }
|
||||
};
|
||||
@@ -54,7 +55,8 @@ export async function render(el) {
|
||||
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 here has to be confirmed before it is rolled back.")),
|
||||
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"),
|
||||
|
||||
Reference in new issue
Block a user