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

+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ t('title') }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="csrf-token" content="{{ csrf }}">
</head>
<body class="{% block body_class %}{% endblock %}">
<header>
<h1>{{ t('title') }}</h1>
<div class="meta">
{% block header_meta %}{% endblock %}
<label class="lang-select">
<span class="sr-only">{{ t('language') }}</span>
<select id="lang" aria-label="{{ t('language') }}">
{% for code, name in languages %}<option value="{{ code }}"{% if code == lang %} selected{% endif %}>{{ name }}</option>{% endfor %}
</select>
</label>
{% if user %}
<span class="user" title="{{ t('signed_in_as', user=user) }}">{{ user }}</span>
{% if logout_local %}
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit">{{ t('logout') }}</button>
</form>
{% elif logout_url %}
<a class="btn-link" href="{{ logout_url }}">{{ t('logout') }}</a>
{% endif %}
{% endif %}
</div>
</header>
{% block content %}{% endblock %}
<footer>
{% if geo_enabled %}{{ t('footer_geo') }} <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP Lite</a> (CC BY 4.0) · {% endif %}
<a href="https://git.retronerd.at/schiwagoa/fail2ban-webui" target="_blank" rel="noopener">fail2ban-webui</a> {{ version }}
</footer>
<script src="{{ url_for('static', filename='common.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ t('error_title', code=code) }} – {{ t('title') }}{% endblock %}
{% block content %}
<main class="login">
<section class="panel">
<h2>{{ t('error_title', code=code) }}</h2>
<p class="msg error">{{ message }}</p>
<p><a href="{{ url_for('index') }}">{{ t('error_back') }}</a></p>
</section>
</main>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block header_meta %}
<span id="generated">–</span>
<button id="refresh" type="button">{{ t('refresh') }}</button>
{% if client_config.grafana and client_config.grafanaDashboard %}<a class="btn-link" href="{{ client_config.grafana }}{{ client_config.grafanaDashboard }}" target="_blank" rel="noopener">{{ t('grafana') }}</a>{% endif %}
{% endblock %}
{% block content %}
<main>
<section id="hosts" class="cards"></section>
{% if geo_enabled %}
<section class="panel map-panel">
<div class="toolbar">
<h2>{{ t('map_heading') }}</h2>
<span id="map-summary" class="muted"></span>
<button id="map-reset" type="button" class="small" hidden>{{ t('map_reset') }}</button>
</div>
<div class="map">
<svg id="map" viewBox="0 5 360 145" role="img" aria-label="{{ t('map_heading') }}">
<image href="{{ url_for('static', filename='world.svg') }}" x="0" y="5" width="360" height="145"/>
<g id="map-points"></g>
</svg>
</div>
<p class="hint">{{ t('map_hint') }}</p>
</section>
{% endif %}
<section class="panel">
<h2>{{ t('ban_heading') }}</h2>
<form id="ban-form" autocomplete="off">
<input id="ban-ip" type="text" placeholder="{{ t('ban_ip_placeholder') }}" required>
<input id="ban-note" type="text" placeholder="{{ t('ban_note_placeholder') }}" maxlength="200">
<span class="hosts-select">
{% for h in hosts %}<label><input type="checkbox" name="ban-host" value="{{ h }}" checked> {{ h }}</label>{% endfor %}
</span>
<button type="submit" class="danger">{{ t('ban_button') }}</button>
</form>
<p class="hint">{{ t('ban_hint', jail=manual_jail) }}</p>
</section>
<section class="panel">
<div class="toolbar">
<h2>{{ t('bans_heading') }} <span id="count" class="badge">0</span></h2>
<input id="filter-text" type="search" placeholder="{{ t('filter_search') }}">
<select id="filter-host"><option value="">{{ t('filter_all_hosts') }}</option></select>
<select id="filter-jail"><option value="">{{ t('filter_all_jails') }}</option></select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>{{ t('col_host') }}</th><th>{{ t('col_jail') }}</th><th>{{ t('col_ip') }}</th><th>{{ t('col_origin') }}</th><th>{{ t('col_since') }}</th><th>{{ t('col_until') }}</th><th>{{ t('col_actions') }}</th></tr>
</thead>
<tbody id="bans"><tr><td colspan="7" class="muted">{{ t('loading') }}</td></tr></tbody>
</table>
</div>
</section>
<section class="panel">
<h2>{{ t('audit_heading') }}</h2>
<ul id="audit" class="audit"></ul>
</section>
</main>
<dialog id="history">
<div class="dialog-head">
<h2 id="history-title"></h2>
<button type="button" id="history-close" aria-label="{{ t('history_close') }}">✕</button>
</div>
<div id="history-body"></div>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
<script id="i18n" type="application/json">{{ i18n | tojson }}</script>
<script id="client-config" type="application/json">{{ client_config | tojson }}</script>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static', filename='app.js') }}"></script>{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}{{ t('login_title') }} – {{ t('title') }}{% endblock %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<main class="login">
<section class="panel">
<h2>{{ t('login_title') }}</h2>
{% for category, message in get_flashed_messages(with_categories=true) %}
<p class="msg {{ category }}">{{ message }}</p>
{% endfor %}
{% if local %}
<form method="post" action="{{ url_for('auth.login_post') }}" class="login-form">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label>{{ t('login_user') }}<input name="username" autocomplete="username" required autofocus></label>
<label>{{ t('login_password') }}<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" class="primary">{{ t('login_button') }}</button>
</form>
{% endif %}
{% if local and oidc %}<p class="or muted">{{ t('login_or') }}</p>{% endif %}
{% if oidc %}
<a class="btn-link primary sso" href="{{ url_for('auth.oidc_start') }}">{{ t('login_oidc', provider=oidc_label) }}</a>
{% endif %}
</section>
</main>
{% endblock %}