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>
This commit is contained in:
1 parent
ebec2cfcca
commit
2880f7ddc9
63 files changed
+4018
-385
No files matched your search
@@ -5,6 +5,8 @@ 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 {
|
||||
@@ -24,9 +26,10 @@ export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||
let onChange = () => {};
|
||||
export function setChangeHandler(fn) { onChange = fn; }
|
||||
|
||||
// api calls the REST API. opts: {body, query}.
|
||||
// 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";
|
||||
@@ -51,20 +54,125 @@ export async function api(method, path, opts = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export const get = (path, query) => api("GET", path, { query });
|
||||
// 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) });
|
||||
|
||||
// change sends a configuration change and reports it: a toast with the
|
||||
// history entry (to undo it from History) and one per new warning. It
|
||||
// returns the response.
|
||||
// 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 = {}) {
|
||||
const r = await api(method, path, { body, query: opts.query });
|
||||
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.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
|
||||
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");
|
||||
|
||||
Reference in new issue
Block a user