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,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