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:
abrown38andClaude Opus 5.5 committed 2026-10-19 13:23:28 +01:00
1 parent fc390eb57c
commit e4efdf9c78
19 files changed
+384 -50

No files matched your search

+27 -4
View File
@@ -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) {