docs: add standardized comments to all services (Python, Go, Bash)
- Add docs/commenting-standard.md defining per-language comment standards (Go godoc, Python PEP-257, C Doxygen, Bash header blocks, SQL banners) - services/dashboard: 100% docstring coverage (100/100 functions) - All FastAPI route handlers, helpers, classes, and models documented - Language: French (project convention) - services/bot-detector: 100% docstring coverage (53/53 symbols) - bot_detector.py: 14 functions + module docstring - anubis/fetch_rules.py: 9 functions - shared/python/ja4_common: full docstrings on ClickHouseClient (7 methods) and ClickHouseSettings class - services/correlator: 24 godoc comments added across 6 Go files - correlation_service.go: 10 private helpers - unixsocket/source.go: 6 parsing/socket helpers - correlated_log.go: 4 field extraction helpers - orchestrator.go, logger.go, main.go: 4 comments - services/correlator/scripts/audit-architecture.sh: standardized header block Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
210
docs/commenting-standard.md
Normal file
210
docs/commenting-standard.md
Normal file
@ -0,0 +1,210 @@
|
||||
# Standard de commentaires — ja4-platform
|
||||
|
||||
Ce document définit les conventions de commentaires pour tous les projets du monorepo.
|
||||
Toutes les nouvelles contributions doivent respecter ce standard.
|
||||
Les commentaires de **code fonctionnel** sont en **français** ; les identifiants, types, et
|
||||
noms de variables restent en anglais (convention Go / Python).
|
||||
|
||||
---
|
||||
|
||||
## Go
|
||||
|
||||
### Règles
|
||||
- Tout package public : `// Package foo fournit...`
|
||||
- Toute fonction/méthode **exportée** : commentaire godoc commençant par le nom de la fonction
|
||||
- Toute fonction/méthode **privée non triviale** (>5 lignes) : une ligne `// nomFonction fait X`
|
||||
- Structures exportées : un commentaire par champ si la sémantique n'est pas évidente
|
||||
- Pas de commentaires pour les getters/setters triviaux ni les stubs
|
||||
|
||||
### Exemple
|
||||
|
||||
```go
|
||||
// Package capture gère la capture de paquets réseau bruts via libpcap.
|
||||
package capture
|
||||
|
||||
// RawPacket représente un paquet TCP/IP brut capturé sur l'interface réseau.
|
||||
type RawPacket struct {
|
||||
Data []byte // Contenu brut du paquet (à partir de la couche Ethernet)
|
||||
Timestamp time.Time // Horodatage de capture (précision nanoseconde)
|
||||
SrcIP net.IP // Adresse IP source
|
||||
DstIP net.IP // Adresse IP destination
|
||||
}
|
||||
|
||||
// Capture définit l'interface pour la capture de paquets bruts.
|
||||
// Les implémentations doivent écouter sur une interface réseau configurée,
|
||||
// appliquer des filtres BPF et émettre des RawPacket vers un canal.
|
||||
// Close doit être appelé pour libérer le handle pcap.
|
||||
type Capture interface {
|
||||
Start(ctx context.Context) (<-chan RawPacket, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewCapture crée une nouvelle instance de capture pcap sur l'interface donnée.
|
||||
// Retourne une erreur si l'interface n'existe pas ou si les droits NET_RAW sont absents.
|
||||
func NewCapture(iface string, ports []int) (Capture, error) { ... }
|
||||
|
||||
// buildBPFFilter construit le filtre BPF pour les ports et IPs locales spécifiés.
|
||||
// Format : "(tcp dst port 443) and (dst host 192.168.1.10)"
|
||||
func buildBPFFilter(ports []int, localIPs []net.IP) string { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Python
|
||||
|
||||
### Règles
|
||||
- Tout module : docstring triple-guillemets en première ligne
|
||||
- Toute classe : docstring décrivant le rôle et les attributs principaux
|
||||
- Toute fonction/méthode publique : docstring une ou plusieurs lignes
|
||||
- Fonctions privées simples (`_helper`) : une ligne si non triviales
|
||||
- Routes FastAPI : docstring courte décrivant ce que renvoie l'endpoint (utilisée par Swagger/OpenAPI)
|
||||
- Langue : **français**
|
||||
|
||||
### Exemple
|
||||
|
||||
```python
|
||||
"""
|
||||
Module de détection de bots par machine learning.
|
||||
|
||||
Utilise un modèle IsolationForest entraîné sur les features comportementales
|
||||
extraites de ja4_processing.agg_host_ip_ja4_1h.
|
||||
"""
|
||||
|
||||
class BotDetector:
|
||||
"""
|
||||
Détecteur de bots basé sur IsolationForest.
|
||||
|
||||
Attributes:
|
||||
model: Modèle entraîné (None avant le premier entraînement).
|
||||
threshold: Seuil de score d'anomalie en dessous duquel un IP est flaggée.
|
||||
"""
|
||||
|
||||
def train(self, df: pd.DataFrame) -> None:
|
||||
"""
|
||||
Entraîne le modèle IsolationForest sur le DataFrame fourni.
|
||||
|
||||
Le DataFrame doit contenir les colonnes de features définies dans FEATURE_COLS.
|
||||
Le modèle précédent est remplacé à chaque appel.
|
||||
"""
|
||||
|
||||
def score_batch(self, ips: list[str]) -> dict[str, float]:
|
||||
"""Calcule le score d'anomalie pour une liste d'IPs. Retourne {ip: score}."""
|
||||
|
||||
|
||||
# --- Routes FastAPI ---
|
||||
|
||||
@router.get("/hourly")
|
||||
async def get_heatmap_hourly(db=Depends(get_db)):
|
||||
"""Retourne les hits agrégés par heure sur les 72 dernières heures."""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## C
|
||||
|
||||
### Règles
|
||||
- Chaque fichier : bloc d'en-tête `/* filename.c — description */`
|
||||
- Chaque fonction : bloc `/** @brief description */` au-dessus de la déclaration
|
||||
- Macros complexes : commentaire inline expliquant le comportement
|
||||
- Section logique : bannière `/* ====== Nom de section ====== */`
|
||||
- Membres de struct : commentaire `/* description */` en fin de ligne si non évident
|
||||
|
||||
### Exemple
|
||||
|
||||
```c
|
||||
/*
|
||||
* mod_reqin_log.c — Module Apache HTTPD pour la journalisation JSON des requêtes HTTP.
|
||||
*
|
||||
* Ce module capture chaque requête entrante, sérialise ses métadonnées (headers,
|
||||
* IP, méthode, path) en JSON et les envoie vers une socket Unix pour le correlateur.
|
||||
*/
|
||||
|
||||
/* ====== Fonctions de buffer dynamique ====== */
|
||||
|
||||
/**
|
||||
* @brief Initialise un buffer dynamique avec une capacité initiale.
|
||||
*
|
||||
* @param buf Pointeur vers le buffer à initialiser.
|
||||
* @param pool Pool APR utilisé pour les allocations.
|
||||
* @param init Capacité initiale en octets.
|
||||
*/
|
||||
static void dynbuf_init(dynbuf_t *buf, apr_pool_t *pool, size_t init) { ... }
|
||||
|
||||
/**
|
||||
* @brief Ajoute une chaîne dans le buffer, réalloue si nécessaire.
|
||||
*
|
||||
* Croissance exponentielle (×2) pour amortir les allocations.
|
||||
* Retourne 0 en cas de succès, -1 si la taille maximale MAX_JSON_SIZE est dépassée.
|
||||
*/
|
||||
static int dynbuf_append(dynbuf_t *buf, const char *str, size_t len) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bash
|
||||
|
||||
### Règles
|
||||
- Tout script : bloc d'en-tête standardisé avec description, Usage, et variables d'environnement
|
||||
- Toute fonction : commentaire `# nomFonction — description` sur la ligne précédente
|
||||
- Variables globales non évidentes : commentaire `# description` en fin de ligne
|
||||
|
||||
### Exemple
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# script.sh — Description courte du script
|
||||
#
|
||||
# Description longue si nécessaire.
|
||||
#
|
||||
# Usage:
|
||||
# ./script.sh [OPTIONS]
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Simuler sans modifier
|
||||
# --verbose Afficher les détails
|
||||
#
|
||||
# Variables d'environnement:
|
||||
# CLICKHOUSE_HOST — Hôte ClickHouse (défaut: localhost)
|
||||
# CLICKHOUSE_PORT — Port natif ClickHouse (défaut: 9000)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# log — Affiche un message horodaté sur stderr
|
||||
log() { echo "[$(date +%H:%M:%S)] $*" >&2; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SQL (ClickHouse)
|
||||
|
||||
### Règles
|
||||
- Chaque fichier : bannière `-- ==== filename.sql — description ====`
|
||||
- Chaque table/vue/dictionnaire : section `-- --- Nom ---` + description du rôle
|
||||
- Colonnes groupées : commentaire de groupe `-- Groupe (ex: Réseau, TLS, Métadonnées IP)`
|
||||
- TODOs de sécurité : `-- TODO: ...` clairement identifiés
|
||||
|
||||
### Exemple
|
||||
|
||||
```sql
|
||||
-- =============================================================================
|
||||
-- 01_raw_tables.sql — Tables brutes (ingestion directe du correlateur)
|
||||
--
|
||||
-- Ces tables reçoivent les logs JSON bruts du correlateur via INSERT.
|
||||
-- Le TTL d'un jour évite l'accumulation de données non traitées.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- http_logs_raw — Table d'ingestion brute
|
||||
-- Reçoit les entrées du correlateur ; la MV mv_http_logs parse et enrichit.
|
||||
-- TTL : 1 jour (données transformées conservées dans http_logs)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ja4_logs.http_logs_raw
|
||||
(
|
||||
-- Identifiant de flux
|
||||
conn_id String,
|
||||
-- Payload JSON brut
|
||||
raw_json String CODEC(ZSTD(3))
|
||||
)
|
||||
ENGINE = MergeTree ...
|
||||
```
|
||||
@ -72,6 +72,7 @@ UA_PARENT_OVERRIDE: dict[str, str] = {}
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _fetch_url(url: str, timeout: int = 15) -> str | None:
|
||||
"""Télécharge le contenu d'une URL en texte UTF-8. Retourne None en cas d'erreur réseau."""
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
@ -81,6 +82,7 @@ def _fetch_url(url: str, timeout: int = 15) -> str | None:
|
||||
|
||||
|
||||
def fetch_yaml_url(url: str) -> list | dict | None:
|
||||
"""Télécharge et désérialise un fichier YAML depuis une URL. Retourne None si inaccessible."""
|
||||
content = _fetch_url(url)
|
||||
if content:
|
||||
return yaml.safe_load(content)
|
||||
@ -334,6 +336,7 @@ def collect_all_rules() -> tuple[list, list, list, list]:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_ch_client():
|
||||
"""Crée et retourne un client ClickHouse configuré depuis les variables d'environnement."""
|
||||
return clickhouse_connect.get_client(
|
||||
host=os.environ.get("CLICKHOUSE_HOST", "clickhouse"),
|
||||
database=os.environ.get("CLICKHOUSE_DB_PROCESSING", os.environ.get("CLICKHOUSE_DB", "ja4_processing")),
|
||||
@ -346,6 +349,10 @@ DB_PROC = os.environ.get("CLICKHOUSE_DB_PROCESSING", os.environ.get("CLICKHOUSE_
|
||||
|
||||
|
||||
def insert_ua_rules(client, rules: list[dict]) -> None:
|
||||
"""Tronque et remplace la table anubis_ua_rules avec les règles User-Agent fournies.
|
||||
|
||||
Le format cible est REGEXP_TREE (colonnes id, parent_id, regexp, keys[], values[]).
|
||||
"""
|
||||
if not rules:
|
||||
print("[INFO] Aucune règle UA.")
|
||||
return
|
||||
@ -366,6 +373,7 @@ def insert_ua_rules(client, rules: list[dict]) -> None:
|
||||
|
||||
|
||||
def insert_ip_rules(client, rules: list[dict]) -> None:
|
||||
"""Tronque et remplace la table anubis_ip_rules avec les règles CIDR/IP fournies."""
|
||||
if not rules:
|
||||
print("[INFO] Aucune règle IP.")
|
||||
return
|
||||
@ -381,6 +389,7 @@ def insert_ip_rules(client, rules: list[dict]) -> None:
|
||||
|
||||
|
||||
def insert_asn_rules(client, rules: list[dict]) -> None:
|
||||
"""Tronque et remplace la table anubis_asn_rules avec les règles ASN fournies."""
|
||||
if not rules:
|
||||
print("[INFO] Aucune règle ASN.")
|
||||
return
|
||||
@ -392,6 +401,7 @@ def insert_asn_rules(client, rules: list[dict]) -> None:
|
||||
|
||||
|
||||
def insert_country_rules(client, rules: list[dict]) -> None:
|
||||
"""Tronque et remplace la table anubis_country_rules avec les règles pays fournies."""
|
||||
if not rules:
|
||||
print("[INFO] Aucune règle pays.")
|
||||
return
|
||||
@ -403,6 +413,7 @@ def insert_country_rules(client, rules: list[dict]) -> None:
|
||||
|
||||
|
||||
def reload_dicts(client) -> None:
|
||||
"""Recharge les quatre dictionnaires ClickHouse Anubis après mise à jour des tables sources."""
|
||||
dicts = [
|
||||
f"{DB_PROC}.dict_anubis_ua",
|
||||
f"{DB_PROC}.dict_anubis_ip",
|
||||
@ -422,6 +433,7 @@ def reload_dicts(client) -> None:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def print_summary(ua_rules, ip_rules, asn_rules, country_rules):
|
||||
"""Affiche un résumé lisible des règles collectées (UA, IP, ASN, pays) sur la sortie standard."""
|
||||
print("\n── Règles UA ──")
|
||||
by_cat: dict[str, list] = {}
|
||||
for r in ua_rules:
|
||||
@ -460,6 +472,7 @@ def print_summary(ua_rules, ip_rules, asn_rules, country_rules):
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
"""Point d'entrée principal : collecte les règles Anubis et les charge dans ClickHouse."""
|
||||
print("[INFO] Collecte des règles Anubis depuis GitHub…")
|
||||
ua_rules, ip_rules, asn_rules, country_rules = collect_all_rules()
|
||||
|
||||
|
||||
@ -1,3 +1,13 @@
|
||||
"""Détecteur de bots par apprentissage automatique semi-supervisé (IsolationForest).
|
||||
|
||||
Ce module implémente le cycle de détection IA du service bot_detector :
|
||||
- chargement et retraining automatique du modèle IsolationForest,
|
||||
- scoring, normalisation et classification du trafic (fenêtre 1h / 24h),
|
||||
- intégration des règles Anubis (ALLOW / DENY / WEIGH),
|
||||
- clustering comportemental DBSCAN, déduplication inter-cycles,
|
||||
- explainabilité SHAP, détection de dérive conceptuelle,
|
||||
- écriture des résultats dans ClickHouse (ml_detected_anomalies, ml_all_scores).
|
||||
"""
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
@ -30,6 +40,10 @@ warnings.filterwarnings('ignore')
|
||||
# CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def _require_float(name, default, lo=None, hi=None):
|
||||
"""Lit une variable d'environnement comme flottant et valide la plage si spécifiée.
|
||||
|
||||
Lève SystemExit si la valeur est non numérique ou hors plage (lo, hi) exclusive.
|
||||
"""
|
||||
raw = os.getenv(name, str(default))
|
||||
try:
|
||||
v = float(raw)
|
||||
@ -119,9 +133,15 @@ logger.addHandler(_file_handler)
|
||||
|
||||
# Wrapper court pour homogénéiser les appels de logging (évite d'importer logger partout).
|
||||
def log_info(message: str):
|
||||
"""Enregistre un message de niveau INFO dans le logger du service."""
|
||||
logger.info(message)
|
||||
|
||||
def log_decision(event: str, cycle_id: str, model: str = '', row: dict = None):
|
||||
"""Enregistre un événement de décision IA au format JSONL dans le fichier de log rotatif.
|
||||
|
||||
Chaque ligne contient l'horodatage, le cycle_id, l'événement, le modèle,
|
||||
la contamination, le seuil et les données supplémentaires de ``row``.
|
||||
"""
|
||||
entry = {
|
||||
'ts': datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'cycle_id': cycle_id,
|
||||
@ -136,6 +156,7 @@ def log_decision(event: str, cycle_id: str, model: str = '', row: dict = None):
|
||||
_file_handler.stream.flush()
|
||||
|
||||
def _append_training_history(entry: dict):
|
||||
"""Ajoute une entrée de métadonnées d'entraînement au fichier d'historique JSONL."""
|
||||
with open(TRAINING_HISTORY_FILE, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False, default=str) + '\n')
|
||||
|
||||
@ -143,6 +164,7 @@ def _append_training_history(entry: dict):
|
||||
# ARRÊT PROPRE ET HEALTH CHECK
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def _shutdown(sig, frame):
|
||||
"""Gestionnaire de signal SIGTERM/SIGINT : journalise l'arrêt et quitte proprement."""
|
||||
log_info(f"Signal {sig} reçu — arrêt propre.")
|
||||
log_decision('SERVICE_STOP', 'shutdown', '', {'signal': sig})
|
||||
sys.exit(0)
|
||||
@ -152,12 +174,20 @@ signal.signal(signal.SIGINT, _shutdown)
|
||||
|
||||
_service_healthy = True
|
||||
class _HealthHandler(BaseHTTPRequestHandler):
|
||||
"""Gestionnaire HTTP minimal pour le point de santé du service.
|
||||
|
||||
Répond 200/OK si le service est sain, 503/DEGRADED dans le cas contraire.
|
||||
"""
|
||||
|
||||
def do_GET(self):
|
||||
"""Répond à la requête GET : renvoie 200 OK ou 503 DEGRADED selon l'état du service."""
|
||||
code = 200 if _service_healthy else 503
|
||||
self.send_response(code)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'OK' if _service_healthy else b'DEGRADED')
|
||||
def log_message(self, *args): pass
|
||||
def log_message(self, *args):
|
||||
"""Supprime les logs HTTP internes pour ne pas polluer la sortie standard."""
|
||||
pass
|
||||
|
||||
threading.Thread(
|
||||
target=lambda: HTTPServer(('', HEALTH_PORT), _HealthHandler).serve_forever(),
|
||||
@ -174,7 +204,10 @@ def get_client():
|
||||
return _ja4_get_client().connect()
|
||||
|
||||
def score_to_threat_level(score: float) -> str:
|
||||
# Seuils : CRITICAL < -0.30 | HIGH < -0.15 | MEDIUM < -0.05 | LOW < 0 | NORMAL ≥ 0
|
||||
"""Convertit un score d'anomalie brut IsolationForest en niveau de menace textuel.
|
||||
|
||||
Seuils : CRITICAL < −0.30 | HIGH < −0.15 | MEDIUM < −0.05 | LOW < 0 | NORMAL ≥ 0.
|
||||
"""
|
||||
if score < -0.30: return 'CRITICAL'
|
||||
if score < -0.15: return 'HIGH'
|
||||
if score < -0.05: return 'MEDIUM'
|
||||
@ -185,9 +218,11 @@ def score_to_threat_level(score: float) -> str:
|
||||
# GESTION DES MODÈLES
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def _current_pointer_path(name: str) -> str:
|
||||
"""Retourne le chemin du fichier pointeur vers la version courante du modèle ``name``."""
|
||||
return os.path.join(MODEL_DIR, f'model_{name}.current')
|
||||
|
||||
def _get_current_version(name: str):
|
||||
"""Lit le fichier pointeur et retourne (chemin_modèle, métadonnées) ou (None, None) si absent."""
|
||||
pointer = _current_pointer_path(name)
|
||||
if not os.path.exists(pointer): return None, None
|
||||
with open(pointer) as f: version_id = f.read().strip()
|
||||
@ -198,6 +233,7 @@ def _get_current_version(name: str):
|
||||
return model_path, meta
|
||||
|
||||
def _purge_old_versions(name: str):
|
||||
"""Supprime les versions excédentaires du modèle ``name`` en ne conservant que MODEL_HISTORY_COUNT fichiers."""
|
||||
pattern = os.path.join(MODEL_DIR, f'model_{name}_*.joblib')
|
||||
versions = sorted(glob.glob(pattern))
|
||||
to_delete = versions[:-MODEL_HISTORY_COUNT] if len(versions) > MODEL_HISTORY_COUNT else []
|
||||
@ -209,6 +245,15 @@ def _purge_old_versions(name: str):
|
||||
log_info(f"[{name}] Version purgée : {version_id} (limite={MODEL_HISTORY_COUNT})")
|
||||
|
||||
def load_or_train_model(name: str, human_baseline: pd.DataFrame, features: list, cycle_id: str):
|
||||
"""Charge le modèle IsolationForest existant ou en entraîne un nouveau si nécessaire.
|
||||
|
||||
Réutilise le modèle si son âge est inférieur à RETRAIN_INTERVAL_H et si aucune
|
||||
dérive conceptuelle significative n'est détectée (A1). En cas d'expiration ou de
|
||||
dérive, entraîne un nouveau modèle sur ``human_baseline``, le sérialise sur disque,
|
||||
met à jour le fichier pointeur et purge les anciennes versions.
|
||||
|
||||
Retourne l'objet IsolationForest entraîné ou rechargé.
|
||||
"""
|
||||
model_path, meta = _get_current_version(name)
|
||||
if model_path and meta:
|
||||
trained_at = datetime.fromisoformat(meta['trained_at'])
|
||||
@ -475,7 +520,15 @@ def _cluster_anomalies(anomalies: pd.DataFrame, features: list) -> pd.DataFrame:
|
||||
# ANALYSE SEMI-SUPERVISÉE
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def run_semi_supervised_logic(df, features, name, cycle_id, recurrence_map):
|
||||
# ── Trifurcation du trafic selon bot_name et Anubis ─────────────────────
|
||||
"""Applique le pipeline de détection semi-supervisée sur un sous-ensemble du trafic.
|
||||
|
||||
Trifurque le trafic en bots connus, bots Anubis ALLOW et trafic inconnu,
|
||||
entraîne ou charge le modèle IsolationForest sur la baseline humaine,
|
||||
score le trafic inconnu, applique les améliorations A2/A4/A6/A8,
|
||||
et retourne (threats, all_scored) sous forme de DataFrames.
|
||||
|
||||
Effets de bord : écriture dans les logs de décision via log_decision.
|
||||
"""
|
||||
# 1. Bots connus (dict_bot_ip / dict_bot_ja4) → exclus du scoring IF
|
||||
known_bots = df[df['bot_name'] != ''].copy()
|
||||
rest = df[df['bot_name'] == ''].copy()
|
||||
@ -668,6 +721,7 @@ def _filter_recent_detections(client, all_anom: pd.DataFrame) -> pd.DataFrame:
|
||||
return all_anom
|
||||
recent_map = dict(zip(recent_df['src_ip'], recent_df['best_score']))
|
||||
def _should_insert(row):
|
||||
"""Détermine si une anomalie doit être réinsérée selon l'évolution du score."""
|
||||
prev = recent_map.get(row['src_ip'])
|
||||
if prev is None:
|
||||
return True
|
||||
@ -712,7 +766,13 @@ def _preprocess_df(df: pd.DataFrame) -> pd.DataFrame:
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
_consecutive_failures = 0
|
||||
def fetch_and_analyze():
|
||||
global _service_healthy, _consecutive_failures
|
||||
"""Exécute un cycle complet de détection : requête ClickHouse, scoring et insertion des résultats.
|
||||
|
||||
Récupère le trafic depuis la vue view_ai_features_1h (et optionnellement view_ai_features_24h),
|
||||
applique run_semi_supervised_logic sur les deux modèles (Complet / Applicatif),
|
||||
insère les scores dans ml_all_scores et les anomalies dans ml_detected_anomalies.
|
||||
Met à jour _service_healthy et _consecutive_failures en cas d'échec de requête.
|
||||
"""
|
||||
cycle_id = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
log_info('=== Lancement cycle IA ===')
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Package main initialise et démarre le service logcorrelator.
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -23,6 +24,7 @@ import (
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
// main configure les sources, les puits et le service de corrélation, puis démarre l'orchestrateur.
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yml", "path to configuration file")
|
||||
version := flag.Bool("version", false, "print version and exit")
|
||||
|
||||
@ -117,6 +117,7 @@ func (s *UnixSocketSource) Start(ctx context.Context, eventChan chan<- *domain.N
|
||||
return nil
|
||||
}
|
||||
|
||||
// readDatagrams lit en continu les datagrammes sur la socket Unix et envoie les événements normalisés sur le canal.
|
||||
func (s *UnixSocketSource) readDatagrams(ctx context.Context, eventChan chan<- *domain.NormalizedEvent) {
|
||||
buf := make([]byte, MaxDatagramSize)
|
||||
|
||||
@ -176,6 +177,7 @@ func (s *UnixSocketSource) readDatagrams(ctx context.Context, eventChan chan<- *
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSource détermine la source d'un événement à partir du type déclaré ou de la présence d'en-têtes HTTP.
|
||||
func resolveSource(sourceType string, headers map[string]string) domain.EventSource {
|
||||
switch strings.ToLower(strings.TrimSpace(sourceType)) {
|
||||
case "a", "apache", "http":
|
||||
@ -191,6 +193,7 @@ func resolveSource(sourceType string, headers map[string]string) domain.EventSou
|
||||
}
|
||||
}
|
||||
|
||||
// parseJSONEvent désérialise un datagramme JSON et construit un NormalizedEvent validé avec ses champs obligatoires.
|
||||
func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
@ -298,6 +301,7 @@ func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, er
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// getString extrait la valeur d'une clé sous forme de chaîne depuis une map JSON désérialisée.
|
||||
func getString(m map[string]any, key string) (string, bool) {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
@ -307,6 +311,7 @@ func getString(m map[string]any, key string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// getInt extrait la valeur d'une clé sous forme d'entier depuis une map JSON en gérant les conversions de types courants.
|
||||
func getInt(m map[string]any, key string) (int, bool) {
|
||||
if v, ok := m[key]; ok {
|
||||
switch val := v.(type) {
|
||||
@ -328,6 +333,7 @@ func getInt(m map[string]any, key string) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// getInt64 extrait la valeur d'une clé sous forme d'entier 64 bits depuis une map JSON en gérant les conversions de types courants.
|
||||
func getInt64(m map[string]any, key string) (int64, bool) {
|
||||
if v, ok := m[key]; ok {
|
||||
switch val := v.(type) {
|
||||
|
||||
@ -103,6 +103,7 @@ func (o *Orchestrator) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// processEvents lit les événements du canal, les soumet au service de corrélation et écrit les résultats dans le puits.
|
||||
func (o *Orchestrator) processEvents(eventChan <-chan *domain.NormalizedEvent) {
|
||||
for {
|
||||
select {
|
||||
|
||||
@ -101,6 +101,7 @@ func NewCorrelatedLog(apacheEvent, networkEvent *NormalizedEvent) CorrelatedLog
|
||||
}
|
||||
}
|
||||
|
||||
// extractFields copie l'ensemble des champs bruts d'un événement dans une nouvelle map.
|
||||
func extractFields(e *NormalizedEvent) map[string]any {
|
||||
result := make(map[string]any)
|
||||
for k, v := range e.Raw {
|
||||
@ -109,6 +110,7 @@ func extractFields(e *NormalizedEvent) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeFields fusionne les champs bruts de deux événements en préfixant les clés en collision par "a_" et "b_".
|
||||
func mergeFields(a, b *NormalizedEvent) map[string]any {
|
||||
result := make(map[string]any)
|
||||
|
||||
@ -136,6 +138,7 @@ func mergeFields(a, b *NormalizedEvent) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
// coalesceString retourne la première chaîne non vide parmi les deux arguments.
|
||||
func coalesceString(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
@ -143,6 +146,7 @@ func coalesceString(a, b string) string {
|
||||
return b
|
||||
}
|
||||
|
||||
// coalesceInt retourne le premier entier non nul parmi les deux arguments.
|
||||
func coalesceInt(a, b int) int {
|
||||
if a != 0 {
|
||||
return a
|
||||
|
||||
@ -74,6 +74,7 @@ type eventBuffer struct {
|
||||
events *list.List
|
||||
}
|
||||
|
||||
// newEventBuffer crée un nouveau tampon d'événements vide basé sur une liste doublement chaînée.
|
||||
func newEventBuffer() *eventBuffer {
|
||||
return &eventBuffer{
|
||||
events: list.New(),
|
||||
@ -288,6 +289,7 @@ func (s *CorrelationService) ProcessEvent(event *NormalizedEvent) []CorrelatedLo
|
||||
return results
|
||||
}
|
||||
|
||||
// getBufferSize retourne la taille actuelle du tampon correspondant à la source donnée.
|
||||
func (s *CorrelationService) getBufferSize(source EventSource) int {
|
||||
switch source {
|
||||
case SourceA:
|
||||
@ -298,6 +300,7 @@ func (s *CorrelationService) getBufferSize(source EventSource) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// isBufferFull vérifie si le tampon de la source donnée a atteint sa capacité maximale.
|
||||
func (s *CorrelationService) isBufferFull(source EventSource) bool {
|
||||
switch source {
|
||||
case SourceA:
|
||||
@ -355,6 +358,7 @@ func (s *CorrelationService) rotateOldestB() {
|
||||
delete(s.networkTTLs, elem)
|
||||
}
|
||||
|
||||
// processSourceA traite un événement de source A (HTTP/Apache) et retourne les journaux corrélés ou les place en attente d'orphelins.
|
||||
func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]CorrelatedLog, bool) {
|
||||
key := event.CorrelationKey()
|
||||
// Assign Keep-Alive sequence number (1-based) for this connection
|
||||
@ -457,6 +461,7 @@ func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]Correlate
|
||||
return nil, true
|
||||
}
|
||||
|
||||
// processSourceB traite un événement de source B (réseau) et retourne les journaux corrélés si une correspondance est trouvée.
|
||||
func (s *CorrelationService) processSourceB(event *NormalizedEvent) ([]CorrelatedLog, bool) {
|
||||
key := event.CorrelationKey()
|
||||
s.logger.Debugf("processing B event: key=%s timestamp=%v", key, event.Timestamp)
|
||||
@ -511,6 +516,7 @@ func (s *CorrelationService) processSourceB(event *NormalizedEvent) ([]Correlate
|
||||
return nil, true
|
||||
}
|
||||
|
||||
// eventsMatch vérifie si deux événements se trouvent dans la fenêtre temporelle de corrélation configurée.
|
||||
func (s *CorrelationService) eventsMatch(a, b *NormalizedEvent) bool {
|
||||
diff := a.Timestamp.Sub(b.Timestamp)
|
||||
if diff < 0 {
|
||||
@ -536,6 +542,7 @@ func (s *CorrelationService) bEventHasValidTTL(bEvent *NormalizedEvent) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// addEvent ajoute un événement au tampon correspondant à sa source et initialise son TTL réseau si nécessaire.
|
||||
func (s *CorrelationService) addEvent(event *NormalizedEvent) {
|
||||
key := event.CorrelationKey()
|
||||
|
||||
@ -551,6 +558,7 @@ func (s *CorrelationService) addEvent(event *NormalizedEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanExpired supprime les événements expirés des tampons et retourne les orphelins forcés par l'expiration du TTL réseau.
|
||||
func (s *CorrelationService) cleanExpired() []CorrelatedLog {
|
||||
// Clean expired B events first - use TTL map only (not event timestamp)
|
||||
// This is critical for Keep-Alive: TTL is reset on each correlation,
|
||||
@ -693,6 +701,7 @@ func (s *CorrelationService) cleanNetworkBufferByTTL() []CorrelatedLog {
|
||||
return forced
|
||||
}
|
||||
|
||||
// findAndPopFirstMatch recherche et supprime le premier événement satisfaisant le critère dans le tampon.
|
||||
func (s *CorrelationService) findAndPopFirstMatch(
|
||||
buffer *eventBuffer,
|
||||
pending map[string][]*list.Element,
|
||||
@ -908,6 +917,7 @@ func (s *CorrelationService) EmitPendingOrphans() []CorrelatedLog {
|
||||
return s.emitPendingOrphans()
|
||||
}
|
||||
|
||||
// removeElementFromSlice retire l'élément ciblé d'une tranche de list.Element sans modifier l'ordre.
|
||||
func removeElementFromSlice(elements []*list.Element, target *list.Element) []*list.Element {
|
||||
if len(elements) == 0 {
|
||||
return elements
|
||||
|
||||
@ -4,8 +4,10 @@ package observability
|
||||
|
||||
import jalogger "github.com/antitbone/ja4/ja4common/logger"
|
||||
|
||||
// Type aliases — all existing correlator code compiles unchanged.
|
||||
// Logger est un alias du type Logger de ja4common pour la journalisation structurée.
|
||||
type Logger = jalogger.Logger
|
||||
|
||||
// LogLevel est un alias du type LogLevel de ja4common pour le niveau de journalisation.
|
||||
type LogLevel = jalogger.LogLevel
|
||||
|
||||
const (
|
||||
|
||||
@ -1,4 +1,21 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# audit-architecture.sh — Vérifie la conformité de l'architecture du correlateur
|
||||
#
|
||||
# Ce script valide que les composants implémentés (service systemd, packaging RPM,
|
||||
# configuration YAML, sockets Unix, sinks de sortie, logique de corrélation) sont
|
||||
# présents et correctement configurés, conformément aux spécifications d'architecture.
|
||||
#
|
||||
# Usage:
|
||||
# ./audit-architecture.sh
|
||||
# docker run --rm -v $(pwd):/src <image> /src/scripts/audit-architecture.sh
|
||||
#
|
||||
# Prérequis:
|
||||
# - Exécuté depuis le répertoire source /src du correlateur (ou monté en volume)
|
||||
# - Les sources Go doivent être présentes (les checks sont basés sur grep)
|
||||
#
|
||||
# Variables d'environnement: aucune
|
||||
# =============================================================================
|
||||
set -e
|
||||
|
||||
echo "=== AUDIT ARCHITECTURE COMPLIANCE ==="
|
||||
|
||||
@ -1 +1 @@
|
||||
# Backend package
|
||||
"""Package principal du backend FastAPI bot-detector."""
|
||||
|
||||
@ -5,6 +5,7 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Paramètres de configuration de l'application chargés depuis l'environnement."""
|
||||
# ClickHouse
|
||||
CLICKHOUSE_HOST: str = "clickhouse"
|
||||
CLICKHOUSE_PORT: int = 8123
|
||||
@ -22,6 +23,7 @@ class Settings(BaseSettings):
|
||||
CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
|
||||
class Config:
|
||||
"""Configuration Pydantic pour le chargement du fichier .env."""
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ from enum import Enum
|
||||
|
||||
|
||||
class ThreatLevel(str, Enum):
|
||||
"""Niveaux de menace supportés par le modèle de détection."""
|
||||
CRITICAL = "CRITICAL"
|
||||
HIGH = "HIGH"
|
||||
MEDIUM = "MEDIUM"
|
||||
@ -19,6 +20,7 @@ class ThreatLevel(str, Enum):
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class MetricsSummary(BaseModel):
|
||||
"""Résumé agrégé des métriques sur les dernières 24 heures."""
|
||||
total_detections: int
|
||||
critical_count: int
|
||||
high_count: int
|
||||
@ -30,6 +32,7 @@ class MetricsSummary(BaseModel):
|
||||
|
||||
|
||||
class TimeSeriesPoint(BaseModel):
|
||||
"""Point de série temporelle par heure pour les métriques."""
|
||||
hour: datetime
|
||||
total: int
|
||||
critical: int
|
||||
@ -39,6 +42,7 @@ class TimeSeriesPoint(BaseModel):
|
||||
|
||||
|
||||
class MetricsResponse(BaseModel):
|
||||
"""Réponse complète des métriques du dashboard avec série temporelle."""
|
||||
summary: MetricsSummary
|
||||
timeseries: List[TimeSeriesPoint]
|
||||
threat_distribution: Dict[str, int]
|
||||
@ -49,6 +53,7 @@ class MetricsResponse(BaseModel):
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Detection(BaseModel):
|
||||
"""Représentation d'une détection d'anomalie émise par le modèle ML."""
|
||||
detected_at: datetime
|
||||
src_ip: str
|
||||
ja4: str
|
||||
@ -82,6 +87,7 @@ class Detection(BaseModel):
|
||||
|
||||
|
||||
class DetectionsListResponse(BaseModel):
|
||||
"""Liste paginée de détections d'anomalies."""
|
||||
items: List[Detection]
|
||||
total: int
|
||||
page: int
|
||||
@ -94,6 +100,7 @@ class DetectionsListResponse(BaseModel):
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AttributeValue(BaseModel):
|
||||
"""Valeur d'attribut avec comptage, pourcentage et métadonnées temporelles."""
|
||||
value: str
|
||||
count: int
|
||||
percentage: float
|
||||
@ -105,6 +112,7 @@ class AttributeValue(BaseModel):
|
||||
|
||||
|
||||
class VariabilityAttributes(BaseModel):
|
||||
"""Ensemble des attributs de variabilité comportementale pour une entité."""
|
||||
user_agents: List[AttributeValue] = Field(default_factory=list)
|
||||
ja4: List[AttributeValue] = Field(default_factory=list)
|
||||
countries: List[AttributeValue] = Field(default_factory=list)
|
||||
@ -115,11 +123,13 @@ class VariabilityAttributes(BaseModel):
|
||||
|
||||
|
||||
class Insight(BaseModel):
|
||||
"""Message d'analyse contextuelle (alerte, information ou succès)."""
|
||||
type: str # "warning", "info", "success"
|
||||
message: str
|
||||
|
||||
|
||||
class VariabilityResponse(BaseModel):
|
||||
"""Réponse d'analyse de variabilité pour un attribut donné."""
|
||||
type: str
|
||||
value: str
|
||||
total_detections: int
|
||||
@ -134,11 +144,13 @@ class VariabilityResponse(BaseModel):
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AttributeListItem(BaseModel):
|
||||
"""Élément de la liste des valeurs uniques d'un attribut avec son comptage."""
|
||||
value: str
|
||||
count: int
|
||||
|
||||
|
||||
class AttributeListResponse(BaseModel):
|
||||
"""Réponse de la liste des valeurs uniques pour un type d'attribut."""
|
||||
type: str
|
||||
items: List[AttributeListItem]
|
||||
total: int
|
||||
@ -149,6 +161,7 @@ class AttributeListResponse(BaseModel):
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserAgentValue(BaseModel):
|
||||
"""Valeur de User-Agent avec comptage et plage temporelle d'observation."""
|
||||
value: str
|
||||
count: int
|
||||
percentage: float
|
||||
@ -157,6 +170,7 @@ class UserAgentValue(BaseModel):
|
||||
|
||||
|
||||
class UserAgentsResponse(BaseModel):
|
||||
"""Réponse de la liste des User-Agents associés à une entité."""
|
||||
type: str
|
||||
value: str
|
||||
user_agents: List[UserAgentValue]
|
||||
@ -169,12 +183,14 @@ class UserAgentsResponse(BaseModel):
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ClassificationLabel(str, Enum):
|
||||
"""Étiquettes de classification SOC pour les IPs et fingerprints JA4."""
|
||||
LEGITIMATE = "legitimate"
|
||||
SUSPICIOUS = "suspicious"
|
||||
MALICIOUS = "malicious"
|
||||
|
||||
|
||||
class ClassificationBase(BaseModel):
|
||||
"""Modèle de base partagé pour les classifications SOC."""
|
||||
ip: Optional[str] = None
|
||||
ja4: Optional[str] = None
|
||||
label: ClassificationLabel
|
||||
@ -198,6 +214,7 @@ class Classification(ClassificationBase):
|
||||
|
||||
|
||||
class ClassificationsListResponse(BaseModel):
|
||||
"""Liste paginée des classifications SOC enregistrées."""
|
||||
items: List[Classification]
|
||||
total: int
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
# Routes package
|
||||
"""Package des routes FastAPI de l'API bot-detector."""
|
||||
|
||||
@ -374,6 +374,7 @@ async def analyze_user_agents(ip: str):
|
||||
|
||||
# Classification des UAs
|
||||
def classify_ua(ua: str) -> str:
|
||||
"""Classe un User-Agent en 'bot', 'script', 'browser' ou 'unknown'."""
|
||||
ua_lower = ua.lower()
|
||||
if any(bot in ua_lower for bot in ['bot', 'crawler', 'spider', 'curl', 'wget', 'python', 'requests', 'scrapy']):
|
||||
return 'bot'
|
||||
|
||||
@ -10,6 +10,7 @@ router = APIRouter(prefix="/api/botnets", tags=["botnets"])
|
||||
|
||||
|
||||
def _botnet_class(unique_countries: int) -> str:
|
||||
"""Classifie un JA4 selon sa dispersion géographique."""
|
||||
if unique_countries > 100:
|
||||
return "global_botnet"
|
||||
if unique_countries > 20:
|
||||
|
||||
@ -222,6 +222,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None:
|
||||
continue
|
||||
|
||||
def avg_f(key: str, crows: list[dict] = cluster_rows[j]) -> float:
|
||||
"""Calcule la moyenne flottante d'un champ numérique sur les lignes du cluster."""
|
||||
return float(np.mean([float(r.get(key) or 0) for r in crows]))
|
||||
|
||||
mean_ttl = avg_f("ttl")
|
||||
@ -245,6 +246,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None:
|
||||
orgs = [str(r.get("asn_org") or "") for r in cluster_rows[j] if r.get("asn_org")]
|
||||
|
||||
def topk(lst: list[str], n: int = 5) -> list[str]:
|
||||
"""Retourne les n valeurs les plus fréquentes d'une liste (valeurs vides exclues)."""
|
||||
return [v for v, _ in Counter(lst).most_common(n) if v]
|
||||
|
||||
radar = [
|
||||
|
||||
@ -489,6 +489,7 @@ async def get_ua_analysis(
|
||||
|
||||
|
||||
def _build_ua_risk_flags(ua: str, ua_type: str, unique_ja4s: int, ip_count: int) -> list:
|
||||
"""Construit la liste des indicateurs de risque pour un User-Agent."""
|
||||
flags = []
|
||||
if ua_type == "bot":
|
||||
flags.append("ua_bot_signature")
|
||||
|
||||
@ -144,6 +144,7 @@ async def get_metrics_baseline():
|
||||
row = r.result_rows[0] if r.result_rows else None
|
||||
|
||||
def pct_change(today: int, yesterday: int) -> float:
|
||||
"""Calcule la variation en pourcentage entre aujourd'hui et hier. Retourne 100 si hier=0 et aujourd'hui>0."""
|
||||
if yesterday == 0:
|
||||
return 100.0 if today > 0 else 0.0
|
||||
return round((today - yesterday) / yesterday * 100, 1)
|
||||
|
||||
@ -11,6 +11,7 @@ router = APIRouter(prefix="/api/ml", tags=["ml_features"])
|
||||
|
||||
def _attack_type(fuzzing_index: float, hit_velocity: float,
|
||||
is_fake_nav: int, ua_ch_mismatch: int) -> str:
|
||||
"""Déduit le type d'attaque depuis les métriques comportementales."""
|
||||
if fuzzing_index > 50:
|
||||
return "brute_force"
|
||||
if hit_velocity > 1.0:
|
||||
@ -113,6 +114,7 @@ async def get_ip_radar(ip: str):
|
||||
row = result.result_rows[0]
|
||||
|
||||
def _f(v) -> float:
|
||||
"""Convertit une valeur nullable en float (None ou falsy → 0.0)."""
|
||||
return float(v or 0)
|
||||
|
||||
return {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Unified singleton ClickHouse client for the JA4 security suite."""
|
||||
"""Client ClickHouse singleton partagé pour la suite de sécurité JA4."""
|
||||
import clickhouse_connect
|
||||
from typing import Optional
|
||||
|
||||
@ -6,10 +6,19 @@ from .settings import settings
|
||||
|
||||
|
||||
class ClickHouseClient:
|
||||
"""Client ClickHouse singleton avec reconnexion automatique.
|
||||
|
||||
Attributs :
|
||||
_client : instance du client clickhouse_connect sous-jacent,
|
||||
ou None si la connexion n'est pas encore établie.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialise le client sans ouvrir de connexion immédiate."""
|
||||
self._client: Optional[clickhouse_connect.driver.client.Client] = None
|
||||
|
||||
def connect(self) -> clickhouse_connect.driver.client.Client:
|
||||
"""Retourne un client connecté, en créant ou rétablissant la connexion si nécessaire."""
|
||||
if self._client is None or not self._ping():
|
||||
self._client = clickhouse_connect.get_client(
|
||||
host=settings.CLICKHOUSE_HOST,
|
||||
@ -22,6 +31,7 @@ class ClickHouseClient:
|
||||
return self._client
|
||||
|
||||
def _ping(self) -> bool:
|
||||
"""Vérifie que la connexion existante est active. Retourne False en cas d'erreur."""
|
||||
try:
|
||||
if self._client:
|
||||
self._client.ping()
|
||||
@ -31,15 +41,19 @@ class ClickHouseClient:
|
||||
return False
|
||||
|
||||
def query(self, query: str, params: Optional[dict] = None):
|
||||
"""Exécute une requête SELECT et retourne le résultat."""
|
||||
return self.connect().query(query, params)
|
||||
|
||||
def command(self, query: str, params: Optional[dict] = None):
|
||||
"""Exécute une commande DDL/DML (INSERT, CREATE, TRUNCATE, etc.)."""
|
||||
return self.connect().command(query, parameters=params)
|
||||
|
||||
def insert(self, table: str, data, column_names=None):
|
||||
"""Insère des données dans la table cible."""
|
||||
return self.connect().insert(table, data, column_names=column_names)
|
||||
|
||||
def close(self):
|
||||
"""Ferme la connexion et réinitialise le client interne."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
@ -49,6 +63,7 @@ _client: Optional[ClickHouseClient] = None
|
||||
|
||||
|
||||
def get_client() -> ClickHouseClient:
|
||||
"""Retourne l'instance singleton du ClickHouseClient, en la créant si nécessaire."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = ClickHouseClient()
|
||||
|
||||
@ -1,8 +1,19 @@
|
||||
"""Unified ClickHouse settings using pydantic-settings."""
|
||||
"""Paramètres de connexion ClickHouse centralisés, chargés depuis les variables d'environnement."""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ClickHouseSettings(BaseSettings):
|
||||
"""Paramètres de connexion ClickHouse lus depuis l'environnement ou un fichier .env.
|
||||
|
||||
Attributs :
|
||||
CLICKHOUSE_HOST : hôte du serveur ClickHouse.
|
||||
CLICKHOUSE_PORT : port HTTP de l'API ClickHouse (défaut 8123).
|
||||
CLICKHOUSE_DB : base de données de connexion par défaut.
|
||||
CLICKHOUSE_DB_LOGS : base de données des logs bruts.
|
||||
CLICKHOUSE_DB_PROCESSING : base de données de traitement analytique.
|
||||
CLICKHOUSE_USER : nom d'utilisateur.
|
||||
CLICKHOUSE_PASSWORD : mot de passe (chaîne vide si aucun).
|
||||
"""
|
||||
CLICKHOUSE_HOST: str = "clickhouse"
|
||||
CLICKHOUSE_PORT: int = 8123
|
||||
CLICKHOUSE_DB: str = "ja4_processing" # default connection database
|
||||
|
||||
Reference in New Issue
Block a user