A program bans an address (every port, or a protocol's ports) for a time; the ban list says what it is kept from: the firewall, some hosts behind it, or everything. Bans drop ahead of every rule, close the address's connections, group IPv6 by /64, are kept in the database across restarts and audited. Never banned: the inside, the firewall's own addresses, the caller, the list's never. bans grant (bans:write:LIST), hc banlist/bans/ban/unban, Traffic -> Bans, a fail2ban action in contrib/, and failed logins and refused tokens logged for Guard and fail2ban. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
10 KiB
JavaScript
147 lines
10 KiB
JavaScript
// Ban lists, and the addresses banned in them (docs/bans.md).
|
||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, toast, relTime } from "../ui.js";
|
||
import { get, api, change, can } from "../api.js";
|
||
import { rerender, onLeave } from "../app.js";
|
||
import { listEditor, fmtSeconds, parseSeconds, rowClass, lookupNames, named, hits, counters } from "../widgets.js";
|
||
|
||
const scopes = {
|
||
firewall: "the firewall itself",
|
||
hosts: "its hosts, behind the firewall",
|
||
everything: "the firewall and everything behind it",
|
||
};
|
||
|
||
export async function render(el) {
|
||
const config = can("config", false);
|
||
const write = can("config", true);
|
||
const lists = await get("/banlists");
|
||
const out = h("div");
|
||
const draw = async () => {
|
||
const [bans, cs] = await Promise.all([get("/bans"), counters()]);
|
||
const names = await lookupNames(bans.map((b) => b.address));
|
||
const count = {};
|
||
for (const b of bans) count[b.list_id] = (count[b.list_id] || 0) + 1;
|
||
mount(out,
|
||
h("h2", null, "Ban lists"),
|
||
h("div", { class: "panel" }, table([
|
||
{ label: "Name", render: (l) => h("strong", null, l.name) },
|
||
{ label: "Keeps banned addresses from", render: (l) => [scopes[l.scope] || l.scope, l.hosts && l.hosts.length ? h("span", { class: "mono small" }, ": " + l.hosts.join(", ")) : null] },
|
||
{ label: "Never bans", render: (l) => h("span", { class: "mono small" }, (l.never || []).join(", ") || "–") },
|
||
{ label: "Bans", render: (l) => String(count[l.id] || 0) },
|
||
{ label: "Dropped", render: (l) => hits(cs, "ban" + l.id) },
|
||
{ label: "Comment", render: (l) => l.comment },
|
||
write ? { label: "", tdClass: "actions-cell", render: (l) => l.pending === "deleted" ? badge("deleted", "err") : [
|
||
h("button", { class: "small reveal", onclick: () => listDialog(l) }, "Edit"), " ",
|
||
h("button", { class: "small reveal", onclick: () => del(l) }, "Delete")] } : null,
|
||
].filter(Boolean), lists, { empty: "No ban lists yet.", rowClass })),
|
||
h("h2", null, "Banned addresses"),
|
||
h("div", { class: "panel" }, table([
|
||
{ label: "Address", render: (b) => h("span", { class: "mono" }, named(names, b.address)) },
|
||
{ label: "List", render: (b) => b.list },
|
||
{ label: "Ports", render: (b) => b.protocol ? b.protocol + "/" + b.port : "all" },
|
||
{ label: "Ends in", render: (b) => b.timeout ? fmtSeconds(b.timeout) : "never" },
|
||
{ label: "Banned", render: (b) => relTime(b.created) },
|
||
{ label: "By", render: (b) => [b.who, b.source ? h("span", { class: "muted small" }, " " + b.source) : null] },
|
||
{ label: "Reason", render: (b) => h("span", { class: "small", title: b.reason || "" }, (b.reason || "").length > 80 ? b.reason.slice(0, 79) + "…" : b.reason || "") },
|
||
{ label: "", tdClass: "actions-cell", render: (b) => mayBan() ? h("button", { class: "small reveal", onclick: () => unban(b, draw) }, "Unban") : null },
|
||
], bans, { empty: "Nothing is banned." })));
|
||
};
|
||
mount(el,
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Bans"), h("div", { class: "muted" },
|
||
"Programs such as Guard and fail2ban ban addresses into a ban list; the list says what a banned address is kept from. Bans are dropped ahead of every rule, established connections too, and end by themselves.")),
|
||
h("div", { class: "actions" },
|
||
lists.length && mayBan() ? h("button", { onclick: () => banDialog(lists, draw) }, "Ban an address…") : null,
|
||
write ? h("button", { class: "primary", onclick: () => listDialog(null) }, "Add ban list") : null)),
|
||
config || lists.length ? out : h("div", { class: "empty" }, "No ban lists you may see."));
|
||
await draw();
|
||
const t = setInterval(() => { if (!document.hidden && !document.querySelector(".modal")) draw().catch(() => {}); }, 15000);
|
||
onLeave(() => clearInterval(t));
|
||
}
|
||
|
||
// mayBan: a bans:write grant may name its lists; the server checks.
|
||
function mayBan() { return can("bans", true); }
|
||
|
||
async function unban(b, draw) {
|
||
const what = b.address + (b.protocol ? " (" + b.protocol + "/" + b.port + ")" : "");
|
||
if (!await confirmDialog("Unban", "Lift the ban on " + what + " in " + b.list + "?", "Unban")) return;
|
||
try { await api("DELETE", "/banlists/" + encodeURIComponent(b.list) + "/bans/" + b.id); toast("Unbanned " + what); draw(); } catch (e) { toast(e.message, "err"); }
|
||
}
|
||
|
||
async function del(l) {
|
||
if (!await confirmDialog("Delete ban list", "Delete ban list " + l.name + "? Its bans are lifted, and programs banning into it are refused.", "Delete", "danger")) return;
|
||
try { await change("DELETE", "/banlists/" + l.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
|
||
}
|
||
|
||
async function banDialog(lists, draw) {
|
||
const list = select(lists.map((l) => [l.name, l.name + " (" + l.scope + ")"]), lists[0].name);
|
||
const addr = input({ placeholder: "203.0.113.9 or 2001:db8::9", class: "mono" });
|
||
const proto = select([["", "every port"], ["tcp", "TCP"], ["udp", "UDP"]], "");
|
||
const ports = input({ placeholder: "22, ssh, http,https" });
|
||
const t = input({ placeholder: "the list's default", class: "short" });
|
||
const reason = input({ placeholder: "why, for the record" });
|
||
const err = h("div");
|
||
await modal("Ban an address", h("div", null, h("div", { class: "form-grid" },
|
||
field("Ban list", list), field("Address", addr, "An IPv6 address is banned with the list's grouping: its /64 unless the list says otherwise."),
|
||
field("Ports", h("div", { class: "filter-row" }, proto, ports), "Every port, or a protocol's ports (numbers or service names)."),
|
||
field("For", t, "1h, 1d; empty: the list's default."), field("Reason", reason)), err), [
|
||
{ label: "Cancel", value: null },
|
||
{ label: "Ban", kind: "danger", onclick: async () => {
|
||
mount(err);
|
||
const secs = parseSeconds(t.value);
|
||
if (secs === null) { mount(err, h("div", { class: "msg err" }, "Write the time as 1h, 30m or 1d.")); return false; }
|
||
if (proto.value && !ports.value.trim()) { mount(err, h("div", { class: "msg err" }, "Give the ports, or ban every port.")); return false; }
|
||
try {
|
||
const r = await api("POST", "/banlists/" + encodeURIComponent(list.value) + "/bans", { body: { address: addr.value.trim(), protocol: proto.value,
|
||
ports: proto.value ? ports.value.trim() : undefined, time: secs || 0, reason: reason.value.trim(), source: "web" } });
|
||
toast("Banned " + r.bans.map((b) => b.address + (b.protocol ? " " + b.protocol + "/" + b.port : "")).join(", ") + (r.closed ? "; " + r.closed + " connections closed" : ""));
|
||
draw();
|
||
return true;
|
||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||
} },
|
||
], { wide: true });
|
||
}
|
||
|
||
async function listDialog(l) {
|
||
l = l || { scope: "firewall", hosts: [], never: [] };
|
||
const name = input({ value: l.name || "", placeholder: "web", disabled: !!l.id });
|
||
const scope = select(Object.entries(scopes).map(([k, v]) => [k, k + ": " + v]), l.scope);
|
||
const hostsEd = listEditor(l.hosts || [], { placeholder: "10.0.10.57, 2001:db8:10::57, or a set", addLabel: "Add host" });
|
||
const never = listEditor(l.never || [], { placeholder: "198.51.100.0/24, or a set", addLabel: "Add address" });
|
||
const hostFields = field("Hosts", hostsEd, "The machines this list protects: their addresses, IPv4 and IPv6, or sets. Port forwards to them are covered too.").filter(Boolean);
|
||
const showHosts = () => { for (const n of hostFields) n.classList.toggle("hidden", scope.value !== "hosts"); };
|
||
scope.addEventListener("change", showHosts);
|
||
showHosts();
|
||
const time = input({ value: l.default_time ? fmtSeconds(l.default_time) : "", placeholder: "1h", class: "short" });
|
||
const maxTime = input({ value: l.max_time > 0 ? fmtSeconds(l.max_time) : "", placeholder: "7d", class: "short" });
|
||
const forever = checkbox("no limit: bans may last for ever", l.max_time < 0);
|
||
const entries = input({ value: l.max_entries || "", placeholder: "10000", class: "short", type: "number" });
|
||
const group = input({ value: l.group_v6 || "", placeholder: "64", class: "short", type: "number" });
|
||
const disabled = checkbox("disabled: the list's bans aren't in force", !!l.disabled);
|
||
const comment = input({ value: l.comment || "" });
|
||
const err = h("div");
|
||
await modal(l.id ? "Edit ban list " + l.name : "Add ban list", h("div", null, h("div", { class: "form-grid" },
|
||
field("Name", name, l.id ? "Programs and tokens know the list by its name, so it can't be changed." : "Programs ban into the list by this name; give each a token for it alone (bans:write:NAME)."),
|
||
field("Scope", scope, "What a banned address is kept from."), ...hostFields,
|
||
field("Never ban", never, "Besides what is never banned anyway: inside networks, the firewall's own addresses and whoever asks."),
|
||
field("Ban time", time, "A ban's length when the program asks for none."),
|
||
field("Longest ban", maxTime, "Longer bans are cut to this."), h("label"), forever,
|
||
field("Most bans", entries), field("IPv6 banned by", group, "Prefix length: 64 bans what one customer usually gets, 128 one address."),
|
||
h("label"), disabled, field("Comment", comment)), err), [
|
||
{ label: "Cancel", value: null },
|
||
{ label: l.id ? "Save" : "Add ban list", kind: "primary", onclick: async () => {
|
||
mount(err);
|
||
const t = parseSeconds(time.value), m = parseSeconds(maxTime.value);
|
||
if (t === null || m === null) { mount(err, h("div", { class: "msg err" }, "Write times as 1h, 30m or 7d.")); return false; }
|
||
const body = { scope: scope.value, hosts: scope.value === "hosts" ? hostsEd.value() : [], never: never.value(),
|
||
default_time: t || 0, max_time: forever.input.checked ? -1 : m || 0, max_entries: parseInt(entries.value, 10) || 0,
|
||
group_v6: parseInt(group.value, 10) || 0, disabled: disabled.input.checked, comment: comment.value.trim() };
|
||
if (!l.id) body.name = name.value.trim();
|
||
try {
|
||
await change(l.id ? "PATCH" : "POST", l.id ? "/banlists/" + l.id : "/banlists", body);
|
||
rerender();
|
||
return true;
|
||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||
} },
|
||
], { wide: true, sticky: true });
|
||
}
|