diff --git a/scripts/import_adresse_correction.py b/scripts/import_adresse_correction.py
deleted file mode 100644
index b8de9f72..00000000
--- a/scripts/import_adresse_correction.py
+++ /dev/null
@@ -1,197 +0,0 @@
-#!/usr/bin/env python3
-"""
-Import adresse_correction depuis data2.txt (format psql pipe-séparé, avec
-gestion des valeurs multi-lignes et des lignes fusionnées sans newline).
-Usage : python3 import_adresse_correction.py [--dry-run]
-"""
-
-import os
-import re
-import sys
-
-import psycopg2
-
-DB_CONFIG = {
- "host": "localhost",
- "port": 5432,
- "user": "",
- "password": "",
- "dbname": "",
-}
-
-DATA_FILE = os.path.join(os.path.dirname(__file__), "data2.txt")
-DRY_RUN = "--dry-run" in sys.argv
-
-SOURCE_COLUMNS = [
- "id",
- "invalid_address",
- "correct_address",
- "created_at",
- "updated_at",
-]
-
-
-def preprocess(content: str) -> list[str]:
- """
- Sépare les lignes physiquement fusionnées :
- ex: "...2026-03-13 22:33:10.353601 79 | 55 Quai..." → deux lignes.
- Pattern : timestamp immédiatement suivi (sans \n) d'un entier + '|'.
- """
- content = re.sub(
- r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s{2,}(\d+\s*\|)",
- r"\1\n \2",
- content,
- )
- return content.splitlines()
-
-
-def parse_psql_rows(path: str) -> list[list[str]]:
- """
- Parse le format aligné psql :
- - Lignes de continuation (sans id, commençant par ' |') : valeur multi-ligne.
- - Champ se terminant par '+' avant '|' : continuation sur la ligne suivante.
- """
- with open(path, encoding="utf-8") as f:
- raw = f.read()
-
- lines = preprocess(raw)
- rows: list[list[str]] = []
- current: list[str] | None = None
-
- for line in lines:
- line = line.rstrip("\n")
- stripped = line.strip()
-
- # Ligne vide
- if not stripped:
- continue
-
- # Ligne séparateur psql (----+----)
- if (
- set(
- stripped.replace("|", "")
- .replace("+", "")
- .replace("-", "")
- .replace(" ", "")
- )
- == set()
- ):
- continue
-
- parts = line.split("|")
- if len(parts) < 2:
- continue
-
- first = parts[0].strip()
- is_continuation = current is not None and (first == "" or not first.isdigit())
-
- if is_continuation:
- # Fusionner avec la ligne en cours
- cont = parts[:-1] if parts[-1].strip() == "" else parts
- for i, cf in enumerate(cont):
- if i < len(current):
- prev = current[i].rstrip("+").rstrip()
- add = cf.strip()
- current[i] = (prev + (" " if prev and add else "") + add).strip()
- continue
-
- # Sauvegarder la ligne précédente
- if current is not None:
- rows.append(current)
-
- # Nouvelle ligne de données : nettoyer le '+' de continuation
- raw_fields = parts[:-1] if parts[-1].strip() == "" else parts
- current = [f.strip().rstrip("+").strip() for f in raw_fields]
-
- if current is not None:
- rows.append(current)
-
- return rows
-
-
-def parse_value(v: str):
- v = v.strip()
- if v == "" or v.lower() == "null":
- return None
- return v
-
-
-def main():
- raw_rows = parse_psql_rows(DATA_FILE)
- if not raw_rows:
- print("❌ Aucune ligne trouvée dans data2.txt")
- sys.exit(1)
-
- # Filtrer l'en-tête éventuel
- rows = [[parse_value(v) for v in r] for r in raw_rows if r[0].strip() != "id"]
-
- print(f"📂 {len(rows)} lignes de données à importer depuis {DATA_FILE}")
-
- insert_cols = [c for c in SOURCE_COLUMNS if c != "id"]
- insert_cols_all = ["id"] + insert_cols
- placeholders = ", ".join(["%s"] * len(insert_cols_all))
- col_names = ", ".join(insert_cols_all)
- update_expr = ", ".join(f"{c} = EXCLUDED.{c}" for c in insert_cols)
-
- sql = f"""
- INSERT INTO adresse_correction ({col_names})
- VALUES ({placeholders})
- ON CONFLICT (invalid_address) DO UPDATE SET {update_expr}
- """
-
- conn = psycopg2.connect(**DB_CONFIG)
- conn.autocommit = False
- cur = conn.cursor()
-
- inserted = updated = errors = 0
-
- for i, row in enumerate(rows, 1):
- if len(row) < len(SOURCE_COLUMNS):
- print(f" ⚠️ Ligne {i} ignorée ({len(row)} champs) : {row}")
- continue
-
- row_dict = {SOURCE_COLUMNS[j]: row[j] for j in range(len(SOURCE_COLUMNS))}
- values = [row_dict[c] for c in insert_cols_all]
- inv_addr = row_dict.get("invalid_address", "?")
-
- if DRY_RUN:
- print(
- f" [DRY-RUN] {i:3}. {inv_addr!r} → {row_dict.get('correct_address')!r}"
- )
- inserted += 1
- continue
-
- try:
- cur.execute(
- "SELECT 1 FROM adresse_correction WHERE invalid_address = %s",
- (inv_addr,),
- )
- exists = cur.fetchone() is not None
- cur.execute(sql, values)
- if exists:
- updated += 1
- else:
- inserted += 1
-
- except Exception as e:
- print(f" ❌ Erreur ligne {i} ({inv_addr!r}) : {e}")
- conn.rollback()
- errors += 1
-
- if not DRY_RUN:
- cur.execute(
- "SELECT setval('adresse_correction_id_seq', COALESCE((SELECT MAX(id) FROM adresse_correction), 1))"
- )
- conn.commit()
-
- cur.close()
- conn.close()
-
- print(f"\n{'🔍 DRY-RUN — ' if DRY_RUN else ''}✅ Terminé")
- print(f" Insérés : {inserted}")
- print(f" Mis à jour : {updated}")
- print(f" Erreurs : {errors}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/import_clients.py b/scripts/import_clients.py
deleted file mode 100644
index eea7e5e5..00000000
--- a/scripts/import_clients.py
+++ /dev/null
@@ -1,179 +0,0 @@
-#!/usr/bin/env python3
-"""
-Import clients depuis data.txt (format psql pipe-séparé) vers PostgreSQL.
-Usage : python3 import_clients.py [--dry-run]
-"""
-
-import os
-import sys
-
-import psycopg2
-
-# ── Connexion ──────────────────────────────────────────────────────────────────
-DB_CONFIG = {
- "host": "localhost",
- "port": 5432,
- "user": "",
- "password": "",
- "dbname": "",
-}
-
-DATA_FILE = os.path.join(os.path.dirname(__file__), "data.txt")
-DRY_RUN = "--dry-run" in sys.argv
-
-# ── Ordre des colonnes dans data.txt (DB production, construite incrémentalement)
-# None = colonne inconnue/obsolète à ignorer
-SOURCE_COLUMNS = [
- "id",
- "username",
- "password",
- "nom",
- "prenom",
- "telephone",
- "command",
- "amende",
- "cancel_commande",
- "referral_balance", # position 9 : NUMERIC(10,2) → "0.00"
- "cancellations_count", # position 10 : INTEGER → "0"
- None, # position 11 : colonne obsolète/inconnue, ignorée
- "last_penalty_reason", # position 12 : TEXT
- "updated_at",
- "created_at",
- "must_change_password", # position 15 : BOOLEAN → "f"/"t"
-]
-
-
-def parse_value(v: str):
- """Convertit une valeur brute psql en type Python (None si vide)."""
- v = v.strip()
- if v == "" or v.lower() == "null":
- return None
- if v.lower() == "t":
- return True
- if v.lower() == "f":
- return False
- return v
-
-
-def load_data(path: str) -> list[list]:
- rows = []
- with open(path, encoding="utf-8") as f:
- for line in f:
- line = line.rstrip("\n")
- if not line.strip():
- continue
- # Lignes séparateur psql (ex: "----+----")
- if (
- set(
- line.replace("|", "")
- .replace("+", "")
- .replace("-", "")
- .replace(" ", "")
- )
- == set()
- ):
- continue
- parts = line.split("|")
- values = (
- [parse_value(p) for p in parts[:-1]]
- if parts[-1].strip() == ""
- else [parse_value(p) for p in parts]
- )
- if values:
- rows.append(values)
- return rows
-
-
-def main():
- rows = load_data(DATA_FILE)
- if not rows:
- print("❌ Aucune ligne trouvée dans data.txt")
- sys.exit(1)
-
- print(f"📂 {len(rows)} lignes lues depuis {DATA_FILE}")
-
- data_col_count = len(rows[0])
- print(
- f"📋 Colonnes data.txt : {data_col_count} | SOURCE_COLUMNS définis : {len(SOURCE_COLUMNS)}"
- )
-
- if data_col_count != len(SOURCE_COLUMNS):
- print(
- f"⚠️ Ajuste SOURCE_COLUMNS dans le script ({len(SOURCE_COLUMNS)} définis, {data_col_count} dans le fichier)"
- )
- sys.exit(1)
-
- # Colonnes à insérer (on filtre les None = colonnes ignorées)
- insert_cols = [col for col in SOURCE_COLUMNS if col is not None and col != "id"]
- # On conserve aussi l'id pour FORCE_ID
- insert_cols_with_id = ["id"] + insert_cols
-
- placeholders = ", ".join(["%s"] * len(insert_cols_with_id))
- col_names = ", ".join(insert_cols_with_id)
- update_expr = ", ".join(f"{c} = EXCLUDED.{c}" for c in insert_cols)
-
- sql = f"""
- INSERT INTO clients ({col_names})
- VALUES ({placeholders})
- ON CONFLICT (username) DO UPDATE SET {update_expr}
- """
-
- conn = psycopg2.connect(**DB_CONFIG)
- conn.autocommit = False
- cur = conn.cursor()
-
- inserted = 0
- updated = 0
- errors = 0
-
- for i, row in enumerate(rows, 1):
- # Construire un dict {col_name: value} depuis SOURCE_COLUMNS
- row_dict = {}
- for pos, col in enumerate(SOURCE_COLUMNS):
- if col is None:
- continue
- row_dict[col] = row[pos] if pos < len(row) else None
-
- values = [row_dict.get(c) for c in insert_cols_with_id]
- username = row_dict.get("username", "?")
-
- try:
- if DRY_RUN:
- print(f" [DRY-RUN] ligne {i} : {username} → {row_dict}")
- inserted += 1
- continue
-
- cur.execute("SELECT 1 FROM clients WHERE username = %s", (username,))
- exists = cur.fetchone() is not None
-
- cur.execute(sql, values)
-
- if exists:
- updated += 1
- else:
- inserted += 1
-
- except Exception as e:
- print(f" ❌ Erreur ligne {i} ({username}) : {e}")
- conn.rollback()
- errors += 1
- continue
-
- if not DRY_RUN:
- cur.execute(
- "SELECT setval('clients_id_seq', COALESCE((SELECT MAX(id) FROM clients), 1))"
- )
- conn.commit()
-
- cur.close()
- conn.close()
-
- print(f"\n{'🔍 DRY-RUN — ' if DRY_RUN else ''}✅ Terminé")
- print(f" Insérés : {inserted}")
- print(f" Mis à jour : {updated}")
- print(f" Erreurs : {errors}")
- print(f" ℹ️ Séquence clients_id_seq resynchronisée sur MAX(id)")
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/import_users.py b/scripts/import_users.py
deleted file mode 100644
index 7b509daf..00000000
--- a/scripts/import_users.py
+++ /dev/null
@@ -1,162 +0,0 @@
-#!/usr/bin/env python3
-"""
-Import users depuis data3.txt (format psql pipe-séparé) vers PostgreSQL.
-Usage : python3 import_users.py [--dry-run]
-"""
-
-import os
-import sys
-
-import psycopg2
-
-DB_CONFIG = {
- "host": "localhost",
- "port": 5432,
- "user": "",
- "password": "",
- "dbname": "",
-}
-
-DATA_FILE = os.path.join(os.path.dirname(__file__), "data3.txt")
-DRY_RUN = "--dry-run" in sys.argv
-
-# Colonnes dans data3.txt — None = ignoré (push_token supprimé)
-SOURCE_COLUMNS = [
- "id",
- "username",
- "password",
- "role",
- "total",
- "livraison",
- "created_at",
- "updated_at",
-]
-
-
-def parse_value(v: str):
- v = v.strip()
- if v == "" or v.lower() == "null":
- return None
- if v.lower() == "t":
- return True
- if v.lower() == "f":
- return False
- return v
-
-
-def load_data(path: str) -> list[list]:
- rows = []
- with open(path, encoding="utf-8") as f:
- for line in f:
- line = line.rstrip("\n")
- if not line.strip():
- continue
- if (
- set(
- line.replace("|", "")
- .replace("+", "")
- .replace("-", "")
- .replace(" ", "")
- )
- == set()
- ):
- continue
- parts = line.split("|")
- values = (
- [parse_value(p) for p in parts[:-1]]
- if parts[-1].strip() == ""
- else [parse_value(p) for p in parts]
- )
- # Ignorer la ligne d'en-tête
- if values and values[0] == "id":
- continue
- if values:
- rows.append(values)
- return rows
-
-
-def main():
- rows = load_data(DATA_FILE)
- if not rows:
- print("❌ Aucune ligne trouvée dans data3.txt")
- sys.exit(1)
-
- print(f"📂 {len(rows)} lignes lues depuis {DATA_FILE}")
-
- data_col_count = len(rows[0])
- print(
- f"📋 Colonnes data3.txt : {data_col_count} | SOURCE_COLUMNS définis : {len(SOURCE_COLUMNS)}"
- )
-
- if data_col_count != len(SOURCE_COLUMNS):
- print(
- f"⚠️ Ajuste SOURCE_COLUMNS ({len(SOURCE_COLUMNS)} définis, {data_col_count} dans le fichier)"
- )
- sys.exit(1)
-
- insert_cols = [col for col in SOURCE_COLUMNS if col is not None and col != "id"]
- insert_cols_with_id = ["id"] + insert_cols
-
- placeholders = ", ".join(["%s"] * len(insert_cols_with_id))
- col_names = ", ".join(insert_cols_with_id)
- update_expr = ", ".join(f"{c} = EXCLUDED.{c}" for c in insert_cols)
-
- sql = f"""
- INSERT INTO users ({col_names})
- VALUES ({placeholders})
- ON CONFLICT (username) DO UPDATE SET {update_expr}
- """
-
- conn = psycopg2.connect(**DB_CONFIG)
- conn.autocommit = False
- cur = conn.cursor()
-
- inserted = updated = errors = 0
-
- for i, row in enumerate(rows, 1):
- row_dict = {}
- for pos, col in enumerate(SOURCE_COLUMNS):
- if col is None:
- continue
- row_dict[col] = row[pos] if pos < len(row) else None
-
- values = [row_dict.get(c) for c in insert_cols_with_id]
- username = row_dict.get("username", "?")
-
- try:
- if DRY_RUN:
- print(f" [DRY-RUN] {i}. {username} → {row_dict}")
- inserted += 1
- continue
-
- cur.execute("SELECT 1 FROM users WHERE username = %s", (username,))
- exists = cur.fetchone() is not None
- cur.execute(sql, values)
- if exists:
- updated += 1
- else:
- inserted += 1
-
- except Exception as e:
- print(f" ❌ Erreur ligne {i} ({username}) : {e}")
- conn.rollback()
- errors += 1
-
- if not DRY_RUN:
- cur.execute(
- "SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 1))"
- )
- conn.commit()
-
- cur.close()
- conn.close()
-
- print(f"\n{'🔍 DRY-RUN — ' if DRY_RUN else ''}✅ Terminé")
- print(f" Insérés : {inserted}")
- print(f" Mis à jour : {updated}")
- print(f" Erreurs : {errors}")
- print(" ℹ️ Séquence users_id_seq resynchronisée sur MAX(id)")
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/ssh-monitor.service b/scripts/ssh-monitor.service
deleted file mode 100644
index 0f78edac..00000000
--- a/scripts/ssh-monitor.service
+++ /dev/null
@@ -1,25 +0,0 @@
-[Unit]
-Description=SSH Monitor — alertes Telegram + bannissement automatique
-Documentation=https://github.com/pyTelegramBotAPI/pyTelegramBotAPI
-After=network-online.target
-Wants=network-online.target
-
-[Service]
-Type=simple
-ExecStart=/usr/bin/python3 /home/xor_fakers/projet_gestion_commande/scripts/ssh_monitor.py
-Restart=always
-RestartSec=5
-
-# Root requis pour iptables
-User=root
-
-# Sortie dans journald (journalctl -u ssh-monitor -f)
-StandardOutput=journal
-StandardError=journal
-SyslogIdentifier=ssh-monitor
-
-# Redémarrer même si le process quitte proprement (ex : erreur init)
-RestartForceExitStatus=1
-
-[Install]
-WantedBy=multi-user.target
diff --git a/scripts/ssh_monitor.py b/scripts/ssh_monitor.py
deleted file mode 100644
index 9c7eb197..00000000
--- a/scripts/ssh_monitor.py
+++ /dev/null
@@ -1,230 +0,0 @@
- #!/usr/bin/env python3
- """
- pip install pyTelegramBotAPI
- """
-
- import os
- import re
- import subprocess
- import sys
- import time
- from collections import defaultdict
- from datetime import datetime
-
- import telebot
-
- # ============================================================
- # CONFIGURATION — à modifier avant de lancer
- # ============================================================
-
- BOT_TOKEN = "7671151228:AAHI_xJqaXkbf2RnDGotn6ZwO_fWgoY-WJk"
- CHAT_ID = "7950889398"
-
- LOG_FILE = "/var/log/auth.log"
-
- BRUTE_FORCE_THRESHOLD = 5
- BRUTE_FORCE_WINDOW = 60
-
- # Fenêtre de déduplication : évite d'envoyer 2 fois le même type
- # d'alerte pour la même IP en moins de DEDUP_WINDOW secondes.
- # Toute IP hors whitelist est considérée malveillante → on alerte toujours.
- DEDUP_WINDOW = 10
-
- WHITELIST_IPS = {"86.217.21.144"}
-
- ACTIVE_RESPONSE = True
-
- BAN_ON_BRUTE_FORCE = True
- BAN_ON_MAX_AUTH = True
-
- banned_ips: set[str] = set()
-
- RE_FAILED = re.compile(
- r"sshd\[\d+\]: Failed (?:password|publickey) for (?:invalid user )?(\S+) from ([\d.]+)"
- )
- RE_INVALID_USER = re.compile(r"sshd\[\d+\]: Invalid user (\S+) from ([\d.]+)")
- RE_ACCEPTED = re.compile(
- r"sshd\[\d+\]: Accepted (?:password|publickey) for (\S+) from ([\d.]+)"
- )
- RE_MAX_AUTH = re.compile(
- r"sshd\[\d+\]: error: maximum authentication attempts exceeded.*from ([\d.]+)"
- )
- RE_DISCONNECT_INVALID = re.compile(
- r"sshd\[\d+\]: Disconnected from invalid user (\S+) ([\d.]+)"
- )
-
-
- failed_attempts: dict[str, list[float]] = defaultdict(list)
-
- last_sent: dict[tuple, float] = {}
-
- known_ips: set[str] = set()
-
- # ============================================================
- # TELEGRAM
- # ============================================================
-
- bot = telebot.TeleBot(BOT_TOKEN, parse_mode="HTML")
-
-
- def send_alert(message: str) -> None:
- try:
- bot.send_message(CHAT_ID, message)
- except Exception as e:
- print(f"[TELEGRAM ERROR] {e}", file=sys.stderr)
-
-
- def dedup_ok(ip: str, event: str) -> bool:
- """Retourne True si on peut envoyer l'alerte (pas envoyée depuis DEDUP_WINDOW s)."""
- key = (ip, event)
- now = time.time()
- if key in last_sent and now - last_sent[key] < DEDUP_WINDOW:
- return False
- last_sent[key] = now
- return True
-
-
- def ts() -> str:
- return datetime.now().strftime("%d/%m/%Y %H:%M:%S")
-
-
- # ============================================================
- # ANALYSE DES LIGNES
- # ============================================================
-
-
- def process_line(line: str) -> None:
- line = line.strip()
- if not line:
- return
-
- m = RE_ACCEPTED.search(line)
- if m:
- user, ip = m.group(1), m.group(2)
- if ip in WHITELIST_IPS:
- return
- icon = "🔴" if user == "root" else "🟡"
- new_ip = "" if ip in known_ips else "(nouvelle IP !)"
- known_ips.add(ip)
- send_alert(
- f"{icon}IP Alertes Connecter: {new_ip}\n"
- f"👤 Utilisateur : {user}\n"
- f"🌐 IP : {ip}\n"
- f"🕐 {ts()}"
- )
- return
-
- m = RE_MAX_AUTH.search(line)
- if m:
- ip = m.group(1)
- if ip in WHITELIST_IPS:
- return
- if dedup_ok(ip, "maxauth"):
- send_alert(f"🚫 Max authentifications atteint\n🌐 IP : {ip}\n🕐 {ts()}")
- return
-
- # --- Utilisateur invalide ---
- m = RE_INVALID_USER.search(line)
- if m:
- user, ip = m.group(1), m.group(2)
- if ip in WHITELIST_IPS:
- return
- _record_failure(ip)
- _alert(ip, user)
- return
-
- # --- Mot de passe / clé échouée ---
- m = RE_FAILED.search(line)
- if m:
- user, ip = m.group(1), m.group(2)
- if ip in WHITELIST_IPS:
- return
- _record_failure(ip)
- _alert(ip, user)
- return
-
-
- def _record_failure(ip: str) -> None:
- now = time.time()
- failed_attempts[ip].append(now)
- failed_attempts[ip] = [
- t for t in failed_attempts[ip] if now - t <= BRUTE_FORCE_WINDOW
- ]
-
-
- def _alert(ip: str, user: str) -> None:
- count = len(failed_attempts[ip])
-
- if count >= BRUTE_FORCE_THRESHOLD:
- if dedup_ok(ip, "bruteforce"):
- send_alert(
- f"🔥 Brute force SSH détecté !\n"
- f"🌐 IP : {ip}\n"
- f"👤 Dernier user : {user}\n"
- f"📊 {count} échecs en {BRUTE_FORCE_WINDOW}s\n"
- f"🕐 {ts()}"
- )
- return
-
- if dedup_ok(ip, "fail"):
- send_alert(
- f"⚠️ Tentative SSH échouée\n"
- f"🌐 IP : {ip}\n"
- f"👤 User : {user}\n"
- f"📊 {count} échec(s) récent(s)\n"
- f"🕐 {ts()}"
- )
-
-
- # ============================================================
- # LECTURE EN CONTINU (tail -f)
- # ============================================================
-
-
- def follow(filepath: str):
- if not os.path.exists(filepath):
- print(f"[ERREUR] Fichier introuvable : {filepath}", file=sys.stderr)
- sys.exit(1)
-
- with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
- f.seek(0, os.SEEK_END)
- print(f"[INFO] Surveillance de {filepath} démarrée.")
- send_alert(
- f"✅ SSH Monitor démarré\n📂 {filepath}\n🕐 {ts()}"
- )
-
- while True:
- line = f.readline()
- if line:
- yield line
- else:
- try:
- if os.stat(filepath).st_ino != os.fstat(f.fileno()).st_ino:
- print("[INFO] Rotation du fichier détectée, réouverture.")
- f.close()
- f = open(filepath, "r", encoding="utf-8", errors="ignore")
- except OSError:
- pass
- time.sleep(0.2)
-
-
- # ============================================================
- # POINT D'ENTRÉE
- # ============================================================
-
- if __name__ == "__main__":
- if BOT_TOKEN == "VOTRE_BOT_TOKEN" or CHAT_ID == "VOTRE_CHAT_ID":
- print("[ERREUR] Configure BOT_TOKEN et CHAT_ID avant de lancer le script.")
- sys.exit(1)
-
- print(
- f"[INFO] Brute force : {BRUTE_FORCE_THRESHOLD} échecs en {BRUTE_FORCE_WINDOW}s"
- )
- print(f"[INFO] Déduplication : {DEDUP_WINDOW}s par (IP, type d'événement)")
-
- try:
- for log_line in follow(LOG_FILE):
- process_line(log_line)
- except KeyboardInterrupt:
- print("\n[INFO] Arrêt du moniteur.")
- send_alert(f"🛑 SSH Monitor arrêté\n🕐 {ts()}")