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>
206 lines
7.3 KiB
JavaScript
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;
|
|
}
|