First commit

This commit is contained in:
2026-07-20 11:05:07 +02:00
commit b592ab669f
159 changed files with 10294 additions and 0 deletions
@@ -0,0 +1,28 @@
{% extends "admin/base.html" %}
{% load static %}
{% comment %}
Étend admin/base.html directement (le VRAI parent), pas
admin/base_site.html : puisque banevents précède django.contrib.admin
dans INSTALLED_APPS (nécessaire pour que CE fichier soit trouvé plutôt
que celui de Django), un {% extends "admin/base_site.html" %} ici
retomberait sur ce même fichier et bouclerait à l'infini. Les blocs
title/branding sont donc recopiés depuis le base_site.html par défaut
de Django plutôt qu'hérités.
{% endcomment %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block branding %}
<div id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Django administration') }}</a></div>
{% if user.is_anonymous %}
{% include "admin/color_theme_toggle.html" %}
{% endif %}
{% endblock %}
{% block nav-global %}{% endblock %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'admin/css/emitter_admin.css' %}">
{% endblock %}
@@ -0,0 +1,10 @@
<tr>
<td>{{ event.received_at|date:"c" }}</td>
<td>{{ event.display_node }}</td>
<td>{{ event.jail_name }}</td>
<td><span class="vc-badge {% if event.action == 'BAN' %}vc-badge--danger{% elif event.action == 'UNBAN' %}vc-badge--success{% else %}vc-badge--info{% endif %}">{{ event.action }}</span></td>
<td>{{ event.ip_address }}</td>
<td>{{ event.port|default:"—" }}/{{ event.protocol|default:"—" }}</td>
<td>{{ event.bantime|default:"—" }}</td>
<td>{{ event.city|default:event.country|default:"—" }}</td>
</tr>
@@ -0,0 +1,5 @@
{% load i18n %}
{% if master_dashboard_url %}
<a class="vc-nav-link" href="{{ master_dashboard_url }}/" target="_blank" rel="noopener">↗ Master — {% trans "Direct" %}</a>
<a class="vc-nav-link" href="{{ master_dashboard_url }}/historique/" target="_blank" rel="noopener">↗ Master — {% trans "Historique" %}</a>
{% endif %}
@@ -0,0 +1,65 @@
{% load i18n %}
{% if nodes|length > 1 or top_jails or top_countries %}
<div class="vc-sidebar-stats">
{% if nodes|length > 1 %}
{% comment %}
Heartbeat (last_seen) n'est mis à jour que par master_listen, qui ne
tourne que sur le master : sur un noeud simple, NodeRegistry ne contient
jamais que lui-même — ce bloc n'a de sens que là où plusieurs noeuds
sont effectivement suivis.
{% endcomment %}
<div class="vc-sidebar-stats-title">{% trans "Noeuds" %}</div>
<div class="vc-node-list">
{% for node in nodes %}
<div class="vc-stat-row" title="{% if node.is_online %}{% trans "en ligne" %}{% else %}{% trans "hors ligne" %}{% endif %} {% trans "dernière activité" %} : {{ node.last_seen|timesince }}">
<span class="vc-stat-dot {% if node.is_online %}vc-stat-dot--online{% else %}vc-stat-dot--offline{% endif %}"></span>
{% if node.dashboard_url %}
<a class="vc-stat-label vc-stat-label--link" href="{{ node.dashboard_url }}/" target="_blank" rel="noopener">{{ node.display_name }}</a>
{% else %}
<span class="vc-stat-label">{{ node.display_name }}</span>
{% endif %}
<span class="vc-stat-time">{{ node.last_seen|timesince }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if top_jails %}
<div class="vc-sidebar-stats-title">{% trans "Top jails" %}</div>
{% for row in top_jails %}
<div class="vc-stat-row" title="{{ row.jail_name }} : {{ row.count }}">
<span class="vc-stat-label">{{ row.jail_name }}</span>
<span class="vc-stat-track"><span class="vc-stat-fill vc-stat-fill--jail" style="width: {{ row.pct }}%;"></span></span>
<span class="vc-stat-pct">{{ row.count }}</span>
</div>
{% endfor %}
{% endif %}
{% if top_countries %}
<div class="vc-sidebar-stats-title">{% trans "Top pays attaquants" %}</div>
{% for row in top_countries %}
<div class="vc-stat-row" title="{{ row.country }} : {{ row.count }}">
<span class="vc-stat-label">{{ row.country }}</span>
<span class="vc-stat-track"><span class="vc-stat-fill vc-stat-fill--country" style="width: {{ row.pct }}%;"></span></span>
<span class="vc-stat-pct">{{ row.count }}</span>
</div>
{% endfor %}
{% endif %}
</div>
{% endif %}
{% comment %}
Sélecteur de langue (Phase 5) — POST vers la vue set_language intégrée
de Django (config/urls.py, path("i18n/...")), qui pose le cookie
django_language lu par LocaleMiddleware à chaque requête suivante.
Auto-soumis au changement, pas de bouton "Valider" séparé.
{% endcomment %}
<form action="{% url 'set_language' %}" method="post" class="vc-language-switcher">
{% csrf_token %}
<input name="next" type="hidden" value="{{ request.get_full_path }}">
<select name="language" onchange="this.form.submit()" aria-label="{% trans 'Langue' %}">
{% get_current_language as CURRENT_LANGUAGE %}
{% get_available_languages as AVAILABLE_LANGUAGES %}
{% for code, name in AVAILABLE_LANGUAGES %}
<option value="{{ code }}"{% if code == CURRENT_LANGUAGE %} selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</form>
@@ -0,0 +1,76 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<title>Fail2banActionBanisher — {% trans "Rejoindre la communauté" %}</title>
<link rel="stylesheet" href="/static/banevents/css/main.css">
</head>
<body>
<div class="vc-main-wrap">
<main class="vc-main">
<div class="vc-container" style="max-width: 720px;">
<div class="vc-page-header">
<h2>{% trans "Rejoindre la communauté" %}</h2>
<a href="{% url 'banevents:dashboard' %}" class="vc-btn vc-btn--ghost vc-btn--sm">{% trans "Tableau de bord" %}</a>
</div>
{% if messages %}
<div class="vc-card">
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</div>
{% endif %}
<div class="vc-card">
<div class="vc-card-title">{% trans "Le but" %}</div>
<p>{% blocktrans %}Fail2banActionBanisher est un système distribué de bannissement d'hôtes malveillants (fail2ban + iptables), avec propagation des événements par MQTT. Un serveur "master" centralise la décision (corrélation multi-noeuds, récidive globale) et republie les actions à exécuter vers l'ensemble des noeuds abonnés.{% endblocktrans %}</p>
<p>{% blocktrans %}Rejoindre la communauté ne suppose aucun lien d'appartenance entre les serveurs : chaque membre garde son propre noeud et son propre fail2ban local, seule la propagation des bans est partagée via ce master commun. Aucun accès aux serveurs des autres membres n'est requis ni accordé.{% endblocktrans %}</p>
</div>
<div class="vc-card">
<div class="vc-card-title">{% trans "Le fonctionnement" %}</div>
<p>{% blocktrans %}Votre serveur détecte et bannit localement (fail2ban + iptables), publie l'événement sur un broker Mosquitto local, puis le relaie vers ce master en mTLS. En retour, le master republie les bans décidés (propagation, escalade en cas de récidive détectée sur plusieurs noeuds, ...) vers votre serveur.{% endblocktrans %}</p>
</div>
<div class="vc-card">
<div class="vc-card-title">{% trans "L'installation" %}</div>
<ol>
<li>{% blocktrans %}Cloner le dépôt sur votre serveur (VPS Debian 13), dans le <code>$HOME</code> d'un utilisateur dédié.{% endblocktrans %}</li>
<li>{% blocktrans %}Copier <code>.env.example</code> vers <code>.env</code> et l'éditer.{% endblocktrans %}</li>
<li>{% blocktrans %}Auto-inscription : après acceptation de votre demande ci-dessous, vous recevrez un email avec une commande <code>make join</code> prête à l'emploi — la clé privée de votre serveur est générée localement et ne quitte jamais votre machine.{% endblocktrans %}</li>
<li><code>sudo make install</code></li>
</ol>
</div>
<div class="vc-card">
<div class="vc-card-title">{% trans "Demander à rejoindre" %}</div>
<p>{% trans "Votre demande sera examinée avant qu'un jeton d'inscription ne vous soit envoyé par email." %}</p>
<form method="post" novalidate>
{% csrf_token %}
{{ form.website }}
<div class="vc-form-group">
<label for="{{ form.email.id_for_label }}">{% trans "Email" %}</label>
{{ form.email }}
</div>
<div class="vc-form-group">
<label for="{{ form.node_name.id_for_label }}">{% trans "Nom de noeud souhaité" %}</label>
{{ form.node_name }}
</div>
<div class="vc-form-group">
<label for="{{ form.country.id_for_label }}">{% trans "Pays du serveur (optionnel)" %}</label>
{{ form.country }}
</div>
<div class="vc-form-group">
<label for="{{ form.message.id_for_label }}">{% trans "Message de présentation (optionnel)" %}</label>
{{ form.message }}
</div>
<button type="submit" class="vc-btn vc-btn--primary">{% trans "Envoyer la demande" %}</button>
</form>
</div>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,105 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<title>Fail2banActionBanisher — {% trans "Tableau de bord" %}</title>
<link rel="stylesheet" href="/static/banevents/css/main.css">
<link rel="stylesheet" href="/static/banevents/vendor/leaflet/leaflet.css">
<style>#banevents-map { height: 400px; border-radius: var(--radius-lg); overflow: hidden; }</style>
</head>
<body data-selected-node="{{ selected_node }}" style="--sidebar-w: {{ sidebar_width }};">
<div class="vc-messages" id="banevents-messages"></div>
<nav class="vc-sidebar">
<div class="vc-brand"><span>Fail2banActionBanishment</span><small>(C) dd@miraceti</small></div>
<div class="vc-sidebar-nav">
<a class="vc-nav-link vc-nav-link--active" href="{% url 'banevents:dashboard' %}">{% trans "Bannissements (direct)" %}</a>
<a class="vc-nav-link" href="{% url 'banevents:history' %}">{% trans "Historique" %}</a>
{% if community_enrollment_enabled %}
<a class="vc-nav-link" href="{% url 'banevents:community' %}">{% trans "Rejoindre la communauté" %}</a>
{% endif %}
{% include "banevents/_master_nav.html" %}
</div>
{% include "banevents/_sidebar_stats.html" %}
</nav>
<div class="vc-main-wrap vc-main-wrap--with-sidebar">
<main class="vc-main">
<div class="vc-container">
<div class="vc-page-header">
<h2>{% trans "Événements de bannissement" %}</h2>
{% if filterable_nodes|length > 1 %}
<form method="get" class="vc-header-actions">
<select name="node" class="vc-btn vc-btn--ghost vc-btn--sm" onchange="this.form.submit()">
<option value=""{% if not selected_node %} selected{% endif %}>{% trans "Tous les noeuds" %}</option>
{% for node in filterable_nodes %}
<option value="{{ node.node_id }}"{% if node.node_id == selected_node %} selected{% endif %}>{{ node.display_name }}{% if not node.is_online %} {% trans "(hors ligne)" %}{% endif %}</option>
{% endfor %}
</select>
</form>
{% endif %}
</div>
<div class="vc-card">
<div class="vc-card-title" style="justify-content: space-between;">
<span>{% trans "Carte" %}</span>
<button type="button" id="banevents-map-reset" class="vc-btn vc-btn--ghost vc-btn--sm">{% trans "Réinitialiser la vue" %}</button>
</div>
<div id="banevents-map"></div>
</div>
<div class="vc-card">
<table class="vc-table">
<thead>
<tr>
<th>{% trans "Reçu le" %}</th>
<th>{% trans "Noeud" %}</th>
<th>{% trans "Jail" %}</th>
<th>{% trans "Action" %}</th>
<th>IP</th>
<th>{% trans "Port/Protocole" %}</th>
<th>{% trans "Durée du ban" %}</th>
<th>{% trans "Localisation" %}</th>
</tr>
</thead>
<tbody id="banevents-table-body">
{% for event in events %}
{% include "banevents/_event_row.html" %}
{% endfor %}
</tbody>
</table>
{% if not events %}
<div class="vc-empty-state" id="banevents-empty-state">
<h3>{% trans "Aucun événement pour le moment" %}</h3>
<p>{% trans "En attente d'un ban à diffuser en direct via WebSocket (manage.py mqtt_listen doit tourner)." %}</p>
</div>
{% endif %}
</div>
</div>
</main>
</div>
<script id="banevents-initial-data" type="application/json">{{ events_json|safe }}</script>
{% comment %}
notifications.js n'est pas rendu côté serveur : les quelques mots
qu'il affiche (statut d'une commande reçue du master) passent par ce
bloc JSON plutôt que d'être codés en dur en français dans le .js.
{% endcomment %}
{% trans "Commande reçue du master (non gérée)" as js_i18n_unhandled %}
{% trans "appliqué" as js_i18n_ok %}
{% trans "échec" as js_i18n_error %}
{% trans "Fermer" as js_i18n_close %}
<script id="banevents-i18n" type="application/json">{
"unhandled": "{{ js_i18n_unhandled|escapejs }}",
"ok": "{{ js_i18n_ok|escapejs }}",
"error": "{{ js_i18n_error|escapejs }}",
"close": "{{ js_i18n_close|escapejs }}"
}</script>
<script src="/static/banevents/vendor/leaflet/leaflet.js"></script>
<script src="/static/banevents/js/stream.js"></script>
<script src="/static/banevents/js/table.js"></script>
<script src="/static/banevents/js/map.js"></script>
<script src="/static/banevents/js/notifications.js"></script>
<script src="/static/banevents/js/dashboard.js"></script>
</body>
</html>
@@ -0,0 +1,103 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<title>Fail2banActionBanisher — {% trans "Historique" %}</title>
<link rel="stylesheet" href="/static/banevents/css/main.css">
<link rel="stylesheet" href="/static/banevents/vendor/leaflet/leaflet.css">
<style>#banevents-map { height: 400px; border-radius: var(--radius-lg); overflow: hidden; }</style>
</head>
<body style="--sidebar-w: {{ sidebar_width }};">
<nav class="vc-sidebar">
<div class="vc-brand"><span>Fail2banActionBanisher</span><small>(C) dd@miraceti</small></div>
<div class="vc-sidebar-nav">
<a class="vc-nav-link" href="{% url 'banevents:dashboard' %}">{% trans "Bannissements (direct)" %}</a>
<a class="vc-nav-link vc-nav-link--active" href="{% url 'banevents:history' %}">{% trans "Historique" %}</a>
{% if community_enrollment_enabled %}
<a class="vc-nav-link" href="{% url 'banevents:community' %}">{% trans "Rejoindre la communauté" %}</a>
{% endif %}
{% include "banevents/_master_nav.html" %}
</div>
{% include "banevents/_sidebar_stats.html" %}
</nav>
<div class="vc-main-wrap vc-main-wrap--with-sidebar">
<main class="vc-main">
<div class="vc-container">
<div class="vc-page-header">
<h2>{% trans "Historique des bannissements" %}</h2>
</div>
<div class="vc-card">
<form method="get" style="display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap;">
<div class="vc-form-group" style="margin-bottom: 0;">
<label for="history-from">{% trans "Depuis" %}</label>
<input type="date" id="history-from" name="from" value="{{ from_date }}">
</div>
<div class="vc-form-group" style="margin-bottom: 0;">
<label for="history-to">{% trans "Jusqu'à" %}</label>
<input type="date" id="history-to" name="to" value="{{ to_date }}">
</div>
{% if filterable_nodes|length > 1 %}
<div class="vc-form-group" style="margin-bottom: 0;">
<label for="history-node">{% trans "Noeud" %}</label>
<select id="history-node" name="node">
<option value=""{% if not selected_node %} selected{% endif %}>{% trans "Tous les noeuds" %}</option>
{% for node in filterable_nodes %}
<option value="{{ node.node_id }}"{% if node.node_id == selected_node %} selected{% endif %}>{{ node.display_name }}{% if not node.is_online %} {% trans "(hors ligne)" %}{% endif %}</option>
{% endfor %}
</select>
</div>
{% endif %}
<button type="submit" class="vc-btn vc-btn--primary vc-btn--sm">{% trans "Filtrer" %}</button>
<a href="{% url 'banevents:history' %}" class="vc-btn vc-btn--ghost vc-btn--sm">{% trans "Réinitialiser" %}</a>
</form>
</div>
<div class="vc-card">
<div class="vc-card-title" style="justify-content: space-between;">
<span>{% trans "Carte" %}</span>
<button type="button" id="banevents-map-reset" class="vc-btn vc-btn--ghost vc-btn--sm">{% trans "Réinitialiser la vue" %}</button>
</div>
<div id="banevents-map"></div>
</div>
<div class="vc-card">
<table class="vc-table">
<thead>
<tr>
<th>{% trans "Reçu le" %}</th>
<th>{% trans "Noeud" %}</th>
<th>{% trans "Jail" %}</th>
<th>{% trans "Action" %}</th>
<th>IP</th>
<th>{% trans "Port/Protocole" %}</th>
<th>{% trans "Durée du ban" %}</th>
<th>{% trans "Localisation" %}</th>
</tr>
</thead>
<tbody id="banevents-table-body">
{% for event in events %}
{% include "banevents/_event_row.html" %}
{% endfor %}
</tbody>
</table>
{% if not events %}
<div class="vc-empty-state" id="banevents-empty-state">
<h3>{% trans "Aucun événement sur cette période" %}</h3>
{% url 'banevents:history' as reset_url %}
<p>{% blocktrans %}Ajuste les dates ou le noeud ci-dessus, ou <a href="{{ reset_url }}">réinitialise le filtre</a>.{% endblocktrans %}</p>
</div>
{% endif %}
</div>
</div>
</main>
</div>
<script id="banevents-initial-data" type="application/json">{{ events_json|safe }}</script>
<script src="/static/banevents/vendor/leaflet/leaflet.js"></script>
<script src="/static/banevents/js/map.js"></script>
<script src="/static/banevents/js/history.js"></script>
</body>
</html>