Files
schiwagoaandClaude Opus 5.5 2e3f79236c 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>
2026-10-25 15:38:17 +01:00

260 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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);