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>
260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
"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);
|