First commit

This commit is contained in:
2026-07-20 11:05:07 +02:00
commit b592ab669f
159 changed files with 10294 additions and 0 deletions
@@ -0,0 +1,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).'