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");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Housecarl web UI: router, layout, login and the probation banner.
|
||||
import { h, mount, toast, errorBox } from "./ui.js";
|
||||
import { h, mount, toast, errorBox, modal } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler } from "./api.js";
|
||||
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js";
|
||||
import * as changes from "./pages/changes.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
import * as rules from "./pages/rules.js";
|
||||
import * as nat from "./pages/nat.js";
|
||||
@@ -27,6 +28,7 @@ const routes = [
|
||||
{ pattern: /^log$/, page: log, nav: "log" },
|
||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||
{ pattern: /^import$/, page: importPage, nav: "import" },
|
||||
{ pattern: /^changes$/, page: changes, nav: "changes" },
|
||||
{ pattern: /^history$/, page: history, nav: "history" },
|
||||
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
|
||||
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
||||
@@ -46,6 +48,7 @@ const navItems = [
|
||||
["services", "#/services", "Services", "config"],
|
||||
] },
|
||||
["log", "#/log", "Log", "log"],
|
||||
["changes", "#/changes", "Changes", "config"],
|
||||
["history", "#/history", "History", "config"],
|
||||
{ menu: "Server", items: [
|
||||
["settings", "#/settings", "Settings and built-ins", "config"],
|
||||
@@ -56,7 +59,7 @@ const navItems = [
|
||||
];
|
||||
|
||||
const app = document.getElementById("app");
|
||||
let mainEl, navEl, bannerEl, current = { key: "" }, routeGen = 0;
|
||||
let mainEl, navEl, bannerEl, barEl, modeHolder, current = { key: "" }, routeGen = 0;
|
||||
|
||||
function parseHash() {
|
||||
const raw = location.hash.replace(/^#\/?/, "");
|
||||
@@ -92,6 +95,8 @@ async function loggedIn(me) {
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
layout();
|
||||
onDraftChange(renderBar);
|
||||
if (can("config", true)) await loadDraft();
|
||||
window.addEventListener("hashchange", route);
|
||||
await route();
|
||||
}
|
||||
@@ -100,6 +105,8 @@ function layout() {
|
||||
navEl = h("nav", { class: "main" });
|
||||
mainEl = h("main");
|
||||
bannerEl = h("div", { class: "probation-banner hidden", role: "alert" });
|
||||
barEl = h("div");
|
||||
modeHolder = can("config", true) ? h("span") : null;
|
||||
const me = state.me;
|
||||
const header = h("header", { class: "top" },
|
||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||
@@ -107,16 +114,66 @@ function layout() {
|
||||
wordmark({ rule: false, height: 17, cls: "brand-name" })),
|
||||
navEl,
|
||||
h("div", { class: "header-right" },
|
||||
modeHolder,
|
||||
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||
h("a", { href: "#/account" }, "Account"),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||
mount(app, h("div", { class: "shell" }, header, bannerEl, mainEl, footer(me.build)));
|
||||
mount(app, h("div", { class: "shell" }, header, barEl, bannerEl, mainEl, footer(me.build)));
|
||||
renderNav();
|
||||
refreshProbation();
|
||||
}
|
||||
|
||||
// ---- pending changes: edits are staged for review (as in Jarl)
|
||||
|
||||
// renderBar shows the pending-changes bar (while there are pending
|
||||
// changes) and the Review/Live switch in the header.
|
||||
function renderBar() {
|
||||
if (!barEl) return;
|
||||
const d = state.draft;
|
||||
const n = d ? (d.ops || []).length : 0;
|
||||
if (modeHolder) {
|
||||
const live = state.immediate;
|
||||
mount(modeHolder, h("button", {
|
||||
type: "button", class: "mode-toggle" + (live ? " live" : ""), "aria-pressed": live ? "true" : "false",
|
||||
title: live ? "Edits are applied right away (each on probation over the network). Click to collect them for review first."
|
||||
: "Edits are collected as pending changes, reviewed with the ruleset's diff and applied together under Changes. Click to apply edits right away.",
|
||||
onclick: () => { setImmediate(!live); rerender(); },
|
||||
}, h("span", { class: "dot" }), live ? "Live edits" : "Review edits"));
|
||||
}
|
||||
mount(barEl, n > 0 && !state.immediate ? h("div", { class: "pending-bar" },
|
||||
h("strong", null, n === 1 ? "1 pending change" : n + " pending changes"),
|
||||
h("span", { class: "muted" }, "Collected in changeset #" + d.id + "; not in force yet."),
|
||||
h("a", { class: "button small", href: "#/changes" }, "Review"),
|
||||
h("button", { class: "primary small apply-now", title: "Apply the pending changes now, without reviewing them first", onclick: (ev) => applyDraft(ev.target, d) }, "Apply now")) : null);
|
||||
}
|
||||
|
||||
// applyDraft applies the draft changeset from the bar and stays on the
|
||||
// page. If it is refused, a dialog says why and leads to Changes.
|
||||
export async function applyDraft(btn, d, description) {
|
||||
btn.disabled = true;
|
||||
const label = btn.textContent;
|
||||
btn.textContent = "Applying…";
|
||||
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);
|
||||
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();
|
||||
return r;
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = label;
|
||||
const review = await modal("The changes were not applied", errorBox(e), [{ label: "Close", value: false }, { label: "Review changes", kind: "primary", value: true }], { wide: true });
|
||||
if (review) location.hash = "#/changes";
|
||||
return null;
|
||||
} finally {
|
||||
rerender();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- probation: changes made here roll back unless confirmed
|
||||
|
||||
let probationTimer = null, deadline = 0, pending = 0;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Changes: the pending changeset — its operations, the check, and the
|
||||
// ruleset's diff — to apply together or discard. Other open changesets
|
||||
// (from hc or the API) can be taken over here.
|
||||
import { h, mount, table, input, toast, errorBox, confirmDialog, diffView, problemsList, time, plural, badge } from "../ui.js";
|
||||
import { api, state, useDraft, refreshDraft, can } from "../api.js";
|
||||
import { rerender, applyDraft } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
await refreshDraft();
|
||||
const open = await api("GET", "/changesets", { query: { status: "open", limit: 50 } });
|
||||
const d = state.draft;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Pending changes"),
|
||||
h("div", { class: "muted" }, "Edits are collected in a changeset. Nothing reaches the firewall until you apply it; then the whole changeset is checked by Housecarl and nftables and put in force in one step, as one entry in the history. Over the network it is on probation: confirm it, or it rolls back.")))];
|
||||
if (state.immediate) {
|
||||
parts.push(h("div", { class: "msg info" }, "Live edits are on (the switch at the top): edits are applied right away, not collected here."));
|
||||
}
|
||||
if (!d || !(d.ops || []).length) {
|
||||
parts.push(h("div", { class: "panel" }, h("div", { class: "empty" }, "No pending changes. Edit rules, NAT, objects or settings and they show up here.")));
|
||||
} else {
|
||||
parts.push(draftPanel(d));
|
||||
}
|
||||
const others = open.filter((c) => !d || c.id !== d.id);
|
||||
if (others.length) {
|
||||
parts.push(h("div", { class: "panel" }, h("h2", null, "Other open changesets"),
|
||||
h("p", { class: "muted small" }, "Made with hc or the API, by others, or earlier here. Switch to one to review and apply it."),
|
||||
table([
|
||||
{ label: "#", render: (c) => String(c.id) },
|
||||
{ label: "Description", render: (c) => c.description || h("span", { class: "muted" }, "(none)") },
|
||||
{ label: "Operations", render: (c) => String((c.ops || []).length) },
|
||||
{ label: "By", render: (c) => [c.username, h("span", { class: "muted small" }, " via " + c.via)] },
|
||||
{ label: "Updated", render: (c) => time(c.updated_at) },
|
||||
can("config", true) ? { label: "", tdClass: "actions-cell", render: (c) => [
|
||||
h("button", { class: "small", onclick: () => { useDraft(c); rerender(); } }, "Switch to"), " ",
|
||||
h("button", { class: "small danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Discard", "Discard changeset #" + c.id + "?", "Discard", "danger"))) return;
|
||||
try { await api("POST", "/changesets/" + c.id + "/discard"); } catch (e) { toast(e.message, "err"); }
|
||||
rerender();
|
||||
} }, "Discard")] } : null,
|
||||
].filter(Boolean), others)));
|
||||
}
|
||||
mount(el, parts);
|
||||
}
|
||||
|
||||
function draftPanel(d) {
|
||||
const ops = d.ops || [];
|
||||
const desc = input({ value: d.description || "", placeholder: "Describe this change (it becomes the history entry's description)", class: "wide" });
|
||||
const preview = h("div", { class: "muted" }, "Checking…");
|
||||
const result = h("div");
|
||||
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
||||
const removeOp = async (seq) => {
|
||||
try {
|
||||
useDraft(await api("DELETE", "/changesets/" + d.id + "/ops/" + seq));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const opsTable = table([
|
||||
{ label: "#", render: (o) => String(o.seq) },
|
||||
{ label: "Change", render: (o) => o.summary || o.op },
|
||||
{ label: "Added", render: (o) => time(o.added_at) },
|
||||
{ label: "", tdClass: "actions-cell", render: (o) => h("button", { class: "small", title: "Take this operation out of the changeset", onclick: () => removeOp(o.seq) }, "Remove") },
|
||||
], ops);
|
||||
// failedOp marks the operation that no longer applies, and offers to
|
||||
// take it out.
|
||||
const failedOp = (err) => {
|
||||
const i = ops.findIndex((o) => o.seq === err.op);
|
||||
const rows = opsTable.querySelectorAll("tbody tr");
|
||||
if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err });
|
||||
rows[i].classList.add("op-failed");
|
||||
return h("div", { class: "msg err" },
|
||||
h("div", null, err.message),
|
||||
h("div", { class: "form-actions" },
|
||||
h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op),
|
||||
" ", h("span", { class: "muted small" }, "It is marked in the list above. Something it names changed since it was staged.")));
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const p = await api("GET", "/changesets/" + d.id + "/preview");
|
||||
applyBtn.disabled = !p.valid;
|
||||
mount(preview,
|
||||
p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null,
|
||||
p.valid ? h("div", { class: "msg ok" }, "Valid: Housecarl's checks pass and nftables accepts the new ruleset.") : null,
|
||||
!p.error && !p.valid ? h("div", { class: "msg info" }, "Together these operations change nothing.") : null,
|
||||
problemsList(p.warnings),
|
||||
h("h3", null, "The ruleset (/var/lib/housecarl/ruleset.nft)"),
|
||||
p.valid ? diffView(p.diff) : h("div", { class: "muted" }, "No diff until the changeset can be applied."));
|
||||
} catch (e) { mount(preview, errorBox(e)); }
|
||||
})();
|
||||
applyBtn.addEventListener("click", async () => {
|
||||
mount(result);
|
||||
if (desc.value !== (d.description || "")) {
|
||||
try { await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); } catch { /* the apply says it */ }
|
||||
}
|
||||
const r = await applyDraft(applyBtn, d, desc.value);
|
||||
if (r && r.history_id) location.hash = "#/history/" + r.history_id;
|
||||
});
|
||||
const discard = h("button", { class: "danger", onclick: async () => {
|
||||
if (!(await confirmDialog("Discard changes", "Throw away all " + plural(ops.length, "pending change") + "?", "Discard", "danger"))) return;
|
||||
try { await api("POST", "/changesets/" + d.id + "/discard"); } catch (e) { toast(e.message, "err"); }
|
||||
useDraft(null);
|
||||
rerender();
|
||||
} }, "Discard all");
|
||||
return h("div", null,
|
||||
h("div", { class: "panel" },
|
||||
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
||||
desc,
|
||||
h("div", { class: "form-actions" }, opsTable),
|
||||
h("div", { class: "actions form-actions" }, applyBtn, discard),
|
||||
result),
|
||||
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
||||
}
|
||||
@@ -22,7 +22,8 @@ export async function render(el, params) {
|
||||
async function undo(e) {
|
||||
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return;
|
||||
try {
|
||||
await change("POST", "/history/" + e.id + "/revert");
|
||||
// An undo can't be staged: it is applied at once.
|
||||
await change("POST", "/history/" + e.id + "/revert", undefined, { immediate: true });
|
||||
rerender();
|
||||
} catch (err) { modal("Can't undo", errorBox(err)); }
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { names, matchEditor, describe, hits, counters, listEditor } from "../widgets.js";
|
||||
import { names, matchEditor, describe, hits, counters, listEditor, rowClass } from "../widgets.js";
|
||||
|
||||
export async function render(el) {
|
||||
const [fwds, snats, redirects, cs] = await Promise.all([get("/forwards"), get("/snats"), get("/redirects"), counters()]);
|
||||
const write = can("config", true);
|
||||
const actions = (kind, x, edit) => write ? [
|
||||
const actions = (kind, x, edit) => x.pending === "deleted" ? badge("deleted", "err") : write ? [
|
||||
h("button", { class: "small reveal", onclick: () => edit(x) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => toggle(kind, x) }, x.disabled ? "Enable" : "Disable"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(kind, x) }, "Delete")] : null;
|
||||
@@ -25,7 +25,7 @@ export async function render(el) {
|
||||
{ label: "Hairpin", render: (f) => f.hairpin ? badge((f.hairpin_sources || []).length ? "from " + f.hairpin_sources.join(", ") : "yes", "ok") : "" },
|
||||
{ label: "Hits", render: (f) => hits(cs, "f" + f.id, "f" + f.id + "-6") },
|
||||
{ label: "", tdClass: "actions-cell", render: (f) => actions("forwards", f, forwardDialog) },
|
||||
], fwds, { empty: "No port forwards.", rowClass: (x) => x.disabled ? "disabled" : "" })),
|
||||
], fwds, { empty: "No port forwards.", rowClass })),
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "panel-head" }, h("h2", null, "Source NAT"), write ? h("button", { class: "primary small", onclick: () => snatDialog(null) }, "Add source NAT") : null),
|
||||
table([
|
||||
@@ -35,7 +35,7 @@ export async function render(el) {
|
||||
{ label: "Hits", render: (x) => hits(cs, "s" + x.id) },
|
||||
{ label: "Comment", render: (x) => x.comment },
|
||||
{ label: "", tdClass: "actions-cell", render: (x) => actions("snats", x, snatDialog) },
|
||||
], snats, { empty: "No source NAT.", rowClass: (x) => x.disabled ? "disabled" : "" })),
|
||||
], snats, { empty: "No source NAT.", rowClass })),
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "panel-head" }, h("h2", null, "Redirects"), write ? h("button", { class: "primary small", onclick: () => redirectDialog(null) }, "Add redirect") : null),
|
||||
table([
|
||||
@@ -44,7 +44,7 @@ export async function render(el) {
|
||||
{ label: "Hits", render: (x) => hits(cs, "d" + x.id) },
|
||||
{ label: "Comment", render: (x) => x.comment },
|
||||
{ label: "", tdClass: "actions-cell", render: (x) => actions("redirects", x, redirectDialog) },
|
||||
], redirects, { empty: "No redirects.", rowClass: (x) => x.disabled ? "disabled" : "" })));
|
||||
], redirects, { empty: "No redirects.", rowClass })));
|
||||
}
|
||||
|
||||
async function toggle(kind, x) {
|
||||
@@ -78,13 +78,13 @@ async function dialog(title, kind, x, fields, value) {
|
||||
async function forwardDialog(f) {
|
||||
const n = await names();
|
||||
f = f || { protocols: ["tcp"], hairpin: true };
|
||||
const name = input({ value: f.name || "", placeholder: "jellyfin" });
|
||||
const name = input({ value: f.name || "", placeholder: "web" });
|
||||
const tcp = checkbox("TCP", f.protocols.includes("tcp")), udp = checkbox("UDP", f.protocols.includes("udp"));
|
||||
const ports = input({ value: f.ports || "", placeholder: "8096 or 7777-7780", class: "mono" });
|
||||
const ports = input({ value: f.ports || "", placeholder: "8080, or a range: 27015-27030", class: "mono" });
|
||||
const pub = listEditor(f.public_addresses, { suggestions: n.sets, placeholder: "your public IPv4 address, or a set", addLabel: "Add address" });
|
||||
const to = input({ value: f.to || "", placeholder: "10.0.10.57", class: "mono" });
|
||||
const to = input({ value: f.to || "", placeholder: "192.168.1.10", class: "mono" });
|
||||
const toPort = input({ value: f.to_port || "", placeholder: "the same", class: "mono short" });
|
||||
const to6 = input({ value: f.to6 || "", placeholder: "2001:db8:10::57 (optional)", class: "mono" });
|
||||
const to6 = input({ value: f.to6 || "", placeholder: "2001:db8:1::10 (optional)", class: "mono" });
|
||||
const src = listEditor(f.sources, { suggestions: n.sets, placeholder: "anywhere", addLabel: "Add source" });
|
||||
const hairpin = checkbox("the public address also works from inside (hairpin NAT)", f.hairpin);
|
||||
const hairSrc = listEditor(f.hairpin_sources, { suggestions: n.sets, placeholder: "all inside sources", addLabel: "Add source" });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Rules: each chain's rules in order, with their hits; add, edit, move.
|
||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { names, matchEditor, describe, hits, counters, listEditor } from "../widgets.js";
|
||||
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas } from "../widgets.js";
|
||||
|
||||
const chains = [
|
||||
["input", "Input", "Traffic to the firewall itself."],
|
||||
@@ -25,7 +25,9 @@ const builtins = {
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const chain = query.get("chain") || "input";
|
||||
const [rules, settings, cs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters()]);
|
||||
const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]);
|
||||
// As nftables checks them: the rules that count traffic first.
|
||||
const rules = [...all.filter(countsTraffic), ...all.filter((r) => !countsTraffic(r))];
|
||||
const write = can("config", true);
|
||||
const info = chains.find((c) => c[0] === chain) || chains[0];
|
||||
const policy = settings[chain + "_policy"];
|
||||
@@ -41,33 +43,93 @@ export async function render(el, params, query) {
|
||||
bi.map(([, mark, text], i) => [i ? " · " : "", text, mark ? [" (", hits(cs, "b-" + mark), ")"] : ""]), " — ",
|
||||
h("a", { href: "#/settings" }, "settings")) : null,
|
||||
table([
|
||||
{ label: "#", render: (r, i) => String(i + 1) },
|
||||
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
|
||||
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
|
||||
: [describe(r), extras(r)] },
|
||||
: [countsTraffic(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
|
||||
{ label: "Action", render: actionBadge },
|
||||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||
{ label: "Comment", render: (r) => r.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (r, i) => [
|
||||
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rules[i - 1].id }) }, "↑"), " ",
|
||||
h("button", { class: "small reveal", title: "Move down", disabled: i === rules.length - 1, onclick: () => move(r, { after: rules[i + 1].id }) }, "↓"), " ",
|
||||
write ? { label: "", tdClass: "actions-cell", render: (r) => {
|
||||
if (r.pending === "deleted") return badge("deleted", "err");
|
||||
// Moves stay among the rules of the same kind (counting traffic,
|
||||
// or not): nftables checks those first.
|
||||
const rs = live(rules).filter((x) => countsTraffic(x) === countsTraffic(r)), i = rs.indexOf(r);
|
||||
return [
|
||||
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rs[i - 1].id }) }, "↑"), " ",
|
||||
h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[i + 1].id }) }, "↓"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => ruleDialog(r, chain) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
|
||||
] } : null,
|
||||
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => r.disabled ? "disabled" : "" }),
|
||||
];
|
||||
} } : null,
|
||||
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }),
|
||||
rules.some(countsTraffic) ? h("div", { class: "muted small" }, "Rules marked “every packet” have a quota or a byte rate: they are checked before established connections are accepted, so they count all of a connection's traffic, not only its first packet. They can drop, reject or log, not accept.") : null,
|
||||
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
||||
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
|
||||
}
|
||||
|
||||
function extras(r) {
|
||||
// countsTraffic: a quota or a byte rate (as model.Rule.CountsTraffic).
|
||||
function countsTraffic(r) {
|
||||
return !r.raw && (!!r.quota || !!(r.limit && ["bytes", "kbytes", "mbytes"].includes(r.limit.unit)));
|
||||
}
|
||||
|
||||
function extras(r, q) {
|
||||
const x = [];
|
||||
if ((r.ct_state || []).length) x.push("state " + r.ct_state.join(","));
|
||||
if ((r.ct_status || []).length) x.push("status " + r.ct_status.join(","));
|
||||
if ((r.days || []).length) x.push(r.days.join(","));
|
||||
if (r.hour_from) x.push(r.hour_from + "–" + r.hour_to);
|
||||
if (r.limit) x.push((r.limit.over ? "over " : "") + r.limit.rate + "/" + r.limit.per + (r.limit.per_source ? " per source" : ""));
|
||||
if (r.limit) {
|
||||
const l = r.limit;
|
||||
x.push((l.over ? "over " : "") + l.rate + (l.unit && l.unit !== "packets" ? " " + l.unit : "") + "/" + l.per +
|
||||
(l.per_source ? " per source" : l.per_destination ? " per destination" : ""));
|
||||
}
|
||||
if (r.conn_limit) x.push("over " + r.conn_limit + " connections");
|
||||
return x.length ? h("div", { class: "muted small" }, x.join(" · ")) : null;
|
||||
const out = x.length ? [h("div", { class: "muted small" }, x.join(" · "))] : [];
|
||||
if (r.quota) out.push(quotaLine(r, q));
|
||||
return out;
|
||||
}
|
||||
|
||||
// quotaLine shows a quota, and what it used: in total, or per address
|
||||
// (the busiest, and the rest in a dialog).
|
||||
function quotaLine(r, q) {
|
||||
const qt = r.quota;
|
||||
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
||||
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand";
|
||||
let used = null;
|
||||
if (q && !qt.per_source && !qt.per_destination) {
|
||||
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"];
|
||||
} else if (q) {
|
||||
const n = (q.addresses || []).length;
|
||||
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ",
|
||||
n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null];
|
||||
}
|
||||
return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets,
|
||||
used ? [" · ", used] : null,
|
||||
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null,
|
||||
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset")] : null);
|
||||
}
|
||||
|
||||
async function usageDialog(r, q) {
|
||||
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
|
||||
table([
|
||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
|
||||
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
||||
{ label: "", render: (a) => h("div", { class: "usage" }, h("div", { class: "usage-bar" + (a.used >= r.quota.bytes ? " full" : "") }, bar(a.used / r.quota.bytes))) },
|
||||
], q.addresses)), [{ label: "Close", value: null }], { wide: true });
|
||||
}
|
||||
|
||||
function bar(f) {
|
||||
const seg = h("div", { class: "seg leased" });
|
||||
seg.style.width = Math.min(100, f * 100) + "%";
|
||||
return seg;
|
||||
}
|
||||
|
||||
async function resetQuota(r) {
|
||||
if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" +
|
||||
(r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||||
try { await api("POST", "/rules/" + r.id + "/reset-quota"); toast("Quota reset"); rerender(); } catch (e) { modal("Not reset", errorBox(e)); }
|
||||
}
|
||||
|
||||
async function move(r, where) {
|
||||
@@ -101,14 +163,30 @@ export async function ruleDialog(r, chain) {
|
||||
const hourTo = input({ value: r.hour_to || "", placeholder: "17:30", class: "short" });
|
||||
const l = r.limit || {};
|
||||
const rate = input({ value: l.rate || "", placeholder: "10", class: "short", type: "number", min: 1 });
|
||||
const per = select(["second", "minute", "hour", "day"], l.per || "minute");
|
||||
const unit = select([["", "packets"], ["bytes", "bytes"], ["kbytes", "KB"], ["mbytes", "MB"]], l.unit || "", { class: "inline" });
|
||||
const per = select(["second", "minute", "hour", "day"], l.per || "minute", { class: "inline" });
|
||||
const burst = input({ value: l.burst || "", placeholder: "burst", class: "short", type: "number", min: 0 });
|
||||
const over = checkbox("match beyond the rate (to drop floods)", l.over);
|
||||
const perSource = checkbox("per source address", l.per_source);
|
||||
const over = checkbox("match beyond the rate (to drop floods, or traffic over a bandwidth)", l.over);
|
||||
const keyOf = (x) => x.per_source ? "source" : x.per_destination ? "destination" : "";
|
||||
const keys = [["", "for everything the rule matches"], ["source", "for each source address"], ["destination", "for each destination address"]];
|
||||
const limitKey = select(keys, keyOf(l), { class: "inline" });
|
||||
const qt = r.quota || {};
|
||||
const gb = qt.bytes && qt.bytes % (1 << 30) === 0;
|
||||
const quotaAmount = input({ value: qt.bytes ? qt.bytes / (gb ? 1 << 30 : 1 << 20) : "", placeholder: "50", class: "short", type: "number", min: 1 });
|
||||
const quotaUnit = select([["mb", "MB"], ["gb", "GB"]], gb || !qt.bytes ? "gb" : "mb", { class: "inline" });
|
||||
const quotaOver = checkbox("match once it is used up (to drop); otherwise while it lasts (to accept)", qt.over === undefined ? true : qt.over);
|
||||
const quotaKey = select(keys, keyOf(qt), { class: "inline" });
|
||||
const quotaReset = select([["", "only by hand"], ["daily", "daily, at midnight"], ["weekly", "weekly, on Mondays"], ["monthly", "monthly, on day"]], qt.reset || "", { class: "inline" });
|
||||
const quotaDay = input({ value: qt.day || "", placeholder: "1", class: "short", type: "number", min: 1, max: 31 });
|
||||
const showDay = () => quotaDay.classList.toggle("hidden", quotaReset.value !== "monthly");
|
||||
quotaReset.addEventListener("change", showDay);
|
||||
showDay();
|
||||
const group4 = input({ value: r.group_v4 || "", placeholder: "32", class: "short", type: "number", min: 1, max: 32 });
|
||||
const group6 = input({ value: r.group_v6 || "", placeholder: "64", class: "short", type: "number", min: 1, max: 128 });
|
||||
const conn = input({ value: r.conn_limit || "", placeholder: "e.g. 20", class: "short", type: "number", min: 1 });
|
||||
const log = checkbox("log matching packets (Log page)", r.log);
|
||||
const logPrefix = input({ value: r.log_prefix || "", placeholder: "default: hc CHAIN #ID ACTION:" });
|
||||
const raw = textarea(r.raw || "", { rows: 2, class: "mono", placeholder: "iifname != \"wan\" ct state invalid ip daddr @trusted_v4 accept" });
|
||||
const raw = textarea(r.raw || "", { rows: 2, class: "mono", placeholder: "ip saddr @admins_v4 tcp dport 22 ct state new accept" });
|
||||
const comment = input({ value: r.comment || "" });
|
||||
const disabled = checkbox("disabled: keep the rule but don't use it", r.disabled);
|
||||
const err = h("div");
|
||||
@@ -121,7 +199,12 @@ export async function ruleDialog(r, chain) {
|
||||
field("Connection status", statuses, "dnat: the connection was port forwarded."),
|
||||
field("Days", days),
|
||||
field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight."),
|
||||
field("Rate limit", h("div", null, rate, " packets per ", per, " ", burst, over, perSource), "Empty: no limit."),
|
||||
field("Rate limit", h("div", null, rate, " ", unit, " per ", per, " ", burst, " ", limitKey, over),
|
||||
"Empty: no limit. In bytes, it is a bandwidth (KB or MB per second or minute); traffic beyond it is dropped, not queued. The burst is in the same unit."),
|
||||
field("Quota", h("div", null, quotaAmount, " ", quotaUnit, " ", quotaKey, " resets ", quotaReset, " ", quotaDay, quotaOver),
|
||||
"Empty: none. An allowance of traffic; Housecarl keeps the count across changes and restarts."),
|
||||
field("Group addresses", h("div", null, "IPv4 /", group4, " IPv6 /", group6),
|
||||
"Per-address limits, connection limits and quotas count addresses by these prefixes. Default: each IPv4 address, IPv6 by /64 (one device has several addresses)."),
|
||||
field("Connection limit", conn, "Match a source that already has more than this many connections."),
|
||||
field("Log", h("div", null, log, logPrefix)),
|
||||
field("Raw nftables", raw, "Instead of all the above: one nftables rule, for what Housecarl can't say. Checked by nftables."),
|
||||
@@ -138,7 +221,12 @@ export async function ruleDialog(r, chain) {
|
||||
mount(err);
|
||||
const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: action.value === "reject" ? rejectWith.value : "",
|
||||
ct_state: states.value(), ct_status: statuses.value(), days: days.value(), hour_from: hourFrom.value.trim(), hour_to: hourTo.value.trim(),
|
||||
limit: rate.value ? { rate: parseInt(rate.value, 10), per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked, per_source: perSource.input.checked } : null,
|
||||
limit: rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked,
|
||||
per_source: limitKey.value === "source", per_destination: limitKey.value === "destination" } : null,
|
||||
quota: quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked,
|
||||
per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination",
|
||||
reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null,
|
||||
group_v4: parseInt(group4.value || "0", 10), group_v6: parseInt(group6.value || "0", 10),
|
||||
conn_limit: parseInt(conn.value || "0", 10), log: log.input.checked, log_prefix: logPrefix.value.trim(), raw: raw.value.trim(), comment: comment.value.trim() };
|
||||
if (r.id) v.disabled = disabled.input.checked;
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { h, mount, table, modal, errorBox, field, input, confirmDialog, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { listEditor } from "../widgets.js";
|
||||
import { listEditor, rowClass } from "../widgets.js";
|
||||
|
||||
export async function render(el) {
|
||||
const services = await get("/services");
|
||||
@@ -16,11 +16,11 @@ export async function render(el) {
|
||||
{ label: "Entries", render: (s) => h("span", { class: "mono small" }, s.entries.join(", ")) },
|
||||
{ label: "", render: (s) => s.builtin ? badge("built in", "") : badge("yours", "accent") },
|
||||
{ label: "Comment", render: (s) => s.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (s) => s.builtin
|
||||
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : s.builtin
|
||||
? h("button", { class: "small reveal", onclick: () => serviceDialog({ name: s.name, entries: s.entries }) }, "Change")
|
||||
: [h("button", { class: "small reveal", onclick: () => serviceDialog(s) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
|
||||
].filter(Boolean), services)));
|
||||
].filter(Boolean), services, { rowClass })));
|
||||
}
|
||||
|
||||
async function del(s) {
|
||||
@@ -30,8 +30,8 @@ async function del(s) {
|
||||
|
||||
async function serviceDialog(s) {
|
||||
s = s || { entries: [] };
|
||||
const name = input({ value: s.name || "", placeholder: "jellyfin" });
|
||||
const list = listEditor(s.entries, { suggestions: ["tcp/", "udp/", "icmp/echo-request", "icmpv6/echo-request"], placeholder: "tcp/8096, udp/27015-27030, icmp/echo-request", addLabel: "Add entry" });
|
||||
const name = input({ value: s.name || "", placeholder: "web" });
|
||||
const list = listEditor(s.entries, { suggestions: ["tcp/", "udp/", "icmp/echo-request", "icmpv6/echo-request"], placeholder: "tcp/8080, udp/27015-27030, icmp/echo-request", addLabel: "Add entry" });
|
||||
const comment = input({ value: s.comment || "" });
|
||||
const err = h("div");
|
||||
await modal(s.id ? "Edit service " + s.name : "Add service", h("div", null, h("div", { class: "form-grid" },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { listEditor, fmtSeconds, parseSeconds } from "../widgets.js";
|
||||
import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js";
|
||||
|
||||
export async function render(el) {
|
||||
const sets = await get("/sets");
|
||||
@@ -14,12 +14,12 @@ export async function render(el) {
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Name", render: (s) => h("strong", null, s.name) },
|
||||
{ label: "Entries", render: (s) => s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ",
|
||||
h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
|
||||
s.pending ? null : h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
|
||||
{ label: "Comment", render: (s) => s.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (s) => [
|
||||
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : [
|
||||
h("button", { class: "small reveal", onclick: () => setDialog(s) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
|
||||
].filter(Boolean), sets, { empty: "No sets yet." })));
|
||||
].filter(Boolean), sets, { empty: "No sets yet.", rowClass })));
|
||||
}
|
||||
|
||||
async function del(s) {
|
||||
@@ -30,7 +30,7 @@ async function del(s) {
|
||||
async function setDialog(s) {
|
||||
s = s || { entries: [] };
|
||||
const name = input({ value: s.name || "", placeholder: "trusted" });
|
||||
const list = listEditor(s.entries, { placeholder: "10.0.10.0/24, 2001:db8:10::/64, 10.0.0.1-10.0.0.9", addLabel: "Add entry" });
|
||||
const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" });
|
||||
const dynamic = checkbox("dynamic: entries are added at run time", s.dynamic);
|
||||
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
|
||||
const comment = input({ value: s.comment || "" });
|
||||
@@ -57,7 +57,7 @@ async function entries(s) {
|
||||
const write = can("sets", true);
|
||||
const draw = async () => {
|
||||
const es = await get("/sets/" + encodeURIComponent(s.name) + "/elements");
|
||||
const addr = input({ placeholder: "10.0.30.5 or 2001:db8::5", class: "mono" });
|
||||
const addr = input({ placeholder: "192.168.1.50 or 2001:db8:1::50", class: "mono" });
|
||||
const t = input({ placeholder: s.timeout ? fmtSeconds(s.timeout) : "none", class: "short" });
|
||||
mount(body,
|
||||
table([
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function render(el) {
|
||||
const input_ = pol(s.input_policy), forward = pol(s.forward_policy), output = pol(s.output_policy);
|
||||
const logDrops = checkbox("log what the input and forward policies drop (rate-limited; the Log page)", s.log_policy_drops);
|
||||
const bis = builtinText.map(([k, text]) => [k, checkbox(text, s.builtins[k])]);
|
||||
const own = listEditor(s.own_prefixes, { placeholder: "2001:db8:7f3d::/48", addLabel: "Add prefix" });
|
||||
const own = listEditor(s.own_prefixes, { placeholder: "2001:db8::/48", addLabel: "Add prefix" });
|
||||
const v4 = listEditor(s.antispoof_v4, { addLabel: "Add" });
|
||||
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
|
||||
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
|
||||
@@ -38,7 +38,7 @@ export async function render(el) {
|
||||
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
|
||||
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
|
||||
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p }, { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
if (!dry && (r.applied || r.staged)) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { listEditor } from "../widgets.js";
|
||||
import { listEditor, rowClass } from "../widgets.js";
|
||||
|
||||
export async function render(el) {
|
||||
const [zones, ifs] = await Promise.all([get("/zones"), get("/interfaces")]);
|
||||
@@ -16,10 +16,10 @@ export async function render(el) {
|
||||
{ label: "Interfaces", render: (z) => h("span", { class: "mono" }, z.interfaces.join(", ")) },
|
||||
{ label: "", render: (z) => z.external ? badge("external", "warn") : badge("internal", "") },
|
||||
{ label: "Comment", render: (z) => z.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (z) => [
|
||||
write ? { label: "", tdClass: "actions-cell", render: (z) => z.pending === "deleted" ? badge("deleted", "err") : [
|
||||
h("button", { class: "small reveal", onclick: () => zoneDialog(z, ifs) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(z) }, "Delete")] } : null,
|
||||
].filter(Boolean), zones, { empty: "No zones yet: add one per network (wan, lan, guest…)." })),
|
||||
].filter(Boolean), zones, { empty: "No zones yet: add one per network (wan, lan, guest…).", rowClass })),
|
||||
h("div", { class: "panel" }, h("h2", null, "This machine's interfaces"), table([
|
||||
{ label: "Interface", render: (i) => h("span", { class: "mono" }, i.name) },
|
||||
{ label: "State", render: (i) => badge(i.up ? "up" : "down", i.up ? "ok" : "") },
|
||||
|
||||
@@ -383,3 +383,29 @@ input.short { width: 7em; }
|
||||
.checks { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-actions { margin-top: 14px; }
|
||||
tr.disabled td:not(.actions-cell) { opacity: 0.55; }
|
||||
|
||||
/* Pending changes (as in Jarl): the bar, the Review/Live switch, rows. */
|
||||
.pending-bar {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 8px 24px; background: var(--warn-soft); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pending-bar strong { color: var(--warn); }
|
||||
.pending-bar .apply-now { margin-left: auto; }
|
||||
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; margin-right: 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
||||
.mode-toggle:hover { color: var(--text); border-color: var(--accent); }
|
||||
.mode-toggle .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
||||
.mode-toggle.live { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); }
|
||||
.mode-toggle.live .dot { background: var(--warn); }
|
||||
tr.pending-new td { background: var(--add); }
|
||||
tr.pending-changed td { background: var(--warn-soft); }
|
||||
tr.pending-deleted td { background: var(--del); color: var(--muted); }
|
||||
tr.pending-deleted td:not(.actions-cell) { text-decoration: line-through; }
|
||||
tr.op-failed td { background: var(--err-soft); }
|
||||
input.wide { width: 100%; }
|
||||
.diff { background: var(--code-bg); border-radius: var(--radius); overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.4; }
|
||||
.diff div { white-space: pre; padding: 0 10px; }
|
||||
.diff .add { background: var(--add); color: var(--add-text); }
|
||||
.diff .del { background: var(--del); color: var(--del-text); }
|
||||
.diff .hunk { background: var(--hunk); color: var(--accent); }
|
||||
.diff .file { font-weight: 700; padding-top: 6px; }
|
||||
select.inline { width: auto; display: inline-block; }
|
||||
@@ -191,6 +191,21 @@ export function problemsList(problems) {
|
||||
h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null)));
|
||||
}
|
||||
|
||||
// diffView shows a unified diff, lines colored.
|
||||
export function diffView(text) {
|
||||
if (!text) return h("div", { class: "muted" }, "The ruleset doesn't change.");
|
||||
const box = h("div", { class: "diff" });
|
||||
for (const line of text.replace(/\n$/, "").split("\n")) {
|
||||
let cls = "";
|
||||
if (line.startsWith("+++") || line.startsWith("---")) cls = "file";
|
||||
else if (line.startsWith("+")) cls = "add";
|
||||
else if (line.startsWith("-")) cls = "del";
|
||||
else if (line.startsWith("@@")) cls = "hunk";
|
||||
box.append(h("div", { class: cls }, line || " "));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
// errorBox shows an API error with its problems.
|
||||
export function errorBox(err) {
|
||||
if (!err) return null;
|
||||
|
||||
@@ -70,7 +70,7 @@ export function listEditor(values, opts = {}) {
|
||||
export function matchEditor(m, n, opts = {}) {
|
||||
m = m || {};
|
||||
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
|
||||
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 10.0.10.0/24, 2001:db8::/64, 10.0.0.1-10.0.0.9", addLabel: "Add" });
|
||||
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add" });
|
||||
const inZ = zones(m.in_zones), outZ = zones(m.out_zones);
|
||||
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out);
|
||||
const src = addrs(m.sources), dst = addrs(m.destinations);
|
||||
@@ -132,3 +132,36 @@ export function hits(counters, ...marks) {
|
||||
export async function counters() {
|
||||
try { return (await get("/counters")).counters; } catch { return {}; }
|
||||
}
|
||||
|
||||
// ---- pending changes
|
||||
|
||||
// rowClass marks a table row: pending (new, changed, deleted, while
|
||||
// changes are collected for review) and disabled.
|
||||
export function rowClass(x) {
|
||||
return [x.pending ? "pending-" + x.pending : "", x.disabled ? "disabled" : ""].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
// live drops what the pending changes delete (for picking neighbours).
|
||||
export function live(list) {
|
||||
return list.filter((x) => x.pending !== "deleted");
|
||||
}
|
||||
|
||||
// ---- bytes
|
||||
|
||||
// fmtBytes writes bytes for people: "1.8 GB".
|
||||
export function fmtBytes(n) {
|
||||
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(1) + " GB";
|
||||
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + " MB";
|
||||
if (n >= 1 << 10) return (n / (1 << 10)).toFixed(1) + " KB";
|
||||
return n + " bytes";
|
||||
}
|
||||
|
||||
// quotas loads what the rules' quotas used, by rule ID ({} without
|
||||
// log:read).
|
||||
export async function quotas() {
|
||||
try {
|
||||
const out = {};
|
||||
for (const q of await get("/quotas")) out[q.rule_id] = q;
|
||||
return out;
|
||||
} catch { return {}; }
|
||||
}
|
||||
Reference in new issue
Block a user