"""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_. 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, où 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).'