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
View File
+28
View File
@@ -0,0 +1,28 @@
"""Soumission d'un rapport à AbuseIPDB (Phase 4, REPORT_ABUSE) — fonction
pure, sans effet de bord hors l'appel réseau, appelée directement par
management/commands/publish_command.py (jamais diffusée en MQTT, voir sa
docstring pour le pourquoi : un rapport par IP suffit, le diffuser à
chaque noeud produirait des doublons vus comme du bruit par le service).
"""
import requests
from django.conf import settings
ABUSEIPDB_REPORT_URL = 'https://api.abuseipdb.com/api/v2/report'
def submit_report(ip: str, categories: str, comment: str) -> tuple[bool, str]:
"""POST vers l'API v2 AbuseIPDB. Retourne (succès, detail — message
d'erreur si échec), jamais d'exception laissée remonter même forme
que master_client.py::_banip_via_jail, pour un traitement uniforme
côté appelant."""
try:
response = requests.post(
ABUSEIPDB_REPORT_URL,
headers={'Key': settings.ABUSEIPDB_API_KEY, 'Accept': 'application/json'},
data={'ip': ip, 'categories': categories, 'comment': comment},
timeout=10,
)
response.raise_for_status()
except requests.RequestException as e:
return False, str(e)
return True, ''
+97
View File
@@ -0,0 +1,97 @@
import datetime
from django.contrib import admin, messages
from django.core.mail import send_mail
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils import timezone
from .enrollment import issue_join_token, join_command
from .models import BanEvent, EnrollmentRequest, JoinToken, NodeRegistry
from .views import NODE_OFFLINE_THRESHOLD_SECONDS
@admin.register(BanEvent)
class BanEventAdmin(admin.ModelAdmin):
list_display = ('received_at', 'node', 'jail_name', 'action', 'ip_address', 'bantime')
list_filter = ('jail_name', 'action', 'node')
search_fields = ('ip_address', 'node', 'jail_name')
ordering = ('-received_at',)
@admin.register(NodeRegistry)
class NodeRegistryAdmin(admin.ModelAdmin):
"""Seule UI d'édition d'alias/pays par noeud — pas de page dédiée.
node_id reste en premier et hors list_editable (Django exige que la
première colonne serve de lien vers la page de modification)."""
list_display = ('node_id', 'alias', 'country', 'dashboard_url', 'is_online', 'last_seen', 'first_seen')
list_editable = ('alias', 'country', 'dashboard_url')
search_fields = ('node_id', 'alias')
ordering = ('node_id',)
@admin.display(boolean=True, description='En ligne')
def is_online(self, obj: NodeRegistry) -> bool:
threshold = timezone.now() - datetime.timedelta(seconds=NODE_OFFLINE_THRESHOLD_SECONDS)
return obj.last_seen >= threshold
@admin.register(JoinToken)
class JoinTokenAdmin(admin.ModelAdmin):
"""Visibilité des jetons émis (manage.py create_join_token) — jamais
créés/édités ici, token_hash n'a d'ailleurs aucun intérêt à être
affiché (c'est un hash, le jeton en clair n'est montré qu'une fois,
en ligne de commande, jamais persisté)."""
list_display = ('node_name', 'created_at', 'expires_at', 'used_at', 'status')
search_fields = ('node_name',)
ordering = ('-created_at',)
@admin.display(description='Statut')
def status(self, obj: JoinToken) -> str:
if obj.used_at:
return 'Utilisé'
if timezone.now() > obj.expires_at:
return 'Expiré'
return 'Actif'
@admin.register(EnrollmentRequest)
class EnrollmentRequestAdmin(admin.ModelAdmin):
"""Demandes soumises via la page publique /communaute/ — jamais de
jeton émis/envoyé sans passer par l'action approve_and_send_token
ci-dessous, déclenchée à la main."""
list_display = ('email', 'node_name', 'country', 'status', 'created_at', 'processed_at')
list_filter = ('status',)
search_fields = ('email', 'node_name')
ordering = ('-created_at',)
actions = ['approve_and_send_token', 'reject']
@admin.action(description="Approuver et envoyer le jeton d'inscription par email")
def approve_and_send_token(self, request: HttpRequest, queryset: QuerySet[EnrollmentRequest]) -> None:
approved = 0
for enrollment in queryset.filter(status=EnrollmentRequest.STATUS_PENDING):
token, _join_token = issue_join_token(enrollment.node_name)
send_mail(
subject='Fail2banActionBanisher — inscription acceptée',
message=(
f"Votre demande pour rejoindre la communauté (noeud '{enrollment.node_name}') "
'a été acceptée.\n\n'
'Sur votre serveur, après avoir cloné le dépôt et configuré .env :\n\n'
f' {join_command(enrollment.node_name, token)}\n'
' sudo make install\n\n'
'Ce jeton est à usage unique et expire dans 60 minutes.'
),
from_email=None, # DEFAULT_FROM_EMAIL
recipient_list=[enrollment.email],
)
enrollment.status = EnrollmentRequest.STATUS_APPROVED
enrollment.processed_at = timezone.now()
enrollment.save(update_fields=['status', 'processed_at'])
approved += 1
self.message_user(request, f'{approved} demande(s) approuvée(s), jeton envoyé par email.', messages.SUCCESS)
@admin.action(description='Rejeter')
def reject(self, request: HttpRequest, queryset: QuerySet[EnrollmentRequest]) -> None:
updated = queryset.filter(status=EnrollmentRequest.STATUS_PENDING).update(
status=EnrollmentRequest.STATUS_REJECTED, processed_at=timezone.now(),
)
self.message_user(request, f'{updated} demande(s) rejetée(s).', messages.SUCCESS)
+9
View File
@@ -0,0 +1,9 @@
from django.apps import AppConfig
class BaneventsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'banevents'
def ready(self) -> None:
from . import signals # noqa: F401
+26
View File
@@ -0,0 +1,26 @@
import json
from typing import Any
from channels.generic.websocket import AsyncWebsocketConsumer
GROUP_NAME = 'banevents'
class BanEventConsumer(AsyncWebsocketConsumer):
"""Pousse chaque nouveau BanEvent aux clients connectés au tableau de bord."""
async def connect(self) -> None:
await self.channel_layer.group_add(GROUP_NAME, self.channel_name)
await self.accept()
async def disconnect(self, close_code: int) -> None:
await self.channel_layer.group_discard(GROUP_NAME, self.channel_name)
async def ban_event(self, event: dict[str, Any]) -> None:
await self.send(text_data=json.dumps({'kind': 'ban', **event['payload']}))
async def command_notification(self, event: dict[str, Any]) -> None:
"""Issue d'une commande reçue du master (banevents.management.
commands.master_client) succès/échec d'un SYNC_BAN, ou commande
non gérée. Même group_send que ban_event, discriminé par `kind`."""
await self.send(text_data=json.dumps({'kind': 'command', **event['payload']}))
+35
View File
@@ -0,0 +1,35 @@
"""Émission de jetons d'auto-inscription (flux "kubeadm join", voir
ROADMAP.md et banevents/views.py::join_node) factorisé pour être
appelé à la fois par `manage.py create_join_token` (CLI, décision
humaine directe) et par `EnrollmentRequestAdmin.approve_and_send_token`
(validation d'une demande soumise via /communaute/) : un seul endroit qui
touche le hachage/l'expiration du jeton.
"""
import datetime
import hashlib
import os
import secrets
from django.utils import timezone
from .models import JoinToken
DEFAULT_TTL_MINUTES = 60
def issue_join_token(node_name: str, ttl_minutes: int = DEFAULT_TTL_MINUTES) -> tuple[str, JoinToken]:
"""Génère un jeton en clair (jamais persisté tel quel) + l'objet
JoinToken correspondant (seul le hash SHA-256 est stocké)."""
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()
expires_at = timezone.now() + datetime.timedelta(minutes=ttl_minutes)
join_token = JoinToken.objects.create(node_name=node_name, token_hash=token_hash, expires_at=expires_at)
return token, join_token
def join_command(node_name: str, token: str) -> str:
"""Commande complète à communiquer (CLI ou email) — DASHBOARD_DOMAIN
déjà chargé dans os.environ par python-dotenv (cf. settings/base.py)."""
master_url = os.environ.get('DASHBOARD_DOMAIN', '')
master_url = f'https://{master_url}' if master_url else '<url-du-master>'
return f'sudo make join MASTER={master_url} NODE={node_name} TOKEN={token}'
+24
View File
@@ -0,0 +1,24 @@
from django import forms
from .models import EnrollmentRequest
class EnrollmentRequestForm(forms.ModelForm):
"""Formulaire public de /communaute/. `website` n'est pas un champ du
modèle (absent de Meta.fields, jamais sauvegardé) honeypot anti-spam
classique : caché par CSS côté template, un humain ne le remplit
jamais, un bot qui remplit tout aveuglément si. Rempli => la vue
ignore silencieusement la soumission, sans message d'erreur (ne pas
indiquer au bot qu'il a été détecté)."""
website = forms.CharField(required=False, widget=forms.HiddenInput)
class Meta:
model = EnrollmentRequest
fields = ['email', 'node_name', 'country', 'message']
widgets = {
'message': forms.Textarea(attrs={'rows': 4}),
}
def is_spam(self) -> bool:
return bool(self.cleaned_data.get('website'))
+79
View File
@@ -0,0 +1,79 @@
"""Logique d'ingestion partagée entre mqtt_listen (broker local, un seul
noeud) et master_listen (broker master, tous les noeuds) : les deux
persistent dans le même modèle BanEvent, donnant une vue multi-noeuds
unifiée via le filtre par noeud déjà présent sur le tableau de bord."""
import datetime
from typing import Any
from django.utils import timezone
from .models import BanEvent, EnrollmentRequest, NodeRegistry
def clean_tag(value: Any) -> str:
"""Normalise un tag fail2ban optionnel : "-" (défaut Init de l'action,
pour les jails sans port/protocole propre, ex. recidive) devient vide."""
value = str(value or '').strip()
return '' if value == '-' else value
def save_ban_event(payload: dict[str, Any], alias_hint: str = '') -> BanEvent:
event_time = None
raw_time = payload.get('time')
if raw_time:
try:
event_time = timezone.make_aware(
datetime.datetime.fromtimestamp(float(raw_time)), timezone.get_default_timezone()
)
except (TypeError, ValueError, OSError):
event_time = None
bantime = payload.get('bantime')
try:
bantime = int(float(bantime)) if bantime is not None else None
except (TypeError, ValueError):
bantime = None
node = str(payload.get('node', ''))
# filter().update() plutôt que update_or_create() : une seule requête
# sur le chemin chaud (noeud déjà connu, la quasi-totalité du trafic),
# là où update_or_create() fait toujours un SELECT ... FOR UPDATE (no-op
# silencieux sur SQLite, qui ne supporte pas ce verrou) + un UPDATE.
# Contourne .save()/les signaux Django — sans conséquence aujourd'hui
# (NodeRegistry n'a aucun signal), à revoir si un jour l'un lui en ajoute.
if not NodeRegistry.objects.filter(node_id=node).update(last_seen=timezone.now()):
# alias_hint (CN du certificat client, cf. master_listen.py::
# handle_ban) : uniquement à la création, jamais pour écraser un
# alias déjà édité à la main dans /admin/ — get_or_create() ne
# touche 'defaults' que si la ligne est effectivement créée.
defaults = {'alias': alias_hint}
# Requête EnrollmentRequest.country volontairement isolée dans
# cette branche (jamais exécutée sur le chemin chaud, cf.
# commentaire ci-dessus sur filter().update()) : un noeud rejoint
# via /communaute/ a laissé le pays de son serveur dans sa
# demande approuvée — .node_name y correspond au CN présenté ici
# (alias_hint), pas de lien direct en base entre les deux tables.
if alias_hint:
enrollment = (
EnrollmentRequest.objects.filter(
node_name=alias_hint, status=EnrollmentRequest.STATUS_APPROVED, country__gt='',
)
.order_by('-processed_at')
.first()
)
if enrollment:
defaults['country'] = enrollment.country
NodeRegistry.objects.get_or_create(node_id=node, defaults=defaults)
return BanEvent.objects.create(
node=node,
action=str(payload.get('action', '')),
jail_name=str(payload.get('name', '')),
ip_address=payload['ip'],
port=clean_tag(payload.get('port')),
protocol=clean_tag(payload.get('protocol')),
bantime=bantime,
reason=str(payload.get('reason', '')),
event_time=event_time,
raw_payload=payload,
)
@@ -0,0 +1,40 @@
"""Émet un jeton d'auto-inscription à usage unique pour un nouveau noeud
(flux "kubeadm join", voir ROADMAP.md et banevents/views.py::join_node).
À lancer sur le MASTER uniquement le jeton n'a de sens que contre
l'endpoint /api/join/ de cette même instance.
Usage :
manage.py create_join_token <nom_du_noeud> [--ttl-minutes 60]
Le jeton n'est affiché QU'UNE SEULE FOIS ici, en clair seul son hash est
persisté (JoinToken.token_hash). Impossible de le retrouver après coup :
en cas de perte, en émettre un nouveau (le précédent reste valide jusqu'à
son expiration ou son utilisation, aucun conflit).
"""
from typing import Any
from django.core.management.base import BaseCommand
from banevents.enrollment import DEFAULT_TTL_MINUTES, issue_join_token, join_command
class Command(BaseCommand):
help = "Émet un jeton d'auto-inscription à usage unique pour un nouveau noeud."
def add_arguments(self, parser: Any) -> None:
parser.add_argument('node_name')
parser.add_argument('--ttl-minutes', type=int, default=DEFAULT_TTL_MINUTES)
def handle(self, *args: Any, **options: Any) -> None:
node_name = options['node_name']
ttl_minutes = options['ttl_minutes']
token, _ = issue_join_token(node_name, ttl_minutes)
self.stdout.write(self.style.SUCCESS(
f"Jeton émis pour '{node_name}' (expire dans {ttl_minutes} min, usage unique) :"
))
self.stdout.write('')
self.stdout.write(f' {join_command(node_name, token)}')
self.stdout.write('')
self.stdout.write('À lancer sur le nouveau noeud, puis "sudo make install" comme d\'habitude.')
@@ -0,0 +1,439 @@
"""Client MQTT vers le broker master (Phase 2.b/4).
Relaie chaque événement fail2ban publié sur le broker local (même topic
que banevents.mqtt_listen, fail2ban/+/jail) vers le broker master en mTLS,
sous fail2ban/<MQTT_MASTER_NODE_NAME>/ban. S'abonne en retour aux commandes
du master (fail2ban/<MQTT_MASTER_NODE_NAME>/action et
fail2ban/broadcast/action) et aux mises à jour du roster (Phase 3, voir
banevents.management.commands.master_listen).
Commandes reconnues (command_handlers ci-dessous) : SYNC_BAN, BAN_ALLPORTS,
WHITELIST, NOTIFY_ONLY, RATE_LIMIT, ESCALATE. Pour en ajouter une : écrire
une méthode `execute_<nom>(self, command: dict) -> None` qui termine
toujours par un appel à `self.report_outcome(cmd, ip, status,
detail='')`, puis l'ajouter au dict `self.command_handlers` dans
`handle()`. Toute commande reçue sans handler enregistré est journalisée
sans action.
Trois formes possibles pour une commande Phase 4 choisir AVANT
d'écrire du code (voir ROADMAP.md, Phase 4, "procédure d'ajout") :
1. Réaction automatique décidée par le master (comme SYNC_BAN/ESCALATE)
un handler ici + une règle `correlation_rule_<nom>` dans
master_listen.py.
2. Décision manuelle diffusée à tous les noeuds (comme
WHITELIST/BAN_ALLPORTS/RATE_LIMIT) un handler ici + ajout aux
MANUAL_COMMANDS de publish_command.py.
3. Action master-only, jamais exécutée par un noeud (comme REPORT_ABUSE)
PAS de handler ici du tout, branchement direct dans
publish_command.py.
Ce relais est un process indépendant de mqtt_listen : les deux s'abonnent
séparément au même topic local, chacun avec sa propre connexion/identité.
"""
import ipaddress
import json
import os
import ssl
import subprocess
import threading
import uuid
from typing import Any
import paho.mqtt.client as mqtt
import redis
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from banevents.consumers import GROUP_NAME
from banevents.models import NodeRegistry
from banevents.stats import ROSTER_REDIS_DB, ROSTER_REDIS_KEY, ROSTER_TTL_SECONDS
MASTER_SYNC_JAIL = 'master-sync'
MASTER_RATELIMIT_JAIL = 'master-ratelimit'
MASTER_ESCALATE_JAIL = 'master-escalate'
# Intervalle de heartbeat (Phase 3, supervision) : assez court pour qu'une
# coupure soit détectée en quelques minutes côté dashboard (seuil "hors
# ligne" = 3x cet intervalle, cf. views.py), assez long pour rester
# négligeable en trafic MQTT.
HEARTBEAT_INTERVAL_SECONDS = 60
class Command(BaseCommand):
help = 'Relaie les événements fail2ban locaux vers le broker master et applique ses commandes (SYNC_BAN).'
def handle(self, *args: Any, **options: Any) -> None:
if not settings.MQTT_MASTER_NODE_NAME:
raise CommandError(
'MQTT_MASTER_NODE_NAME est vide. Doit être identique au nom '
'passé à scripts/generate-node-cert.sh (le CN du certificat '
"client) : c'est ce que le master utilisera pour restreindre "
'ce noeud via ACL (fail2ban/<nom>/...).'
)
self.node_topic_prefix = f'fail2ban/{settings.MQTT_MASTER_NODE_NAME}'
# Identité fail2ban locale (uuid.getnode(), posée par
# f2b_mqtt_action_banisher.py dans chaque payload) — distincte de
# MQTT_MASTER_NODE_NAME (l'alias/CN mTLS) : sert uniquement à
# reconnaître "ce SYNC_BAN vient de ce noeud", pas à s'authentifier.
self.local_node_id = str(uuid.getnode())
# Communiqué au master via le heartbeat (voir send_heartbeat) pour
# alimenter automatiquement NodeRegistry.dashboard_url côté master
# (master_listen.py::handle_heartbeat) — os.environ direct, pas un
# Django setting : DASHBOARD_DOMAIN est propre à CE noeud (vide sur
# un noeud sans tableau de bord public exposé), même lecture directe
# que enrollment.py::join_command.
dashboard_domain = os.environ.get('DASHBOARD_DOMAIN', '')
self.dashboard_url = f'https://{dashboard_domain}' if dashboard_domain else ''
self.heartbeat_timer: threading.Timer | None = None
self.command_handlers = {
'SYNC_BAN': self.execute_sync_ban,
'BAN_ALLPORTS': self.execute_ban_allports,
'WHITELIST': self.execute_whitelist,
'NOTIFY_ONLY': self.execute_notify_only,
'RATE_LIMIT': self.execute_rate_limit,
'ESCALATE': self.execute_escalate,
}
# Cache du roster (Phase 3, cf. master_listen.py) — DB dédiée
# (db=2) : db=0 déjà pris par Channels (CHANNEL_LAYERS), db=1 par
# Celery (CELERY_BROKER_URL).
self.redis_client = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=ROSTER_REDIS_DB)
self.local_client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2, client_id=f'master-relay-local-{uuid.uuid4().hex[:8]}'
)
self.local_client.username_pw_set(settings.MQTT_BROKER_USERNAME, settings.MQTT_BROKER_PASSWORD)
self.local_client.on_connect = self.on_local_connect
self.local_client.on_message = self.on_local_message
self.master_client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2, client_id=f'master-relay-master-{uuid.uuid4().hex[:8]}'
)
# ca_certs=None : le magasin de CA système (défaut Python) sait déjà
# vérifier le certificat serveur Let's Encrypt du master. Notre CA
# privée (scripts/master-ca-init.sh) ne sert qu'à ce que LE MASTER
# vérifie NOTRE certificat client (mTLS) — elle n'a rien à voir avec
# la vérification du certificat serveur par CE client, et la fournir
# ici écrase le magasin par défaut au lieu de s'y ajouter, faisant
# échouer la validation du certificat serveur (constaté :
# SSLCertVerificationError "unable to get local issuer certificate").
self.master_client.tls_set(
certfile=settings.MQTT_MASTER_CLIENT_CERT,
keyfile=settings.MQTT_MASTER_CLIENT_KEY,
tls_version=ssl.PROTOCOL_TLSv1_2,
)
self.master_client.on_connect = self.on_master_connect
self.master_client.on_message = self.on_master_message
# Callback dédié (pas on_master_message) : le roster est un flux
# séparé des commandes (action), pas la peine de faire cohabiter
# deux formats de message dans un seul handler générique.
self.master_client.message_callback_add('fail2ban/broadcast/roster', self.on_roster_message)
self.stdout.write(f'Connexion locale à {settings.MQTT_BROKER_HOST}:{settings.MQTT_BROKER_PORT}')
self.local_client.connect(settings.MQTT_BROKER_HOST, settings.MQTT_BROKER_PORT, keepalive=60)
self.local_client.loop_start()
# connect_async + retry_first_connection : si le master est injoignable
# au démarrage (réseau, certificat pas encore en place, ...),
# loop_forever() réessaie tout seul au lieu de faire planter toute la
# commande (y compris le relais local, qui fonctionnerait pourtant
# très bien sans lui). Sans retry_first_connection, un échec de LA
# toute première tentative fait remonter l'exception au lieu d'être
# réessayé (constaté : ConnectionRefusedError non rattrapée).
self.stdout.write(f'Connexion master à {settings.MQTT_MASTER_HOST}:{settings.MQTT_MASTER_PORT}')
self.master_client.connect_async(settings.MQTT_MASTER_HOST, settings.MQTT_MASTER_PORT, keepalive=60)
self.master_client.reconnect_delay_set(min_delay=1, max_delay=30)
self.master_client.loop_forever(retry_first_connection=True)
def on_local_connect(
self,
client: mqtt.Client,
userdata: Any,
flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
properties: Any = None,
) -> None:
self.stdout.write(self.style.SUCCESS(f'Connecté au broker local ({reason_code})'))
client.subscribe(settings.MQTT_TOPIC_SUBSCRIBE)
def on_local_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
try:
payload = json.loads(message.payload.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
self.stderr.write(self.style.ERROR(f'Message local invalide : {e}'))
return
# Le topic doit correspondre au CN du certificat mTLS de ce process
# (MQTT_MASTER_NODE_NAME), jamais au "node" interne du payload
# (uuid.getnode() côté fail2ban, un simple identifiant matériel) : le
# master ACL chaque connexion sur son identité de certificat, donc
# un topic qui ne correspond pas à ce CN serait rejeté.
topic = f'{self.node_topic_prefix}/ban'
self.master_client.publish(topic, json.dumps(payload), qos=1)
self.stdout.write(f'Relayé vers le master : {topic} {payload.get("action")} {payload.get("ip")}')
def on_master_connect(
self,
client: mqtt.Client,
userdata: Any,
flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
properties: Any = None,
) -> None:
self.stdout.write(self.style.SUCCESS(f'Connecté au master ({reason_code})'))
client.subscribe(f'{self.node_topic_prefix}/action')
client.subscribe('fail2ban/broadcast/action')
client.subscribe('fail2ban/broadcast/roster')
# on_master_connect peut se redéclencher à chaque reconnexion :
# annuler toute chaîne de timer précédente pour ne pas en empiler
# plusieurs en parallèle (heartbeats en double).
if self.heartbeat_timer is not None:
self.heartbeat_timer.cancel()
self.send_heartbeat()
def send_heartbeat(self) -> None:
# Le segment de topic (fail2ban/<MQTT_MASTER_NODE_NAME>/heartbeat)
# est l'alias/CN mTLS, imposé par l'ACL (pattern write
# fail2ban/%u/heartbeat) — jamais le même identifiant que
# NodeRegistry.node_id (uuid.getnode() brut, cf. commentaire plus
# haut sur ces deux identités distinctes). L'id brut voyage donc
# dans le payload, exactement comme /ban le fait déjà.
self.master_client.publish(
f'{self.node_topic_prefix}/heartbeat',
json.dumps({'node': self.local_node_id, 'dashboard_url': self.dashboard_url}),
qos=0,
)
self.heartbeat_timer = threading.Timer(HEARTBEAT_INTERVAL_SECONDS, self.send_heartbeat)
self.heartbeat_timer.daemon = True
self.heartbeat_timer.start()
def report_outcome(self, cmd: str, ip: str, status: str, detail: str = '') -> None:
"""Diffuse l'issue d'une commande reçue du master vers ses deux
destinations : accusé de réception MQTT vers le master
(fail2ban/<noeud>/ack) et notification WebSocket vers ce dashboard
(même canal que les BanEvent, cf. signals.py) un SYNC_BAN réussi
finit par apparaître indirectement via le BanEvent qu'il déclenche,
mais un échec ou une commande non gérée ne laissaient jusqu'ici
aucune trace visible hors journalctl. Point d'entrée unique pour
toute nouvelle commande : chaque execute_<nom> doit terminer par un
appel ici plutôt que de publier séparément vers les deux canaux."""
payload = {'cmd': cmd, 'ip': ip, 'status': status, 'detail': detail}
self.master_client.publish(f'{self.node_topic_prefix}/ack', json.dumps(payload), qos=1)
channel_layer = get_channel_layer()
if channel_layer is not None:
async_to_sync(channel_layer.group_send)(
GROUP_NAME, {'type': 'command.notification', 'payload': payload},
)
def on_master_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
try:
command = json.loads(message.payload.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
self.stderr.write(self.style.ERROR(f'Commande master invalide : {e}'))
return
cmd = command.get('cmd')
handler = self.command_handlers.get(cmd)
if handler is not None:
handler(command)
else:
# Phase 4 restante (ESCALATE, RATE_LIMIT, REPORT_ABUSE,
# REQUEST_GEOLOCATE) : pas de handler enregistré, journalisée
# seulement.
self.stdout.write(self.style.WARNING(f'Commande reçue du master (non gérée) : {command}'))
self.report_outcome(str(cmd or '?'), '', 'unhandled')
def _banip_via_jail(self, jail: str, ip: str) -> tuple[bool, str]:
"""Bannit `ip` via la jail `jail` (`fail2ban-client set <jail>
banip <ip>`) primitive partagée par toute commande qui se
résume à "bannir cette IP dans une jail dédiée à injection
manuelle" (master-sync/SYNC_BAN, master-sync/BAN_ALLPORTS,
master-ratelimit/RATE_LIMIT, master-escalate/ESCALATE) : seuls le
nom de la jail et l'origine de la décision changent. Retourne
(succès, detail message d'erreur si échec)."""
try:
result = subprocess.run(
['sudo', settings.FAIL2BAN_CLIENT_PATH, 'set', jail, 'banip', ip],
capture_output=True, text=True, timeout=10,
)
except (subprocess.SubprocessError, OSError) as e:
return False, str(e)
if result.returncode == 0:
return True, ''
return False, result.stderr.strip() or result.stdout.strip()
def _list_active_jails(self) -> list[str]:
"""Parse la sortie de `fail2ban-client status` ("Jail list: a, b, c")
pas d'option JSON native côté fail2ban-client pour cette info."""
try:
result = subprocess.run(
['sudo', settings.FAIL2BAN_CLIENT_PATH, 'status'],
capture_output=True, text=True, timeout=10, check=True,
)
except (subprocess.SubprocessError, OSError, subprocess.CalledProcessError) as e:
self.stderr.write(self.style.ERROR(f'Impossible de lister les jails actives : {e}'))
return []
for line in result.stdout.splitlines():
if 'Jail list:' in line:
return [j.strip() for j in line.split(':', 1)[1].split(',') if j.strip()]
return []
def execute_sync_ban(self, command: dict[str, Any]) -> None:
if str(command.get('origin_node', '')) == self.local_node_id:
self.stdout.write(f'SYNC_BAN ignoré (origine = ce noeud) : {command.get("ip")}')
return
try:
ip = str(ipaddress.ip_address(command.get('ip', '')))
except (ValueError, TypeError):
self.stderr.write(self.style.ERROR(f'SYNC_BAN : IP invalide reçue : {command.get("ip")!r}'))
self.report_outcome('SYNC_BAN', str(command.get('ip', '')), 'error', 'IP invalide')
return
ok, detail = self._banip_via_jail(MASTER_SYNC_JAIL, ip)
if ok:
self.stdout.write(self.style.SUCCESS(f'SYNC_BAN appliqué : {ip} (jail {MASTER_SYNC_JAIL})'))
self.report_outcome('SYNC_BAN', ip, 'ok')
else:
self.stderr.write(self.style.ERROR(f'SYNC_BAN échec pour {ip} : {detail}'))
self.report_outcome('SYNC_BAN', ip, 'error', detail)
def execute_ban_allports(self, command: dict[str, Any]) -> None:
"""Décision ciblée explicite (pas une synchro auto : pas de check
origin_node déclenchée à la main via publish_command.py, doit
s'appliquer sur tous les noeuds qui la reçoivent, y compris
l'origine si jamais elle en porte une)."""
try:
ip = str(ipaddress.ip_address(command.get('ip', '')))
except (ValueError, TypeError):
self.stderr.write(self.style.ERROR(f'BAN_ALLPORTS : IP invalide reçue : {command.get("ip")!r}'))
self.report_outcome('BAN_ALLPORTS', str(command.get('ip', '')), 'error', 'IP invalide')
return
ok, detail = self._banip_via_jail(MASTER_SYNC_JAIL, ip)
if ok:
self.stdout.write(self.style.SUCCESS(f'BAN_ALLPORTS appliqué : {ip} (jail {MASTER_SYNC_JAIL})'))
self.report_outcome('BAN_ALLPORTS', ip, 'ok')
else:
self.stderr.write(self.style.ERROR(f'BAN_ALLPORTS échec pour {ip} : {detail}'))
self.report_outcome('BAN_ALLPORTS', ip, 'error', detail)
def execute_whitelist(self, command: dict[str, Any]) -> None:
"""Ajoute l'IP à ignoreip sur toutes les jails actives + la
débannit si elle l'était. Limitation connue : `addignoreip` est un
réglage en mémoire, non persistant perdu au prochain redémarrage
de fail2ban.service (install.sh ne touche pas jail.local donc un
`install.sh install` ne l'efface pas, mais un restart manuel du
service, oui). Persister ça proprement (fichier dédié non écrasé
par install.sh, relu au démarrage) reste à faire séparément."""
try:
ip = str(ipaddress.ip_address(command.get('ip', '')))
except (ValueError, TypeError):
self.stderr.write(self.style.ERROR(f'WHITELIST : IP invalide reçue : {command.get("ip")!r}'))
self.report_outcome('WHITELIST', str(command.get('ip', '')), 'error', 'IP invalide')
return
jails = self._list_active_jails()
if not jails:
self.report_outcome('WHITELIST', ip, 'error', 'aucune jail active trouvée')
return
for jail in jails:
subprocess.run(
['sudo', settings.FAIL2BAN_CLIENT_PATH, 'set', jail, 'addignoreip', ip],
capture_output=True, text=True, timeout=10,
)
# Échec ignoré : l'IP peut légitimement ne pas être bannie
# dans cette jail précise.
subprocess.run(
['sudo', settings.FAIL2BAN_CLIENT_PATH, 'set', jail, 'unbanip', ip],
capture_output=True, text=True, timeout=10,
)
self.stdout.write(self.style.SUCCESS(f'WHITELIST appliqué : {ip} ({len(jails)} jails)'))
self.report_outcome('WHITELIST', ip, 'ok')
def execute_notify_only(self, command: dict[str, Any]) -> None:
"""Aucune action système — la visibilité "alerte admin" demandée
pour cette commande (cf. ROADMAP.md) est déjà couverte par le
toast que report_outcome déclenche côté dashboard."""
ip = str(command.get('ip', ''))
self.stdout.write(f'NOTIFY_ONLY reçu : {ip}')
self.report_outcome('NOTIFY_ONLY', ip, 'ok')
def execute_rate_limit(self, command: dict[str, Any]) -> None:
"""Décision ciblée explicite (manuelle, via publish_command.py) :
throttle au lieu d'un blocage total, jail dédiée master-ratelimit
(banaction f2b-iptables-hashlimit)."""
try:
ip = str(ipaddress.ip_address(command.get('ip', '')))
except (ValueError, TypeError):
self.stderr.write(self.style.ERROR(f'RATE_LIMIT : IP invalide reçue : {command.get("ip")!r}'))
self.report_outcome('RATE_LIMIT', str(command.get('ip', '')), 'error', 'IP invalide')
return
ok, detail = self._banip_via_jail(MASTER_RATELIMIT_JAIL, ip)
if ok:
self.stdout.write(self.style.SUCCESS(f'RATE_LIMIT appliqué : {ip} (jail {MASTER_RATELIMIT_JAIL})'))
self.report_outcome('RATE_LIMIT', ip, 'ok')
else:
self.stderr.write(self.style.ERROR(f'RATE_LIMIT échec pour {ip} : {detail}'))
self.report_outcome('RATE_LIMIT', ip, 'error', detail)
def execute_escalate(self, command: dict[str, Any]) -> None:
"""Auto-publiée par master_listen.py (correlation_rule_escalate)
quand une IP est bannie indépendamment sur plusieurs noeuds
distincts. Contrairement à SYNC_BAN, pas de check origin_node :
doit s'appliquer aussi sur le(s) noeud(s) d'origine (leur ban
initial n'est pas permanent, master-escalate est une jail
séparée, pas de conflit à réappliquer)."""
try:
ip = str(ipaddress.ip_address(command.get('ip', '')))
except (ValueError, TypeError):
self.stderr.write(self.style.ERROR(f'ESCALATE : IP invalide reçue : {command.get("ip")!r}'))
self.report_outcome('ESCALATE', str(command.get('ip', '')), 'error', 'IP invalide')
return
ok, detail = self._banip_via_jail(MASTER_ESCALATE_JAIL, ip)
if ok:
self.stdout.write(self.style.SUCCESS(f'ESCALATE appliqué : {ip} (jail {MASTER_ESCALATE_JAIL}, permanent)'))
self.report_outcome('ESCALATE', ip, 'ok')
else:
self.stderr.write(self.style.ERROR(f'ESCALATE échec pour {ip} : {detail}'))
self.report_outcome('ESCALATE', ip, 'error', detail)
def on_roster_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
"""Roster périodique publié par master_listen.py (Phase 3) : les
noeuds connus se propagent dans NodeRegistry (déjà le modèle lu par
la sidebar un client voit ainsi les autres noeuds, pas que
lui-même) ; les stats globales jail/pays (pas des lignes de table,
des agrégats transitoires) vont en cache Redis, lu par views.py
avec repli sur le calcul local si absent/périmé."""
try:
roster = json.loads(message.payload.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
self.stderr.write(self.style.ERROR(f'Roster invalide : {e}'))
return
for node in roster.get('nodes', []):
node_id = node.get('node_id')
if not node_id:
continue
# filter().update() plutôt que update_or_create() : last_seen a
# auto_now=True, qui écraserait la vraie valeur du master par
# "maintenant" si on passait par .save() (déclenché en interne
# par update_or_create) — .update() fait une requête SQL directe,
# sans repasser par la logique auto_now du champ.
fields = {
'alias': node.get('alias', ''),
'country': node.get('country', ''),
'dashboard_url': node.get('dashboard_url', ''),
}
if node.get('last_seen'):
fields['last_seen'] = node['last_seen']
if not NodeRegistry.objects.filter(node_id=node_id).update(**fields):
NodeRegistry.objects.get_or_create(node_id=node_id, defaults=fields)
self.redis_client.set(
ROSTER_REDIS_KEY,
json.dumps({'top_jails': roster.get('top_jails', []), 'top_countries': roster.get('top_countries', [])}),
ex=ROSTER_TTL_SECONDS,
)
@@ -0,0 +1,237 @@
"""Client MQTT côté master (Phase 3/4, minimal) : écoute fail2ban/+/ban
(tous les noeuds connectés) en mTLS, persiste chaque événement dans le
même modèle BanEvent que mqtt_listen dashboard multi-noeuds unifié via
le filtre par noeud déjà présent, sans rien dupliquer et applique un
registre de règles de corrélation (self.correlation_rules, construit
dans handle()) à chaque BAN reçu : SYNC_BAN (propagation systématique à
tous les noeuds) et ESCALATE (ban permanent si l'IP est bannie
indépendamment sur plusieurs noeuds distincts, cf.
correlation_rule_escalate). Pour ajouter une future règle
auto-déclenchée : écrire une méthode `correlation_rule_<nom>(self,
client, payload) -> None` et l'ajouter à self.correlation_rules dans
handle() voir ROADMAP.md (Phase 4, "procédure d'ajout") pour les deux
autres formes possibles (commande manuelle diffusée, action master-only)
selon la nature de la nouvelle commande.
Publie aussi périodiquement un "roster" (fail2ban/broadcast/roster, Phase
3) : la liste des noeuds connus (NodeRegistry) + les compteurs jail/pays
globaux sans ça, la sidebar d'un noeud client n'aurait jamais que SES
propres données (aucun autre process que celui-ci ne maintient
NodeRegistry à jour pour plus d'un noeud). master_client.py de chaque
noeud s'y abonne et republie localement (NodeRegistry + cache Redis, voir
banevents/stats.py).
Identité mTLS dédiée (MQTT_MASTER_LISTENER_CERT/KEY, CN "master-internal"
par convention), distincte de celle de master_client : ce process doit à
la fois LIRE tous les noeuds (fail2ban/+/ban) et ÉCRIRE sur le canal de
diffusion deux droits plus larges que ceux d'un noeud pris
individuellement, donc son propre certificat (voir master-acl.conf).
"""
import datetime
import json
import ssl
import threading
import uuid
from typing import Any
import paho.mqtt.client as mqtt
from django.conf import settings
from django.core.management.base import BaseCommand
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from banevents.ingestion import save_ban_event
from banevents.models import BanEvent, NodeRegistry
from banevents.stats import top_jails_and_countries
MASTER_BAN_TOPIC = 'fail2ban/+/ban'
MASTER_HEARTBEAT_TOPIC = 'fail2ban/+/heartbeat'
MASTER_ACK_TOPIC = 'fail2ban/+/ack'
BROADCAST_ACTION_TOPIC = 'fail2ban/broadcast/action'
BROADCAST_ROSTER_TOPIC = 'fail2ban/broadcast/roster'
# Même ordre de grandeur que HEARTBEAT_INTERVAL_SECONDS (master_client.py) —
# pas besoin d'être plus réactif, la sidebar n'a rien de temps réel critique.
ROSTER_INTERVAL_SECONDS = 60
# ESCALATE (Phase 4) : seuil de corrélation cross-node.
ESCALATE_NODE_THRESHOLD = 2
ESCALATE_WINDOW_HOURS = 24
# Jails "réactives" (déclenchées par une commande, pas par une détection
# locale indépendante) — exclues du comptage ESCALATE, sinon la cascade
# SYNC_BAN habituelle (un ban propagé rebondit une fois avant de
# s'arrêter, cf. ROADMAP.md) se compterait elle-même comme une 2e
# détection indépendante.
REACTIVE_JAIL_NAMES = {'master-sync', 'master-ratelimit', 'master-escalate'}
class Command(BaseCommand):
help = (
"Écoute fail2ban/+/ban sur le broker master, enregistre chaque événement, "
"et publie un SYNC_BAN en diffusion générale pour chaque BAN reçu."
)
def handle(self, *args: Any, **options: Any) -> None:
self.roster_timer: threading.Timer | None = None
self.correlation_rules = [self.correlation_rule_sync_ban, self.correlation_rule_escalate]
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2, client_id=f'master-listen-{uuid.uuid4().hex[:8]}'
)
# ca_certs=None : voir master_client.py — le magasin de CA système
# sait déjà vérifier le certificat serveur Let's Encrypt du master.
client.tls_set(
certfile=settings.MQTT_MASTER_LISTENER_CERT,
keyfile=settings.MQTT_MASTER_LISTENER_KEY,
tls_version=ssl.PROTOCOL_TLSv1_2,
)
client.on_connect = self.on_connect
client.on_message = self.on_message
self.stdout.write(f'Connexion master à {settings.MQTT_MASTER_HOST}:{settings.MQTT_MASTER_PORT}')
client.connect_async(settings.MQTT_MASTER_HOST, settings.MQTT_MASTER_PORT, keepalive=60)
client.reconnect_delay_set(min_delay=1, max_delay=30)
client.loop_forever(retry_first_connection=True)
def on_connect(
self,
client: mqtt.Client,
userdata: Any,
flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
properties: Any = None,
) -> None:
self.stdout.write(self.style.SUCCESS(f'Connecté au master ({reason_code})'))
client.subscribe(MASTER_BAN_TOPIC)
client.subscribe(MASTER_HEARTBEAT_TOPIC)
client.subscribe(MASTER_ACK_TOPIC)
# on_connect peut se redéclencher à chaque reconnexion : annuler
# tout timer précédent pour ne pas empiler plusieurs chaînes en
# parallèle (rosters en double), même précaution que le heartbeat
# de master_client.py.
if self.roster_timer is not None:
self.roster_timer.cancel()
self.publish_roster(client)
def on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
# Topics séparés par suffixe (fail2ban/<node>/ban|heartbeat|ack),
# jamais un wildcard multi-niveau fail2ban/+/+ — reste explicite,
# cohérent avec le reste de l'ACL (master-acl.conf).
topic_kind = message.topic.rsplit('/', 1)[-1]
if topic_kind == 'ban':
self.handle_ban(client, message)
elif topic_kind == 'heartbeat':
self.handle_heartbeat(message)
elif topic_kind == 'ack':
self.handle_ack(message)
def handle_ban(self, client: mqtt.Client, message: mqtt.MQTTMessage) -> None:
try:
payload = json.loads(message.payload.decode('utf-8'))
# Segment du topic (fail2ban/<CN>/ban) = CN du certificat client
# = node_name choisi à l'inscription (sign-node-csr.sh) — sert
# uniquement de suggestion d'alias à la création d'un NodeRegistry
# inédit (voir save_ban_event) ; payload['node'] (uuid.getnode())
# reste le vrai node_id, ces deux identifiants ne coïncident pas.
alias_hint = message.topic.split('/')[1]
save_ban_event(payload, alias_hint=alias_hint)
except Exception as e:
self.stderr.write(self.style.ERROR(f'Message master invalide ({message.topic}) : {e}'))
return
self.stdout.write(
f'Événement master enregistré : noeud={payload.get("node")} '
f'{payload.get("name")} {payload.get("action")} {payload.get("ip")}'
)
if payload.get('action') == 'BAN':
for rule in self.correlation_rules:
rule(client, payload)
def handle_heartbeat(self, message: mqtt.MQTTMessage) -> None:
# Le segment de topic (fail2ban/<node>/heartbeat) est le CN du
# certificat client (MQTT_MASTER_NODE_NAME), PAS le même
# identifiant que NodeRegistry.node_id (uuid.getnode() brut, cf.
# payload des BanEvent) — l'id brut voyage donc dans le payload,
# comme pour /ban.
try:
payload = json.loads(message.payload.decode('utf-8'))
node_id = str(payload['node'])
except (json.JSONDecodeError, UnicodeDecodeError, KeyError) as e:
self.stderr.write(self.style.ERROR(f'Heartbeat invalide ({message.topic}) : {e}'))
return
# .update() plutôt que get_or_create() : un noeud pas encore connu
# de NodeRegistry (jamais banni depuis son déploiement) est un
# no-op silencieux ici — il apparaîtra dès son premier ban, pas la
# peine de le créer juste pour un heartbeat.
update_fields: dict[str, Any] = {'last_seen': timezone.now()}
# dashboard_url (DASHBOARD_DOMAIN de CE noeud, cf. master_client.py::
# send_heartbeat) : resynchronisé à CHAQUE heartbeat, pas seulement à
# la création — contrairement à alias/country (des labels choisis
# une fois), c'est une donnée technique qui doit refléter le .env
# réel du noeud. Absent/vide (noeud sans tableau de bord public
# exposé) => clé omise, ne jamais écraser une valeur déjà connue
# avec du vide.
dashboard_url = payload.get('dashboard_url')
if dashboard_url:
update_fields['dashboard_url'] = dashboard_url
NodeRegistry.objects.filter(node_id=node_id).update(**update_fields)
def handle_ack(self, message: mqtt.MQTTMessage) -> None:
node_id = message.topic.split('/')[1]
try:
payload = json.loads(message.payload.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
self.stderr.write(self.style.ERROR(f'ACK invalide ({message.topic}) : {e}'))
return
self.stdout.write(
f'ACK reçu de {node_id} : {payload.get("cmd")} {payload.get("ip")} -> '
f'{payload.get("status")} {payload.get("detail", "")}'.rstrip()
)
def publish_roster(self, client: mqtt.Client) -> None:
nodes = list(
NodeRegistry.objects.all().values('node_id', 'alias', 'country', 'dashboard_url', 'last_seen')
)
roster = {'nodes': nodes, **top_jails_and_countries()}
client.publish(BROADCAST_ROSTER_TOPIC, json.dumps(roster, cls=DjangoJSONEncoder), qos=0)
self.roster_timer = threading.Timer(ROSTER_INTERVAL_SECONDS, self.publish_roster, args=[client])
self.roster_timer.daemon = True
self.roster_timer.start()
def correlation_rule_sync_ban(self, client: mqtt.Client, payload: dict[str, Any]) -> None:
command = {
'cmd': 'SYNC_BAN',
'ip': payload.get('ip'),
'ttl': payload.get('bantime'),
'reason': payload.get('name', ''),
'origin_node': payload.get('node', ''),
}
client.publish(BROADCAST_ACTION_TOPIC, json.dumps(command), qos=1)
self.stdout.write(f'SYNC_BAN publié : {command}')
def correlation_rule_escalate(self, client: mqtt.Client, payload: dict[str, Any]) -> None:
"""Publie ESCALATE (ban permanent, jail master-escalate côté
noeuds) si `ip` a été bannie de façon INDÉPENDANTE (hors jails
REACTIVE_JAIL_NAMES sinon la cascade SYNC_BAN habituelle se
compterait elle-même) sur au moins ESCALATE_NODE_THRESHOLD noeuds
distincts dans les dernières ESCALATE_WINDOW_HOURS heures."""
ip = payload.get('ip')
if not ip:
return
window_start = timezone.now() - datetime.timedelta(hours=ESCALATE_WINDOW_HOURS)
distinct_nodes = (
BanEvent.objects.filter(ip_address=ip, action='BAN', received_at__gte=window_start)
.exclude(jail_name__in=REACTIVE_JAIL_NAMES)
.values('node').distinct().count()
)
if distinct_nodes < ESCALATE_NODE_THRESHOLD:
return
command = {
'cmd': 'ESCALATE',
'ip': ip,
'reason': payload.get('name', ''),
'origin_node': payload.get('node', ''),
}
client.publish(BROADCAST_ACTION_TOPIC, json.dumps(command), qos=1)
self.stdout.write(self.style.WARNING(
f'ESCALATE publié ({distinct_nodes} noeuds indépendants en {ESCALATE_WINDOW_HOURS}h) : {command}'
))
@@ -0,0 +1,54 @@
"""Client MQTT local : ingère les événements fail2ban publiés par
f2b-mqtt-action-banisher et les persiste en base (modèle BanEvent)."""
import json
import uuid
from typing import Any
import paho.mqtt.client as mqtt
from django.conf import settings
from django.core.management.base import BaseCommand
from banevents.ingestion import save_ban_event
class Command(BaseCommand):
help = "Écoute le broker MQTT local et enregistre chaque événement fail2ban reçu."
def handle(self, *args: Any, **options: Any) -> None:
# client_id unique par process : deux instances partageant le même id
# MQTT (deux déploiements, ou un test local pointé sur le même
# broker) s'éjectent mutuellement en boucle sinon (constaté en
# prod : une instance de dev oubliée connectée via le VPN et le
# service systemd du VPS se disputaient le même id).
client_id = f'emitter-mqtt-listen-{uuid.uuid4().hex[:8]}'
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id)
client.username_pw_set(settings.MQTT_BROKER_USERNAME, settings.MQTT_BROKER_PASSWORD)
client.on_connect = self.on_connect
client.on_message = self.on_message
self.stdout.write(
f'Connexion à {settings.MQTT_BROKER_HOST}:{settings.MQTT_BROKER_PORT} '
f'(topic {settings.MQTT_TOPIC_SUBSCRIBE})'
)
client.connect(settings.MQTT_BROKER_HOST, settings.MQTT_BROKER_PORT, keepalive=60)
client.loop_forever()
def on_connect(
self,
client: mqtt.Client,
userdata: Any,
flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
properties: Any = None,
) -> None:
self.stdout.write(self.style.SUCCESS(f'Connecté au broker ({reason_code})'))
client.subscribe(settings.MQTT_TOPIC_SUBSCRIBE)
def on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
try:
payload = json.loads(message.payload.decode('utf-8'))
save_ban_event(payload)
except Exception as e:
self.stderr.write(self.style.ERROR(f'Message MQTT invalide ({message.topic}) : {e}'))
return
self.stdout.write(f'Événement enregistré : {payload.get("name")} {payload.get("action")} {payload.get("ip")}')
@@ -0,0 +1,115 @@
"""Déclenche à la main une commande sans émission automatique (Phase 4) :
contrairement à SYNC_BAN/ESCALATE (toujours publiées par master_listen.py
via self.correlation_rules), WHITELIST/BAN_ALLPORTS/NOTIFY_ONLY/RATE_LIMIT
correspondent à une décision humaine ("je sais que cette IP est
particulière"), pas à une règle automatique — pas de logique de
corrélation à construire pour ça. Diffusées à tous les noeuds abonnés via
fail2ban/broadcast/action, exécutées par master_client.py::execute_<nom>.
REPORT_ABUSE fait exception : PAS de diffusion MQTT. Diffuser à chaque
noeud produirait un rapport en double par noeud connecté pour le même
incident (vu comme du bruit par AbuseIPDB, et demanderait une clé API par
noeud). Ce process tourne déjà sur le master, avec accès à l'historique
BanEvent complet (tous noeuds) pour construire un commentaire pertinent
il soumet donc le rapport lui-même (banevents/abuseipdb.py), sans passer
par MQTT ni par master_client.py.
Usage (à lancer sur le master, vit le certificat master-internal, seule
identité autorisée en écriture sur fail2ban/broadcast/action) :
manage.py publish_command WHITELIST 203.0.113.5
manage.py publish_command BAN_ALLPORTS 203.0.113.5
manage.py publish_command NOTIFY_ONLY 203.0.113.5
manage.py publish_command RATE_LIMIT 203.0.113.5
manage.py publish_command REPORT_ABUSE 203.0.113.5 --comment "texte"
"""
import json
import ssl
import time
import uuid
from typing import Any
import paho.mqtt.client as mqtt
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from banevents.abuseipdb import submit_report
from banevents.models import BanEvent
BROADCAST_ACTION_TOPIC = 'fail2ban/broadcast/action'
# Commandes diffusées en MQTT à tous les noeuds — cf. master_client.py
# command_handlers pour la liste des commandes qu'un noeud sait exécuter.
MANUAL_COMMANDS = ('WHITELIST', 'BAN_ALLPORTS', 'NOTIFY_ONLY', 'RATE_LIMIT')
DEFAULT_ABUSEIPDB_CATEGORIES = '18' # Brute-Force
class Command(BaseCommand):
help = 'Publie une commande manuelle (WHITELIST/BAN_ALLPORTS/NOTIFY_ONLY/RATE_LIMIT/REPORT_ABUSE).'
def add_arguments(self, parser: Any) -> None:
parser.add_argument('cmd', choices=(*MANUAL_COMMANDS, 'REPORT_ABUSE'))
parser.add_argument('ip')
parser.add_argument('--categories', default=DEFAULT_ABUSEIPDB_CATEGORIES, help='REPORT_ABUSE uniquement')
parser.add_argument('--comment', default='', help='REPORT_ABUSE uniquement')
def handle(self, *args: Any, **options: Any) -> None:
cmd = options['cmd']
ip = options['ip']
if cmd == 'REPORT_ABUSE':
self.handle_report_abuse(ip, options['categories'], options['comment'])
return
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2, client_id=f'publish-command-{uuid.uuid4().hex[:8]}'
)
# Même identité que master_listen.py (master-internal) — seule
# scopée en écriture sur fail2ban/broadcast/action, cf.
# master-acl.conf.
client.tls_set(
certfile=settings.MQTT_MASTER_LISTENER_CERT,
keyfile=settings.MQTT_MASTER_LISTENER_KEY,
tls_version=ssl.PROTOCOL_TLSv1_2,
)
client.connect(settings.MQTT_MASTER_HOST, settings.MQTT_MASTER_PORT, keepalive=10)
client.loop_start()
payload = {'cmd': cmd, 'ip': ip, 'origin_node': 'manual', 'reason': 'manual'}
info = client.publish(BROADCAST_ACTION_TOPIC, json.dumps(payload), qos=1)
info.wait_for_publish(timeout=10)
client.loop_stop()
client.disconnect()
if not info.is_published():
raise CommandError(f'Échec de publication de {cmd} pour {ip} (PUBACK non reçu).')
self.stdout.write(self.style.SUCCESS(f'{cmd} publié pour {ip} sur {BROADCAST_ACTION_TOPIC}'))
# Laisse le temps au disconnect propre de partir avant que le
# process ne se termine (paho ferme le socket en tâche de fond).
time.sleep(0.2)
def handle_report_abuse(self, ip: str, categories: str, comment: str) -> None:
comment = comment or self._default_comment(ip)
if not settings.ABUSEIPDB_ENABLED or not settings.ABUSEIPDB_API_KEY:
self.stdout.write(self.style.WARNING(
f'[dry-run] AbuseIPDB désactivé (ABUSEIPDB_ENABLED/ABUSEIPDB_API_KEY, voir .env) — '
f'rien envoyé. Aurait signalé {ip} (catégories {categories}) : {comment}'
))
return
ok, detail = submit_report(ip, categories, comment)
if ok:
self.stdout.write(self.style.SUCCESS(f'{ip} signalé à AbuseIPDB (catégories {categories}).'))
else:
raise CommandError(f'Échec du signalement AbuseIPDB pour {ip} : {detail}')
def _default_comment(self, ip: str) -> str:
"""Commentaire par défaut construit depuis l'historique BanEvent
de cette IP le master voit tous les noeuds, contrairement à un
rapport qui partirait d'un seul d'entre eux."""
events = BanEvent.objects.filter(ip_address=ip, action='BAN').order_by('-received_at')[:5]
jails = sorted({e.jail_name for e in events if e.jail_name})
nodes = sorted({e.node for e in events if e.node})
if not jails:
return f'Fail2Ban: banned IP {ip}.'
return f'Fail2Ban: banned for {", ".join(jails)} on {len(nodes)} node(s).'
@@ -0,0 +1,39 @@
"""Relance la géolocalisation pour des BanEvent (Phase 4 — repurposing de
REQUEST_GEOLOCATE). La géoloc est déjà automatique et centralisée à
l'ingestion (signal post_save sur BanEvent, cf. signals.py, déclenché pour
tout événement y compris ceux ingérés par master_listen depuis n'importe
quel noeud) : un aller-retour MQTT "demande au noeud sa géoloc" n'aurait
rien à demander, le noeud fail2ban ne calcule rien lui-même. Cette
commande se contente de rejouer les échecs (ip-api.com en rate limit, IP
privée/invalide, ...) via le même Celery task, sans nouvelle logique.
Usage :
manage.py retry_geolocation # tous les BanEvent sans coordonnées
manage.py retry_geolocation --ip 203.0.113.5 # une IP précise, même déjà tentée
"""
from typing import Any
from django.core.management.base import BaseCommand
from banevents.models import BanEvent
from banevents.tasks import geolocate_ip
class Command(BaseCommand):
help = 'Relance la géolocalisation (Celery) pour les BanEvent sans coordonnées, ou une IP précise.'
def add_arguments(self, parser: Any) -> None:
parser.add_argument('--ip', default='', help='Ne relancer que pour cette IP (même si déjà géolocalisée)')
def handle(self, *args: Any, **options: Any) -> None:
qs = BanEvent.objects.all()
if options['ip']:
qs = qs.filter(ip_address=options['ip'])
else:
qs = qs.filter(geolocated_at__isnull=True)
count = 0
for event_id in qs.values_list('id', flat=True):
geolocate_ip.delay(event_id)
count += 1
self.stdout.write(self.style.SUCCESS(f'{count} géolocalisation(s) relancée(s).'))
@@ -0,0 +1,34 @@
# Generated by Django 6.0.7 on 2026-07-14 16:15
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='BanEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('node', models.CharField(max_length=64)),
('action', models.CharField(max_length=32)),
('jail_name', models.CharField(max_length=64)),
('ip_address', models.GenericIPAddressField()),
('port', models.PositiveIntegerField(blank=True, null=True)),
('protocol', models.CharField(blank=True, max_length=16)),
('bantime', models.IntegerField(blank=True, null=True)),
('reason', models.CharField(blank=True, max_length=255)),
('event_time', models.DateTimeField(blank=True, null=True)),
('received_at', models.DateTimeField(auto_now_add=True)),
('raw_payload', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['-received_at'],
},
),
]
@@ -0,0 +1,43 @@
# Generated by Django 6.0.7 on 2026-07-14 21:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('banevents', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='banevent',
name='city',
field=models.CharField(blank=True, max_length=128),
),
migrations.AddField(
model_name='banevent',
name='country',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='banevent',
name='country_code',
field=models.CharField(blank=True, max_length=2),
),
migrations.AddField(
model_name='banevent',
name='geolocated_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='banevent',
name='latitude',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='banevent',
name='longitude',
field=models.FloatField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-07-15 08:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('banevents', '0002_banevent_city_banevent_country_banevent_country_code_and_more'),
]
operations = [
migrations.AlterField(
model_name='banevent',
name='port',
field=models.CharField(blank=True, default='', max_length=64),
),
]
@@ -0,0 +1,51 @@
# Generated by Django 6.0.7 on 2026-07-17 16:04
from django.db import migrations, models
def backfill_node_registry(apps, schema_editor):
"""Sans ça, NodeRegistry reste vide au déploiement jusqu'à ce que
chaque noeud republie un événement le filtre par noeud du dashboard
(masqué s'il n'y a qu'une seule valeur) disparaîtrait pour les noeuds
déjà connus dans BanEvent, régression par rapport à l'ancien
_distinct_nodes() qui interrogeait BanEvent directement."""
BanEvent = apps.get_model('banevents', 'BanEvent')
NodeRegistry = apps.get_model('banevents', 'NodeRegistry')
for node_id in BanEvent.objects.order_by().values_list('node', flat=True).distinct():
if node_id:
NodeRegistry.objects.get_or_create(node_id=node_id)
class Migration(migrations.Migration):
dependencies = [
('banevents', '0003_alter_banevent_port'),
]
operations = [
migrations.CreateModel(
name='NodeRegistry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('node_id', models.CharField(max_length=64, unique=True)),
('alias', models.CharField(blank=True, max_length=100)),
('country', models.CharField(blank=True, max_length=100)),
('first_seen', models.DateTimeField(auto_now_add=True)),
('last_seen', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['node_id'],
},
),
migrations.AlterField(
model_name='banevent',
name='country',
field=models.CharField(blank=True, db_index=True, max_length=64),
),
migrations.AlterField(
model_name='banevent',
name='jail_name',
field=models.CharField(db_index=True, max_length=64),
),
migrations.RunPython(backfill_node_registry, migrations.RunPython.noop),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-07-18 06:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('banevents', '0004_noderegistry_alter_banevent_country_and_more'),
]
operations = [
migrations.AddField(
model_name='noderegistry',
name='dashboard_url',
field=models.URLField(blank=True, max_length=255),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 6.0.7 on 2026-07-18 10:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('banevents', '0005_noderegistry_dashboard_url'),
]
operations = [
migrations.CreateModel(
name='JoinToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('node_name', models.CharField(max_length=64)),
('token_hash', models.CharField(max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.7 on 2026-07-18 17:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('banevents', '0006_jointoken'),
]
operations = [
migrations.CreateModel(
name='EnrollmentRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('node_name', models.CharField(max_length=64)),
('message', models.TextField(blank=True)),
('status', models.CharField(choices=[('pending', 'En attente'), ('approved', 'Approuvée'), ('rejected', 'Rejetée')], default='pending', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('processed_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-07-20 07:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('banevents', '0007_enrollmentrequest'),
]
operations = [
migrations.AddField(
model_name='enrollmentrequest',
name='country',
field=models.CharField(blank=True, max_length=100),
),
]
+151
View File
@@ -0,0 +1,151 @@
from typing import TYPE_CHECKING
from django.db import models
from django.utils import timezone
class BanEvent(models.Model):
"""Un événement de ban/notice reçu via MQTT depuis un noeud fail2ban."""
node = models.CharField(max_length=64)
action = models.CharField(max_length=32)
jail_name = models.CharField(max_length=64, db_index=True)
ip_address = models.GenericIPAddressField()
# Chaîne, pas un entier : fail2ban accepte des noms de service
# ("http,https") ou des listes de ports ("8883,8884") pour ce champ.
port = models.CharField(max_length=64, blank=True, default='')
protocol = models.CharField(max_length=16, blank=True)
bantime = models.IntegerField(null=True, blank=True)
reason = models.CharField(max_length=255, blank=True)
event_time = models.DateTimeField(null=True, blank=True)
received_at = models.DateTimeField(auto_now_add=True)
raw_payload = models.JSONField(default=dict, blank=True)
latitude = models.FloatField(null=True, blank=True)
longitude = models.FloatField(null=True, blank=True)
country = models.CharField(max_length=64, blank=True, db_index=True)
country_code = models.CharField(max_length=2, blank=True)
city = models.CharField(max_length=128, blank=True)
geolocated_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-received_at']
if TYPE_CHECKING:
# Attaché dynamiquement par views.py::_attach_display_node (alias
# résolu depuis NodeRegistry), pas un champ persisté — annotation
# purement pour le typage, aucun effet à l'exécution.
display_node: str
def __str__(self) -> str:
return f'{self.jail_name} {self.action} {self.ip_address}'
class NodeRegistry(models.Model):
"""Métadonnées par noeud (alias, pays du VPS), en plus du `node` brut
(uuid.getnode() stringifié) porté par chaque BanEvent. Alimenté
automatiquement par banevents.ingestion.save_ban_event dès qu'un noeud
inédit publie un événement ; alias/country restent vides jusqu'à édition
manuelle via /admin/ (seule UI d'édition, pas de page dédiée)."""
node_id = models.CharField(max_length=64, unique=True)
alias = models.CharField(max_length=100, blank=True)
# Pays du VPS/noeud lui-même (saisi à la main) — à ne pas confondre avec
# BanEvent.country, le pays géolocalisé de l'IP *bannie*, un concept
# différent qui existe déjà indépendamment de ce modèle.
country = models.CharField(max_length=100, blank=True)
# URL de base du dashboard de CE noeud (ex. https://banishing.example.org,
# sans slash final), saisie à la main via /admin/ — republiée à tous les
# noeuds par le roster (master_listen.py) pour que le bloc "Noeuds" de
# la sidebar (n'importe où il y a plus d'un noeud connu) puisse pointer
# vers chacun sans maillage N×N codé en dur (cf. MASTER_DASHBOARD_URL,
# qui reste le seul lien client -> master).
dashboard_url = models.URLField(max_length=255, blank=True)
first_seen = models.DateTimeField(auto_now_add=True)
last_seen = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['node_id']
if TYPE_CHECKING:
# Attaché dynamiquement par views.py::_node_choices (calculé depuis
# last_seen, pas persisté) — annotation purement pour le typage.
is_online: bool
def __str__(self) -> str:
return self.alias or self.node_id
@property
def display_name(self) -> str:
return self.alias or self.node_id
class JoinToken(models.Model):
"""Jeton à usage unique pour l'auto-inscription d'un noeud (flux
"kubeadm join", voir manage.py create_join_token et
banevents/views.py::join_node). Seul le hash est stocké jamais le
jeton en clair, même logique qu'un hachage de mot de passe : une fuite
de la base ne rend pas les jetons utilisables."""
node_name = models.CharField(max_length=64)
token_hash = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
def __str__(self) -> str:
if self.used_at:
status = 'utilisé'
elif timezone.now() > self.expires_at:
status = 'expiré'
else:
status = 'actif'
return f'{self.node_name} ({status})'
class EnrollmentRequest(models.Model):
"""Demande d'inscription à la communauté, soumise via la page publique
/communaute/ (désactivée par défaut, cf. COMMUNITY_ENROLLMENT_ENABLED).
Ne crée jamais directement d'entrée NodeRegistry : le node_id réel
(uuid.getnode() du futur noeud) n'existe pas encore à ce stade, il
n'apparaît qu'au premier événement/heartbeat une fois le noeud
effectivement rattaché NodeRegistry reste le résultat final du
parcours, pas une action immédiate de cette demande. Validée à la main
dans /admin/ (voir EnrollmentRequestAdmin.approve_and_send_token) :
jamais de jeton émis/envoyé sans décision humaine explicite."""
STATUS_PENDING = 'pending'
STATUS_APPROVED = 'approved'
STATUS_REJECTED = 'rejected'
STATUS_CHOICES = [
(STATUS_PENDING, 'En attente'),
(STATUS_APPROVED, 'Approuvée'),
(STATUS_REJECTED, 'Rejetée'),
]
email = models.EmailField()
node_name = models.CharField(max_length=64)
# Pays du VPS/serveur candidat (pas de l'IP d'un attaquant) — signalé
# comme utile en pratique : les data-centers de pays différents ne
# font pas face au même trafic malveillant, une information qu'on ne
# peut pas déduire automatiquement à l'inscription. Propagé vers
# NodeRegistry.country à la création (ingestion.save_ban_event), sur
# le même principe que node_name -> NodeRegistry.alias.
country = models.CharField(max_length=100, blank=True)
message = models.TextField(blank=True)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
created_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
def __str__(self) -> str:
# dict(...)[...] plutôt que get_status_display() : cette méthode
# auto-générée par Django (choices=) n'est visible de pyright
# qu'avec le plugin mypy de django-stubs, non supporté ici (même
# limitation que Model.pk vs .id, voir CONTEXT.md/ROADMAP.md).
return f'{self.email} ({self.node_name}) — {dict(self.STATUS_CHOICES)[self.status]}'
+12
View File
@@ -0,0 +1,12 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
# re_path() réutilisé pour du routage ASGI (pattern documenté par
# Channels lui-même) — django-stubs type `view` pour des vues WSGI,
# pas des consumers ASGI : décalage entre les stubs des deux paquets,
# sans rapport avec un vrai bug (fonctionne, testé en conditions
# réelles toute la journée, cf. ROADMAP.md).
re_path(r'^ws/banevents/$', consumers.BanEventConsumer.as_asgi()), # type: ignore[reportCallIssue, reportArgumentType]
]
+30
View File
@@ -0,0 +1,30 @@
from typing import Any
from .models import BanEvent
def event_payload(instance: BanEvent, node_alias: str | None = None) -> dict[str, Any]:
"""Représentation JSON-sérialisable d'un BanEvent, partagée par le
WebSocket temps réel et le bootstrap initial du tableau de bord.
node_alias : alias lisible du noeud (NodeRegistry.display_name),
résolu par l'appelant plutôt qu'ici pour ne pas cacher une requête DB
par événement (voir views.py, qui résout tous les alias en une seule
requête avant d'appeler cette fonction en boucle)."""
return {
'id': instance.pk,
'received_at': instance.received_at.isoformat(),
'node': instance.node,
'node_alias': node_alias or instance.node,
'jail_name': instance.jail_name,
'action': instance.action,
'ip_address': instance.ip_address,
'port': instance.port,
'protocol': instance.protocol,
'bantime': instance.bantime,
'latitude': instance.latitude,
'longitude': instance.longitude,
'country': instance.country,
'country_code': instance.country_code,
'city': instance.city,
}
+56
View File
@@ -0,0 +1,56 @@
"""Diffuse chaque BanEvent (création et mise à jour géoloc) aux clients
WebSocket via le channel layer, et déclenche la géolocalisation asynchrone
à la création. Journalise aussi les verrouillages django-axes (protection
/admin/, cf. AXES_* dans config/settings/base.py) pour que fail2ban
(generic/fail2ban/jail.d/emitter-admin-auth.conf) puisse réagir."""
import logging
from typing import Any
from asgiref.sync import async_to_sync
from axes.signals import user_locked_out
from channels.layers import get_channel_layer
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.http import HttpRequest
from .consumers import GROUP_NAME
from .models import BanEvent, NodeRegistry
from .serializers import event_payload
from .tasks import geolocate_ip
fail2ban_axes_logger = logging.getLogger('fail2ban.axes')
@receiver(post_save, sender=BanEvent)
def broadcast_ban_event(sender: type[BanEvent], instance: BanEvent, created: bool, **kwargs: Any) -> None:
channel_layer = get_channel_layer()
if channel_layer is not None:
# Se déclenche deux fois par event (création, puis mise à jour
# géoloc par geolocate_ip ci-dessous) : lookup unique indexé, sans
# conséquence à tourner deux fois (résultat identique).
node_alias = NodeRegistry.objects.filter(node_id=instance.node).values_list('alias', flat=True).first()
async_to_sync(channel_layer.group_send)(
GROUP_NAME,
{'type': 'ban.event', 'payload': event_payload(instance, node_alias)},
)
if created:
geolocate_ip.delay(instance.pk)
@receiver(user_locked_out)
def log_axes_lockout(sender: Any, request: HttpRequest, username: str | None, ip_address: str, **kwargs: Any) -> None:
"""Un seul point de vérité pour le seuil : axes décide (AXES_FAILURE_LIMIT)
qu'il y a un problème, cette ligne fait réagir fail2ban (ban réseau +
propagation SYNC_BAN aux autres noeuds, comme toute autre jail cf.
jail.local [DEFAULT] action). maxretry=1 côté jail : chaque ligne ici
EST déjà la décision de verrouillage, pas la peine de recompter.
Format de message imposé par filter.d/app-auth.conf (générique,
réutilisable par toute appli du VPS pas seulement ce projet) :
"Verrouillage <NOM_APPLI> après échecs répétés depuis <IP> (...)",
<NOM_APPLI> = "émetteur" ici, un seul mot (\\S+ côté filtre)."""
fail2ban_axes_logger.warning(
'Verrouillage émetteur après échecs répétés depuis %s (utilisateur tenté : %s)',
ip_address, username or '?',
)
@@ -0,0 +1,199 @@
/* ============================================================
Emitter — Django Admin Theme Override
Thème sombre, couleurs depuis main.css
============================================================ */
/* --- Variables Django admin 4.x ----------------------------- */
:root {
--primary : #00d4aa;
--secondary : #00a884;
--accent : #00d4aa;
--primary-fg : #0d1117;
--body-fg : #e6edf3;
--body-bg : #0d1117;
--body-quiet-color : #8b949e;
--body-loud-color : #e6edf3;
--header-color : #e6edf3;
--header-branding-color: #00d4aa;
--header-bg : #161b22;
--header-link-color : #e6edf3;
--breadcrumbs-fg : #8b949e;
--breadcrumbs-link-fg : #00d4aa;
--breadcrumbs-bg : #161b22;
--link-fg : #00d4aa;
--link-hover-color : #00a884;
--link-selected-fg : #00d4aa;
--hairline-color : #1f2a36;
--border-color : #1f2a36;
--error-fg : #f85149;
--message-success-bg : rgba(63,185,80,.15);
--message-warning-bg : rgba(210,153,34,.15);
--message-error-bg : rgba(248,81,73,.15);
--darkened-bg : #161b22;
--selected-bg : #21262d;
--selected-row : #2d333b;
--button-fg : #0d1117;
--button-bg : #00d4aa;
--button-hover-bg : #00a884;
--default-button-fg : #0d1117;
--default-button-bg : #00d4aa;
--default-button-hover-bg: #00a884;
--close-button-bg : #21262d;
--close-button-hover-bg : #2d333b;
--object-tools-fg : #0d1117;
--object-tools-bg : #00d4aa;
--object-tools-hover-bg : #00a884;
--font-family : 'Segoe UI', system-ui, -apple-system, sans-serif;
}
/* --- Global ------------------------------------------------- */
html, body { font-family: var(--font-family); background: var(--body-bg); color: var(--body-fg); }
a { color: var(--link-fg); }
a:hover { color: var(--link-hover-color); }
/* --- Header ------------------------------------------------- */
#header {
background : var(--header-bg);
color : var(--header-color);
border-bottom: 1px solid #1f2a36;
}
#header a:link, #header a:visited { color: var(--header-color); }
#branding h1, #branding h1 a:link, #branding h1 a:visited {
color: var(--accent);
font-weight: 700;
}
#user-tools a { color: #8b949e; }
#user-tools a:hover { color: var(--accent); }
/* --- Breadcrumbs -------------------------------------------- */
div.breadcrumbs {
background : var(--breadcrumbs-bg);
color : var(--breadcrumbs-fg);
border-bottom: 1px solid #1f2a36;
}
div.breadcrumbs a { color: var(--accent); }
div.breadcrumbs a:hover { color: var(--link-hover-color); }
/* --- Sidebar / nav ------------------------------------------ */
#nav-sidebar { background: #161b22; border-right: 1px solid #1f2a36; }
#nav-sidebar .current-app .section:link,
#nav-sidebar .current-app .section:visited { color: var(--accent); }
/* --- Content area ------------------------------------------- */
#content { background: var(--body-bg); }
#content-main { background: var(--body-bg); }
.colMS #content-main { border-right: 1px solid #1f2a36; }
/* --- Module headers (blocs gris-bleu → sombre+accent) ------- */
.module caption,
.inline-group h2,
fieldset.module h2,
div.submit-row {
background : #161b22;
color : var(--body-fg);
border-bottom: 1px solid #1f2a36;
}
.module { background: #161b22; border: 1px solid #1f2a36; }
fieldset.module { background: #161b22; border: 1px solid #1f2a36; }
/* --- Tables ------------------------------------------------- */
#result_list thead th { background: #161b22; color: var(--body-quiet-color); border-bottom: 1px solid #1f2a36; }
#result_list tr.row1 { background: #0d1117; }
#result_list tr.row2 { background: #111820; }
#result_list tr:hover td { background: #21262d !important; }
#result_list td, #result_list th { border-right-color: #1f2a36; }
table { border-color: #1f2a36; }
td, th { border-color: #1f2a36; }
/* --- Filtres (colonne droite) -------------------------------- */
#changelist-filter { background: #161b22; border-left: 1px solid #1f2a36; }
#changelist-filter h2 { background: #161b22; color: var(--accent); border-bottom: 1px solid #1f2a36; }
#changelist-filter h3 { color: var(--body-quiet-color); border-bottom: 1px solid #1f2a36; }
#changelist-filter li.selected a { color: var(--accent); font-weight: 600; }
#changelist-filter a { color: var(--body-fg); }
#changelist-filter a:hover { color: var(--accent); }
/* --- Formulaires -------------------------------------------- */
input, select, textarea {
background : #21262d;
color : var(--body-fg);
border : 1px solid #2d3748;
border-radius: 4px;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
outline : none;
box-shadow : 0 0 0 2px rgba(0,212,170,.2);
}
.form-row { border-bottom-color: #1f2a36; }
.aligned label { color: var(--body-quiet-color); }
.help { color: #8b949e; }
/* --- Boutons ------------------------------------------------ */
.button, input[type=submit], input[type=button], .submit-row input, a.button {
background : var(--button-bg);
color : var(--button-fg);
border : none;
border-radius: 4px;
font-weight : 600;
}
.button:hover, input[type=submit]:hover, input[type=button]:hover {
background: var(--button-hover-bg);
color : var(--button-fg);
}
.button.default, input[type=submit].default {
background: var(--accent);
color : #0d1117;
}
a.deletelink { background: #7f1d1d; color: #fca5a5; border-radius: 4px; padding: 4px 8px; }
a.deletelink:hover { background: #991b1b; color: #fca5a5; }
/* --- Object tools (haut des formulaires) ------------------- */
ul.object-tools li a { background: #21262d; color: var(--accent); border: 1px solid #1f2a36; }
ul.object-tools li a:hover { background: #2d333b; }
ul.object-tools li a.addlink { background: var(--accent); color: #0d1117; border: none; }
ul.object-tools li a.addlink:hover { background: var(--link-hover-color); }
/* --- Pagination -------------------------------------------- */
.paginator a, .paginator span {
background : #21262d;
color : var(--body-fg);
border : 1px solid #1f2a36;
border-radius: 3px;
}
.paginator a:hover { background: #2d333b; }
.paginator .this-page { background: var(--accent); color: #0d1117; border-color: var(--accent); }
/* --- Messages (success/warning/error) ---------------------- */
.messagelist li { border-radius: 4px; padding: 10px 14px; }
.messagelist li.success { background: rgba(63,185,80,.15); color: #3fb950; border: 1px solid rgba(63,185,80,.3); }
.messagelist li.warning { background: rgba(210,153,34,.15); color: #d29922; border: 1px solid rgba(210,153,34,.3); }
.messagelist li.error { background: rgba(248,81,73,.15); color: #f85149; border: 1px solid rgba(248,81,73,.3); }
/* --- Dashboard (index admin) -------------------------------- */
#content-main .module h2 { color: var(--body-fg); }
.dashboard #content { background: var(--body-bg); }
/* --- Inlines ------------------------------------------------ */
.inline-group { border: 1px solid #1f2a36; }
.inline-group thead tr { background: #161b22; }
.inline-related h3 { background: #161b22; border: 1px solid #1f2a36; color: var(--accent); }
/* --- Misc --------------------------------------------------- */
.errornote { background: rgba(248,81,73,.1); color: #f85149; border: 1px solid rgba(248,81,73,.4); }
.errorlist li { color: #f85149; }
.selector { background: #161b22; border: 1px solid #1f2a36; }
.selector select { background: #0d1117; color: var(--body-fg); border: none; }
.selector-available h2, .selector-chosen h2 { background: #161b22; color: var(--body-quiet-color); }
.selector-filter input { background: #21262d; color: var(--body-fg); border-color: #2d3748; }
@@ -0,0 +1,760 @@
/* ============================================================
Feuille de style principale — main
Thème sombre, zéro dépendance externe
============================================================ */
/* --- Variables -------------------------------------------- */
:root {
--bg-base : #0d1117;
--bg-surface : #1c2128;
--bg-elevated : #2d333b;
--bg-hover : #373e47;
--accent : #00d4aa;
--accent-dim : #00a884;
--accent-glow : rgba(0,212,170,.15);
--danger : #f85149;
--warning : #e3b341;
--info : #58a6ff;
--success : #56d364;
--text-primary : #f0f6fc;
--text-secondary:#b1bac4;
--text-muted : #768390;
--border : #30363d;
--radius-sm : 6px;
--radius-md : 10px;
--radius-lg : 16px;
--shadow-sm : 0 1px 3px rgba(0,0,0,.4);
--shadow-md : 0 4px 16px rgba(0,0,0,.5);
--shadow-lg : 0 8px 32px rgba(0,0,0,.6);
--sidebar-w : 220px;
--font : 'Segoe UI', system-ui, -apple-system, sans-serif;
}
/* --- Reset ------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; scroll-behavior: smooth; }
body {
font-family : var(--font);
background : var(--bg-base);
color : var(--text-primary);
min-height : 100vh;
display : flex;
flex-direction : row;
line-height : 1.6;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-dim); }
img { display: block; }
button { font-family: var(--font); cursor: pointer; }
/* --- Sidebar ---------------------------------------------- */
.vc-sidebar {
position : fixed;
top: 0; left: 0; bottom: 0;
width : var(--sidebar-w);
background : var(--bg-surface);
border-right : 1px solid var(--border);
display : flex;
flex-direction : column;
z-index : 1000;
overflow-y : auto;
}
.vc-brand {
display : flex;
flex-direction : column;
align-items : flex-start;
gap : 2px;
font-size : 1.1rem;
font-weight : 700;
color : var(--accent);
padding : 20px 16px 16px;
border-bottom : 1px solid var(--border);
white-space : nowrap;
}
.vc-brand:hover { color: var(--accent); }
.vc-brand small {
display : block;
font-size : 0.7rem;
font-weight : 400;
color : var(--text-muted);
}
.vc-sidebar-nav {
display : flex;
flex-direction : column;
gap : 2px;
padding : 12px 8px;
flex : 1;
}
.vc-nav-link {
display : flex;
align-items : center;
gap : 10px;
padding : 9px 12px;
border-radius : var(--radius-sm);
color : var(--text-secondary);
font-size : .9rem;
transition : background .15s, color .15s;
}
.vc-nav-link:hover,
.vc-nav-link--active {
background : var(--bg-hover);
color : var(--text-primary);
}
.vc-nav-link--active {
color : var(--accent);
}
.vc-nav-link { position: relative; }
button.vc-nav-link {
width : 100%;
background : transparent;
border : none;
cursor : pointer;
text-align : left;
font-family : inherit;
}
.vc-nav-alert-badge {
margin-left : auto;
display : inline-flex;
align-items : center;
justify-content: center;
min-width : 18px;
height : 18px;
padding : 0 5px;
border-radius : 9px;
font-size : .7rem;
font-weight : 700;
background : var(--danger, #f85149);
color : #fff;
line-height : 1;
}
.vc-sidebar-bottom {
border-top : 1px solid var(--border);
padding : 12px 8px;
display : flex;
flex-direction : column;
gap : 4px;
}
.vc-sidebar-user {
display : flex;
align-items : center;
gap : 8px;
padding : 8px 12px;
border-radius : var(--radius-sm);
color : var(--text-secondary);
font-size : .88rem;
}
.vc-logout-btn {
display : flex;
align-items : center;
gap : 8px;
width : 100%;
padding : 8px 12px;
background : transparent;
border : none;
border-radius : var(--radius-sm);
color : var(--danger);
font-size : .88rem;
text-align : left;
transition : background .12s;
}
.vc-logout-btn:hover { background: rgba(248,81,73,.1); }
/* --- Navbar pagination ------------------------------------ */
.vc-nav-page {
display : none;
flex-direction : column;
gap : 2px;
}
.vc-nav-page--active {
display : flex;
animation : vc-page-in .18s ease;
}
@keyframes vc-page-in {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.vc-nav-pager {
display : flex;
align-items : center;
justify-content: center;
gap : 6px;
padding : 5px 0 2px;
}
.vc-pager-btn {
background : transparent;
border : none;
color : var(--text-muted);
font-size : 1.15rem;
line-height : 1;
padding : 1px 5px;
border-radius : var(--radius-sm);
cursor : pointer;
transition : color .12s, background .12s;
}
.vc-pager-btn:hover:not(:disabled) {
color : var(--text-primary);
background : var(--bg-hover);
}
.vc-pager-btn:disabled { opacity: .25; cursor: default; }
.vc-pager-dots {
display : flex;
gap : 5px;
align-items : center;
}
.vc-pager-dot {
width : 5px;
height : 5px;
border-radius : 50%;
background : var(--text-muted);
cursor : pointer;
transition : background .15s, transform .15s;
}
.vc-pager-dot:hover { background: var(--text-secondary); }
.vc-pager-dot--active {
background : var(--accent);
transform : scale(1.4);
}
/* --- Sidebar collapse (desktop) --------------------------- */
.vc-sidebar {
transition : transform .25s ease;
}
.vc-sidebar--collapsed {
transform : translateX(-100%);
}
.vc-sidebar-collapse-btn {
display : flex;
align-items : center;
justify-content: center;
width : 100%;
padding : 7px 12px;
background : transparent;
border : none;
border-top : 1px solid var(--border);
color : var(--text-muted);
font-size : .8rem;
gap : 6px;
cursor : pointer;
transition : color .15s, background .15s;
font-family : inherit;
}
.vc-sidebar-collapse-btn:hover { color: var(--text-primary); background: var(--bg-hover); }
/* Bouton flottant pour ré-ouvrir le sidebar (desktop) */
.vc-sidebar-open-btn {
display : none;
position : fixed;
top : 12px;
left : 8px;
z-index : 1100;
background : var(--bg-surface);
border : 1px solid var(--border);
border-radius : var(--radius-sm);
padding : 6px 8px;
color : var(--text-primary);
cursor : pointer;
}
.vc-sidebar-open-btn:hover { background: var(--bg-hover); }
.vc-main-wrap { transition: margin-left .25s ease; }
/* --- Hamburger (mobile) ----------------------------------- */
.vc-sidebar-toggle {
display : none;
position : fixed;
top : 12px;
left : 12px;
z-index : 1100;
background : var(--bg-surface);
border : 1px solid var(--border);
border-radius : var(--radius-sm);
padding : 6px 8px;
color : var(--text-primary);
}
/* --- Layout wrapper --------------------------------------- */
.vc-main-wrap {
flex : 1;
display : flex;
flex-direction : column;
min-height : 100vh;
min-width : 0;
}
.vc-main-wrap--with-sidebar {
margin-left : var(--sidebar-w);
}
/* --- Main / layout ---------------------------------------- */
.vc-main { flex: 1; }
.vc-container { max-width: 1280px; margin: 0 auto; padding: 32px 24px; }
.vc-container--narrow { max-width: 900px; }
.vc-grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
/* --- En-tête de page -------------------------------------- */
.vc-page-header {
display : flex;
align-items : center;
justify-content: space-between;
margin-bottom : 28px;
gap : 16px;
flex-wrap : wrap;
}
.vc-page-header h2 { font-size: 1.5rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.vc-page-header h2 svg { color: var(--accent); }
.vc-header-actions { display: flex; gap: 8px; }
/* --- Cartes ----------------------------------------------- */
.vc-card {
background : var(--bg-surface);
border : 1px solid var(--border);
border-radius : var(--radius-lg);
padding : 24px;
margin-bottom : 20px;
}
.vc-card-title {
font-size : 1rem;
font-weight : 600;
margin-bottom : 16px;
display : flex;
align-items : center;
gap : 8px;
}
.vc-card-title svg { color: var(--accent); }
/* --- Boutons ---------------------------------------------- */
.vc-btn {
display : inline-flex;
align-items : center;
justify-content: center;
gap : 7px;
padding : 8px 18px;
border-radius : var(--radius-sm);
font-size : .9rem;
font-weight : 500;
line-height : 1.4;
border : 1px solid transparent;
transition : all .15s;
white-space : nowrap;
text-decoration: none;
}
.vc-btn:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
.vc-btn--primary { background: var(--accent); color: #0d1117; border-color: var(--accent); }
.vc-btn--primary:hover { background: var(--accent-dim); border-color: var(--accent-dim); color: #0d1117; }
.vc-btn--ghost { background: var(--bg-surface); color: var(--text-primary); border-color: #6e7681; }
.vc-btn--ghost:hover { background: var(--bg-hover); color: var(--text-primary); border-color: #8b949e; }
.vc-btn--danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.vc-btn--danger:hover { background: var(--danger); color: #fff; }
.vc-btn--large { padding: 12px 28px; font-size: 1rem; }
.vc-btn--block { width: 100%; }
.vc-btn--sm { padding: 5px 10px; font-size: .82rem; }
/* --- Formulaires ------------------------------------------ */
.vc-form-group { margin-bottom: 18px; }
.vc-form-group label {
display : flex;
align-items : center;
gap : 6px;
font-size : .85rem;
font-weight : 500;
color : var(--text-secondary);
margin-bottom : 6px;
}
.vc-form-group input[type="text"],
.vc-form-group input[type="email"],
.vc-form-group input[type="password"],
.vc-form-group input[type="number"],
.vc-form-group textarea,
.vc-form-group select {
width : 100%;
padding : 9px 12px;
background : var(--bg-elevated);
border : 1px solid var(--border);
border-radius : var(--radius-sm);
color : var(--text-primary);
font-size : .9rem;
font-family : var(--font);
transition : border-color .15s, box-shadow .15s;
}
.vc-form-group input:focus,
.vc-form-group textarea:focus,
.vc-form-group select:focus {
outline : none;
border-color : var(--accent);
box-shadow : 0 0 0 3px var(--accent-glow);
}
.vc-form-group input::placeholder,
.vc-form-group textarea::placeholder { color: var(--text-muted); }
.vc-field-error { color: var(--danger); font-size: .82rem; margin-top: 4px; }
.vc-field-hint { color: var(--text-muted); font-size: .8rem; margin-top: 4px; }
.vc-form-actions {
display : flex;
gap : 10px;
justify-content: flex-end;
padding-top : 16px;
border-top : 1px solid var(--border);
margin-top : 20px;
}
/* --- Badges ----------------------------------------------- */
.vc-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 20px;
font-size: .78rem;
font-weight: 600;
}
.vc-badge--success { background: rgba(63,185,80,.15); color: var(--success); }
.vc-badge--danger { background: rgba(248,81,73,.15); color: var(--danger); }
.vc-badge--info { background: rgba(56,139,253,.15); color: var(--info); }
.vc-badge--warning { background: rgba(210,153,34,.15); color: var(--warning); }
/* --- Messages système ------------------------------------- */
.vc-messages {
position : fixed;
top : 12px;
right : 16px;
z-index : 2000;
display : flex;
flex-direction : column;
gap : 8px;
max-width : 360px;
}
.vc-alert {
display : flex;
align-items : center;
justify-content: space-between;
gap : 12px;
padding : 12px 16px;
border-radius : var(--radius-md);
background : var(--bg-elevated);
border-left : 4px solid var(--accent);
box-shadow : var(--shadow-md);
font-size : .88rem;
}
.vc-alert--error { border-color: var(--danger); }
.vc-alert--warning { border-color: var(--warning); }
.vc-alert--info { border-color: var(--info); }
.vc-alert button { background: none; border: none; color: var(--text-muted); font-size: .9rem; padding: 2px; flex-shrink: 0; }
.vc-alert button:hover { color: var(--text-primary); }
/* --- Authentification ------------------------------------- */
.vc-auth-wrap {
min-height : 100vh;
display : flex;
align-items : center;
justify-content: center;
padding : 32px 16px;
background : radial-gradient(ellipse at 60% 30%, rgba(0,212,170,.06) 0%, transparent 70%);
}
.vc-auth-card {
width : 100%;
max-width : 420px;
background : var(--bg-surface);
border : 1px solid var(--border);
border-radius : var(--radius-lg);
padding : 40px 36px;
box-shadow : var(--shadow-lg);
}
.vc-auth-header { text-align: center; margin-bottom: 28px; }
.vc-auth-icon {
width : 56px; height: 56px;
border-radius : 50%;
background : var(--accent-glow);
border : 1px solid var(--accent);
display : flex;
align-items : center;
justify-content: center;
margin : 0 auto 16px;
color : var(--accent);
}
.vc-auth-header h1 { font-size: 1.6rem; margin-bottom: 6px; }
.vc-auth-header p { color: var(--text-secondary); font-size: .88rem; }
/* --- Table générique -------------------------------------- */
.vc-table { width: 100%; border-collapse: collapse; font-size: .9rem; }
.vc-table th {
text-align: left; padding: 10px 12px;
border-bottom: 2px solid var(--border);
color: var(--text-secondary); font-weight: 600; font-size: .8rem;
text-transform: uppercase; letter-spacing: .05em; white-space: nowrap;
}
.vc-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.vc-table tr:last-child td { border-bottom: none; }
.vc-table tr:hover td { background: var(--bg-elevated); }
/* --- État vide -------------------------------------------- */
.vc-empty-state { text-align: center; padding: 72px 24px; }
.vc-empty-state svg { color: var(--bg-elevated); display: block; margin: 0 auto 16px; }
.vc-empty-state h3 { color: var(--text-secondary); margin-bottom: 8px; }
.vc-empty-state p { color: var(--text-muted); margin-bottom: 16px; }
/* --- Dashboard grille ------------------------------------- */
.vc-dashboard-grid {
display : grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows : 120px;
gap : 12px;
padding : 24px;
align-items : start;
}
.vc-widget {
background : var(--bg-surface);
border : 1px solid var(--border);
border-radius : var(--radius-lg);
overflow : hidden;
display : flex;
flex-direction : column;
transition : border-color .2s, box-shadow .2s;
height : 100%;
}
.vc-widget:hover { border-color: var(--accent); box-shadow: 0 4px 20px rgba(0,212,170,.08); }
.vc-widget--dragging {
opacity : .5;
border-color : var(--accent);
}
.vc-widget--drag-over {
border-color : var(--accent);
box-shadow : 0 0 0 2px var(--accent-glow);
}
.vc-widget[draggable="true"] { cursor: grab; }
.vc-widget[draggable="true"]:active { cursor: grabbing; }
.vc-widget-header {
display : flex;
align-items : center;
justify-content: space-between;
padding : 10px 14px;
border-bottom : 1px solid var(--border);
font-size : .85rem;
font-weight : 600;
flex-shrink : 0;
gap : 8px;
}
.vc-widget-header svg { color: var(--accent); flex-shrink: 0; }
.vc-widget-body {
padding : 14px;
flex : 1;
overflow : hidden;
}
/* Widget : texte / boîte */
.vc-widget-text {
font-size : .9rem;
line-height : 1.7;
color : var(--text-secondary);
word-break : break-word;
}
/* Widget : Grafana iframe */
.vc-widget-iframe {
width : 100%;
height : 100%;
border : none;
display : block;
}
.vc-widget-body--iframe {
padding : 0;
}
/* Widget : placeholder (caméra / device) */
.vc-widget-placeholder {
height : 100%;
display : flex;
flex-direction : column;
align-items : center;
justify-content: center;
gap : 8px;
color : var(--text-muted);
font-size : .82rem;
}
.vc-widget-placeholder svg { color: var(--bg-elevated); }
/* Widget : grille imbriquée */
.vc-widget-grid {
display : grid;
grid-template-columns: 1fr 1fr;
gap : 8px;
height : 100%;
}
.vc-widget-grid-cell {
background : var(--bg-elevated);
border-radius : var(--radius-sm);
display : flex;
align-items : center;
justify-content: center;
color : var(--text-muted);
font-size : .8rem;
}
/* --- Footer ----------------------------------------------- */
.vc-footer {
background : var(--bg-surface);
border-top : 1px solid var(--border);
padding : 12px 24px;
font-size : .8rem;
color : var(--text-muted);
display : flex;
align-items : center;
gap : 12px;
}
.vc-footer a { color: var(--text-muted); }
.vc-footer a:hover { color: var(--accent); }
/* --- Texte utilitaire ------------------------------------- */
.vc-text-muted { color: var(--text-muted); font-size: .88rem; display: flex; align-items: center; gap: 6px; }
.vc-section-title { font-size: 1rem; font-weight: 600; color: var(--text-secondary); margin: 0 0 16px; display: flex; align-items: center; gap: 8px; }
.vc-section-title svg { color: var(--accent); }
/* --- Sélecteur de langue (Phase 5) -------------------------- */
.vc-language-switcher {
padding: 10px 12px;
border-top: 1px solid var(--border);
margin-top: auto;
}
.vc-language-switcher select {
width: 100%;
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-size: .85rem;
}
/* --- Stats système sidebar --------------------------------- */
.vc-sidebar-stats {
padding : 10px 12px 8px;
border-bottom : 1px solid var(--border);
display : flex;
flex-direction : column;
gap : 6px;
}
.vc-stat-row {
display : flex;
align-items : center;
gap : 7px;
color : var(--text-muted);
cursor : default;
}
.vc-stat-icon {
width : 13px;
height : 13px;
flex-shrink : 0;
color : var(--text-secondary);
}
.vc-stat-track {
flex : 1;
height : 3px;
background : var(--bg-elevated);
border-radius : 2px;
overflow : hidden;
}
.vc-stat-fill {
height : 100%;
width : 0%;
border-radius : 2px;
transition : width .6s ease;
}
.vc-stat-fill--cpu { background: #6366f1; }
.vc-stat-fill--mem { background: #f97316; }
.vc-stat-fill--disk { background: #eab308; }
.vc-stat-fill--warn { background: #ef4444; }
.vc-stat-pct {
width : 30px;
text-align : right;
font-size : 10px;
font-variant-numeric: tabular-nums;
color : var(--text-muted);
flex-shrink : 0;
}
/* Compteurs top-jail/top-pays : réutilisent le mécanisme track+fill+pct
ci-dessus (barre relative au plus grand compte de la liste, pas un
pourcentage réel), avec un libellé texte à la place de l'icône —
catégories nominales sans ordre naturel, donc UNE seule couleur par
widget plutôt qu'une rotation par ligne (cf. skill dataviz : ne pas
double-encoder la longueur de barre avec une teinte différente par
catégorie quand rien ne justifie un ordre). */
.vc-stat-label {
width : 84px;
flex-shrink : 0;
overflow : hidden;
text-overflow : ellipsis;
white-space : nowrap;
font-size : 11px;
}
.vc-stat-label--link { color: var(--text-secondary); text-decoration: none; }
.vc-stat-label--link:hover { color: var(--accent); text-decoration: underline; }
.vc-stat-fill--jail { background: var(--accent); }
.vc-stat-fill--country { background: var(--info); }
.vc-sidebar-stats-title {
font-size : .68rem;
text-transform : uppercase;
letter-spacing : .05em;
color : var(--text-muted);
margin-top : 2px;
}
.vc-sidebar-stats-title:first-child { margin-top: 0; }
/* Plafonne le bloc "Noeuds" à ~6 lignes avec défilement propre à lui —
sans ça, beaucoup de noeuds (roster, Phase 3) repoussent indéfiniment
"Top jails"/"Top pays attaquants" plus bas, jusqu'à devoir scroller
toute la sidebar (nav comprise) pour les atteindre. */
.vc-node-list {
display : flex;
flex-direction : column;
gap : 6px;
max-height : 150px;
overflow-y : auto;
}
.vc-stat-dot {
width : 7px;
height : 7px;
border-radius : 50%;
flex-shrink : 0;
}
.vc-stat-dot--online { background: var(--success); }
.vc-stat-dot--offline { background: var(--danger); }
.vc-stat-time {
margin-left : auto;
font-size : 10px;
color : var(--text-muted);
flex-shrink : 0;
}
/* --- Responsive ------------------------------------------- */
@media (max-width: 1200px) {
.vc-dashboard-grid {
grid-template-columns: repeat(8, 1fr);
}
}
@media (max-width: 768px) {
.vc-sidebar {
transform : translateX(-100%);
transition : transform .25s ease;
}
.vc-sidebar--open {
transform : translateX(0);
}
.vc-sidebar-toggle { display: flex; align-items: center; }
.vc-sidebar-collapse-btn { display: none; }
.vc-sidebar-open-btn { display: none !important; }
.vc-main-wrap--with-sidebar { margin-left: 0; }
.vc-grid-2col { grid-template-columns: 1fr; }
.vc-container { padding: 16px; }
.vc-auth-card { padding: 28px 20px; }
.vc-page-header { flex-direction: column; align-items: flex-start; }
.vc-dashboard-grid {
grid-template-columns: repeat(4, 1fr);
padding: 12px;
gap: 8px;
}
}
@@ -0,0 +1,44 @@
document.addEventListener('DOMContentLoaded', () => {
const selectedNode = document.body.dataset.selectedNode || '';
const matchesFilter = (banEvent) => !selectedNode || banEvent.node === selectedNode;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const stream = new BanEventStream(`${protocol}//${window.location.host}/ws/banevents/`);
const notifications = new NotificationCenter('#banevents-messages');
stream.onEvent((message) => {
if (message.kind === 'command') {
notifications.show(message);
}
});
const table = new BanEventTable('#banevents-table-body', '#banevents-empty-state');
stream.onEvent((banEvent) => {
if (banEvent.kind === 'ban' && matchesFilter(banEvent)) {
table.prependRow(banEvent);
}
});
const mapElement = document.getElementById('banevents-map');
if (mapElement) {
const map = new BanEventMap('banevents-map');
stream.onEvent((banEvent) => {
if (banEvent.kind === 'ban' && matchesFilter(banEvent)) {
map.addMarker(banEvent);
}
});
const initialDataElement = document.getElementById('banevents-initial-data');
if (initialDataElement) {
const initialEvents = JSON.parse(initialDataElement.textContent);
initialEvents.forEach((banEvent) => map.addMarker(banEvent));
}
const resetButton = document.getElementById('banevents-map-reset');
if (resetButton) {
resetButton.addEventListener('click', () => map.resetView());
}
}
stream.connect();
});
@@ -0,0 +1,19 @@
document.addEventListener('DOMContentLoaded', () => {
const mapElement = document.getElementById('banevents-map');
if (!mapElement) {
return;
}
const map = new BanEventMap('banevents-map');
const initialDataElement = document.getElementById('banevents-initial-data');
if (initialDataElement) {
const initialEvents = JSON.parse(initialDataElement.textContent);
initialEvents.forEach((banEvent) => map.addMarker(banEvent));
}
const resetButton = document.getElementById('banevents-map-reset');
if (resetButton) {
resetButton.addEventListener('click', () => map.resetView());
}
});
@@ -0,0 +1,42 @@
/* Carte Leaflet : un marqueur par BanEvent géolocalisé. Les événements
* sans latitude/longitude (géolocalisation pas encore terminée) sont
* ignorés jusqu'à la mise à jour poussée par la tâche Celery. */
class BanEventMap {
static DEFAULT_CENTER = [20, 0];
static DEFAULT_ZOOM = 2;
constructor(elementId) {
this.map = L.map(elementId).setView(BanEventMap.DEFAULT_CENTER, BanEventMap.DEFAULT_ZOOM);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 18,
}).addTo(this.map);
this.markers = L.layerGroup().addTo(this.map);
this.markersByEventId = new Map();
}
resetView() {
this.map.setView(BanEventMap.DEFAULT_CENTER, BanEventMap.DEFAULT_ZOOM);
}
addMarker(banEvent) {
if (banEvent.latitude == null || banEvent.longitude == null) {
return;
}
this.removeMarker(banEvent.id);
const marker = L.marker([banEvent.latitude, banEvent.longitude]);
const location = [banEvent.city, banEvent.country].filter(Boolean).join(', ');
marker.bindPopup(`<strong>${banEvent.ip_address}</strong><br>${banEvent.jail_name}${banEvent.action}<br>${location}`);
marker.addTo(this.markers);
this.markersByEventId.set(banEvent.id, marker);
}
removeMarker(eventId) {
const existing = this.markersByEventId.get(eventId);
if (existing) {
this.markers.removeLayer(existing);
this.markersByEventId.delete(eventId);
}
}
}
@@ -0,0 +1,50 @@
/* Notifications transitoires (.vc-messages/.vc-alert, main.css) pour les
* commandes reçues du master (banevents.consumers.command_notification) —
* un SYNC_BAN réussi apparaît déjà indirectement via la ligne de table
* qu'il déclenche ; ceci couvre les échecs et commandes non gérées, qui
* autrement ne laissent aucune trace visible hors journalctl. */
const ALERT_CLASSES = {
ok: '',
error: 'vc-alert--error',
unhandled: 'vc-alert--warning',
};
const AUTO_DISMISS_MS = 8000;
// Chaînes traduites (Phase 5) posées par dashboard.html (#banevents-i18n,
// json_script-style) — jamais codées en dur ici, pour rester cohérentes
// avec la langue active côté serveur (fr/en).
const I18N_EL = document.getElementById('banevents-i18n');
const I18N = I18N_EL ? JSON.parse(I18N_EL.textContent) : {
unhandled: 'Commande reçue du master (non gérée)', ok: 'appliqué', error: 'échec', close: 'Fermer',
};
function commandNotificationText(notification) {
const { cmd, ip, status, detail } = notification;
if (status === 'unhandled') {
return `${I18N.unhandled} : ${cmd}`;
}
const outcome = status === 'ok' ? I18N.ok : `${I18N.error}${detail ? ` (${detail})` : ''}`;
return `${cmd} ${ip}${outcome}`;
}
class NotificationCenter {
constructor(containerSelector) {
this.container = document.querySelector(containerSelector);
}
show(notification) {
if (!this.container) {
return;
}
const alertClass = ALERT_CLASSES[notification.status] || '';
const alert = document.createElement('div');
alert.className = `vc-alert ${alertClass}`.trim();
alert.innerHTML = `
<span>${commandNotificationText(notification)}</span>
<button type="button" aria-label="${I18N.close}">✕</button>
`;
alert.querySelector('button').addEventListener('click', () => alert.remove());
this.container.appendChild(alert);
setTimeout(() => alert.remove(), AUTO_DISMISS_MS);
}
}
@@ -0,0 +1,26 @@
/* Connexion WebSocket unique au tableau de bord, redistribuée aux écouteurs
* (tableau, carte) via onEvent(). Reconnexion automatique sur coupure. */
class BanEventStream {
constructor(url) {
this.url = url;
this.listeners = [];
this.socket = null;
}
onEvent(callback) {
this.listeners.push(callback);
}
connect() {
this.socket = new WebSocket(this.url);
this.socket.addEventListener('message', (event) => {
const banEvent = JSON.parse(event.data);
this.listeners.forEach((callback) => callback(banEvent));
});
this.socket.addEventListener('close', () => this.scheduleReconnect());
}
scheduleReconnect() {
setTimeout(() => this.connect(), 3000);
}
}
@@ -0,0 +1,38 @@
/* Ajoute chaque BanEvent reçu en tête du tableau du tableau de bord. */
const ACTION_BADGE_CLASSES = {
BAN: 'vc-badge--danger',
UNBAN: 'vc-badge--success',
};
function actionBadgeClass(action) {
return ACTION_BADGE_CLASSES[action] || 'vc-badge--info';
}
class BanEventTable {
constructor(tableBodySelector, emptyStateSelector) {
this.tableBody = document.querySelector(tableBodySelector);
this.emptyState = document.querySelector(emptyStateSelector);
}
prependRow(banEvent) {
if (this.emptyState) {
this.emptyState.remove();
this.emptyState = null;
}
if (!this.tableBody) {
return;
}
const row = document.createElement('tr');
row.innerHTML = `
<td>${banEvent.received_at}</td>
<td>${banEvent.node_alias}</td>
<td>${banEvent.jail_name}</td>
<td><span class="vc-badge ${actionBadgeClass(banEvent.action)}">${banEvent.action}</span></td>
<td>${banEvent.ip_address}</td>
<td>${banEvent.port || '—'}/${banEvent.protocol || '—'}</td>
<td>${banEvent.bantime ?? '—'}</td>
<td>${banEvent.city || banEvent.country || '—'}</td>
`;
this.tableBody.prepend(row);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
"""Agrégats jail/pays partagés entre views.py (calcul local, toujours
disponible) et les commandes management master_listen.py (publication du
roster) / master_client.py (cache Redis du roster reçu, cf. constantes
ROSTER_* ci-dessous) une seule requête à maintenir plutôt que deux
copies qui divergent."""
from django.db.models import Count
from .models import BanEvent
TOP_N = 5
# Cache Redis du roster reçu par un noeud client (master_client.py) — DB
# dédiée (db=2) : db=0 déjà pris par Channels (CHANNEL_LAYERS), db=1 par
# Celery (CELERY_BROKER_URL). Expiration alignée sur NODE_OFFLINE_THRESHOLD_SECONDS
# (views.py) : une donnée périmée doit disparaître plutôt que mentir si le
# roster cesse d'arriver.
ROSTER_REDIS_DB = 2
ROSTER_REDIS_KEY = 'roster:global_stats'
ROSTER_TTL_SECONDS = 180
def with_bar_pct(rows: list[dict]) -> list[dict]:
"""Ajoute `pct` = largeur de barre relative au plus grand compte de la
liste pas un vrai pourcentage (la somme ne fait pas 100), juste une
échelle visuelle pour la barre du widget sidebar."""
max_count = max((row['count'] for row in rows), default=0)
for row in rows:
row['pct'] = round(row['count'] / max_count * 100) if max_count else 0
return rows
def top_jails_and_countries() -> dict[str, list[dict]]:
"""Compteurs globaux (toutes dates, tous noeuds connus de CE process)
sur le master, "tous les noeuds" ; sur un client, seulement lui-même
(d'où le roster, qui republie la version calculée côté master)."""
top_jails = list(
BanEvent.objects.values('jail_name').annotate(count=Count('id')).order_by('-count')[:TOP_N]
)
top_countries = list(
BanEvent.objects.exclude(country='')
.values('country').annotate(count=Count('id')).order_by('-count')[:TOP_N]
)
return {'top_jails': with_bar_pct(top_jails), 'top_countries': with_bar_pct(top_countries)}
+40
View File
@@ -0,0 +1,40 @@
"""Géolocalisation asynchrone des IP bannies (hors du chemin d'ingestion MQTT).
Fallback API externe (ip-api.com, sans clé) tant qu'aucune base GeoLite2
locale (MaxMind) n'est configurée — voir ROADMAP.md, Phase 2.a.
"""
import requests
from celery import shared_task
from django.utils import timezone
from .models import BanEvent
GEOLOCATION_API_URL = 'http://ip-api.com/json/{ip}'
@shared_task(bind=True, max_retries=3, default_retry_delay=10)
def geolocate_ip(self, ban_event_id: int) -> None:
try:
event = BanEvent.objects.get(pk=ban_event_id)
except BanEvent.DoesNotExist:
return
try:
response = requests.get(GEOLOCATION_API_URL.format(ip=event.ip_address), timeout=5)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
raise self.retry(exc=e)
if data.get('status') != 'success':
return
event.latitude = data.get('lat')
event.longitude = data.get('lon')
event.country = data.get('country', '')
event.country_code = data.get('countryCode', '')
event.city = data.get('city', '')
event.geolocated_at = timezone.now()
event.save(update_fields=[
'latitude', 'longitude', 'country', 'country_code', 'city', 'geolocated_at',
])
@@ -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>
+26
View File
@@ -0,0 +1,26 @@
from django.test import TestCase
from .ingestion import save_ban_event
from .models import NodeRegistry
class NodeRegistryIngestionTests(TestCase):
def test_creates_node_registry_row_for_new_node(self):
save_ban_event({'node': '12345', 'action': 'BAN', 'name': 'sshd', 'ip': '203.0.113.1'})
node = NodeRegistry.objects.get(node_id='12345')
self.assertEqual(node.alias, '')
self.assertEqual(node.display_name, '12345')
def test_second_event_updates_last_seen_without_duplicating(self):
save_ban_event({'node': '12345', 'action': 'BAN', 'name': 'sshd', 'ip': '203.0.113.1'})
first_seen = NodeRegistry.objects.get(node_id='12345').last_seen
save_ban_event({'node': '12345', 'action': 'UNBAN', 'name': 'sshd', 'ip': '203.0.113.1'})
self.assertEqual(NodeRegistry.objects.filter(node_id='12345').count(), 1)
self.assertGreaterEqual(NodeRegistry.objects.get(node_id='12345').last_seen, first_seen)
def test_display_name_prefers_alias(self):
node = NodeRegistry.objects.create(node_id='12345', alias='miraceti-vps1')
self.assertEqual(node.display_name, 'miraceti-vps1')
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from . import views
app_name = 'banevents'
urlpatterns = [
path('', views.dashboard, name='dashboard'),
path('historique/', views.history, name='history'),
path('api/join/', views.join_node, name='join_node'),
path('communaute/', views.community_landing, name='community'),
]
+293
View File
@@ -0,0 +1,293 @@
import datetime
import hashlib
import json
import logging
import subprocess
import tempfile
from pathlib import Path
import redis
from django.conf import settings
from django.contrib import messages
from django.core.mail import mail_admins
from django.core.serializers.json import DjangoJSONEncoder
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from ipware import get_client_ip
from .forms import EnrollmentRequestForm
from .models import BanEvent, JoinToken, NodeRegistry
from .serializers import event_payload
from .stats import ROSTER_REDIS_DB, ROSTER_REDIS_KEY, top_jails_and_countries
join_logger = logging.getLogger('banevents.join')
SCRIPTS_DIR = Path(settings.BASE_DIR).parent / 'scripts'
HISTORY_MAX_EVENTS = 500
# 3x l'intervalle de heartbeat (HEARTBEAT_INTERVAL_SECONDS dans
# master_client.py) : tolère un accroc réseau/reconnexion sans faux
# positif "hors ligne".
NODE_OFFLINE_THRESHOLD_SECONDS = 180
def _node_choices() -> list[NodeRegistry]:
"""Attache `is_online` (calculé, pas un champ modèle) à chaque noeud —
un noeud pas encore redéployé avec le heartbeat (Phase 3) apparaîtra
"hors ligne" même s'il fonctionne, jusqu'à son prochain déploiement."""
threshold = timezone.now() - datetime.timedelta(seconds=NODE_OFFLINE_THRESHOLD_SECONDS)
nodes = list(NodeRegistry.objects.all())
for node in nodes:
node.is_online = node.last_seen >= threshold
return nodes
def _filterable_nodes(nodes: list[NodeRegistry]) -> list[NodeRegistry]:
"""Sous-ensemble de `nodes` ayant au moins un BanEvent en local —
depuis le roster (Phase 3), NodeRegistry liste TOUS les noeuds connus
du master, y compris ceux dont ce process-ci n'a ingéré aucun
événement (un noeud client ne voit localement que ses propres bans).
Le menu de filtre ne doit proposer que des choix qui renvoient
vraiment quelque chose la liste "Noeuds" de la sidebar, elle,
continue d'afficher tout le monde (c'est son rôle)."""
local_node_ids = set(BanEvent.objects.order_by().values_list('node', flat=True).distinct())
return [node for node in nodes if node.node_id in local_node_ids]
def _attach_display_node(events: list[BanEvent], nodes: list[NodeRegistry]) -> None:
"""Résout l'alias de chaque event en une seule requête (déjà chargée
dans `nodes`) plutôt qu'un lookup par event — attaché comme attribut
Python, lu à la fois par le template (event.display_node) et par
event_payload() pour le JSON de bootstrap."""
alias_map = {node.node_id: node.display_name for node in nodes}
for event in events:
event.display_node = str(alias_map.get(event.node, event.node))
def _sidebar_stats() -> dict[str, list[dict]]:
"""Compteurs jail/pays pour le widget de la sidebar — volontairement
pas filtrés par le ?node=/plage de dates de la page courante, cf.
plan : c'est un résumé permanent, pas du contenu de page.
Essaie d'abord le roster reçu du master (Phase 3, cache Redis alimenté
par master_client.py) : sur un noeud client, les BanEvent locaux ne
couvrent que ce noeud, la vue globale vient forcément d'ailleurs. Repli
sur le calcul local si absent/périmé vrai sur le master lui-même
(qui republie ce qu'il vient de calculer localement, donc aucune
différence pratique) ou sur un noeud pas encore redéployé avec le
roster."""
try:
cached = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=ROSTER_REDIS_DB).get(
ROSTER_REDIS_KEY
)
except redis.RedisError:
cached = None
# isinstance, pas juste `if cached:` : redis-py type .get() de façon
# générique (client sync ET async partagent la signature), pyright ne
# peut pas savoir statiquement que CE client est synchrone — ce client
# ne renvoie jamais un awaitable en pratique.
if isinstance(cached, (str, bytes)):
return json.loads(cached)
return top_jails_and_countries()
def dashboard(request: HttpRequest) -> HttpResponse:
selected_node = request.GET.get('node', '')
events_qs = BanEvent.objects.all()
if selected_node:
events_qs = events_qs.filter(node=selected_node)
events = list(events_qs[:100])
nodes = _node_choices()
_attach_display_node(events, nodes)
events_json = json.dumps(
[event_payload(event, event.display_node) for event in events], cls=DjangoJSONEncoder
)
return render(request, 'banevents/dashboard.html', {
'events': events,
'events_json': events_json,
'nodes': nodes,
'filterable_nodes': _filterable_nodes(nodes),
'selected_node': selected_node,
'master_dashboard_url': settings.MASTER_DASHBOARD_URL,
'sidebar_width': settings.DASHBOARD_SIDEBAR_WIDTH,
'community_enrollment_enabled': settings.COMMUNITY_ENROLLMENT_ENABLED,
**_sidebar_stats(),
})
def _join_client_ip(request: HttpRequest) -> str:
"""Même résolution d'IP réelle qu'axes (nginx en unique reverse-proxy,
cf. AXES_IPWARE_PROXY_COUNT/AXES_IPWARE_META_PRECEDENCE_ORDER dans
config/settings/base.py et son historique de bugs) réutilisée ici
uniquement pour l'audit (logs), pas pour une décision de sécurité."""
ip, _ = get_client_ip(
request,
proxy_count=settings.AXES_IPWARE_PROXY_COUNT,
request_header_order=settings.AXES_IPWARE_META_PRECEDENCE_ORDER,
)
return ip or '?'
def _join_error(request: HttpRequest, node_name: str, reason: str) -> JsonResponse:
"""Message volontairement générique côté client quelle que soit la
cause réelle (jeton inconnu, expiré, déjà utilisé, CN invalide, échec
de signature) ne jamais donner d'indice à un tiers qui sonderait cet
endpoint (énumération de noms de noeuds, etc.). Le détail part
uniquement dans les logs serveur."""
join_logger.warning('Échec join pour %s depuis %s : %s', node_name, _join_client_ip(request), reason)
return JsonResponse({'error': 'Jeton invalide ou expiré.'}, status=403)
@csrf_exempt
@require_POST
def join_node(request: HttpRequest) -> JsonResponse:
"""Auto-inscription d'un noeud par jeton à usage unique (flux
"kubeadm join", voir manage.py create_join_token et ROADMAP.md).
Appel machine-à-machine (sudo make join côté noeud), pas de
session navigateur csrf_exempt. Aucune jail fail2ban dédiée : les
jetons ont 256 bits d'entropie (secrets.token_urlsafe(32)), un
brute-force est déjà impraticable sans compteur d'échecs, contrairement
à /admin/ (mots de passe, entropie humaine faible, d'où django-axes)."""
try:
payload = json.loads(request.body)
node_name = str(payload['node_name'])
token = str(payload['token'])
csr_pem = str(payload['csr'])
except (json.JSONDecodeError, KeyError, TypeError, UnicodeDecodeError):
return _join_error(request, '?', 'requête JSON invalide')
token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()
now = timezone.now()
# update() atomique : une seule ligne affectée garantit qu'aucune
# requête concurrente n'a déjà consommé ce jeton (protection contre
# une double utilisation en cas de rejeu/course). Si tout ce qui suit
# échoue (CN incohérent, signature en erreur), le jeton est "rendu"
# (used_at remis à NULL, cf. plus bas) plutôt que brûlé pour de bon —
# un échec transitoire côté serveur ne doit pas forcer à réémettre un
# jeton entièrement nouveau alors que le client n'y est pour rien.
updated = JoinToken.objects.filter(
token_hash=token_hash, node_name=node_name, used_at__isnull=True, expires_at__gt=now,
).update(used_at=now)
if updated != 1:
return _join_error(request, node_name, 'jeton inconnu, expiré ou déjà utilisé')
with tempfile.TemporaryDirectory() as tmp_dir:
csr_path = Path(tmp_dir) / 'node.csr'
csr_path.write_text(csr_pem)
# CN de la CSR doit correspondre au node_name déclaré : défense en
# profondeur, un jeton (donc un node_name) ne permet pas de faire
# signer un certificat pour un AUTRE nom. -nameopt oneline,-space_eq
# force un format de sortie stable ("subject=CN=xxx", sans espace
# autour du "="), indépendant de la version d'openssl installée.
subject = subprocess.run(
['openssl', 'req', '-in', str(csr_path), '-noout', '-subject', '-nameopt', 'oneline,-space_eq'],
capture_output=True, text=True, timeout=10,
)
if subject.returncode != 0 or subject.stdout.strip() != f'subject=CN={node_name}':
JoinToken.objects.filter(token_hash=token_hash).update(used_at=None)
return _join_error(request, node_name, f'CSR illisible ou CN incohérent ({subject.stdout.strip()!r})')
result = subprocess.run(
['sudo', str(SCRIPTS_DIR / 'sign-node-csr.sh'), node_name, str(csr_path)],
capture_output=True, text=True, timeout=15,
)
if result.returncode != 0:
JoinToken.objects.filter(token_hash=token_hash).update(used_at=None)
return _join_error(request, node_name, f'échec de signature : {result.stderr.strip()}')
# ca.crt appartient à root:mosquitto, mode 640 (cf. master-ca-init.sh) —
# le user de déploiement (celui qui fait tourner Daphne) n'est pas dans
# ce groupe, une lecture directe échoue (PermissionError, repéré en
# conditions réelles). sudo cat, comme pour la signature elle-même :
# même accès déjà en place (NOPASSWD:ALL), pas de nouveau droit à ouvrir.
ca_cert_result = subprocess.run(
['sudo', 'cat', str(Path(settings.MQTT_MASTER_CA_DIR) / 'ca.crt')],
capture_output=True, text=True, timeout=10,
)
ca_cert_pem = ca_cert_result.stdout if ca_cert_result.returncode == 0 else ''
join_logger.info('Noeud %s inscrit depuis %s', node_name, _join_client_ip(request))
return JsonResponse({'cert': result.stdout, 'ca_cert': ca_cert_pem}, status=201)
def history(request: HttpRequest) -> HttpResponse:
selected_node = request.GET.get('node', '')
from_date = request.GET.get('from', '')
to_date = request.GET.get('to', '')
events_qs = BanEvent.objects.all()
if selected_node:
events_qs = events_qs.filter(node=selected_node)
if from_date:
events_qs = events_qs.filter(received_at__date__gte=from_date)
if to_date:
events_qs = events_qs.filter(received_at__date__lte=to_date)
events = list(events_qs[:HISTORY_MAX_EVENTS])
nodes = _node_choices()
_attach_display_node(events, nodes)
events_json = json.dumps(
[event_payload(event, event.display_node) for event in events], cls=DjangoJSONEncoder
)
return render(request, 'banevents/history.html', {
'events': events,
'events_json': events_json,
'nodes': nodes,
'filterable_nodes': _filterable_nodes(nodes),
'selected_node': selected_node,
'from_date': from_date,
'to_date': to_date,
'master_dashboard_url': settings.MASTER_DASHBOARD_URL,
'sidebar_width': settings.DASHBOARD_SIDEBAR_WIDTH,
'community_enrollment_enabled': settings.COMMUNITY_ENROLLMENT_ENABLED,
**_sidebar_stats(),
})
def community_landing(request: HttpRequest) -> HttpResponse:
"""Page publique expliquant le projet et proposant de rejoindre la
communauté (master uniquement, désactivée par défaut voir
COMMUNITY_ENROLLMENT_ENABLED). Ne crée jamais de jeton ni d'accès
direct : la soumission place juste une EnrollmentRequest en attente,
validée à la main dans /admin/ (voir
EnrollmentRequestAdmin.approve_and_send_token)."""
if not settings.COMMUNITY_ENROLLMENT_ENABLED:
raise Http404
if request.method == 'POST':
form = EnrollmentRequestForm(request.POST)
if form.is_valid():
if form.is_spam():
# Ignoré silencieusement : ne pas révéler à un bot qu'il a
# été détecté (pas d'erreur, pas d'entrée créée).
return redirect('banevents:community')
enrollment = form.save()
try:
mail_admins(
'Nouvelle demande d\'inscription à la communauté',
f"Email : {enrollment.email}\n"
f"Noeud souhaité : {enrollment.node_name}\n"
f"Message : {enrollment.message or '(vide)'}\n\n"
f"À traiter dans /admin/banevents/enrollmentrequest/",
)
except Exception:
# Best-effort : la demande est déjà enregistrée (visible dans
# /admin/ de toute façon) — un échec d'envoi ne doit jamais
# faire perdre la soumission de l'utilisateur.
join_logger.exception('Échec de notification admin pour une nouvelle EnrollmentRequest')
messages.success(request, _(
'Votre demande est enregistrée. Vous recevrez un email si elle est acceptée.'
))
return redirect('banevents:community')
else:
form = EnrollmentRequestForm()
return render(request, 'banevents/community.html', {'form': form})
+3
View File
@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)
+16
View File
@@ -0,0 +1,16 @@
"""ASGI config: sert le HTTP classique (Django) et les WebSockets (Channels)."""
import os
import django
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
django.setup()
from banevents.routing import websocket_urlpatterns # noqa: E402
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': URLRouter(websocket_urlpatterns),
})
+10
View File
@@ -0,0 +1,10 @@
"""App Celery : tâches asynchrones (géolocalisation) hors du chemin d'ingestion MQTT."""
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
app = Celery('config')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
View File
+373
View File
@@ -0,0 +1,373 @@
"""Django settings shared by every environment (dev, production)."""
import os
from pathlib import Path
from django.core.exceptions import DisallowedHost
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# .env vit à la racine du dépôt (BASE_DIR/../.env), pas dans emitter/ — voir
# .env.example. Chargé ici pour que `manage.py ...` lise les mêmes valeurs
# que systemd (EnvironmentFile), sans avoir à les exporter à la main en dev.
# N'écrase jamais une variable déjà présente dans l'environnement (override=False
# par défaut) : sous systemd, EnvironmentFile reste la source de vérité.
load_dotenv(BASE_DIR.parent / '.env')
INSTALLED_APPS = [
'daphne',
# banevents AVANT django.contrib.admin : override du thème admin
# (templates/admin/base_site.html) — avec APP_DIRS=True, la résolution
# de template prend le premier app_dirs match dans l'ordre
# d'INSTALLED_APPS, donc notre template ne serait jamais atteint si
# django.contrib.admin passait en premier (piège documenté par Django
# lui-même pour toute personnalisation de l'admin).
'banevents',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'channels',
'axes',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
# Doit se trouver après SessionMiddleware et avant CommonMiddleware
# (exigé par Django) : lit la langue depuis le cookie de session, puis
# l'en-tête Accept-Language, avant que CommonMiddleware ne traite la
# requête. Bascule fr/en du dashboard (Phase 5) — voir set_language
# dans config/urls.py et le sélecteur dans _sidebar_stats.html.
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# Doit rester en dernier (exigé par django-axes).
'axes.middleware.AxesMiddleware',
]
# django-axes en premier (exigé) : verrouille après trop d'échecs de
# connexion admin, cf. banevents/signals.py (receiver user_locked_out) qui
# journalise l'événement pour fail2ban — voir generic/fail2ban/jail.d/
# emitter-admin-auth.conf. AXES ne bannit rien au réseau lui-même, juste
# l'application ; le ban réseau + la propagation aux autres noeuds
# viennent de fail2ban comme pour toutes les autres jails.
AUTHENTICATION_BACKENDS = [
'axes.backends.AxesBackend',
'django.contrib.auth.backends.ModelBackend',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# LANGUAGE_CODE/LANGUAGES dans les templates (ex.
# <html lang="{{ LANGUAGE_CODE }}">, sélecteur de langue
# dans _sidebar_stats.html) — Phase 5.
'django.template.context_processors.i18n',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
ASGI_APPLICATION = 'config.asgi.application'
REDIS_HOST = os.environ.get('REDIS_HOST', '127.0.0.1')
REDIS_PORT = int(os.environ.get('REDIS_PORT', '6379'))
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [(REDIS_HOST, REDIS_PORT)],
},
},
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# fr (défaut, langue source des templates) / en (traduction, voir
# locale/en/LC_MESSAGES/django.po, généré par makemessages -l en et
# compilé par compilemessages — make lint/test ne le font pas
# automatiquement, cf. README).
LANGUAGES = [
('fr', 'Français'),
('en', 'English'),
]
LOCALE_PATHS = [BASE_DIR / 'locale']
# Celery — DB Redis distincte de celle des Channels (0), pour ne pas mélanger
# les deux usages du même serveur Redis.
CELERY_BROKER_URL = f'redis://{REDIS_HOST}:{REDIS_PORT}/1'
CELERY_RESULT_BACKEND = f'redis://{REDIS_HOST}:{REDIS_PORT}/1'
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TIMEZONE = TIME_ZONE
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'banevents' / 'static']
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Client MQTT local (management command banevents/mqtt_listen) — même broker
# local que celui utilisé par f2b-mqtt-action-banisher (generic/mosquitto/),
# mais avec un compte dédié à l'émetteur Django.
#MQTT_BROKER_HOST = os.environ.get('MQTT_BROKER_HOST', '127.0.0.1')
MQTT_BROKER_HOST = os.environ.get('MQTT_BROKER_HOST', '192.168.250.1')
MQTT_BROKER_PORT = int(os.environ.get('MQTT_BROKER_PORT', '1883'))
MQTT_BROKER_USERNAME = os.environ.get('MQTT_BROKER_USERNAME', 'emitter')
MQTT_BROKER_PASSWORD = os.environ.get('MQTT_BROKER_PASSWORD', 'banishing')
MQTT_TOPIC_SUBSCRIBE = os.environ.get('MQTT_TOPIC_SUBSCRIBE', 'fail2ban/+/jail')
# Client MQTT vers le broker master (Phase 2.b, management command
# banevents/master_client) — mTLS : le certificat client (signé par la CA
# privée du master, cf. scripts/generate-node-cert.sh) sert d'identité,
# pas de mot de passe. Par défaut, cherche les fichiers dans
# emitter/master-tls/ (jamais commité, cf. .gitignore).
# Pas de MQTT_MASTER_CA_CERT : le certificat SERVEUR du master est signé
# Let's Encrypt, vérifié par le magasin de CA système par défaut. La CA
# privée du master ne sert qu'à ce que LUI vérifie NOTRE certificat client —
# elle n'a pas sa place ici (cf. master_client.py).
MQTT_MASTER_HOST = os.environ.get('MQTT_MASTER_HOST', 'mqtt.linuxtarn.org')
MQTT_MASTER_PORT = int(os.environ.get('MQTT_MASTER_PORT', '8883'))
MQTT_MASTER_CLIENT_CERT = os.environ.get('MQTT_MASTER_CLIENT_CERT', str(BASE_DIR / 'master-tls' / 'node.crt'))
MQTT_MASTER_CLIENT_KEY = os.environ.get('MQTT_MASTER_CLIENT_KEY', str(BASE_DIR / 'master-tls' / 'node.key'))
# Doit être identique au nom passé à scripts/generate-node-cert.sh (le CN du
# certificat client) : le master ACL ce noeud sur fail2ban/<ce nom>/... via
# use_identity_as_username, donc un nom qui ne correspond pas au CN se fera
# rejeter toute publication/abonnement par l'ACL.
MQTT_MASTER_NODE_NAME = os.environ.get('MQTT_MASTER_NODE_NAME', 'testclient')
# Client MQTT côté master (Phase 3, management command
# banevents/master_listen) — identité mTLS DISTINCTE de MQTT_MASTER_CLIENT_*
# ci-dessus : ce process doit lire fail2ban/+/ban (tous les noeuds), un
# droit plus large que celui de n'importe quel noeud pris individuellement,
# donc son propre certificat (CN "master-internal" par convention, voir
# generic/mosquitto/master-acl.conf) plutôt que de réutiliser l'identité
# d'un noeud.
MQTT_MASTER_LISTENER_CERT = os.environ.get('MQTT_MASTER_LISTENER_CERT', str(BASE_DIR / 'master-tls' / 'listener.crt'))
MQTT_MASTER_LISTENER_KEY = os.environ.get('MQTT_MASTER_LISTENER_KEY', str(BASE_DIR / 'master-tls' / 'listener.key'))
# CA privée du master (scripts/master-ca-init.sh) — master uniquement,
# lue par banevents/views.py::join_node pour renvoyer ca.crt au noeud qui
# s'auto-inscrit (flux par jeton, voir ROADMAP.md). Même défaut que
# master-ca-init.sh/generate-node-cert.sh/sign-node-csr.sh : à garder
# cohérent si jamais CA_DIR est un jour personnalisé sur ce VPS.
MQTT_MASTER_CA_DIR = os.environ.get('MQTT_MASTER_CA_DIR', '/etc/mosquitto/master-ca')
# Page publique /communaute/ (master uniquement) : désactivée par défaut
# — quiconque peut y déposer une demande d'inscription (email, nom de
# noeud souhaité), mais aucun jeton n'est émis/envoyé sans validation
# manuelle dans /admin/ (voir banevents/admin.py::EnrollmentRequestAdmin).
COMMUNITY_ENROLLMENT_ENABLED = os.environ.get('COMMUNITY_ENROLLMENT_ENABLED', 'false').lower() == 'true'
# Email (validation d'une demande d'inscription, cf. ci-dessus) — backend
# sendmail (django-sendmail-backend), pas le backend SMTP standard de
# Django : ce dernier exige un socket TCP en écoute (ex. 127.0.0.1:25),
# absent sur un VPS dont le MTA local (exim4) n'est configuré qu'en
# remise directe (invocation du binaire /usr/sbin/sendmail, vérifié
# fonctionnel en conditions réelles, sans dépendre du daemon exim4.service
# — qui peut très bien être arrêté/en échec sans que ça n'empêche l'envoi
# via ce backend). Aucune garantie de délivrabilité vers une adresse
# EXTERNE sans relais SMTP (smarthost) configuré par ailleurs.
EMAIL_BACKEND = 'django_sendmail_backend.backends.EmailBackend'
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', '')
# ADMINS alimente mail_admins() — notification à l'admin qu'une nouvelle
# EnrollmentRequest attend une décision dans /admin/ (banevents/views.py::
# community_landing), en plus de l'email envoyé au demandeur une fois
# validée. ADMIN_EMAIL vide => ADMINS reste vide => mail_admins() ne fait
# rien (pas d'erreur), la demande reste visible dans /admin/ de toute
# façon — cette notification est un confort, pas la source de vérité.
_admin_email = os.environ.get('ADMIN_EMAIL', '')
ADMINS = [('admin', _admin_email)] if _admin_email else []
# Chemin absolu de fail2ban-client, utilisé par master_client pour exécuter
# un SYNC_BAN reçu (sudo fail2ban-client set master-sync banip <ip>) — doit
# correspondre exactement au chemin autorisé dans le sudoers déployé par
# install.sh (generic/emitter/sudoers-master-sync), sudo ne fait pas de
# résolution de $PATH.
FAIL2BAN_CLIENT_PATH = os.environ.get('FAIL2BAN_CLIENT_PATH', '/usr/bin/fail2ban-client')
# AbuseIPDB (REPORT_ABUSE, Phase 4, banevents/abuseipdb.py) — désactivé
# par défaut : ABUSEIPDB_ENABLED=false ou ABUSEIPDB_API_KEY vide font
# tomber publish_command.py en dry-run (rien envoyé, juste journalisé).
# Jamais de rapport public réel sans configuration explicite dans .env.
ABUSEIPDB_API_KEY = os.environ.get('ABUSEIPDB_API_KEY', '')
ABUSEIPDB_ENABLED = os.environ.get('ABUSEIPDB_ENABLED', 'false').lower() == 'true'
# Lien vers le dashboard du master (sidebar) — vide sur le master
# lui-même (pas de lien vers soi-même), renseigné sur les noeuds clients.
# Un seul lien hiérarchique (client -> master), pas un maillage entre
# noeuds (tentative de liens croisés N x N essayée puis abandonnée —
# ne passe pas à l'échelle avec beaucoup de clients abonnés).
MASTER_DASHBOARD_URL = os.environ.get('MASTER_DASHBOARD_URL', '').rstrip('/')
# Largeur de la sidebar (CSS, ex. "220px" ou "16rem") — la valeur par
# défaut (--sidebar-w dans main.css) convient à peu de noeuds/alias courts ;
# ajustable sans toucher au CSS si le roster (Phase 3) fait grandir le
# bloc "Noeuds" au point que les libellés soient tronqués.
# `or` plutôt que get(key, default) : .env.example laisse la variable
# présente mais vide (convention de ce fichier), qui doit retomber sur le
# défaut au même titre qu'une variable absente.
DASHBOARD_SIDEBAR_WIDTH = os.environ.get('DASHBOARD_SIDEBAR_WIDTH') or '220px'
# django-axes (protection anti-brute-force sur /admin/, cf. AUTHENTICATION_
# BACKENDS ci-dessus) — verrouille par IP seule (pas IP+utilisateur) pour
# rester cohérent avec le modèle fail2ban : une IP qui abuse doit être
# bloquée quel que soit le compte visé, pas juste ce compte-là.
AXES_FAILURE_LIMIT = 5
AXES_LOCKOUT_PARAMETERS = ['ip_address']
AXES_COOLOFF_TIME = 1 # heures — verrouillage applicatif ; le vrai blocage
# réseau (fail2ban, cf. plus bas) dure bien plus longtemps (24h).
# Le dashboard est derrière nginx (generic/nginx/fail2ban-emitter.example.conf,
# qui pose déjà X-Real-IP/X-Forwarded-For) : sans ceci, axes verrait
# 127.0.0.1 (nginx) sur chaque requête au lieu de la vraie IP cliente.
# 0, pas 1 : nginx est l'unique proxy et un vrai client n'envoie jamais son
# propre X-Forwarded-For, donc $proxy_add_x_forwarded_for (gabarit nginx)
# ne contient qu'UNE entrée (le $remote_addr observé par nginx lui-même).
# python-ipware exige (nb d'entrées - 1) >= proxy_count : avec 1 entrée et
# proxy_count=1, la validation échoue systématiquement et l'IP résolue
# vaut None (vérifié en conditions réelles : ligne de log
# "... depuis None ..." malgré 5 échecs de connexion réels). proxy_count=0
# prend ip_list[-1], donc TOUJOURS le dernier maillon ajouté par nginx —
# fiable même si le client falsifie son propre X-Forwarded-For en amont.
AXES_IPWARE_PROXY_COUNT = 0
# axes ignore X-Forwarded-For par défaut (AXES_IPWARE_META_PRECEDENCE_ORDER
# vaut ('REMOTE_ADDR',) tant que ce n'est pas explicite — protection contre
# le spoofing par un déploiement mal configuré) : sans ceci, chaque requête
# est vue avec l'IP de nginx (127.0.0.1), jamais la vraie IP cliente.
AXES_IPWARE_META_PRECEDENCE_ORDER = ('HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR')
# Log dédié pour fail2ban (generic/fail2ban/jail.d/emitter-admin-auth.conf)
# — à l'intérieur du checkout (ReadWritePaths=__EMITTER_DIR__ dans
# fail2ban-emitter-web.service couvre déjà tout l'arbre, pas besoin
# d'étendre le sandboxing systemd pour un nouveau chemin /var/log/...).
# Le receiver banevents/signals.py écrit une ligne stable et contrôlée par
# nous sur le logger 'fail2ban.axes', pas les logs internes d'axes (le
# format exact de la librairie n'est pas un contrat, il pourrait changer
# entre versions).
LOG_DIR = BASE_DIR / 'logs'
# install.sh (deploy_emitter) crée déjà ce dossier en prod avec le bon
# propriétaire avant le premier manage.py, mais un checkout local (dev,
# CI) n'a jamais lancé install.sh : sans ce mkdir, django.setup() plante
# dès l'import des settings (FileHandler ne crée pas son dossier parent).
LOG_DIR.mkdir(parents=True, exist_ok=True)
def _skip_disallowed_host(record):
"""Coupe l'email admin (mail_admins, cf. handler ci-dessous) pour les
DisallowedHost bruit constant sur un serveur à IP publique (scanners
qui tapent l'IP brute plutôt que DASHBOARD_DOMAIN), sans intérêt
actionnable contrairement aux autres erreurs 500. Django rejette déjà
correctement la requête (ALLOWED_HOSTS) ; seule la notification est
supprimée, pas la protection elle-même. Recette officielle Django, voir
https://docs.djangoproject.com/en/stable/howto/error-reporting/#filtering-error-reports
"""
if record.exc_info:
exc_type = record.exc_info[0]
if exc_type is not None and issubclass(exc_type, DisallowedHost):
return False
return True
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'filters': {
# Redéfini ici (identique à DEFAULT_LOGGING de Django) : dictConfig
# ne fusionne pas entre l'appel interne de Django et celui-ci pour
# notre propre LOGGING, un nom de filtre référencé plus bas sans
# être défini dans CE dict lèverait "Unable to configure filter".
'require_debug_false': {'()': 'django.utils.log.RequireDebugFalse'},
'skip_disallowed_host': {
'()': 'django.utils.log.CallbackFilter',
'callback': _skip_disallowed_host,
},
},
'formatters': {
# TIME_ZONE='UTC' ci-dessus force tout le process en UTC
# (Settings.__init__ de Django appelle time.tzset() dès le
# chargement des settings) — %(asctime)s (time.localtime() sous
# le capot) produit donc des heures UTC, mais SANS le dire.
# fail2ban (process séparé, non affecté par Django) lit l'horloge
# système réelle du VPS (ex. Europe/Berlin, CEST = UTC+2) : un
# timestamp UTC non marqué paraît alors "vieux de 2h", ce qui l'a
# fait ignorer silencieusement une ligne de verrouillage fraîche
# juste après un restart de fail2ban.service ("Ignoring all log
# entries older than 3600s" — repéré en re-testant après la
# généralisation du filtre app-auth). Suffixe "Z" explicite
# (ISO 8601 UTC, reconnu par la détection de date de fail2ban) :
# supprime toute ambiguïté, quel que soit le fuseau du VPS.
'fail2ban': {'format': '%(asctime)s.%(msecs)03dZ %(message)s', 'datefmt': '%Y-%m-%dT%H:%M:%S'},
},
'handlers': {
'fail2ban_axes': {
'class': 'logging.FileHandler',
'filename': str(LOG_DIR / 'admin-auth.log'),
'formatter': 'fail2ban',
},
# Redéfinit le handler par défaut de Django (même class/level que
# DEFAULT_LOGGING) pour n'y ajouter que le filtre ci-dessus — une
# config LOGGING partielle remplace entièrement une clé du même nom
# plutôt que de la fusionner, il faut donc la réécrire au complet.
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false', 'skip_disallowed_host'],
'class': 'django.utils.log.AdminEmailHandler',
},
# Requis par le logger 'django' ci-dessous (même piège que
# require_debug_false : un handler référencé doit être défini dans
# CE dict, jamais hérité de l'appel dictConfig() interne de
# Django) — conserve la visibilité des erreurs dans journalctl,
# inchangée par rapport à avant ce correctif.
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'fail2ban.axes': {
'handlers': ['fail2ban_axes'],
'level': 'WARNING',
'propagate': False,
},
# Redéfini explicitement (même comportement que DEFAULT_LOGGING de
# Django : handlers=['console', 'mail_admins'], level='INFO') pour
# forcer le rattachement à NOTRE instance de mail_admins ci-dessus.
# Sans ça, le logger 'django' garde en mémoire la référence vers
# l'instance créée par le tout premier dictConfig() interne de
# Django (DEFAULT_LOGGING, jamais filtrée) : les objets logger
# Python conservent leurs handlers par référence directe, pas par
# nom, un dictConfig() ultérieur qui ne re-déclare pas le logger
# ne le fait donc jamais pointer vers le nouveau handler — piège
# vécu : le filtre ci-dessus n'avait justement aucun effet tant
# que ce logger n'était pas explicitement redéclaré ici.
'django': {
'handlers': ['console', 'mail_admins'],
'level': 'INFO',
},
},
}
+17
View File
@@ -0,0 +1,17 @@
"""Settings de développement local : SQLite, DEBUG actif, hôtes ouverts."""
from .base import * # noqa: F401,F403
SECRET_KEY = 'django-insecure-dev-only-not-for-production'
DEBUG = True
ALLOWED_HOSTS = ['*']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Pas de verrouillage gênant en dev local — fail2ban ne tourne de toute
# façon pas ici (cf. AXES_* dans base.py, pensé pour la prod derrière nginx).
AXES_ENABLED = False
+77
View File
@@ -0,0 +1,77 @@
"""Settings de production : secrets et hôtes fournis par l'environnement."""
import os
from .base import * # noqa: F401,F403
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
DEBUG = False
ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h]
CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if o]
# DB_ENGINE=mariadb (opt-in, voir "sudo ./install.sh install-mariadb") bascule
# vers MariaDB/MySQL via PyMySQL (pas de mysqlclient : évite une dépendance de
# compilation C côté paquets système) ; sqlite reste le défaut, sans rien à
# installer/configurer en plus.
#
# DB_ENGINE=postgresql (opt-in) : contrairement à mariadb, pas de
# sous-commande install.sh dédiée pour l'instant — provisionner le serveur
# PostgreSQL/la base/l'utilisateur reste manuel, puis renseigner DB_*
# ci-dessous dans .env. Driver requirements-postgresql.txt
# (psycopg[binary], pas de compilation C nécessaire non plus) à installer
# à la main dans le venv (pip install -r requirements-postgresql.txt)
# avant de basculer DB_ENGINE.
DB_ENGINE = os.environ.get('DB_ENGINE', 'sqlite')
if DB_ENGINE == 'mariadb':
import pymysql
pymysql.install_as_MySQLdb()
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DB_NAME', 'fail2ban_emitter'),
'USER': os.environ.get('DB_USER', 'fail2ban_emitter'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '3306'),
}
}
elif DB_ENGINE == 'postgresql':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'fail2ban_emitter'),
'USER': os.environ.get('DB_USER', 'fail2ban_emitter'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.environ.get('DJANGO_DB_PATH', str(BASE_DIR / 'db.sqlite3')),
}
}
STATIC_ROOT = BASE_DIR / 'staticfiles'
STORAGES = {
'staticfiles': {
'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage',
},
}
MIDDLEWARE = ['whitenoise.middleware.WhiteNoiseMiddleware'] + MIDDLEWARE
# Pas de TLS géré ici (pas de reverse proxy devant l'émetteur pour l'instant) :
# forcer le HTTPS casserait l'accès (redirection en boucle, cookies rejetés).
# À activer (DJANGO_SECURE_SSL=true) une fois un reverse proxy TLS en place.
DJANGO_SECURE_SSL = os.environ.get('DJANGO_SECURE_SSL', 'false').lower() == 'true'
SECURE_SSL_REDIRECT = DJANGO_SECURE_SSL
SESSION_COOKIE_SECURE = DJANGO_SECURE_SSL
CSRF_COOKIE_SECURE = DJANGO_SECURE_SSL
# Daphne ne voit que la connexion interne en clair depuis nginx (proxy_pass
# http://127.0.0.1:8050) : sans ceci, request.is_secure() est toujours faux
# côté Django, qui redirige alors indéfiniment (SECURE_SSL_REDIRECT actif
# mais jamais satisfait) — le gabarit generic/nginx/fail2ban-emitter.example.conf
# pose bien X-Forwarded-Proto, encore faut-il que Django lui fasse confiance.
if DJANGO_SECURE_SSL:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
+11
View File
@@ -0,0 +1,11 @@
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
# Vue set_language (POST) utilisée par le sélecteur de langue de la
# sidebar (_sidebar_stats.html) — pose le cookie django_language, lu
# par LocaleMiddleware (config/settings/base.py) à chaque requête.
path('i18n/', include('django.conf.urls.i18n')),
path('', include('banevents.urls')),
]
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
application = get_wsgi_application()
Binary file not shown.
+241
View File
@@ -0,0 +1,241 @@
# English translation catalog for the banevents dashboard UI (Phase 5).
# Regenerate the msgid list with `manage.py makemessages -l en`, keep the
# msgstr values below in sync by hand, then `manage.py compilemessages`.
msgid ""
msgstr ""
"Project-Id-Version: Fail2banActionBanisher\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-18 17:34+0000\n"
"PO-Revision-Date: 2026-07-18 17:34+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Django site admin"
msgstr "Django site admin"
msgid "Django administration"
msgstr "Django administration"
msgid "Direct"
msgstr "Live"
msgid "Historique"
msgstr "History"
msgid "Noeuds"
msgstr "Nodes"
msgid "en ligne"
msgstr "online"
msgid "hors ligne"
msgstr "offline"
msgid "dernière activité"
msgstr "last activity"
msgid "Top jails"
msgstr "Top jails"
msgid "Top pays attaquants"
msgstr "Top attacking countries"
msgid "Langue"
msgstr "Language"
msgid "Rejoindre la communauté"
msgstr "Join the community"
msgid "Tableau de bord"
msgstr "Dashboard"
msgid "Le but"
msgstr "The purpose"
msgid ""
"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."
msgstr ""
"Fail2banActionBanisher is a distributed malicious-host banishing system "
"(fail2ban + iptables), with event propagation over MQTT. A \"master\" "
"server centralizes the decision (multi-node correlation, global recidive "
"detection) and republishes the actions to be executed to all subscribed "
"nodes."
msgid ""
"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é."
msgstr ""
"Joining the community doesn't assume any ownership relationship between "
"servers: every member keeps their own node and their own local fail2ban — "
"only ban propagation is shared through this common master. No member gets "
"access to another member's servers."
msgid "Le fonctionnement"
msgstr "How it works"
msgid ""
"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."
msgstr ""
"Your server detects and bans locally (fail2ban + iptables), publishes the "
"event to a local Mosquitto broker, then relays it to this master over "
"mTLS. In return, the master republishes decided bans (propagation, "
"escalation if recidive is detected across several nodes, ...) to your "
"server."
msgid "L'installation"
msgstr "Installation"
msgid ""
"Cloner le dépôt sur votre serveur (VPS Debian 13), dans le <code>$HOME</"
"code> d'un utilisateur dédié."
msgstr ""
"Clone the repository on your server (Debian 13 VPS), into a dedicated "
"user's <code>$HOME</code>."
msgid "Copier <code>.env.example</code> vers <code>.env</code> et l'éditer."
msgstr "Copy <code>.env.example</code> to <code>.env</code> and edit it."
msgid ""
"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."
msgstr ""
"Token-based enrollment: once your request below is accepted, you'll "
"receive an email with a ready-to-use <code>make join</code> command — your "
"server's private key is generated locally and never leaves your machine."
msgid "Demander à rejoindre"
msgstr "Request to join"
msgid ""
"Votre demande sera examinée avant qu'un jeton d'inscription ne vous soit "
"envoyé par email."
msgstr ""
"Your request will be reviewed before an enrollment token is sent to you by "
"email."
msgid "Email"
msgstr "Email"
msgid "Nom de noeud souhaité"
msgstr "Desired node name"
msgid "Pays du serveur (optionnel)"
msgstr "Server country (optional)"
msgid "Message de présentation (optionnel)"
msgstr "Introduction message (optional)"
msgid "Envoyer la demande"
msgstr "Send request"
msgid "Bannissements (direct)"
msgstr "Bans (live)"
msgid "Administration"
msgstr "Administration"
msgid "Événements de bannissement"
msgstr "Ban events"
msgid "Tous les noeuds"
msgstr "All nodes"
msgid "(hors ligne)"
msgstr "(offline)"
msgid "Carte"
msgstr "Map"
msgid "Réinitialiser la vue"
msgstr "Reset view"
msgid "Reçu le"
msgstr "Received"
msgid "Noeud"
msgstr "Node"
msgid "Jail"
msgstr "Jail"
msgid "Action"
msgstr "Action"
msgid "Port/Protocole"
msgstr "Port/Protocol"
msgid "Durée du ban"
msgstr "Ban duration"
msgid "Localisation"
msgstr "Location"
msgid "Aucun événement pour le moment"
msgstr "No events yet"
msgid ""
"En attente d'un ban à diffuser en direct via WebSocket (manage.py "
"mqtt_listen doit tourner)."
msgstr ""
"Waiting for a ban to stream live via WebSocket (manage.py mqtt_listen must "
"be running)."
msgid "Commande reçue du master (non gérée)"
msgstr "Command received from master (unhandled)"
msgid "appliqué"
msgstr "applied"
msgid "échec"
msgstr "failed"
msgid "Fermer"
msgstr "Close"
msgid "Historique des bannissements"
msgstr "Ban history"
msgid "Depuis"
msgstr "From"
msgid "Jusqu'à"
msgstr "To"
msgid "Filtrer"
msgstr "Filter"
msgid "Réinitialiser"
msgstr "Reset"
msgid "Aucun événement sur cette période"
msgstr "No events for this period"
#, python-format
msgid ""
"Ajuste les dates ou le noeud ci-dessus, ou <a "
"href=\"%(reset_url)s\">réinitialise le filtre</a>."
msgstr ""
"Adjust the dates or node above, or <a href=\"%(reset_url)s\">reset the "
"filter</a>."
msgid ""
"Votre demande est enregistrée. Vous recevrez un email si elle est acceptée."
msgstr ""
"Your request has been recorded. You will receive an email if it is "
"accepted."
+23
View File
@@ -0,0 +1,23 @@
#!.venv/bin/python
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+11
View File
@@ -0,0 +1,11 @@
# Dépendances de développement uniquement (typage statique) — jamais
# nécessaires à l'exécution, seulement à `make lint` (pyright). Sans
# elles, pyright ne peut pas résoudre les attributs dynamiques de Django
# (Model.objects, BaseCommand.style.SUCCESS/ERROR, ...) — ces paquets
# fournissent des stubs statiques (.pyi), pas de plugin mypy requis :
# pyright ne supporte de toute façon pas le plugin mypy de django-stubs,
# seuls ses fichiers de stubs (objects, Style, etc.) sont exploitables ici.
# Version alignée sur la plage Django de requirements.txt (>=5.0,<7) —
# le versionnage de django-stubs suit celui de Django par convention.
django-stubs>=5.0,<7
celery-types>=0.26,<1
+6
View File
@@ -0,0 +1,6 @@
# Dépendance optionnelle pour DB_ENGINE=mariadb (voir .env.example et
# "sudo ./install.sh install-mariadb"). PyMySQL est un driver pur Python
# (pymysql.install_as_MySQLdb(), cf. config/settings/production.py) : pas de
# mysqlclient, pas de dépendance de compilation (libmysqlclient-dev) côté
# paquets système.
PyMySQL>=1,<2
+6
View File
@@ -0,0 +1,6 @@
# Dépendance optionnelle pour DB_ENGINE=postgresql (voir .env.example et
# config/settings/production.py). psycopg[binary] : driver officiel
# PostgreSQL pour Django, wheels précompilées — pas de dépendance de
# compilation C (libpq-dev) côté paquets système, même raisonnement que
# PyMySQL pour DB_ENGINE=mariadb (requirements-mariadb.txt).
psycopg[binary]>=3,<4
+12
View File
@@ -0,0 +1,12 @@
Django>=5.0,<7
paho-mqtt>=2,<3
channels>=4,<5
channels_redis>=4,<5
daphne>=4,<5
redis>=5,<6
celery>=5,<6
requests>=2,<3
whitenoise>=6,<7
python-dotenv>=1,<2
django-axes[ipware]>=8,<9
django-sendmail-backend>=0.1,<1