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);
+11
View File
@@ -0,0 +1,11 @@
"use strict";
// Language dropdown: remember the choice for a year and reload
const langSelect = document.getElementById("lang");
if (langSelect) {
langSelect.addEventListener("change", () => {
const secure = location.protocol === "https:" ? "; Secure" : "";
document.cookie = `lang=${encodeURIComponent(langSelect.value)}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
location.reload();
});
}
+143
View File
@@ -0,0 +1,143 @@
:root {
--bg: #f5f6f8; --panel: #ffffff; --text: #1d2330; --muted: #6b7385; --border: #dfe3ea;
--accent: #2f6fdb; --danger: #c93b3b; --ok: #2e8b57; --warn: #c98a1b; --row: #f0f3f8;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171d; --panel: #1c2028; --text: #e3e7ee; --muted: #8f98a8; --border: #2c3240;
--accent: #5b8ff0; --danger: #e05d5d; --ok: #4fbf84; --warn: #e0a73c; --row: #222733;
}
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); }
header { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); }
h1 { font-size: 18px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 10px; }
.meta { display: flex; gap: 10px; align-items: center; color: var(--muted); }
main { padding: 16px 20px; display: grid; gap: 16px; max-width: 1400px; margin: 0 auto; }
main > * { min-width: 0; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
.card h3 { margin: 0 0 8px; font-size: 15px; display: flex; justify-content: space-between; }
.card .err { color: var(--danger); }
.jail-list { display: grid; grid-template-columns: 1fr auto auto; gap: 2px 12px; font-variant-numeric: tabular-nums; }
.jail-list .h { color: var(--muted); font-size: 12px; }
button, .btn-link { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel); color: var(--text); cursor: pointer; text-decoration: none; }
button:hover, .btn-link:hover { border-color: var(--accent); }
button.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
button.small { padding: 2px 8px; font-size: 12px; }
button:disabled { opacity: .5; cursor: wait; }
input, select { font: inherit; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); }
#ban-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
#ban-ip { width: 260px; } #ban-note { flex: 1; min-width: 200px; }
.hosts-select { display: flex; gap: 10px; }
.hint { color: var(--muted); font-size: 12px; margin: 8px 0 0; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }
.toolbar h2 { margin: 0 auto 0 0; }
.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 0 8px; font-size: 12px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
th { color: var(--muted); font-weight: 600; font-size: 12px; }
tbody tr:hover { background: var(--row); }
td.actions { display: flex; gap: 4px; }
.muted { color: var(--muted); }
.perm { color: var(--danger); font-weight: 600; }
code { font-size: 12px; }
.audit { list-style: none; margin: 0; padding: 0; font-size: 13px; }
.audit li { padding: 4px 0; border-bottom: 1px solid var(--border); }
.audit .bad { color: var(--danger); }
dialog { width: min(1000px, 95vw); max-height: 85vh; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 0; }
dialog::backdrop { background: rgba(0, 0, 0, .5); }
.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); }
#history-body { padding: 12px 16px; overflow: auto; }
#history-body pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; margin: 0; }
#history-body .line { padding: 4px 0; border-bottom: 1px solid var(--border); }
#toast { position: fixed; right: 20px; bottom: 20px; max-width: 420px; padding: 10px 14px; border-radius: 8px; background: var(--text); color: var(--panel); opacity: 0; transition: opacity .2s; pointer-events: none; }
#toast.show { opacity: .95; }
#toast.error { background: var(--danger); color: #fff; }
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 12px 0 24px; }
footer a { color: inherit; }
.audit li { overflow-wrap: anywhere; }
/* ---------- Tablet ---------- */
@media (max-width: 1000px) {
main { padding: 12px 16px; gap: 12px; }
#ban-ip { flex: 1 1 220px; width: auto; }
.toolbar h2 { flex: 1 1 100%; }
.toolbar input[type="search"] { flex: 1 1 220px; }
}
/* ---------- Handy ---------- */
@media (max-width: 820px) {
header { padding: 12px 16px; }
.meta { flex-wrap: wrap; width: 100%; }
#generated { flex: 1 1 100%; font-size: 12px; }
.meta button, .meta .btn-link { flex: 1; text-align: center; }
.panel, .card { padding: 12px; }
/* 16px verhindert das automatische Zoomen in iOS beim Antippen */
input, select { font-size: 16px; min-height: 40px; }
button, .btn-link { min-height: 40px; }
.btn-link { display: inline-flex; align-items: center; justify-content: center; }
#ban-form > input, #ban-form > button { flex: 1 1 100%; width: 100%; min-width: 0; }
.hosts-select { flex-wrap: wrap; gap: 6px 16px; }
.hosts-select label { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; }
.toolbar input[type="search"] { flex: 1 1 100%; }
.toolbar select { flex: 1 1 0; min-width: 0; }
/* Tabelle wird zur Kartenliste */
.table-wrap { overflow: visible; }
table, tbody, tr, td { display: block; width: 100%; }
thead { display: none; }
tbody tr { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; }
tbody tr:hover { background: none; }
td { display: flex; gap: 12px; justify-content: space-between; align-items: baseline; padding: 4px 0; border: 0; white-space: normal; text-align: right; overflow-wrap: anywhere; }
td[data-label]::before { content: attr(data-label); color: var(--muted); font-size: 12px; text-align: left; flex: none; }
td.ip { order: -1; justify-content: flex-start; font-size: 15px; font-weight: 600; padding-bottom: 6px; }
td.ip::before { display: none; }
td.ip code { font-size: 15px; }
td[colspan] { display: block; text-align: left; }
tbody tr:has(td[colspan]) { border: 0; padding: 0; }
td.actions { flex-wrap: wrap; gap: 6px; padding-top: 8px; margin-top: 4px; border-top: 1px solid var(--border); }
td.actions > * { flex: 1 1 calc(50% - 6px); text-align: center; min-height: 36px; font-size: 13px; padding: 6px 8px; }
dialog { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; border: 0; }
.dialog-head { padding: 10px 12px; }
#history-body { padding: 10px 12px; }
#toast { left: 12px; right: 12px; bottom: 12px; max-width: none; }
}
/* ---------- Header: language, user, logout ---------- */
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.lang-select select { padding: 5px 8px; }
.user { font-weight: 600; color: var(--text); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
form.inline { display: inline; margin: 0; }
button.primary, .btn-link.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ---------- Login / error page ---------- */
main.login { max-width: 420px; }
.login-form { display: grid; gap: 12px; }
.login-form label { display: grid; gap: 4px; color: var(--muted); font-size: 13px; }
.login-form input { width: 100%; }
.login .sso { display: block; text-align: center; }
.login .or { text-align: center; margin: 12px 0; }
.msg { padding: 8px 10px; border-radius: 6px; margin: 0 0 12px; overflow-wrap: anywhere; }
.msg.error { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); }
@media (max-width: 820px) {
.lang-select, .lang-select select { flex: 1; }
.user { flex: 1 1 100%; max-width: none; }
form.inline { flex: 1; display: flex; }
form.inline button { flex: 1; }
}
/* ---------- Origin map ---------- */
.map svg { display: block; width: 100%; height: auto; max-height: 360px; }
.map circle { fill: var(--danger); fill-opacity: .5; stroke: var(--danger); stroke-width: .3; cursor: pointer; transition: fill-opacity .15s; }
.map circle:hover { fill-opacity: .9; }
.map-panel .toolbar h2 { margin-right: 0; }
.map-panel #map-summary { margin-right: auto; font-size: 13px; }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 5 360 145"><!-- Natural Earth 110m land (public domain), equirectangular --><path fill="#8a8f98" fill-opacity=".35" d="M112.2 143.8L113.5 144.5L114.9 144.7L114.5 145.2L113.5 145.2L113 144.9L112.7 145.3L111.9 145.6L110.8 145.5L110 145.2L109 145.1L107.7 144.5L106.7 144L105.3 142.8L106.2 143L107.6 143.7L108.9 144.1L109.4 143.6L109.7 142.9L110.7 142.5L111.4 142.6L111.7 143.1L112.2 143.8ZM121.5 141.1L122.3 141.5L122 141.9L120.6 142.2L120.2 141.8L119.3 142.3L118.8 141.8L120 141.3L120.8 141.5L121.5 141.1ZM250.3 139.7L248.7 139.8L248.7 139.2L248.9 138.8L248.9 138.6L249.6 138.9L250.5 139.1L250.6 139.3L250.3 139.7ZM325.4 130.8L326.4 131.1L326.9 131L327.7 130.8L328.3 130.9L328.4 132.1L328 132.4L327.9 133.2L327.6 132.9L326.9 133.6L326.7 133.6L326 133.5L325.4 132.7L325.3 132L324.7 131.2L324.7 130.7L325.4 130.8ZM353 130.9L353.2 131.3L354 130.9L354.2 131.3L354.2 131.8L353.9 132.2L353.2 133L352.7 133.4L353.1 133.9L352.3 133.9L351.5 134.2L351.2 134.9L350.6 135.9L349.8 136.4L349.3 136.6L348.4 136.6L347.8 136.3L346.7 136.2L346.5 135.9L347 135.1L348.3 134.1L348.9 133.9L349.7 133.6L350.5 133L351.1 132.5L351.6 131.8L352 131.5L352.1 131L352.8 130.5L353 130.9ZM354.6 126.2L355.3 127.2L355.4 126.5L355.8 126.8L356 127.6L356.8 127.9L357.4 128L358 127.6L358.5 127.7L358.3 128.6L358 129.2L357.2 129.1L356.9 129.4L357 129.9L356.9 130.1L356.5 130.6L356 131.3L355.2 131.7L355.1 131.4L354.7 131.3L355.2 130.5L354.9 129.9L353.8 129.5L353.9 129.1L354.6 128.8L354.7 128L354.7 127.4L354.3 126.7L354.3 126.5L353.8 126.1L353.1 125.2L352.6 124.5L353 124.5L353.6 125L354.3 125.3L354.6 126.2ZM347.1 112.2L346.7 112.4L346.2 112.1L345.5 111.7L344.8 111.1L344.2 110.4L344 110.1L344.5 110.1L345 110.5L345.5 110.8L345.8 111.1L346.6 111.7L347.1 112.2ZM358.4 107.3L358.7 107.6L358.6 108.2L357.9 108.3L357.4 108.2L357.3 107.7L357.7 107.4L358.1 107.5L358.4 107.3ZM359.4 106.8L358.7 107L358.6 106.6L359.1 106.4L359.4 106.4ZM0 106.1L0.2 106L0.1 106.5L0 106.6ZM347.8 106.5L347.5 106.6L347.2 106.2L347.2 105.9L347.8 106.5ZM347.1 104.9L347.3 105.7L347 105.6L346.8 105.7L346.7 105.4L346.6 104.6L347.1 104.9ZM230.1 103.6L230.2 104.8L230.5 105.2L230.4 105.7L230.2 106L229.9 105.4L229.7 105.7L229.9 106.5L229.8 106.9L229.5 107.1L229.4 108L229 109.1L228.5 110.5L227.9 112.4L227.5 113.8L227.1 114.9L226.3 115.2L225.4 115.6L224.8 115.3L224 115L223.8 114.5L223.7 113.6L223.3 112.8L223.3 112.1L223.4 111.3L223.9 111.2L223.9 110.8L224.4 110.1L224.5 109.4L224.2 109L224 108.3L224 107.4L224.3 106.9L224.4 106.2L224.9 106.2L225.5 106L225.9 105.8L226.3 105.8L226.9 105.2L227.7 104.6L228 104.1L227.9 103.7L228.3 103.8L228.8 103.1L228.9 102.5L229.2 102L229.5 102.5L229.8 102.9L230.1 103.6ZM323.6 103.8L323.9 104.5L324.6 104.2L324.9 104.6L325.4 105L325.3 105.4L325.5 106.3L325.6 106.8L325.9 106.9L326.2 107.8L326.1 108.3L326.4 109L327.5 109.5L328.2 110L328.8 110.4L328.7 110.6L329.3 111.3L329.7 112.3L330.1 112.1L330.5 112.6L330.7 112.4L330.9 113.5L331.6 114.1L332.1 114.5L332.9 115.3L333.1 116.1L333.2 116.6L333.1 117.3L333.6 118.1L333.5 119L333.3 119.5L333.1 120.4L333.1 120.9L332.9 121.6L332.4 122.6L331.7 123L331.3 123.8L331 124.3L330.7 125.2L330.3 125.7L330.1 126.4L329.9 127.1L330 127.4L329.4 127.8L328.3 127.8L327.4 128.2L326.9 128.6L326.3 129L325.5 128.6L324.9 128.4L325 127.9L324.5 128.1L323.6 128.8L322.7 128.5L322.2 128.4L321.6 128.3L320.6 128L320 127.4L319.8 126.6L319.6 126.1L319.1 125.7L318.1 125.6L318.4 125.1L318.2 124.4L317.7 125.1L316.8 125.3L317.4 124.7L317.5 124.1L317.9 123.6L317.8 122.9L317 123.8L316.4 124.1L316 124.9L315.2 124.5L315.2 123.9L314.6 123.2L314.1 122.8L314.3 122.6L313 122L312.3 122L311.3 121.5L309.5 121.6L308.2 121.9L307.1 122.3L306.1 122.2L305.1 122.7L304.2 123L304 123.5L303.7 123.9L302.8 123.9L302.2 124L301.3 123.8L300.6 123.9L299.9 124L299.3 124.5L299 124.5L298.5 124.7L298 125.1L297.3 125L296.6 125L295.6 124.4L295 124.2L295 123.6L295.5 123.5L295.7 123.3L295.7 122.9L295.8 122.2L295.7 121.6L295.2 120.6L295 120L295 119.5L294.6 118.8L294.6 118.5L294.2 118.1L294 117.3L293.5 116.5L293.3 116.1L293.8 116.5L293.4 115.6L293.9 115.9L294.2 116.3L294.2 115.8L293.7 115L293.6 114.7L293.4 114.4L293.5 113.8L293.7 113.6L293.8 113.1L293.7 112.5L294.1 111.8L294.2 112.5L294.6 111.8L295.5 111.5L295.9 111.1L296.7 110.7L297.2 110.6L297.4 110.7L298.2 110.4L298.8 110.3L299 110L299.3 110L299.8 110L300.9 109.7L301.4 109.2L301.7 108.7L302.2 108.2L302.3 107.8L302.3 107.3L303 106.4L303.4 107.3L303.9 107.1L303.5 106.6L303.8 106.1L304.3 106.3L304.4 105.6L304.9 105.1L305.2 104.7L305.7 104.5L305.7 104.2L306.1 104.3L306.1 104.1L306.6 104L307.1 103.8L307.8 104.3L308.4 104.9L309 104.9L309.6 105L309.4 104.4L309.9 103.6L310.3 103.4L310.2 103.1L310.6 102.5L311.2 102.2L311.7 102.3L312.6 102.1L312.6 101.6L311.8 101.3L312.4 101.1L313 101.4L313.6 101.8L314.4 102L314.7 101.9L315.3 102.2L315.9 102L316.3 102L316.5 101.9L317 102.4L316.7 102.9L316.3 103.3L316 103.3L316.1 103.7L315.8 104.2L315.4 104.7L315.5 105L316.3 105.6L317.1 105.9L317.6 106.2L318.3 106.8L318.6 106.8L319.1 107.1L319Line truncated

After

Width:  |  Height:  |  Size: 46 KiB