Input and early have no outgoing interface, output no incoming one, so the dialog hides those fields (they could only be refused) and a zone left in a hidden field isn't sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
9.5 KiB
JavaScript
215 lines
9.5 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, api } 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 inField = opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null;
|
||
const outField = opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null;
|
||
const fields = [
|
||
inField,
|
||
outField,
|
||
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 };
|
||
// A hidden zone field (the chain has no such interface) matches nothing.
|
||
if (inField) Object.assign(v, inField[1].classList.contains("hidden") ? { in_zones: [], not_in: false } : { in_zones: inZ.value(), not_in: notIn.input.checked });
|
||
if (outField) Object.assign(v, outField[1].classList.contains("hidden") ? { out_zones: [], not_out: false } : { out_zones: outZ.value(), not_out: notOut.input.checked });
|
||
return v;
|
||
};
|
||
// zones shows the zone fields a chain can use: input and early have no
|
||
// outgoing interface, output no incoming one.
|
||
const show = (f, on) => f && f.filter(Boolean).forEach((e) => e.classList.toggle("hidden", !on));
|
||
fields.zones = (inOn, outOn) => { show(inField, inOn); show(outField, outOn); };
|
||
return fields;
|
||
}
|
||
|
||
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
||
export function describe(m) {
|
||
const parts = [];
|
||
const list = (not, xs) => (not ? "any but " : "") + xs.join(", ");
|
||
// Zones are a direction, "lan → wan"; the firewall itself is the other
|
||
// end of the input and output chains.
|
||
const ins = m.in_zones || [], outs = m.out_zones || [];
|
||
if (ins.length || outs.length) {
|
||
parts.push((m.chain === "output" ? "firewall" : ins.length ? list(m.not_in, ins) : "any") + " → " +
|
||
(m.chain === "input" ? "firewall" : outs.length ? list(m.not_out, outs) : "any"));
|
||
}
|
||
if (m.sources && m.sources.length) parts.push("from " + list(m.not_sources, m.sources));
|
||
if (m.destinations && m.destinations.length) parts.push("to " + list(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";
|
||
}
|
||
|
||
// pct writes used as a share of total: "25%", "<1%" for a little.
|
||
export function pct(used, total) {
|
||
if (!total || used <= 0) return "0%";
|
||
const p = used / total * 100;
|
||
return p < 1 ? "<1%" : Math.floor(p) + "%";
|
||
}
|
||
|
||
// usageBar draws used out of total as a bar, red once it is all used.
|
||
export function usageBar(used, total) {
|
||
const seg = h("div", { class: "seg leased" });
|
||
seg.style.width = Math.min(100, used / total * 100) + "%";
|
||
return h("div", { class: "usage" }, h("div", { class: "usage-bar" + (used >= total ? " full" : "") }, seg));
|
||
}
|
||
|
||
// lookupNames asks for inside addresses' names (docs/configuration.md):
|
||
// {address: name}, {} when names are off or can't be had.
|
||
export async function lookupNames(addrs) {
|
||
const list = [...new Set(addrs.filter(Boolean))];
|
||
if (!list.length) return {};
|
||
try { return (await api("POST", "/names", { body: { addresses: list } })).names || {}; } catch { return {}; }
|
||
}
|
||
|
||
// named shows an address (or text with it, such as address:port) after
|
||
// its name's first label; the whole name is in the tooltip.
|
||
export function named(names, addr, text = addr) {
|
||
const n = names[addr];
|
||
return n ? h("span", { title: n + " (" + addr + ")" }, h("span", { class: "host-name" }, n.split(".")[0]), " ", h("span", { class: "muted" }, text)) : text;
|
||
}
|
||
|
||
// withNames sets each top-list entry's name from its key (an address).
|
||
export async function withNames(...lists) {
|
||
const names = await lookupNames(lists.flat().map((e) => e.key));
|
||
for (const e of lists.flat()) if (names[e.key]) e.name = names[e.key].split(".")[0];
|
||
}
|
||
|
||
// 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 {}; }
|
||
}
|