<index> / <wazabiedr> / server
[ en | fr ]
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Partie 6 — Le serveur
~ lululufr
SOMMAIRE
  0  pourquoi python côté serveur
  1  trois stores, un job chacun
  2  lifespan — ordre de démarrage et arrêt
  3  deux publics, deux authentifications
  4  le chemin chaud d'ingestion
  5  pourquoi pas kafka devant
  6  implémenté vs. stub

──[ 0. Pourquoi Python Côté Serveur ]──

Le driver et l'agent sont en Rust parce qu'ils tournent potentiellement sur des 
milliers d'endpoints, en kernel mode et en user mode où on a "juré-promis pas de
paging", où panic vaut bug-check et un événement manqué est un événement manqué.
Le serveur tourne dans un conteneur Linux que l'opérateur scale, redémarre,
observe. Son goulot, c'est l'I/O réseau et les round-trips de base de données,
pas la disposition mémoire ni la panic safety. La stack choisie, c'est Python 3.12 + FastAPI (un framework web async construit
sur Starlette et Pydantic) + SQLAlchemy 2 async (l'ORM Python standard dans son
incarnation native-async) + Alembic (l'outil de migrations compagnon de
SQLAlchemy) + le client async officiel OpenSearch + le client `redis` async.
Docker Compose noue le runtime ensemble pour le développement local ; les
déploiements de production substitueraient des équivalents managés. Ce post explique les choix et les fuites. Le détail par route vit dans
l'`ARCHITECTURE.md` du dépôt serveur.
──[ 1. Trois Stores, Un Job Chacun ]──
   ┌────────────────────────── Serveur Wazabi (FastAPI) ──────────────────────────┐
   │                                                                                │
   │   agents  ───►  /api/v1/agents/*   ──┐                                          │
   │   console ───►  /api/v1/{rules…}   ──┤                                          │
   │                                      │                                          │
   │                                      ├──► services/ingest.py ──bulk──►  OpenSearch
   │                                      │                                    (events,
   │                                      │                                     alertes)
   │                                      │                                          │
   │                                      └──► SQLAlchemy 2 async ───►  PostgreSQL  │
   │                                                                    (endpoints, │
   │                                                                     profils,   │
   │                                                                     règles,    │
   │                                                                     commandes) │
   │                                                                                  │
   │                                       Redis (cache + file de commandes)         │
   └────────────────────────────────────────────────────────────────────────────────┘
**PostgreSQL** porte l'état relationnel et transactionnel — l'inventaire 
d'endpoints, les profils qui leur sont assignés, les règles qui composent un
profil, les utilisateurs de la console, la file de commandes par-agent, la
compta de licence. Tout ce qui demande des `JOIN` ou une cohérence stricte vit
ici. **OpenSearch** (un fork d'Elasticsearch 7 maintenu après le split de licence ;
fonctionnellement équivalent pour l'ingest, la recherche et les aggregations)
porte le firehose à fort volume : les événements depuis le kernel, les alertes
depuis les moteurs de règles, la télémétrie des plugins. Des index multi-To
qu'un analyste cherche par texte libre, par IP source, par PID, par fenêtre
temporelle. Le store relationnel ne survivrait pas à se faire demander ça. **Redis** porte l'état éphémère : caches de session, file de commandes par-agent
(un agent poll la file toutes les 30 secondes ; ce qu'il trouve est le prochain
ensemble de commandes à exécuter), compteurs de rate-limit. Rien dans Redis
n'est porteur — si l'instance disparaît le système dégrade mais ne perd pas
d'état persistant. Trois stores coûtent plus opérationnellement qu'un seul. La justification, c'est
la forme : chacun est le bon ajustement pour sa charge, et forcer n'importe
laquelle des trois charges dans le mauvais store produit une douleur immédiate
(OLTP sur OpenSearch, recherche full-text sur PostgreSQL, état persistant dans
Redis).
──[ 2. Lifespan — Ordre de Démarrage et Arrêt ]──

FastAPI expose un context manager `lifespan` qui tourne une fois au démarrage du 
processus et une fois à l'arrêt du processus :
@asynccontextmanager
async def lifespan(_app: FastAPI):
    await init_postgres()
    await init_redis()
    await init_opensearch()
    await ensure_index_templates()
    try:
        yield
    finally:
        await close_opensearch()
        await close_redis()
        await close_postgres()
L'ordre est volontaire. Postgres remonte en premier parce que tout autre 
sous-système finit par lire de l'état endpoint / utilisateur depuis lui. Redis
remonte en second parce que le middleware d'auth en a besoin. OpenSearch remonte
en dernier parce que la création d'index template sur un cluster froid peut être
lente et qu'on ne veut pas qu'elle bloque les deux autres. `ensure_index_templates` est idempotent — il émet des appels `PUT
_index_template/<name>` que le cluster accepte comme no-ops si le template
existe déjà à la même version. Redéployer l'image contre un cluster existant ne
coûte donc que la latence du round-trip. Le shutdown déroule l'ordre. `try/finally` est obligatoire parce qu'un `docker
stop` peut interrompre à n'importe quel point pendant le `yield`, et qu'un pool
de connexions à moitié fermé qui survit au processus peut wedge le prochain
démarrage.
──[ 3. Deux Publics, Deux Authentifications ]──

Les agents et les humains parlent tous les deux au même processus FastAPI. Ils 
ne sont pas le même type de client et ils ne partagent pas un chemin d'auth :
    Console (humains)  ── JWT Bearer + refresh tokens
    Agents (machines)  ── mTLS + token d'enrôlement par-agent

Le flux console est conventionnel. `POST /auth/login` échange username et 
password (ou assertion SSO) contre un JWT de courte durée de vie (*JWT, JSON Web
Token* : un token base64-encodé et signé portant des claims ; la signature
laisse le serveur vérifier le token sans lookup base de données par requête) et
un refresh token de plus longue durée. Chaque appel à
`/api/v1/{endpoints,rules,alerts,…}` porte le JWT dans `Authorization: Bearer
…`, et le RBAC est imposé au niveau des routes. Le flux agent combine deux couches. Niveau connexion : mTLS (*mTLS, mutual TLS*
: TLS standard étendu pour que le serveur exige du client qu'il présente un
certificat ; les deux endpoints s'authentifient l'un l'autre avant que tout
trafic applicatif ne coule), avec le certificat d'enrôlement de l'agent comme
cert client et la CA du serveur comme ancre de confiance. Niveau applicatif : un
bearer token par-agent émis au moment de l'enrôlement, utilisé pour
désambiguïser à quel agent appartient le certificat dans une flotte qui peut
partager une CA. L'endpoint agent est `POST /api/v1/agents/checkin`, appelé toutes les ~30
secondes. Chaque appel porte :
    - heartbeat (last_seen, version d'agent, info OS)
    - les alertes batchées que l'agent a accumulées
    - les acks des commandes précédemment reçues

La réponse contient le prochain batch de commandes pour l'agent (isoler, lancer 
un scan, uploader un artefact, redémarrer). Du short polling à cadence 30
secondes plutôt que du long-polling ou un WebSocket veut dire que chaque
transport est une requête HTTPS routinière que les load balancers, WAF, et
proxies intermédiaires gèrent sans configuration spéciale. Le coût, c'est une
minute de latence de commande pire-cas, ce qui est acceptable pour les commandes
qu'un agent reçoit.
──[ 4. Le Chemin Chaud D'Ingestion ]──

`services/ingest.py` tourne chaque fois que le checkin d'un agent porte des 
événements ou des alertes :
async def bulk_index(docs: list[dict[str, Any]], index_name: str) -> int:
    if not docs:
        return 0

    actions: list[dict[str, Any]] = []
    for doc in docs:
        actions.append({"index": {"_index": index_name}})
        actions.append(doc)

    client = get_opensearch()
    response = await client.bulk(body=actions, refresh=False)

    if response.get("errors"):
        failed = sum(
            1
            for item in response["items"]
            if item.get("index", {}).get("status", 200) >= 400
        )
        logger.warning("OpenSearch bulk had %d failures on %d docs.", failed, len(docs))
        return len(docs) - failed
    return len(docs)
L'API `bulk` d'OpenSearch prend un tableau de paires action/document et les 
indexe en un seul round-trip. Le multiplicateur de débit sur les appels `index`
par-document est de l'ordre de 10×–20× selon la taille des documents — largement
de quoi justifier l'étape de sérialisation supplémentaire de notre côté. `refresh=False` est le paramètre clé. Le refresh d'index d'OpenSearch est ce qui
rend les documents fraîchement indexés cherchables ; avec `refresh=True` chaque
bulk force un refresh, ce qui est correct mais cher. Avec `refresh=False`, les
refreshs se passent sur le planning du cluster (1 seconde par défaut),
échangeant une visibilité éventuelle bornée contre une amélioration de débit
d'un ordre de grandeur. Une réponse `bulk` peut contenir des statuts par-item, échecs partiels inclus :
certains documents indexés, d'autres rejetés (conflit de mapping, valeur
malformée). Le handler log le premier échec, retourne le compte de succès, et
laisse l'appelant décider de retenter ou non le sous-ensemble échoué.
Aujourd'hui l'appelant ne le fait pas — le spool de l'agent garde les originaux
et un retry risquerait des doublons. L'enrichissement côté serveur tourne avant le bulk :
def enrich_doc(doc, endpoint, received_at):
    return {
        **doc,
        "agent_id":    str(endpoint.id),
        "host":        host_dict_from_endpoint(endpoint),
        "received_at": received_at.isoformat(),
    }
L'agent soumet l'événement produit par le kernel plus ses propres annotations. 
Le serveur ajoute `agent_id`, `host` (hostname + IP + OS), et `received_at`.
Sans ces trois champs, une recherche dans un index multi-agent retournerait "un
processus a démarré sur… une de vos machines, à… un moment".
──[ 5. Pourquoi Pas Kafka Devant ]──

Le réflexe pour un firehose d'événements à n'importe quelle échelle, c'est de 
mettre Kafka (ou un autre log distribué) entre les producteurs et la couche de
stockage. On n'en a pas. Deux raisons. L'agent joue déjà ce rôle. Son spool sur disque absorbe les downtimes serveur,
batche efficacement, et retente automatiquement (Partie 5). Un second buffer
devant le serveur bufferiserait des événements déjà bufferisés, n'accélérant
aucun goulot — la contrainte réelle, c'est le pipeline d'ingest d'OpenSearch,
que Kafka devant ne détend pas. Opérationnellement, Kafka ajoute une troisième classe de datastore (après
PostgreSQL et OpenSearch) avec sa propre topologie de cluster, sa gestion de
brokers, sa coordination de consumer-groups, et ses préoccupations de
réplication. Le ratio valeur-à-coût est défavorable jusqu'à ce que le trafic
croise une échelle dont on est très loin. Si on s'en approche jamais, le contrat
agent-vers-serveur ne change pas — le shipper POSTe toujours des batchs `.zst`.
Un worker d'ingestion entre FastAPI et OpenSearch serait inséré alors, derrière
l'API existante.
──[ 6. Implémenté vs. Stub ]──

L'`ARCHITECTURE.md` du dépôt serveur est explicite sur ce qui est fait et ce qui 
est un placeholder. À l'heure où j'écris :
    Implémenté de bout en bout :
      - lifespan + câblage des trois stores
      - enrôlement + checkin + heartbeat des agents
      - chemin d'ingest (events + alertes) vers OpenSearch
      - JWT auth + refresh pour la console

    Stub (route + shape de réponse, pas de vrai comportement) :
      - isolation des endpoints
      - CRUD + évaluation des règles
      - one-liners de déploiement
      - compta de licence

Cette série couvre les parties qui fonctionnent — l'infrastructure qui reçoit le 
firehose. Le langage de détection et la UI console qui consomme l'API sont des
projets séparés sur des timelines différentes. Prochain post : le monde des plugins. Le protocole qu'un processus externe parle
pour pousser de la télémétrie dans le pipeline de l'agent, et le named pipe sur
lequel il le parle.