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>
168 lines
7.0 KiB
JavaScript
168 lines
7.0 KiB
JavaScript
// Widgets shared by several pages: list editors with suggestions, the
|
||
// match editor of rules and NAT, descriptions, hit counts, durations.
|
||
import { h, input, select, field, checkbox } from "./ui.js";
|
||
import { get } from "./api.js";
|
||
|
||
// ---- durations
|
||
|
||
// fmtSeconds writes seconds the short way: 43200 is "12h", 5400 "1h30m".
|
||
export function fmtSeconds(n) {
|
||
if (!n || n <= 0) return "";
|
||
const parts = [];
|
||
for (const [s, u] of [[86400, "d"], [3600, "h"], [60, "m"], [1, "s"]]) {
|
||
if (n >= s) { parts.push(Math.floor(n / s) + u); n %= s; }
|
||
}
|
||
return parts.join("");
|
||
}
|
||
|
||
// parseSeconds reads "12h", "30m", "1d12h" or plain seconds; null when
|
||
// it can't.
|
||
export function parseSeconds(s) {
|
||
s = String(s || "").trim();
|
||
if (s === "") return 0;
|
||
if (/^\d+$/.test(s)) return parseInt(s, 10);
|
||
const re = /(\d+)\s*([dhms])/g;
|
||
let total = 0, m, used = "";
|
||
while ((m = re.exec(s))) {
|
||
total += parseInt(m[1], 10) * { d: 86400, h: 3600, m: 60, s: 1 }[m[2]];
|
||
used += m[0];
|
||
}
|
||
return used.replace(/\s/g, "") === s.replace(/\s/g, "") && total > 0 ? total : null;
|
||
}
|
||
|
||
// ---- names for suggestions
|
||
|
||
// names loads the zones, sets and services (with the built-in ones), for
|
||
// the editors' suggestions.
|
||
export async function names() {
|
||
const [zones, sets, services] = await Promise.all([get("/zones"), get("/sets"), get("/services")]);
|
||
return { zones: zones.map((z) => z.name), sets: sets.map((s) => s.name), services: services.map((s) => s.name), zoneList: zones, setList: sets };
|
||
}
|
||
|
||
let listID = 0;
|
||
|
||
// listEditor edits a list of strings, one per row, with suggestions.
|
||
// value() returns the non-empty entries.
|
||
export function listEditor(values, opts = {}) {
|
||
const rows = h("div", { class: "list-rows" });
|
||
const id = "dl" + (++listID);
|
||
const datalist = h("datalist", { id }, (opts.suggestions || []).map((s) => h("option", { value: s })));
|
||
let items = [];
|
||
const add = (v) => {
|
||
const it = { input: input({ value: v || "", placeholder: opts.placeholder || "", list: id, class: opts.mono === false ? "" : "mono" }) };
|
||
it.row = h("div", { class: "list-row" }, it.input,
|
||
h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); items = items.filter((x) => x !== it); } }, "✕"));
|
||
items.push(it);
|
||
rows.append(it.row);
|
||
return it.input;
|
||
};
|
||
for (const v of values || []) add(v);
|
||
const box = h("div", { class: "list-editor" }, rows, datalist,
|
||
h("button", { type: "button", class: "small", onclick: () => add("").focus() }, "+ " + (opts.addLabel || "Add")));
|
||
box.value = () => items.map((it) => it.input.value.trim()).filter((v) => v !== "");
|
||
return box;
|
||
}
|
||
|
||
// ---- matches
|
||
|
||
// matchEditor edits what a rule, source NAT or redirect matches. It
|
||
// returns the form-grid fields; value() is the match's fields.
|
||
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, 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);
|
||
const notSrc = checkbox("any source but these", m.not_sources), notDst = checkbox("any destination but these", m.not_destinations);
|
||
const svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" });
|
||
const fam = select([["", "IPv4 and IPv6"], ["ipv4", "IPv4 only"], ["ipv6", "IPv6 only"]], m.family || "");
|
||
const fields = [
|
||
opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null,
|
||
opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null,
|
||
field("From", h("div", null, src, notSrc), "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
|
||
field("To", h("div", null, dst, notDst)),
|
||
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
|
||
field("Family", fam),
|
||
];
|
||
fields.value = () => {
|
||
const v = { sources: src.value(), not_sources: notSrc.input.checked, destinations: dst.value(), not_destinations: notDst.input.checked,
|
||
services: svc.value(), family: fam.value };
|
||
if (opts.in !== false) Object.assign(v, { in_zones: inZ.value(), not_in: notIn.input.checked });
|
||
if (opts.out !== false) Object.assign(v, { out_zones: outZ.value(), not_out: notOut.input.checked });
|
||
return v;
|
||
};
|
||
return fields;
|
||
}
|
||
|
||
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
||
export function describe(m) {
|
||
const parts = [];
|
||
const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); };
|
||
side("in", m.not_in, m.in_zones);
|
||
side("out", m.not_out, m.out_zones);
|
||
side("from", m.not_sources, m.sources);
|
||
side("to", m.not_destinations, m.destinations);
|
||
if (m.services && m.services.length) parts.push(m.services.join(", "));
|
||
if (m.family) parts.push(m.family + " only");
|
||
return parts.length ? parts.join(" · ") : "everything";
|
||
}
|
||
|
||
// ---- hits
|
||
|
||
export function humanCount(n) {
|
||
if (n >= 1e10) return Math.floor(n / 1e9) + "G";
|
||
if (n >= 1e7) return Math.floor(n / 1e6) + "M";
|
||
if (n >= 1e4) return Math.floor(n / 1e3) + "k";
|
||
return String(n);
|
||
}
|
||
|
||
// hits shows an object's packets (marks add up), with bytes as a title.
|
||
export function hits(counters, ...marks) {
|
||
let p = 0, b = 0, found = false;
|
||
for (const m of marks) {
|
||
const c = counters && counters[m];
|
||
if (c) { p += c.packets; b += c.bytes; found = true; }
|
||
}
|
||
if (!found) return h("span", { class: "muted" }, "–");
|
||
return h("span", { class: "mono", title: p + " packets, " + b + " bytes" }, humanCount(p));
|
||
}
|
||
|
||
// counters loads the hit counters ({} without log:read).
|
||
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 {}; }
|
||
}
|