Files
housecarl/internal/web/static/assets/api.js
T
abrown38andClaude Opus 5.5 2880f7ddc9 Add changesets, bandwidth limits and quotas; generic examples
Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:18:54 +01:00

197 lines
7.0 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).
let onChange = () => {};
export function setChangeHandler(fn) { onChange = fn; }
// api calls the REST API. opts: {body, query, stage (a changeset ID)}.
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;
}
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
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) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
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();
}
// 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;
}