Files
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

206 lines
7.3 KiB
JavaScript

// API client and shared application state.
import { toast } from "./ui.js";
export const state = {
me: null, // whoami response
csrf: "",
catalog: null,
draft: null, // the open changeset edits are staged into
immediate: false, // apply edits immediately instead of staging them
};
export class APIError extends Error {
constructor(status, body) {
const e = (body && body.error) || {};
super(e.message || ("HTTP " + status));
this.status = status;
this.code = e.code || "";
this.details = e;
}
}
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
// 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),
// timeout (ms)}.
export async function api(method, path, opts = {}) {
const headers = { "Accept": "application/json" };
if (opts.stage) headers["X-Housecarl-Changeset"] = String(opts.stage);
let body;
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
let url = "/api/v1" + path;
if (opts.query) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
const s = q.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
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();
if (!resp.ok) {
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
throw new APIError(resp.status, data);
}
return data;
}
// get reads; with pending changes, the configuration as they will make it
// (objects marked "pending": new, changed, deleted).
export const get = (path, query) => api("GET", path, { query: draftQuery(query) });
// draftQuery names the draft changeset in a query, unless edits are
// applied immediately.
export function draftQuery(query = {}) {
if (state.draft && !state.immediate) return { ...query, changeset: state.draft.id };
return query;
}
// ---- the draft changeset (review mode, as in Jarl)
const draftKey = () => "housecarl.draft." + (state.me ? state.me.user.username : "");
const immediateKey = () => "housecarl.immediate." + (state.me ? state.me.user.username : "");
const listeners = new Set();
export function onDraftChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
function notify() { for (const fn of listeners) fn(); }
function remember(key, value) {
try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } catch { /* private window */ }
}
function recall(key) {
try { return localStorage.getItem(key); } catch { return null; }
}
// loadDraft restores the user's draft changeset if it is still open, or
// picks up their newest open one made in the web UI.
export async function loadDraft() {
state.immediate = recall(immediateKey()) === "1";
state.draft = null;
const id = recall(draftKey());
if (id) {
try {
const cs = await api("GET", "/changesets/" + id);
if (cs.status === "open") state.draft = cs;
} catch { /* gone */ }
}
if (!state.draft) {
try {
const list = await api("GET", "/changesets", { query: { status: "open", mine: 1, limit: 20 } });
state.draft = list.find((c) => c.via === "web") || null;
} catch { /* no config access */ }
}
remember(draftKey(), state.draft ? String(state.draft.id) : null);
notify();
}
export function setImmediate(on) {
state.immediate = on;
remember(immediateKey(), on ? "1" : "0");
notify();
}
export async function refreshDraft() {
if (!state.draft) return;
try {
const cs = await api("GET", "/changesets/" + state.draft.id);
state.draft = cs.status === "open" ? cs : null;
} catch { state.draft = null; }
if (!state.draft) remember(draftKey(), null);
notify();
}
export function useDraft(cs) {
state.draft = cs;
remember(draftKey(), cs ? String(cs.id) : null);
notify();
}
async function ensureDraft() {
if (state.draft) return state.draft;
const cs = await api("POST", "/changesets", { body: { description: "" } });
cs.fresh = true;
useDraft(cs);
return cs;
}
// dropFreshDraft discards a draft made for an edit that was then refused
// (or only checked), so no empty changeset is left open.
async function dropFreshDraft(cs) {
if (!cs.fresh || (state.draft && state.draft.id !== cs.id) || (state.draft && (state.draft.ops || []).length)) return;
try { await api("POST", "/changesets/" + cs.id + "/discard"); } catch { /* leave it */ }
useDraft(null);
}
// change sends a configuration change: staged in the draft changeset, or
// applied at once in immediate mode (or with opts.immediate). It reports
// it (toasts) and returns the response.
export async function change(method, path, body, opts = {}) {
let r;
if (state.immediate || opts.immediate) {
r = await api(method, path, { body, query: opts.query });
} else {
const cs = await ensureDraft();
try {
r = await api(method, path, { body, query: opts.query, stage: cs.id });
} catch (e) {
if (e.status === 409 && /is (applied|discarded)/.test(e.message)) {
useDraft(null);
return change(method, path, body, opts);
}
await dropFreshDraft(cs);
throw e;
}
if (r && r.staged && r.changeset) useDraft(r.changeset);
else await dropFreshDraft(cs);
}
if (!opts.quiet) report(r);
return r;
}
export function report(r) {
if (!r) return;
if (r.staged) {
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 && !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(r);
}
// can reports whether the user may read (or write) a resource.
export function can(resource, write) {
const me = state.me;
if (!me) return false;
if (me.admin) return true;
const perms = me.permissions || [];
return perms.includes(resource + ":write") || (!write && perms.includes(resource + ":read"));
}
// catalog returns the option catalog (cached).
export async function catalog() {
if (!state.catalog) state.catalog = await get("/catalog");
return state.catalog;
}