mirror of
https://github.com/deunix-educ/Fail2banMqttActionBanishment.git
synced 2026-08-24 03:11:58 +02:00
First commit
This commit is contained in:
@@ -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, ''
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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']}))
|
||||
@@ -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}'
|
||||
@@ -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'))
|
||||
@@ -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, 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).'
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
+43
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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]}'
|
||||
@@ -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]
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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: '© <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
@@ -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)}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user