Initial version of fail2ban-webui

Web interface to view, ban and unban fail2ban IPs on several hosts via
SSH, generalised from the private f2b-web tool.

- Web UI (Flask/Waitress): hosts.yaml, local/OIDC/proxy authentication,
  English/German UI, origin map, optional Loki/Grafana/GeoIP, CLI helpers
- Agent f2b-webctl with install.sh/uninstall.sh (dedicated user + sudo,
  from= restriction, protected networks, manual jail)
- Hardened Docker image, examples, documentation, smoke test, testbed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schiwagoaandClaude Opus 5.5 committed 2026-10-25 15:38:17 +01:00
commit 2e3f79236c
44 files changed
+4280

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
"use strict";
const $ = (sel) => document.querySelector(sel);
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
const I18N = JSON.parse($("#i18n").textContent);
const CFG = JSON.parse($("#client-config").textContent);
let state = { bans: [], hosts: {} };
// ---------- Helpers ----------
function t(key, params = {}) {
return (I18N[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
}
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v);
}
for (const c of children) node.append(c instanceof Node ? c : document.createTextNode(c ?? ""));
return node;
}
function toast(msg, isError = false) {
const box = $("#toast");
box.textContent = msg;
box.className = "show" + (isError ? " error" : "");
clearTimeout(toast.timer);
toast.timer = setTimeout(() => { box.className = ""; }, isError ? 8000 : 4000);
}
async function api(path, body) {
const opts = body
? { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": CSRF }, body: JSON.stringify(body) }
: {};
const res = await fetch(path, opts);
let data;
try { data = await res.json(); } catch { data = { ok: false, error: `HTTP ${res.status}` }; }
if (!res.ok && data.ok !== false) data.ok = false;
if (res.status === 401) setTimeout(() => location.reload(), 2500);
return data;
}
function resultText(data) {
if (data.error) return data.error;
return Object.entries(data.results || {})
.map(([h, r]) => `${h}: ${r.ok ? t("result_ok") : (r.error || r.result || t("result_error"))}`).join(" · ");
}
function localName(names, fallback) {
return (names && names[CFG.lang]) || fallback || "";
}
function grafanaLink(ip) {
if (!CFG.grafana || !CFG.loki) return null;
const esc = ip.replaceAll(".", "\\\\.");
const panes = { a: { datasource: CFG.grafanaLokiUid, queries: [{ refId: "A",
datasource: { type: "loki", uid: CFG.grafanaLokiUid }, expr: `${CFG.lokiSelector} |~ "${esc}"` }],
range: { from: `now-${CFG.lokiDays}d`, to: "now" } } };
return `${CFG.grafana}/explore?schemaVersion=1&orgId=1&panes=${encodeURIComponent(JSON.stringify(panes))}`;
}
// ---------- Rendering ----------
function renderHosts() {
const box = $("#hosts");
box.replaceChildren();
for (const [host, info] of Object.entries(state.hosts)) {
const card = el("div", { class: "card" });
const sum = Object.values(info.jails || {}).reduce((a, j) => a + j.current, 0);
card.append(el("h3", {}, host, el("span", { class: "badge" }, String(sum))));
if (!info.ok) {
card.append(el("div", { class: "err" }, info.error));
} else {
const list = el("div", { class: "jail-list" },
el("span", { class: "h" }, t("jail")), el("span", { class: "h" }, t("jail_current")),
el("span", { class: "h" }, t("jail_total")));
for (const [jail, j] of Object.entries(info.jails).sort()) {
list.append(el("span", {}, jail), el("span", {}, String(j.current)), el("span", { class: "muted" }, String(j.total ?? "–")));
}
card.append(list);
}
box.append(card);
}
}
// World map: one point per place (rounded to 0.5°), size grows with the number of bans.
// Projection matches static/world.svg: x = lon + 180, y = 90 - lat.
const SVG_NS = "http://www.w3.org/2000/svg";
function renderMap(rows) {
const layer = $("#map-points");
if (!layer) return;
const places = new Map();
let unknown = 0;
for (const b of rows) {
if (b.lat == null || b.lon == null) { unknown++; continue; }
const key = `${Math.round(b.lat * 2) / 2},${Math.round(b.lon * 2) / 2}`;
const p = places.get(key) || { lat: b.lat, lon: b.lon, n: 0, cc: b.cc,
name: [localName(b.city_names, b.city), localName(b.country_names, b.country)].filter(Boolean).join(", ") || b.cc || "?" };
p.n++;
places.set(key, p);
}
layer.replaceChildren();
// big points first, so small ones stay clickable on top
for (const p of [...places.values()].sort((a, b) => b.n - a.n)) {
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("cx", (p.lon + 180).toFixed(2));
c.setAttribute("cy", (90 - p.lat).toFixed(2));
c.setAttribute("r", (1.3 + Math.sqrt(p.n) * 0.9).toFixed(2));
const title = document.createElementNS(SVG_NS, "title");
title.textContent = t("map_point", { place: p.name, n: p.n });
c.append(title);
c.addEventListener("click", () => {
$("#filter-text").value = p.cc || "";
renderBans();
});
layer.append(c);
}
$("#map-summary").textContent = t("map_summary", { ips: rows.length - unknown, places: places.size })
+ (unknown ? t("map_unknown", { n: unknown }) : "");
$("#map-reset").hidden = !$("#filter-text").value;
}
function fillSelect(sel, values) {
const cur = sel.value;
const first = sel.options[0];
sel.replaceChildren(first, ...values.map((v) => el("option", { value: v }, v)));
sel.value = values.includes(cur) ? cur : "";
}
function renderBans() {
const text = $("#filter-text").value.trim().toLowerCase();
const host = $("#filter-host").value;
const jail = $("#filter-jail").value;
const rows = state.bans.filter((b) =>
(!host || b.host === host) && (!jail || b.jail === jail) &&
(!text || [b.ip, b.cc, b.country, b.city, localName(b.country_names), localName(b.city_names)]
.join(" ").toLowerCase().includes(text)));
$("#count").textContent = String(rows.length);
renderMap(rows);
const tbody = $("#bans");
tbody.replaceChildren();
if (!rows.length) {
tbody.append(el("tr", {}, el("td", { colspan: "7", class: "muted" }, t("no_bans"))));
return;
}
for (const b of rows.slice(0, 1000)) {
const origin = [b.cc, localName(b.country_names, b.country), localName(b.city_names, b.city)]
.filter(Boolean).join(" · ") || "–";
// bantime null: older fail2ban without --with-time, start/end unknown
const until = b.bantime === null ? "–" : b.bantime < 0 ? el("span", { class: "perm" }, t("permanent")) : b.end;
const actions = el("td", { class: "actions" },
el("button", { class: "small", type: "button", onclick: () => unban(b.host, b.jail, b.ip) }, t("btn_unban")),
el("button", { class: "small", type: "button", onclick: () => unban("*", "*", b.ip) }, t("btn_unban_everywhere")));
if (CFG.loki) {
actions.append(el("button", { class: "small", type: "button", onclick: () => history(b.ip) }, t("btn_history")));
}
const link = grafanaLink(b.ip);
if (link) actions.append(el("a", { class: "btn-link small", href: link, target: "_blank", rel: "noopener" }, t("grafana")));
// data-label: column name for the card view on narrow screens
tbody.append(el("tr", {},
el("td", { "data-label": t("col_host") }, b.host), el("td", { "data-label": t("col_jail") }, b.jail),
el("td", { "data-label": t("col_ip"), class: "ip" }, el("code", {}, b.ip)),
el("td", { "data-label": t("col_origin") }, origin), el("td", { "data-label": t("col_since") }, b.start || "–"),
el("td", { "data-label": t("col_until") }, until), actions));
}
if (rows.length > 1000) {
tbody.append(el("tr", {}, el("td", { colspan: "7", class: "muted" }, t("more_rows", { n: rows.length - 1000 }))));
}
}
async function loadAudit() {
const data = await api("/api/audit");
const ul = $("#audit");
ul.replaceChildren();
if (!data.entries || !data.entries.length) { ul.append(el("li", { class: "muted" }, t("audit_empty"))); return; }
for (const e of data.entries) {
const failed = Object.values(e.ok || {}).some((v) => !v);
const what = e.action === "ban" ? t("audit_banned") : t("audit_unbanned");
const where = e.jail && e.jail !== "*" ? t("audit_where_jail", { jail: e.jail })
: e.action === "unban" ? t("audit_where_all") : "";
ul.append(el("li", { class: failed ? "bad" : "" },
t("audit_line", { ts: e.ts, ip: e.ip, what, hosts: (e.hosts || []).join(", ") }) + where,
e.note ? ` – „${e.note}“` : "", failed ? t("audit_failed") : "",
el("span", { class: "muted" }, t("audit_by", { user: e.user || "?", client: e.client }))));
}
}
async function load(force = false) {
const btn = $("#refresh");
btn.disabled = true;
try {
const data = await api("/api/bans" + (force ? "?refresh=1" : ""));
if (!data.hosts) throw new Error(data.error || "?");
state = data;
$("#generated").textContent = t("generated", { ts: data.generated });
fillSelect($("#filter-host"), Object.keys(data.hosts));
fillSelect($("#filter-jail"), [...new Set(data.bans.map((b) => b.jail))].sort());
renderHosts();
renderBans();
} catch (err) {
toast(t("toast_load_failed", { error: err.message || err }), true);
} finally {
btn.disabled = false;
}
loadAudit();
}
// ---------- Actions ----------
async function unban(host, jail, ip) {
const question = host === "*" ? t("confirm_unban_all", { ip }) : t("confirm_unban", { ip, host, jail });
if (!confirm(question)) return;
const data = await api("/api/unban", { ip, host, jail });
toast(t("toast_unban", { ip, result: resultText(data) }), !data.ok);
load();
}
async function ban(ev) {
ev.preventDefault();
const ip = $("#ban-ip").value.trim();
const note = $("#ban-note").value.trim();
const hosts = [...document.querySelectorAll('input[name="ban-host"]:checked')].map((c) => c.value);
if (!hosts.length) { toast(t("select_host"), true); return; }
if (!confirm(t("confirm_ban", { ip, hosts: hosts.join(", ") }))) return;
const data = await api("/api/ban", { ip, hosts, note });
toast(t("toast_ban", { ip, result: resultText(data) }), !data.ok);
if (data.ok) { $("#ban-ip").value = ""; $("#ban-note").value = ""; }
load();
}
async function history(ip) {
const dlg = $("#history");
$("#history-title").textContent = t("history_title", { ip, days: CFG.lokiDays });
const body = $("#history-body");
body.replaceChildren(el("p", { class: "muted" }, t("loading")));
dlg.showModal();
const data = await api(`/api/history?ip=${encodeURIComponent(ip)}`);
body.replaceChildren();
if (!data.ok) { body.append(el("p", { class: "err" }, data.error)); return; }
const g = data.geo || {};
body.append(el("p", {}, [g.cc, localName(g.country_names, g.country), localName(g.city_names, g.city)]
.filter(Boolean).join(" · ") || t("origin_unknown")));
if (!data.lines.length) { body.append(el("p", { class: "muted" }, t("history_none"))); return; }
for (const l of data.lines) {
body.append(el("div", { class: "line" },
el("span", { class: "muted" }, `${l.ts} · ${l.host} · ${l.job}`), el("pre", {}, l.line)));
}
}
// ---------- Start ----------
$("#refresh").addEventListener("click", () => load(true));
$("#ban-form").addEventListener("submit", ban);
$("#history-close").addEventListener("click", () => $("#history").close());
if ($("#map-reset")) $("#map-reset").addEventListener("click", () => { $("#filter-text").value = ""; renderBans(); });
for (const id of ["#filter-text", "#filter-host", "#filter-jail"]) $(id).addEventListener("input", renderBans);
load();
setInterval(() => load(), 60000);