Ban lists: where Guard and fail2ban ban addresses

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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-22 16:29:37 +01:00
1 parent 6f56eefae2
commit 500bfc7bdd
44 files changed
+2541 -39

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
// 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 });
}