mirror of
https://github.com/deunix-educ/Fail2banMqttActionBanishment.git
synced 2026-08-24 11:21:58 +02:00
116 lines
5.4 KiB
Python
116 lines
5.4 KiB
Python
"""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, 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).'
|