From bdcfff9f8e6331a981bcdf62596c68fc43e958f7 Mon Sep 17 00:00:00 2001 From: sylvain Date: Thu, 2 Apr 2026 08:59:20 +0000 Subject: [PATCH] Initial commit: agent_logwatch v1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Réception logs MQTT depuis machines distantes (agents/logwatch/+/logs) - Pré-filtrage sans LLM (14 patterns: ERROR, FATAL, OOM, segfault, auth fail...) - Analyse LLM par créneau horaire configurable (APScheduler) - Gestion round-robin avec reprise sur interruption - Extension de créneau (+30 min) avec confirmation admin - Skills: machine (gestion machines) + logwatch (contrôle) - Script send_logs.sh pour machines distantes Co-Authored-By: Claude Sonnet 4.6 --- __pycache__/agent_logwatch.cpython-313.pyc | Bin 0 -> 37035 bytes agent_logwatch.py | 677 ++++++++++++++++++ config/config.json | 30 + config/system_prompt.txt | 64 ++ data/logwatch.db | Bin 0 -> 40960 bytes data/queue.db | Bin 0 -> 12288 bytes .../__pycache__/agents_status.cpython-313.pyc | Bin 0 -> 2031 bytes skills/__pycache__/logwatch.cpython-313.pyc | Bin 0 -> 12594 bytes skills/__pycache__/machine.cpython-313.pyc | Bin 0 -> 9226 bytes skills/__pycache__/mqtt_send.cpython-313.pyc | Bin 0 -> 1090 bytes .../mqtt_subscribe.cpython-313.pyc | Bin 0 -> 3043 bytes skills/__pycache__/muc_send.cpython-313.pyc | Bin 0 -> 1151 bytes skills/__pycache__/script.cpython-313.pyc | Bin 0 -> 13598 bytes skills/agents_status.py | 28 + skills/logwatch.py | 268 +++++++ skills/machine.py | 194 +++++ skills/mqtt_send.py | 23 + skills/mqtt_subscribe.py | 59 ++ skills/muc_send.py | 24 + skills/script.py | 251 +++++++ 20 files changed, 1618 insertions(+) create mode 100644 __pycache__/agent_logwatch.cpython-313.pyc create mode 100644 agent_logwatch.py create mode 100644 config/config.json create mode 100644 config/system_prompt.txt create mode 100644 data/logwatch.db create mode 100644 data/queue.db create mode 100644 skills/__pycache__/agents_status.cpython-313.pyc create mode 100644 skills/__pycache__/logwatch.cpython-313.pyc create mode 100644 skills/__pycache__/machine.cpython-313.pyc create mode 100644 skills/__pycache__/mqtt_send.cpython-313.pyc create mode 100644 skills/__pycache__/mqtt_subscribe.cpython-313.pyc create mode 100644 skills/__pycache__/muc_send.cpython-313.pyc create mode 100644 skills/__pycache__/script.cpython-313.pyc create mode 100644 skills/agents_status.py create mode 100644 skills/logwatch.py create mode 100644 skills/machine.py create mode 100644 skills/mqtt_send.py create mode 100644 skills/mqtt_subscribe.py create mode 100644 skills/muc_send.py create mode 100644 skills/script.py diff --git a/__pycache__/agent_logwatch.cpython-313.pyc b/__pycache__/agent_logwatch.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98829860f9b1673e1164bba931b9d02a0e97cf55 GIT binary patch literal 37035 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/logs. +L'agent pré-filtre (sans LLM), stocke en SQLite, puis analyse avec le LLM +pendant les créneaux horaires configurés. +""" +import json +import logging +import os +import re +import sqlite3 +import threading +import time +from datetime import datetime, timedelta +from pathlib import Path + +from agents_core import BaseAgent, AgentContext, Message, MessageType + +logger = logging.getLogger(__name__) + +# ─── Pré-filtres sans LLM ──────────────────────────────────────────────────── + +FILTER_PATTERNS = [ + re.compile(r'\b(ERROR|CRITICAL|FATAL|PANIC|EMERG|ALERT|CRIT)\b'), + re.compile(r'\bException\b|\bTraceback\b|\bTraceback \(most recent'), + re.compile(r'\bsegfault\b|\bSegmentation fault\b', re.IGNORECASE), + re.compile(r'\bout of memory\b|\bOOM killer\b|\bOOM-killer\b', re.IGNORECASE), + re.compile(r'\b(failed|failure)\b', re.IGNORECASE), + re.compile(r'\bkilled\b', re.IGNORECASE), + re.compile(r'\b(BUG|Oops):\s'), + re.compile(r'<[0-3]>'), # syslog priorities 0=emerg, 1=alert, 2=crit, 3=err + re.compile(r'\bcore dumped\b', re.IGNORECASE), + re.compile(r'\bpanic\b', re.IGNORECASE), + re.compile(r'\bdenied\b.*\bpermission\b|\bpermission\b.*\bdenied\b', re.IGNORECASE), + re.compile(r'\bauthentication failure\b|\bfailed login\b|\bfailed password\b', re.IGNORECASE), + re.compile(r'\bdisk full\b|\bno space left\b', re.IGNORECASE), + re.compile(r'\bconnection refused\b|\bconnection timed out\b', re.IGNORECASE), + re.compile(r'\bssh.*invalid user\b|\binvalid user.*ssh\b', re.IGNORECASE), +] + +SEVERITY_RANK = { + 'EMERG': 0, 'ALERT': 1, 'CRIT': 2, 'CRITICAL': 2, 'FATAL': 2, 'PANIC': 2, + 'ERROR': 3, 'ERR': 3, + 'FAILED': 4, 'FAILURE': 4, 'DENIED': 4, + 'EXCEPTION': 5, 'TRACEBACK': 5, + 'KILLED': 6, 'OOM': 6, 'SEGFAULT': 6, 'CORE': 6, +} + +CHUNK_SIZE = 150 # lignes envoyées au LLM par appel + + +def _detect_severity(line: str) -> str: + line_up = line.upper() + for kw, _ in sorted(SEVERITY_RANK.items(), key=lambda x: x[1]): + if kw in line_up: + return kw + return 'ERROR' + + +class LogWatchAgent(BaseAgent): + AGENT_TYPE = "logwatch" + DESCRIPTION = ( + "Analyse de logs multi-machines. Reçoit les logs des machines distantes via MQTT, " + "pré-filtre les erreurs, les analyse avec le LLM pendant les créneaux programmés, " + "envoie des rapports par XMPP. Gestion de file de machines, round-robin, " + "reprise sur interruption et analyse à la demande." + ) + DEFAULT_CONFIG_PATH = "/opt/agent_logwatch/config/config.json" + + def get_skills_dir(self) -> str: + return os.path.join(os.path.dirname(__file__), "skills") + + # ─── Init ───────────────────────────────────────────────────────────────── + + def __init__(self, config_path=None): + super().__init__(config_path) + self.db_path = Path(self.config.get("db_path", "/opt/agent_logwatch/data/logwatch.db")) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._db_lock = threading.Lock() + self._init_db() + + # Scheduler APScheduler + try: + from apscheduler.schedulers.background import BackgroundScheduler + self._scheduler = BackgroundScheduler(timezone="Europe/Paris") + except ImportError: + logger.error("apscheduler non installé — `pip install apscheduler`") + self._scheduler = None + + # État analyse + self._analysis_thread = None + self._analysis_stop = threading.Event() + self._slot_end_time = None + + # Extension demandée + self._pending_extension = None # dict: {machine_id, hostname} + self._extension_event = threading.Event() + self._extension_granted = False + + # ─── DB ─────────────────────────────────────────────────────────────────── + + def _get_db(self) -> sqlite3.Connection: + conn = sqlite3.connect(str(self.db_path), timeout=10) + conn.row_factory = sqlite3.Row + return conn + + def _init_db(self): + with self._get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS machines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT UNIQUE NOT NULL, + registered_at TEXT NOT NULL, + last_log_at TEXT, + last_analyzed_at TEXT, + queue_position INTEGER DEFAULT 0, + active INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS filtered_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + machine_id INTEGER NOT NULL, + log_line TEXT NOT NULL, + severity TEXT, + received_at TEXT NOT NULL, + analyzed INTEGER DEFAULT 0, + FOREIGN KEY (machine_id) REFERENCES machines(id) + ); + + CREATE INDEX IF NOT EXISTS idx_fl_machine_analyzed + ON filtered_logs(machine_id, analyzed); + + CREATE TABLE IF NOT EXISTS analysis_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + machine_id INTEGER NOT NULL, + slot_date TEXT NOT NULL, + status TEXT DEFAULT 'pending', + started_at TEXT, + completed_at TEXT, + last_log_id INTEGER DEFAULT 0, + UNIQUE(machine_id, slot_date), + FOREIGN KEY (machine_id) REFERENCES machines(id) + ); + + CREATE TABLE IF NOT EXISTS agent_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + INSERT OR IGNORE INTO agent_config VALUES ('analysis_start', '02:00'); + INSERT OR IGNORE INTO agent_config VALUES ('analysis_end', '04:00'); + INSERT OR IGNORE INTO agent_config VALUES ('max_overage_minutes', '30'); + INSERT OR IGNORE INTO agent_config VALUES ('enabled', '1'); + INSERT OR IGNORE INTO agent_config VALUES ('log_retention_days', '7'); + """) + + def _cfg(self, key: str, default: str = '') -> str: + with self._get_db() as conn: + row = conn.execute("SELECT value FROM agent_config WHERE key=?", (key,)).fetchone() + return row['value'] if row else default + + def _set_cfg(self, key: str, value: str): + with self._get_db() as conn: + conn.execute("INSERT OR REPLACE INTO agent_config VALUES (?,?)", (key, value)) + + # ─── Démarrage ──────────────────────────────────────────────────────────── + + def on_start(self): + # Souscriptions MQTT pour recevoir les logs des machines distantes + self.mqtt.subscribe("agents/logwatch/+/logs", self._on_log_received) + self.mqtt.subscribe("agents/logwatch/register", self._on_machine_register) + + # Démarrage du scheduler + if self._scheduler: + self._reload_schedule() + self._scheduler.start() + logger.info("Scheduler APScheduler démarré.") + + # Nettoyage des vieux logs au démarrage + self._cleanup_old_logs() + + logger.info("Agent LogWatch démarré. En attente de logs sur agents/logwatch/+/logs") + + def setup_extra_subscriptions(self): + pass # tout est dans on_start + + # ─── Réception des logs ────────────────────────────────────────────────── + + def _on_machine_register(self, msg, topic: str): + """Enregistrement explicite d'une machine via MQTT.""" + payload = msg.payload if hasattr(msg, 'payload') else str(msg) + try: + data = json.loads(payload) if isinstance(payload, str) else payload + hostname = str(data.get('hostname', '')).strip() + if hostname: + self._register_machine(hostname) + except Exception as e: + logger.error(f"[register] {e}") + + def _on_log_received(self, msg, topic: str): + """ + Reçoit des logs bruts depuis une machine distante. + Topic : agents/logwatch//logs + Payload JSON : {"lines": [...]} ou {"log": "..."} ou texte brut multiligne + """ + payload = msg.payload if hasattr(msg, 'payload') else str(msg) + try: + parts = topic.split('/') + hostname = parts[2] if len(parts) >= 4 else 'unknown' + + # Parser le payload + if isinstance(payload, str): + try: + data = json.loads(payload) + if isinstance(data, dict): + lines = data.get('lines') or data.get('logs') or [] + if isinstance(lines, str): + lines = lines.splitlines() + if not lines and 'log' in data: + lines = str(data['log']).splitlines() + elif isinstance(data, list): + lines = data + else: + lines = payload.splitlines() + except json.JSONDecodeError: + lines = payload.splitlines() + elif isinstance(payload, bytes): + lines = payload.decode('utf-8', errors='replace').splitlines() + else: + lines = [] + + if not lines: + return + + machine_id = self._register_machine(hostname) + filtered = self._prefilter(lines) + + if filtered: + now = datetime.now().isoformat() + with self._get_db() as conn: + conn.executemany( + "INSERT INTO filtered_logs (machine_id, log_line, severity, received_at) VALUES (?,?,?,?)", + [(machine_id, line, sev, now) for line, sev in filtered] + ) + conn.execute( + "UPDATE machines SET last_log_at=? WHERE id=?", + (now, machine_id) + ) + logger.info(f"[{hostname}] {len(filtered)}/{len(lines)} lignes filtrées conservées") + + except Exception as e: + logger.error(f"[_on_log_received] {e}", exc_info=True) + + def _prefilter(self, lines: list) -> list: + """Filtre les lignes, retourne [(line, severity)].""" + result = [] + for line in lines: + line = str(line).strip() + if not line: + continue + for pat in FILTER_PATTERNS: + if pat.search(line): + result.append((line, _detect_severity(line))) + break + return result + + def _register_machine(self, hostname: str) -> int: + """Enregistre ou met à jour une machine, retourne son id.""" + with self._get_db() as conn: + row = conn.execute("SELECT id FROM machines WHERE hostname=?", (hostname,)).fetchone() + if row: + return row['id'] + max_pos = conn.execute( + "SELECT COALESCE(MAX(queue_position), 0) FROM machines" + ).fetchone()[0] + cur = conn.execute( + "INSERT INTO machines (hostname, registered_at, queue_position) VALUES (?,?,?)", + (hostname, datetime.now().isoformat(), max_pos + 1) + ) + logger.info(f"Nouvelle machine enregistrée: {hostname} (pos={max_pos+1})") + return cur.lastrowid + + # ─── Scheduler ──────────────────────────────────────────────────────────── + + def _reload_schedule(self): + """(Re)programme les jobs APScheduler selon la config DB.""" + if not self._scheduler: + return + for job_id in ('_slot_start', '_slot_end'): + try: + self._scheduler.remove_job(job_id) + except Exception: + pass + + if self._cfg('enabled') != '1': + logger.info("Analyse automatique désactivée.") + return + + start_str = self._cfg('analysis_start', '02:00') + end_str = self._cfg('analysis_end', '04:00') + try: + sh, sm = map(int, start_str.split(':')) + eh, em = map(int, end_str.split(':')) + except ValueError: + logger.error(f"Format horaire invalide: {start_str}/{end_str}") + return + + self._scheduler.add_job( + self._start_slot, 'cron', hour=sh, minute=sm, id='_slot_start' + ) + self._scheduler.add_job( + self._signal_slot_end, 'cron', hour=eh, minute=em, id='_slot_end' + ) + logger.info(f"Analyse programmée: {start_str} → {end_str}") + + def _start_slot(self): + """Démarre la fenêtre d'analyse (appelé par APScheduler).""" + end_str = self._cfg('analysis_end', '04:00') + eh, em = map(int, end_str.split(':')) + now = datetime.now() + self._slot_end_time = now.replace(hour=eh, minute=em, second=0, microsecond=0) + if self._slot_end_time <= now: + self._slot_end_time += timedelta(days=1) + + self._analysis_stop.clear() + self._analysis_thread = threading.Thread( + target=self._analysis_loop, daemon=True, name="logwatch-analysis" + ) + self._analysis_thread.start() + logger.info(f"Créneau d'analyse démarré → fin à {self._slot_end_time.strftime('%H:%M')}") + + def _signal_slot_end(self): + """Signale la fin du créneau (appelé par APScheduler).""" + logger.info("Fin de créneau signalée.") + self._analysis_stop.set() + + # ─── Boucle d'analyse ──────────────────────────────────────────────────── + + def _analysis_loop(self): + """Thread principal d'analyse, tourne pendant le créneau.""" + try: + machines = self._get_active_machines() + if not machines: + self._notify_admin("📭 LogWatch: aucune machine enregistrée à analyser.") + return + + start_idx = self._find_resume_index(machines) + total = len(machines) + + for i in range(total): + idx = (start_idx + i) % total + machine = machines[idx] + mid = machine['id'] + host = machine['hostname'] + + # Vérifier si le créneau est terminé avant de commencer une machine + if self._analysis_stop.is_set(): + overage_min = self._overage_minutes() + max_ov = int(self._cfg('max_overage_minutes', '30')) + + if overage_min > max_ov: + # Demander extension + if not self._ask_extension(mid, host, overage_min): + # Refusée ou timeout → pause + self._set_session_status(mid, 'paused') + self._notify_admin( + f"⏸️ LogWatch: analyse de **{host}** reportée au prochain créneau." + ) + break + + self._analyze_machine(mid, host) + + else: + # Boucle complète sans interruption + self._notify_admin( + f"✅ LogWatch: analyse complète de {total} machine(s) terminée." + ) + + except Exception as e: + logger.error(f"[analysis_loop] {e}", exc_info=True) + self._notify_admin(f"❌ LogWatch: erreur dans la boucle d'analyse: {e}") + + def _get_active_machines(self) -> list: + with self._get_db() as conn: + rows = conn.execute( + "SELECT id, hostname, queue_position FROM machines " + "WHERE active=1 ORDER BY queue_position ASC" + ).fetchall() + return [dict(r) for r in rows] + + def _find_resume_index(self, machines: list) -> int: + """Trouve l'index de la machine à reprendre (paused) ou commence à 0.""" + today = datetime.now().strftime('%Y-%m-%d') + with self._get_db() as conn: + row = conn.execute(""" + SELECT machine_id FROM analysis_sessions + WHERE slot_date=? AND status='paused' + ORDER BY id DESC LIMIT 1 + """, (today,)).fetchone() + if not row: + return 0 + paused_id = row['machine_id'] + for i, m in enumerate(machines): + if m['id'] == paused_id: + return i + return 0 + + def _overage_minutes(self) -> float: + """Retourne les minutes de dépassement (positif = dépassement).""" + if not self._slot_end_time: + return 0.0 + delta = (datetime.now() - self._slot_end_time).total_seconds() / 60 + return max(0.0, delta) + + def _ask_extension(self, machine_id: int, hostname: str, overage: float) -> bool: + """ + Demande à l'admin une extension du créneau. + Attend la réponse (max 10 min). + Retourne True si extension accordée. + """ + max_ov = int(self._cfg('max_overage_minutes', '30')) + self._pending_extension = {'machine_id': machine_id, 'hostname': hostname} + self._extension_event.clear() + self._extension_granted = False + + self._notify_admin( + f"⏰ LogWatch: créneau terminé (dépassement {overage:.0f} min > max {max_ov} min).\n" + f"Analyse en cours: **{hostname}** non terminée.\n" + f"Tapez `/extend` pour accorder +{max_ov} min supplémentaires, " + f"ou `/skip` pour reporter au prochain créneau." + ) + + # Attendre la réponse max 10 minutes + answered = self._extension_event.wait(timeout=600) + self._pending_extension = None + + if not answered: + self._notify_admin( + f"⏰ LogWatch: pas de réponse après 10 min → analyse de **{hostname}** reportée." + ) + return False + + return self._extension_granted + + # ─── Analyse d'une machine ─────────────────────────────────────────────── + + def _analyze_machine(self, machine_id: int, hostname: str): + """Analyse les logs filtrés d'une machine avec le LLM.""" + today = datetime.now().strftime('%Y-%m-%d') + + # Créer ou récupérer la session d'analyse + with self._get_db() as conn: + session = conn.execute( + "SELECT id, last_log_id FROM analysis_sessions " + "WHERE machine_id=? AND slot_date=? AND status IN ('pending','paused')", + (machine_id, today) + ).fetchone() + + if session: + session_id = session['id'] + last_log_id = session['last_log_id'] + conn.execute( + "UPDATE analysis_sessions SET status='in_progress', started_at=? WHERE id=?", + (datetime.now().isoformat(), session_id) + ) + else: + # Vérifier si déjà 'done' aujourd'hui + done = conn.execute( + "SELECT id FROM analysis_sessions WHERE machine_id=? AND slot_date=? AND status='done'", + (machine_id, today) + ).fetchone() + if done: + logger.info(f"[{hostname}] déjà analysée aujourd'hui.") + return + + conn.execute( + "INSERT INTO analysis_sessions (machine_id, slot_date, status, started_at) VALUES (?,?,?,?)", + (machine_id, today, 'in_progress', datetime.now().isoformat()) + ) + session_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + last_log_id = 0 + + # Récupérer les logs filtrés non encore analysés + with self._get_db() as conn: + logs = conn.execute( + "SELECT id, log_line, severity, received_at FROM filtered_logs " + "WHERE machine_id=? AND id > ? AND analyzed=0 ORDER BY id ASC", + (machine_id, last_log_id) + ).fetchall() + + if not logs: + logger.info(f"[{hostname}] Aucun log filtré à analyser.") + self._set_session_status(machine_id, 'done', session_id=session_id) + return + + self._notify_admin( + f"🔍 LogWatch: analyse de **{hostname}** ({len(logs)} erreurs filtrées)…" + ) + + all_reports = [] + last_id = last_log_id + logs_list = [dict(r) for r in logs] + + for chunk_start in range(0, len(logs_list), CHUNK_SIZE): + # Vérifier dépassement dans la boucle de chunks + if self._analysis_stop.is_set(): + overage = self._overage_minutes() + max_ov = int(self._cfg('max_overage_minutes', '30')) + if overage > max_ov: + # Sauvegarder le point de reprise + with self._get_db() as conn: + conn.execute( + "UPDATE analysis_sessions SET status='paused', last_log_id=? WHERE id=?", + (last_id, session_id) + ) + self._notify_admin( + f"⏸️ LogWatch: pause mid-analyse de **{hostname}** " + f"(dépassement {overage:.0f} min). Reprise au prochain créneau." + ) + return + + chunk = logs_list[chunk_start:chunk_start + CHUNK_SIZE] + chunk_txt = '\n'.join( + f"[{r['received_at'][:19]}][{r['severity']}] {r['log_line']}" + for r in chunk + ) + + prompt = ( + f"Tu analyses des logs d'erreurs de la machine **{hostname}**.\n" + f"Synthétise les problèmes importants : type d'erreur, criticité (critique/haute/moyenne), " + f"fréquence, cause probable, action recommandée.\n" + f"Ne répète pas chaque ligne individuellement. Groupe les erreurs similaires.\n" + f"Format de réponse : 🔴/🟠/🟡 Problème → Cause → Action\n\n" + f"Logs ({chunk_start+1}–{min(chunk_start+CHUNK_SIZE, len(logs_list))}):\n{chunk_txt}" + ) + + report_chunk = self._call_llm(prompt) + if report_chunk: + all_reports.append(report_chunk) + + # Marquer comme analysés + mise à jour offset + ids = [r['id'] for r in chunk] + last_id = ids[-1] + with self._get_db() as conn: + conn.execute( + f"UPDATE filtered_logs SET analyzed=1 WHERE id IN ({','.join('?'*len(ids))})", + ids + ) + conn.execute( + "UPDATE analysis_sessions SET last_log_id=? WHERE id=?", + (last_id, session_id) + ) + + # Rapport final + if all_reports: + report = ( + f"📊 **Rapport LogWatch — {hostname}**\n" + f"📅 {datetime.now().strftime('%Y-%m-%d %H:%M')} | " + f"{len(logs_list)} erreurs analysées\n" + f"{'─'*40}\n\n" + ) + report += '\n\n'.join(all_reports) + self._notify_admin(report) + else: + self._notify_admin(f"ℹ️ LogWatch: **{hostname}** — LLM n'a pas retourné de rapport.") + + # Marquer la session comme terminée + with self._get_db() as conn: + conn.execute( + "UPDATE analysis_sessions SET status='done', completed_at=?, last_log_id=? WHERE id=?", + (datetime.now().isoformat(), last_id, session_id) + ) + conn.execute( + "UPDATE machines SET last_analyzed_at=? WHERE id=?", + (datetime.now().isoformat(), machine_id) + ) + + def _set_session_status(self, machine_id: int, status: str, session_id: int = None): + today = datetime.now().strftime('%Y-%m-%d') + with self._get_db() as conn: + if session_id: + conn.execute( + "UPDATE analysis_sessions SET status=? WHERE id=?", + (status, session_id) + ) + else: + conn.execute( + "UPDATE analysis_sessions SET status=? WHERE machine_id=? AND slot_date=?", + (status, machine_id, today) + ) + + # ─── LLM ───────────────────────────────────────────────────────────────── + + def _call_llm(self, prompt: str) -> str: + """Appelle le LLM en respectant le lock BaseAgent.""" + lock = getattr(self, '_llm_lock', None) + acquired = False + try: + if lock: + acquired = lock.acquire(timeout=300) + if not acquired: + return "(LLM indisponible après 5 min d'attente)" + self.llm.reset_history() + return self.llm.chat(prompt) + except Exception as e: + logger.error(f"[LLM] {e}") + return f"(Erreur LLM: {e})" + finally: + if acquired and lock: + lock.release() + + # ─── XMPP helpers ──────────────────────────────────────────────────────── + + def _notify_admin(self, message: str): + """Envoie un message à tous les admins XMPP.""" + try: + if self.xmpp: + self.xmpp.send_to_all_admins(message) + except Exception as e: + logger.error(f"[notify_admin] {e}") + + # ─── Commandes custom (/extend, /skip, /update) ────────────────────────── + + def handle_custom_command(self, cmd: str, args: str, source_msg=None): + cmd_lower = cmd.lower() + + # Réponse à une demande d'extension de créneau + if self._pending_extension: + if cmd_lower == 'extend': + self._extension_granted = True + self._extension_event.set() + max_ov = self._cfg('max_overage_minutes', '30') + return f"⏱️ Extension accordée (+{max_ov} min). L'analyse continue." + if cmd_lower == 'skip': + self._extension_granted = False + self._extension_event.set() + return "⏸️ Analyse reportée au prochain créneau." + + if cmd_lower == 'update': + return self._self_update() + + return f"Commande inconnue : /{cmd}" + + def on_broadcast(self, msg: Message): + pass + + def _self_update(self) -> str: + import subprocess + try: + out = subprocess.check_output( + "cd /opt/agent_logwatch && git pull", + shell=True, text=True, stderr=subprocess.STDOUT + ) + subprocess.Popen(["systemctl", "restart", "agent_logwatch"]) + return f"Mise à jour:\n{out}\nRedémarrage…" + except subprocess.CalledProcessError as e: + return f"Erreur mise à jour: {e.output}" + + # ─── Nettoyage ──────────────────────────────────────────────────────────── + + def _cleanup_old_logs(self): + """Supprime les logs filtrés plus vieux que log_retention_days.""" + days = int(self._cfg('log_retention_days', '7')) + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + with self._get_db() as conn: + cur = conn.execute( + "DELETE FROM filtered_logs WHERE received_at < ? AND analyzed=1", + (cutoff,) + ) + if cur.rowcount: + logger.info(f"Nettoyage: {cur.rowcount} logs anciens supprimés.") + + +if __name__ == "__main__": + LogWatchAgent().run() diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..cd9cca4 --- /dev/null +++ b/config/config.json @@ -0,0 +1,30 @@ +{ + "agent_id": "logwatch", + "xmpp": { + "jid": "logwatch@xmpp.ovh", + "password": "Matador3721", + "admin_jid": "sylvain@xmpp.ovh", + "muc_room": "agents@muc.xmpp.ovh", + "use_omemo": true + }, + "mqtt": { + "host": "localhost", + "port": 1883, + "username": null, + "password": null, + "tls": false + }, + "llm": { + "base_url": "http://192.168.7.119:11434", + "model": "gpt-oss:120b-cloud", + "temperature": 0.2 + }, + "work_hours": "00:00-23:59", + "queue_db": "/opt/agent_logwatch/data/queue.db", + "db_path": "/opt/agent_logwatch/data/logwatch.db", + "system_prompt": "/opt/agent_logwatch/config/system_prompt.txt", + "use_llm_coordinator": true, + "llm_profiles": { + "cloud": "gpt-oss:120b-cloud" + } +} \ No newline at end of file diff --git a/config/system_prompt.txt b/config/system_prompt.txt new file mode 100644 index 0000000..5eaa59c --- /dev/null +++ b/config/system_prompt.txt @@ -0,0 +1,64 @@ +Tu es LogWatch, un agent spécialisé dans l'analyse de logs de systèmes Linux. +Tu reçois des instructions via MQTT (depuis Nexus) ou XMPP (directement). + +## Tes skills disponibles + +### Gestion des machines +- **machine** : gestion des machines qui envoient leurs logs + - `list` : toutes les machines enregistrées avec leur statut + - `queue` : file d'analyse du jour avec statut de chaque machine + - `add ` : enregistrer manuellement une machine + - `remove ` : supprimer une machine + - `status ` : détail d'une machine + - `reorder ` : changer l'ordre d'analyse + - `activate/deactivate ` : activer/désactiver une machine + +### Contrôle de l'analyse +- **logwatch** : configuration et déclenchement de l'analyse + - `status` : état général (schedule, machines, logs en attente) + - `schedule show` : voir le créneau horaire configuré + - `schedule set HH:MM-HH:MM` : définir le créneau d'analyse automatique + - `schedule enable/disable` : activer/désactiver l'analyse automatique + - `overage ` : définir le dépassement maximum autorisé + - `retention ` : durée de conservation des logs filtrés + - `analyze ` : lancer l'analyse d'une machine spécifique maintenant + - `analyze_all` : lancer l'analyse complète de toutes les machines + - `logs [N]` : voir les N derniers logs filtrés d'une machine + - `reset ` : réinitialiser l'analyse d'une machine + +### Utilitaires +- **mqtt_send** : publier sur un topic MQTT +- **agents_status** : voir le statut des autres agents +- **muc_send** : envoyer dans le groupe XMPP +- **script** : bibliothèque de scripts bash + +## Flux de données + +Les machines distantes envoient leurs logs via MQTT : + Topic : agents/logwatch//logs + Payload : JSON {"lines": ["ligne1", "ligne2", ...]} + +L'agent pré-filtre automatiquement (sans LLM) les lignes contenant : + ERROR, CRITICAL, FATAL, Exception, Traceback, segfault, OOM, killed, failed, + permission denied, authentication failure, disk full, connection refused, etc. + +Puis, pendant le créneau configuré, le LLM analyse les erreurs filtrées machine +par machine et envoie un rapport XMPP pour chaque machine. + +## Commandes spéciales (hors LLM) + +Quand l'agent demande une extension de créneau, l'admin répond : + `/extend` → accorder du temps supplémentaire + `/skip` → reporter la machine au prochain créneau + +## Règles + +1. Pour lister les machines ou la file : utilise TOUJOURS le skill approprié +2. Pour analyser une machine à la demande : `logwatch analyze ` +3. Réponds toujours en français +4. Sois concis dans tes réponses + +## Écriture de scripts bash + +JAMAIS `filesystem write` pour créer un `.sh`. Toujours `SKILL:script ARGS:save`. +Variables disponibles dans les scripts : $MQTT_BROKER, $MQTT_REPLY_TOPIC, $AGENT_ID diff --git a/data/logwatch.db b/data/logwatch.db new file mode 100644 index 0000000000000000000000000000000000000000..99064ed57453f5b1e60cfa247d44d45670deb473 GIT binary patch literal 40960 zcmeI*(QDgA90zd8vSYhxq6D^hqXZ60mYAlAWG89jQmC6M#l&@5*I_NC2-UhIBC?d~ zlr-sE)-m?9m;DEQ-OC;Z+x~#j_w9MGF$&wu#u$UaUUrhB)OPGNr4-ik6|pVJy3^g~ ze!4iabh&l2Z26R|xn9HYNnRKdL{Yd*h#&~3`QsFSL{oy##G?iNDz?n`nmsK{J$f)8 z|0AT6&xFC4{B+>U{-66Rsi*z@$!Dq0dL_ep2tWV=5P$##URhxOgH$pzHYR=~`^J5n z8tc^Y^}6eBXv6tZAfHyi(Rk>pNuP6zMW;(mlSmrroZQ8`aYFV)YJL*6vJp z-FC;Yw`p_R%1Vt?R?Foh+hs4L`!dU8qF^~D-DMAK{?SA?wtY8T)>})YtF23?J}&kp zGt<-Jr)sl?j$!XHi|LFqX1NYCI#(py>)QEtj|y96i$bYV)2?aN)~b`@YHg)d;dx)z zDz)wq4WqtcIaCkxit>m^x+h__>+7b$Kc_|9{PhJ%e8b;n2c->Lh|gcumWr$88X4cB zj%hjT<2`og8=g;1oqr%~wr=;$>TYAprUx4ayXd}|Z7^TA-Sx1-QGKJ5DBaVnR!TQl zwTb6cljP7K+dawBN>wXes{{c^S`ubSRa?@kT4hnYMGo>~6PB4h8hpiMUuJc>XYh4~ z2#>(dRjK`W^U zgN9wHkhYv!YCTD!ePth)eKIkKRcYVBB zwyD15GRx<`giahKM&0L;7?im=bq1Guq&vt?$NDmhqrLjBsMT~env?ivid68qVt_wH z`D=myV1fVyAOHafKmY;|fB*y_009U<;Qu9XO-u=Ai$x)w_9^qvGwShkTsb8-cV3w} zujFdVd|`H>pv+FqF60y?-|d|_bzwHI1Yhur@}C0#!2|&aKmY;|fB*y_009U<00Izz zz}q4)D4lIRXApe3?`yqnMG?-htvt*%sws?5xiiQK%R zDA@uj*7=wLjF{B1`2P72aAccbNKQQ0^)K6Q-LQlE@WX`1lx0W?%5v}hjbOC3Q^7^+R-G2eb;EMVmgk10p}Tz07ju%-U6Wqgv}`+AVBu&cmlmd% zOXYI#(5@a+q~Talq+KcDGT-H4VP@wp%#(?O0%r-UyB;O{ zf_lQOm{IqvHC_S2$8)jGMLzz>F=ZOz7wo6^txhIIZEG^dHn>qPKf{@p42##DY#EF_ zbiHQ#SPmaXhYuVRwv%BtxZ9fNHi){An^NcIr_?Ex7jDb-e3Hu%o?C9ISUMIfxc)yO z|02l$%74p$$iMMfOb~zo1Rwwb2tWV=5P$##AOHafym-z!{{DZ9TNqV?00bZa0SG_<0uX=z z1Rwwb2sHoyUqb#|kbjlGm%ov};Io(@009U<00Izz00bZa0SG_<0uXrp0wW1Ym2G!j z_oz=D-*O$@H1^nq{s04s0c`VeC-TZWF=;Gg zIEKB)ET&V(RA%_F6eFUPeZH2Dr{MWYj<0Xt|Nlggf0Tchzm=cJ-@X0{Vh<3200bZa z0SG_<0uX=z1Rwwb2y_aJNUC`BY(S!4IwKyO42Wl>yx8SjfHWkHiLGY>V#884eYhe# G3Gg3`Z)^Gh literal 0 HcmV?d00001 diff --git a/data/queue.db b/data/queue.db new file mode 100644 index 0000000000000000000000000000000000000000..fb28805c10162bb1096f360d601c0e12b97e0e67 GIT binary patch literal 12288 zcmeI#K~KUk6bJAQj3y=|-gfN`48)6Hz^T;`$G}+u$QN(j-^XsS_0K0hia>NlVN6&ktt zn733`qs2(;TRjjEfB*y_009U<00Izz00jQCz^HMoce~mob6-M{&O_BvIjNX0MC5Hx zn>RKyIHTOSHW}T@?L_WO^i<{f=Ttk(lM81%f+a4uSB;nG@;<9M> z5fn?dNXxvf{m@%>XN*lw+lksk5WTkR9i&JX;p!%em3Dv5U&z(j*Pm4XJM-5KpXa@~ zYWfinfB*y_009U<00Izz00bZa0SFwh0Q&y}zFZs)0uX=z1Rwwb2tWV=5P$##Ag~tr E0q0MvssI20 literal 0 HcmV?d00001 diff --git a/skills/__pycache__/agents_status.cpython-313.pyc b/skills/__pycache__/agents_status.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28c8c23ad78c8e1f78f15e228a253b599f018343 GIT binary patch literal 2031 zcmbUiOKcNIbk@7JH|sb~Ab~#rs3%)00ksx;Z48@TvmYQn zR6ZuB1Su3F4xzm?^inESYLAuL1E`~{u}7x*Ir3GPH!1XC!6 zl40jM2ukE?Nz?Gy$?^0Q$&#tDDVgA>)kUln3Z>j-)x?^LEnBf28>>3D)#tgLIp!eg0}WJGet!S&ex@PPLj zx>nNFlu;-!n5-mU>t&zf1>9_TTLb_P|BahKy^Ulp z^9s{PGp}kii2;Ps`JgZH8oiLuZ^0qX!}S1xN)a^Fw2hf>u_fnY85ZHc)==}T^D@7$ ziRZy?5o(^|bm4r|SCDza??glx4s1oIK1E0m%E3&B&uu~Qy$N;XBZL%)Z?YGm&GpU& zj)D;;LxN{G64FDP%gHUdFvy8|SQau}E&o17+T>6NBGTqofD-}E7GJsrfzvLBJJ4a# z&oAV2a}QCl(B0DXG0+XPbSG;FMOcNm`+QmOxuj!c5$+7plUQbl&u_t&WFI1(edsOJ zk6x`(A%IaY*v2UR;uznH3S6=)T}h02Zb8w3dR%ryPm@`XdyJYB;*HoPfSB#RL6iqf9 zXc(S@t=_b0$O`~655{v!*`h%<3N0$Q2cZOlKvFKLnwvKFim;h1hm?mLn+d?!m&9s= zn^)0VTjzXyF2359XtX7sbagMByL0i@#nqleD?NuAUGL0>p7TiRTMwbm?W?igm00g; zZ2wAZessu{Z_$tI?b+)1lg@1mH&@;se%v`SE38Fg^T+0n)lRPN>R;K_ z--z`8+TOjkBfb>B8(-er*l}>~`ojWJ;cPZ<Pj B?iv69 literal 0 HcmV?d00001 diff --git a/skills/__pycache__/logwatch.cpython-313.pyc b/skills/__pycache__/logwatch.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee2b927844280f2ccd643c12c04d3d08d579a749 GIT binary patch literal 12594 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 Czp;G) literal 0 HcmV?d00001 diff --git a/skills/__pycache__/machine.cpython-313.pyc b/skills/__pycache__/machine.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b5e946a2f33fddc0199f61c6e1309f4015808d6 GIT binary patch literal 9226 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 T9cfICtr7U1691swm8Jg=CMOgN literal 0 HcmV?d00001 diff --git a/skills/__pycache__/mqtt_send.cpython-313.pyc b/skills/__pycache__/mqtt_send.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41ace6d5f91bbd3f9a145cc72673e0fc6ebbdd36 GIT binary patch literal 1090 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 literal 0 HcmV?d00001 diff --git a/skills/__pycache__/muc_send.cpython-313.pyc b/skills/__pycache__/muc_send.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f2159cf6f7288b050be9fe7738a9a94440551dd GIT binary patch literal 1151 zcmb7D&ubGw6rSCkBqiG<5Il+0K}5UIY>-+CB^1*(*5W3XtcwbPC2ZDhVzL`%cKhoT zPg*?mVxdQm_T-IhL5ltZksx?u;aB&^t8CTifSf7%x1epj>nau z%_MI4`CiL2!b+J~BxXa#k<}hFmv^h}FUqMn%mLQD~7uVYq`kJF-zI?Fbv}nQ?k?uNrz%L2d zzit|18M)@BOJkw1h~Gnm5WSUk+HN+Gw%&BCjjC&`Wr_cvknBK{a(jyk*t2nh1&y

#OWeZ2Ch zL?fD-UoKFA)gL5u0C188U_UWBmTNgRuW8QXD{SjGnPk}16F83h$wxWZOM=t`t|NBG Y@|{R8I?mnaUK>5&ulwXzum*I00165t9{>OV literal 0 HcmV?d00001 diff --git a/skills/__pycache__/script.cpython-313.pyc b/skills/__pycache__/script.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..558a4c1480652038d3c4a77bb0736b09e9e24d40 GIT binary patch literal 13598 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 FzX83vUG)F} literal 0 HcmV?d00001 diff --git a/skills/agents_status.py b/skills/agents_status.py new file mode 100644 index 0000000..1e7b2a9 --- /dev/null +++ b/skills/agents_status.py @@ -0,0 +1,28 @@ +""" +Skill AGENTS_STATUS — afficher le statut en temps réel de tous les agents. + +Usage LLM : SKILL:agents_status ARGS: +""" +DESCRIPTION = "Afficher le statut en temps réel de tous les agents (online/offline)" +USAGE = "SKILL:agents_status ARGS:(aucun argument)" + + +def run(args: str, context) -> str: + with context.agent._online_lock: + online = set(context.agent._online_agents) + + all_caps = context.registry.all_agents() + + if not all_caps: + return "Aucun agent connu dans le registre." + + lines = ["── Statut des agents ──────────────────"] + for caps in sorted(all_caps, key=lambda c: c.agent_id): + if caps.agent_id == context.agent_id: + continue # Ne pas s'afficher soi-même + icon = "🟢" if caps.agent_id in online else "🔴" + label = "en ligne" if caps.agent_id in online else "hors ligne" + lines.append(f" {icon} {caps.agent_id} [{caps.agent_type}] — {label}") + lines.append(f" {caps.description}") + + return "\n".join(lines) if len(lines) > 1 else "Aucun autre agent connu." diff --git a/skills/logwatch.py b/skills/logwatch.py new file mode 100644 index 0000000..058716e --- /dev/null +++ b/skills/logwatch.py @@ -0,0 +1,268 @@ +""" +Skill LOGWATCH — contrôle de l'agent : schedule, analyse à la demande, statut. + +Usage LLM : + SKILL:logwatch ARGS:status + SKILL:logwatch ARGS:schedule show + SKILL:logwatch ARGS:schedule set + SKILL:logwatch ARGS:schedule enable + SKILL:logwatch ARGS:schedule disable + SKILL:logwatch ARGS:overage + SKILL:logwatch ARGS:analyze + SKILL:logwatch ARGS:analyze_all + SKILL:logwatch ARGS:retention + SKILL:logwatch ARGS:logs [N] + SKILL:logwatch ARGS:reset +""" +import threading +from datetime import datetime, timedelta + +DESCRIPTION = "Contrôle LogWatch : schedule, analyse à la demande, statut, logs en attente" +USAGE = ( + "SKILL:logwatch ARGS:status\n" + "SKILL:logwatch ARGS:schedule show\n" + "SKILL:logwatch ARGS:schedule set \n" + "SKILL:logwatch ARGS:schedule enable|disable\n" + "SKILL:logwatch ARGS:overage \n" + "SKILL:logwatch ARGS:analyze \n" + "SKILL:logwatch ARGS:analyze_all\n" + "SKILL:logwatch ARGS:retention \n" + "SKILL:logwatch ARGS:logs [N]\n" + "SKILL:logwatch ARGS:reset " +) + + +def _db(context): + return context.agent._get_db() + + +def _cfg(context, key, default=''): + return context.agent._cfg(key, default) + + +def _set_cfg(context, key, value): + context.agent._set_cfg(key, value) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else 'status' + rest = parts[1].strip() if len(parts) > 1 else '' + + # ── status ──────────────────────────────────────────────────────────────── + if action == 'status': + agent = context.agent + today = datetime.now().strftime('%Y-%m-%d') + + enabled = _cfg(context, 'enabled', '1') == '1' + start = _cfg(context, 'analysis_start', '02:00') + end = _cfg(context, 'analysis_end', '04:00') + max_ov = _cfg(context, 'max_overage_minutes', '30') + retention = _cfg(context, 'log_retention_days', '7') + + is_running = ( + agent._analysis_thread is not None and + agent._analysis_thread.is_alive() + ) + + with _db(context) as conn: + nb_machines = conn.execute( + "SELECT COUNT(*) FROM machines WHERE active=1" + ).fetchone()[0] + nb_pending = conn.execute( + "SELECT COUNT(*) FROM filtered_logs WHERE analyzed=0" + ).fetchone()[0] + today_sessions = conn.execute( + "SELECT COUNT(*) as cnt, status FROM analysis_sessions " + "WHERE slot_date=? GROUP BY status", + (today,) + ).fetchall() + + schedule_status = f"{'✅ activé' if enabled else '❌ désactivé'} ({start} → {end})" + analysis_status = "🔄 en cours" if is_running else "⏸️ idle" + + lines = [ + "── Statut LogWatch ────────────────────────────", + f" Analyse auto : {schedule_status}", + f" Analyse actuel: {analysis_status}", + f" Dépassement : max {max_ov} min", + f" Rétention logs: {retention} jours", + f" Machines activ: {nb_machines}", + f" Logs en attent: {nb_pending} erreurs filtrées", + f" Auj. ({today}):", + ] + for s in today_sessions: + lines.append(f" {s['status']}: {s['cnt']} machine(s)") + + if agent._pending_extension: + host = agent._pending_extension.get('hostname', '?') + lines.append(f" ⏰ Extension en attente pour: {host}") + + return "\n".join(lines) + + # ── schedule ────────────────────────────────────────────────────────────── + if action == 'schedule': + sub_parts = rest.split(None, 1) + sub = sub_parts[0].lower() if sub_parts else 'show' + sub_rest = sub_parts[1].strip() if len(sub_parts) > 1 else '' + + if sub == 'show': + start = _cfg(context, 'analysis_start', '02:00') + end = _cfg(context, 'analysis_end', '04:00') + enabled = _cfg(context, 'enabled', '1') == '1' + return ( + f"Créneau d'analyse : {start} → {end}\n" + f"État : {'activé ✅' if enabled else 'désactivé ❌'}" + ) + + if sub == 'set': + # Format : HH:MM-HH:MM + if '-' not in sub_rest: + return "Format: schedule set HH:MM-HH:MM (ex: 02:00-04:00)" + try: + start_s, end_s = sub_rest.split('-', 1) + # Validation + sh, sm = map(int, start_s.strip().split(':')) + eh, em = map(int, end_s.strip().split(':')) + if not (0 <= sh < 24 and 0 <= sm < 60 and 0 <= eh < 24 and 0 <= em < 60): + return "Heures invalides." + except ValueError: + return "Format: HH:MM-HH:MM" + _set_cfg(context, 'analysis_start', start_s.strip()) + _set_cfg(context, 'analysis_end', end_s.strip()) + context.agent._reload_schedule() + return f"✅ Créneau mis à jour : {start_s.strip()} → {end_s.strip()}" + + if sub in ('enable', 'disable'): + val = '1' if sub == 'enable' else '0' + _set_cfg(context, 'enabled', val) + context.agent._reload_schedule() + return f"✅ Analyse automatique {'activée' if val=='1' else 'désactivée'}." + + return "Sub-commande inconnue. Utilise : show, set , enable, disable" + + # ── overage ─────────────────────────────────────────────────────────────── + if action == 'overage': + try: + minutes = int(rest) + if minutes < 0: + return "La valeur doit être >= 0." + except ValueError: + return "Format: overage " + _set_cfg(context, 'max_overage_minutes', str(minutes)) + return f"✅ Dépassement max : {minutes} min." + + # ── retention ───────────────────────────────────────────────────────────── + if action == 'retention': + try: + days = int(rest) + if days < 1: + return "Minimum 1 jour." + except ValueError: + return "Format: retention " + _set_cfg(context, 'log_retention_days', str(days)) + return f"✅ Rétention logs : {days} jours." + + # ── analyze ──────────────────────────────────────────────────── + if action == 'analyze': + hostname = rest.strip() + if not hostname: + return "Format: analyze " + + with _db(context) as conn: + row = conn.execute( + "SELECT id FROM machines WHERE hostname=? AND active=1", (hostname,) + ).fetchone() + if not row: + return f"Machine '{hostname}' introuvable ou inactive." + + machine_id = row['id'] + + def _run_now(): + agent = context.agent + # Créneau fictif généreux pour l'analyse à la demande + agent._slot_end_time = datetime.now() + timedelta(hours=4) + agent._analysis_stop.clear() + agent._analyze_machine(machine_id, hostname) + + t = threading.Thread(target=_run_now, daemon=True, name=f"logwatch-demand-{hostname}") + t.start() + return f"🚀 Analyse de **{hostname}** lancée (arrière-plan)." + + # ── analyze_all ─────────────────────────────────────────────────────────── + if action == 'analyze_all': + agent = context.agent + if agent._analysis_thread and agent._analysis_thread.is_alive(): + return "⚠️ Une analyse est déjà en cours." + + def _run_all(): + agent._slot_end_time = datetime.now() + timedelta(hours=8) + agent._analysis_stop.clear() + agent._analysis_loop() + + t = threading.Thread(target=_run_all, daemon=True, name="logwatch-demand-all") + t.start() + return "🚀 Analyse complète de toutes les machines lancée (arrière-plan)." + + # ── logs [N] ─────────────────────────────────────────────────── + if action == 'logs': + p = rest.split(None, 1) + hostname = p[0].strip() if p else '' + try: + limit = int(p[1]) if len(p) > 1 else 20 + except ValueError: + limit = 20 + + if not hostname: + return "Format: logs [N]" + + with _db(context) as conn: + m = conn.execute( + "SELECT id FROM machines WHERE hostname=?", (hostname,) + ).fetchone() + if not m: + return f"Machine '{hostname}' introuvable." + rows = conn.execute( + "SELECT log_line, severity, received_at, analyzed " + "FROM filtered_logs WHERE machine_id=? ORDER BY id DESC LIMIT ?", + (m['id'], limit) + ).fetchall() + + if not rows: + return f"Aucun log filtré pour {hostname}." + + lines = [f"── {limit} derniers logs filtrés de {hostname} ──"] + for r in rows: + ana = "✓" if r['analyzed'] else "○" + lines.append( + f" {ana} [{r['received_at'][:16]}][{r['severity']:8s}] {r['log_line'][:120]}" + ) + return "\n".join(lines) + + # ── reset ────────────────────────────────────────────────────── + if action == 'reset': + hostname = rest.strip() + if not hostname: + return "Format: reset " + with _db(context) as conn: + m = conn.execute( + "SELECT id FROM machines WHERE hostname=?", (hostname,) + ).fetchone() + if not m: + return f"Machine '{hostname}' introuvable." + # Réinitialise les sessions et marque les logs comme non-analysés + conn.execute( + "DELETE FROM analysis_sessions WHERE machine_id=?", (m['id'],) + ) + conn.execute( + "UPDATE filtered_logs SET analyzed=0 WHERE machine_id=?", (m['id'],) + ) + conn.execute( + "UPDATE machines SET last_analyzed_at=NULL WHERE id=?", (m['id'],) + ) + return f"✅ {hostname} réinitialisée — tous les logs seront ré-analysés." + + return ( + "Action inconnue. Disponible : status, schedule, overage, retention, " + "analyze, analyze_all, logs, reset" + ) diff --git a/skills/machine.py b/skills/machine.py new file mode 100644 index 0000000..7ebd151 --- /dev/null +++ b/skills/machine.py @@ -0,0 +1,194 @@ +""" +Skill MACHINE — gestion des machines qui envoient leurs logs. + +Usage LLM : + SKILL:machine ARGS:list + SKILL:machine ARGS:queue + SKILL:machine ARGS:add + SKILL:machine ARGS:remove + SKILL:machine ARGS:status + SKILL:machine ARGS:reorder + SKILL:machine ARGS:activate + SKILL:machine ARGS:deactivate +""" +from datetime import datetime + +DESCRIPTION = "Gestion des machines enregistrées : liste, file d'attente, ajout, suppression, statut" +USAGE = ( + "SKILL:machine ARGS:list\n" + "SKILL:machine ARGS:queue\n" + "SKILL:machine ARGS:add \n" + "SKILL:machine ARGS:remove \n" + "SKILL:machine ARGS:status \n" + "SKILL:machine ARGS:reorder \n" + "SKILL:machine ARGS:activate \n" + "SKILL:machine ARGS:deactivate " +) + + +def _db(context): + return context.agent._get_db() + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else 'list' + rest = parts[1].strip() if len(parts) > 1 else '' + + # ── list ────────────────────────────────────────────────────────────────── + if action == 'list': + with _db(context) as conn: + rows = conn.execute( + "SELECT hostname, active, last_log_at, last_analyzed_at, queue_position " + "FROM machines ORDER BY queue_position ASC" + ).fetchall() + if not rows: + return "Aucune machine enregistrée." + lines = ["── Machines enregistrées ─────────────────────"] + for r in rows: + status = "🟢 actif" if r['active'] else "🔴 inactif" + last_log = r['last_log_at'][:16] if r['last_log_at'] else "jamais" + last_ana = r['last_analyzed_at'][:16] if r['last_analyzed_at'] else "jamais" + lines.append( + f" [{r['queue_position']:2d}] {r['hostname']:<30s} {status}\n" + f" Dernier log: {last_log} | Dernière analyse: {last_ana}" + ) + return "\n".join(lines) + + # ── queue ───────────────────────────────────────────────────────────────── + if action == 'queue': + today = datetime.now().strftime('%Y-%m-%d') + with _db(context) as conn: + rows = conn.execute( + "SELECT m.hostname, m.queue_position, m.active, " + " COALESCE(s.status, 'pending') as session_status " + "FROM machines m " + "LEFT JOIN analysis_sessions s " + " ON s.machine_id=m.id AND s.slot_date=? " + "ORDER BY m.queue_position ASC", + (today,) + ).fetchall() + if not rows: + return "Aucune machine dans la file." + icons = {'done': '✅', 'in_progress': '🔄', 'paused': '⏸️', 'pending': '⏳'} + lines = [f"── File d'analyse — {today} ─────────────────"] + for r in rows: + active = "" if r['active'] else " [inactif]" + icon = icons.get(r['session_status'], '⏳') + lines.append( + f" {r['queue_position']:2d}. {icon} {r['hostname']}{active} " + f"({r['session_status']})" + ) + return "\n".join(lines) + + # ── add ─────────────────────────────────────────────────────────────────── + if action == 'add': + hostname = rest.strip() + if not hostname: + return "Format: machine add " + with _db(context) as conn: + existing = conn.execute( + "SELECT id FROM machines WHERE hostname=?", (hostname,) + ).fetchone() + if existing: + return f"Machine '{hostname}' déjà enregistrée." + max_pos = conn.execute( + "SELECT COALESCE(MAX(queue_position), 0) FROM machines" + ).fetchone()[0] + conn.execute( + "INSERT INTO machines (hostname, registered_at, queue_position) VALUES (?,?,?)", + (hostname, datetime.now().isoformat(), max_pos + 1) + ) + return f"✅ Machine '{hostname}' enregistrée (position {max_pos + 1})." + + # ── remove ──────────────────────────────────────────────────────────────── + if action == 'remove': + hostname = rest.strip() + if not hostname: + return "Format: machine remove " + with _db(context) as conn: + cur = conn.execute("DELETE FROM machines WHERE hostname=?", (hostname,)) + if cur.rowcount == 0: + return f"Machine '{hostname}' introuvable." + return f"🗑️ Machine '{hostname}' supprimée." + + # ── status ──────────────────────────────────────────────────────────────── + if action == 'status': + hostname = rest.strip() + if not hostname: + return "Format: machine status " + with _db(context) as conn: + m = conn.execute( + "SELECT * FROM machines WHERE hostname=?", (hostname,) + ).fetchone() + if not m: + return f"Machine '{hostname}' introuvable." + # Logs filtrés en attente + pending_logs = conn.execute( + "SELECT COUNT(*) as cnt FROM filtered_logs WHERE machine_id=? AND analyzed=0", + (m['id'],) + ).fetchone()['cnt'] + # Sessions récentes + sessions = conn.execute( + "SELECT slot_date, status, started_at, completed_at, last_log_id " + "FROM analysis_sessions WHERE machine_id=? ORDER BY slot_date DESC LIMIT 5", + (m['id'],) + ).fetchall() + + active = "actif" if m['active'] else "inactif" + lines = [ + f"── Statut de {hostname} ──────────────────────", + f" Statut : {active}", + f" Position : {m['queue_position']}", + f" Enregistrée : {m['registered_at'][:16]}", + f" Dernier log : {m['last_log_at'][:16] if m['last_log_at'] else 'jamais'}", + f" Dernière ana: {m['last_analyzed_at'][:16] if m['last_analyzed_at'] else 'jamais'}", + f" Logs en att.: {pending_logs}", + ] + if sessions: + lines.append(" Sessions récentes:") + for s in sessions: + lines.append( + f" {s['slot_date']} : {s['status']} " + f"(offset log #{s['last_log_id']})" + ) + return "\n".join(lines) + + # ── reorder ─────────────────────────────────────────────────────────────── + if action == 'reorder': + p = rest.split(None, 1) + if len(p) < 2: + return "Format: machine reorder " + hostname = p[0].strip() + try: + new_pos = int(p[1].strip()) + except ValueError: + return "La position doit être un entier." + with _db(context) as conn: + cur = conn.execute( + "UPDATE machines SET queue_position=? WHERE hostname=?", + (new_pos, hostname) + ) + if cur.rowcount == 0: + return f"Machine '{hostname}' introuvable." + return f"✅ {hostname} déplacée en position {new_pos}." + + # ── activate / deactivate ───────────────────────────────────────────────── + if action in ('activate', 'deactivate'): + hostname = rest.strip() + if not hostname: + return f"Format: machine {action} " + val = 1 if action == 'activate' else 0 + with _db(context) as conn: + cur = conn.execute( + "UPDATE machines SET active=? WHERE hostname=?", (val, hostname) + ) + if cur.rowcount == 0: + return f"Machine '{hostname}' introuvable." + verb = "activée" if val else "désactivée" + return f"✅ Machine '{hostname}' {verb}." + + return ( + "Action inconnue. Disponible : list, queue, add, remove, status, " + "reorder, activate, deactivate" + ) diff --git a/skills/mqtt_send.py b/skills/mqtt_send.py new file mode 100644 index 0000000..1ed7dd9 --- /dev/null +++ b/skills/mqtt_send.py @@ -0,0 +1,23 @@ +""" +Skill MQTT_SEND — publier un message sur n'importe quel topic MQTT. +Permet à l'agent de communiquer proactivement avec d'autres agents. + +Usage LLM : SKILL:mqtt_send ARGS: | +""" +DESCRIPTION = "Publier un message sur un topic MQTT (communication inter-agents)" +USAGE = "SKILL:mqtt_send ARGS: | " + + +def run(args: str, context) -> str: + if "|" not in args: + return "Format : SKILL:mqtt_send ARGS: | " + + topic, message = args.split("|", 1) + topic = topic.strip() + message = message.strip() + + if not topic: + return "Topic vide." + + context.mqtt.publish_raw(topic, message) + return f"Message publié sur '{topic}'." diff --git a/skills/mqtt_subscribe.py b/skills/mqtt_subscribe.py new file mode 100644 index 0000000..0b0c148 --- /dev/null +++ b/skills/mqtt_subscribe.py @@ -0,0 +1,59 @@ +""" +Skill MQTT_SUBSCRIBE — s'abonner dynamiquement à un topic MQTT. + +Les messages reçus sont transmis via XMPP (admin) et loggés. + +Usage LLM : + SKILL:mqtt_subscribe ARGS:subscribe | + SKILL:mqtt_subscribe ARGS:unsubscribe | + SKILL:mqtt_subscribe ARGS:list +""" +import logging + +DESCRIPTION = "S'abonner / se désabonner dynamiquement d'un topic MQTT et recevoir les messages" +USAGE = "SKILL:mqtt_subscribe ARGS:subscribe| ou unsubscribe| ou list" + +logger = logging.getLogger(__name__) + +# Stockage des souscriptions dynamiques : {topic: callback} +_dynamic_subs: dict = {} + + +def run(args: str, context) -> str: + parts = [p.strip() for p in args.split("|", 1)] + action = parts[0].lower() + + if action == "list": + if not _dynamic_subs: + return "Aucun topic MQTT surveillé." + return "Topics surveillés :\n" + "\n".join(f" • {t}" for t in _dynamic_subs) + + if len(parts) < 2 or not parts[1]: + return "Format : subscribe| ou unsubscribe| ou list" + + topic = parts[1] + + if action == "unsubscribe": + if topic in _dynamic_subs: + del _dynamic_subs[topic] + return f"Désabonné du topic '{topic}'." + return f"Pas abonné à '{topic}'." + + if action == "subscribe": + if topic in _dynamic_subs: + return f"Déjà abonné à '{topic}'." + + agent_id = context.agent_id + + def _on_message(msg, t): + payload = msg.payload if hasattr(msg, "payload") else str(msg) + text = f"[MQTT:{t}] {payload}" + logger.info(f"[mqtt_subscribe] {text}") + if context.xmpp: + context.xmpp.send_to_all_admins(text) + + _dynamic_subs[topic] = _on_message + context.mqtt.subscribe(topic, _on_message) + return f"Abonné au topic '{topic}'. Les messages seront transmis via XMPP." + + return f"Action inconnue '{action}'. Utilise : subscribe, unsubscribe, list." diff --git a/skills/muc_send.py b/skills/muc_send.py new file mode 100644 index 0000000..799edbe --- /dev/null +++ b/skills/muc_send.py @@ -0,0 +1,24 @@ +""" +Skill MUC_SEND — envoyer un message dans le groupe XMPP des agents. + +Le groupe est agents@muc.xmpp.ovh (configuré dans config.json). + +Usage LLM : SKILL:muc_send ARGS: +""" +DESCRIPTION = "Envoyer un message dans le groupe XMPP des agents (MUC)" +USAGE = "SKILL:muc_send ARGS:" + + +def run(args: str, context) -> str: + message = args.strip() + if not message: + return "Message vide." + + if not context.xmpp: + return "XMPP non configuré sur cet agent." + + if not context.xmpp.muc_room: + return "Aucun groupe MUC configuré." + + context.xmpp.send_to_group(message) + return f"Message envoyé dans le groupe {context.xmpp.muc_room}." diff --git a/skills/script.py b/skills/script.py new file mode 100644 index 0000000..ce58ee8 --- /dev/null +++ b/skills/script.py @@ -0,0 +1,251 @@ +""" +Skill SCRIPT — bibliothèque de scripts bash par agent. + +Chaque agent dispose de son propre dossier scripts/ (configurable via +"scripts_dir" dans config.json, sinon /opt//scripts). + +L'environnement du script expose automatiquement : + MQTT_BROKER, MQTT_PORT, MQTT_REPLY_TOPIC, AGENT_ID, SCRIPTS_DIR + +Ainsi un script peut publier son résultat directement : + mosquitto_pub -h $MQTT_BROKER -t $MQTT_REPLY_TOPIC -m "mon résultat" + +Usage LLM : + SKILL:script ARGS:list + SKILL:script ARGS:show + SKILL:script ARGS:save | + SKILL:script ARGS:edit | + SKILL:script ARGS:exec [args...] + SKILL:script ARGS:run | + SKILL:script ARGS:delete +""" +import json +import os +import stat +import subprocess +import tempfile +from datetime import datetime + +DESCRIPTION = "Bibliothèque de scripts bash : sauvegarder, lister, afficher, éditer, exécuter" +USAGE = ( + "SKILL:script ARGS:list\n" + "SKILL:script ARGS:show \n" + "SKILL:script ARGS:save | \n" + "SKILL:script ARGS:edit | \n" + "SKILL:script ARGS:exec [args]\n" + "SKILL:script ARGS:run | \n" + "SKILL:script ARGS:delete " +) + + +def _scripts_dir(context) -> str: + """Détermine le répertoire scripts de cet agent.""" + if context.config.get("scripts_dir"): + return context.config["scripts_dir"] + queue_db = context.config.get("queue_db", "") + if queue_db: + install = os.path.dirname(os.path.dirname(queue_db)) + return os.path.join(install, "scripts") + return f"/opt/{context.agent_id}/scripts" + + +def _ensure_dir(context) -> str: + d = _scripts_dir(context) + os.makedirs(d, exist_ok=True) + return d + + +_FORBIDDEN_EXTENSIONS = {".service", ".timer", ".socket", ".target", ".mount", ".conf", ".py", ".js"} + + +def _safe_name(name: str) -> str: + """Empêche les traversées de répertoire et normalise le nom.""" + n = os.path.basename(name.strip().replace("/", "_")) + # Retire toute extension connue pour obtenir le nom brut + root, ext = os.path.splitext(n) + while ext: + n = root + root, ext = os.path.splitext(n) + return n + + +def _build_env(context, scripts_dir: str) -> dict: + env = os.environ.copy() + mc = context.config.get("mqtt", {}) + env["MQTT_BROKER"] = mc.get("host", "localhost") + env["MQTT_PORT"] = str(mc.get("port", 1883)) + env["MQTT_REPLY_TOPIC"] = "agents/nexus/inbox" + env["AGENT_ID"] = context.agent_id + env["SCRIPTS_DIR"] = scripts_dir + return env + + +def _notify(context, script_name: str, result: str): + """Publie un événement d'exécution sur MQTT pour que Nexus notifie l'utilisateur.""" + try: + context.mqtt.publish_raw("agents/scripts/execution", json.dumps({ + "agent_id": context.agent_id, + "script": script_name, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "result": result[:1000], + })) + except Exception: + pass + + +def _run_script(cmd: str, env: dict, timeout: int = 120) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout, + env=env, executable="/bin/bash", + ) + out = (result.stdout + result.stderr).strip() + if len(out) > 4000: + out = out[:4000] + "\n... [tronqué]" + return out or f"(code retour : {result.returncode})" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s dépassé)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "list" + rest = parts[1] if len(parts) > 1 else "" + + # ── list ────────────────────────────────────────────────────────────── + if action == "list": + d = _ensure_dir(context) + files = sorted(f for f in os.listdir(d) if f.endswith(".sh")) + if not files: + return f"Aucun script dans {d}" + lines = [f"Scripts disponibles ({d}) :"] + for f in files: + path = os.path.join(d, f) + size = os.path.getsize(path) + lines.append(f" {f[:-3]:30s} ({size} octets)") + return "\n".join(lines) + + # ── show ────────────────────────────────────────────────────────────── + if action == "show": + name = _safe_name(rest) + if not name: + return "Précise le nom du script." + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable dans {d}" + with open(path) as f: + content = f.read() + return f"── {name}.sh ──\n{content}" + + # ── save ────────────────────────────────────────────────────────────── + if action == "save": + if "|" not in rest: + return "Format : save | " + name_raw, content = rest.split("|", 1) + name = _safe_name(name_raw) + content = content.strip().replace("\\n", "\n").replace('\\"', '"').replace("\\'", "'") + + if not name: + return "Nom de script invalide." + + # Vérifie extension interdite sur le nom brut + _, raw_ext = os.path.splitext(name_raw.strip()) + if raw_ext.lower() in _FORBIDDEN_EXTENSIONS: + return f"Extension '{raw_ext}' interdite. Utilise un nom sans extension (ex: mon_script)." + + # Vérifie que le contenu est substantiel (pas juste un shebang ou vide) + lines = [l.strip() for l in content.splitlines() if l.strip() and not l.strip().startswith("#")] + if len(lines) < 1: + return "Contenu du script vide ou incomplet. Fournis au moins une commande." + + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + existed = os.path.exists(path) + with open(path, "w") as f: + if not content.startswith("#!"): + f.write("#!/bin/bash\n") + f.write(content + "\n") + os.chmod(path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IROTH) + verb = "mis à jour" if existed else "créé" + return f"Script '{name}' {verb} : {path}" + + # ── edit ────────────────────────────────────────────────────────────── + if action == "edit": + # Format : edit | + if "|" not in rest: + return "Format : edit | \nEx: edit mon_script 3 | echo 'nouveau'" + head, new_line_content = rest.split("|", 1) + head_parts = head.strip().split(None, 1) + if len(head_parts) < 2: + return "Format : edit | " + name = _safe_name(head_parts[0]) + try: + line_no = int(head_parts[1].strip()) + except ValueError: + return "Le numéro de ligne doit être un entier." + if line_no < 1: + return "Le numéro de ligne doit être >= 1." + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable dans {d}" + with open(path) as f: + lines = f.readlines() + if line_no > len(lines): + return f"Le script '{name}' n'a que {len(lines)} lignes." + lines[line_no - 1] = new_line_content.strip() + "\n" + with open(path, "w") as f: + f.writelines(lines) + return f"Ligne {line_no} du script '{name}' modifiée.\nNouveau contenu :\n{''.join(lines)}" + + # ── exec ────────────────────────────────────────────────────────────── + if action == "exec": + parts2 = rest.split(None, 1) + name = _safe_name(parts2[0]) if parts2 else "" + sargs = parts2[1] if len(parts2) > 1 else "" + if not name: + return "Précise le nom du script." + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable. Utilise 'list' pour voir les scripts disponibles." + env = _build_env(context, d) + out = _run_script(f'"{path}" {sargs}', env=env, timeout=120) + _notify(context, name, out) + return out + + # ── run (inline) ────────────────────────────────────────────────────── + if action == "run": + if not rest: + return "Précise le contenu du script." + d = _ensure_dir(context) + content = rest.replace("\\n", "\n") + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False, dir="/tmp" + ) as f: + f.write("#!/bin/bash\nset -e\n" + content) + tmpfile = f.name + os.chmod(tmpfile, stat.S_IRWXU) + env = _build_env(context, d) + out = _run_script(tmpfile, env=env, timeout=60) + os.unlink(tmpfile) + _notify(context, "", out) + return out + + # ── delete ──────────────────────────────────────────────────────────── + if action == "delete": + name = _safe_name(rest) + if not name: + return "Précise le nom du script." + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable dans {d}" + os.unlink(path) + return f"Script '{name}' supprimé." + + return "Action inconnue. Disponible : list, show, save, exec, run, delete"