"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);