First commit

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