commit 084787b106ec827b399e3ccd2cc0b560e12065d4 Author: sylvain Date: Mon Mar 9 09:01:33 2026 +0000 Initial commit — agent_debian v2.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd248a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +*.db +*.log +data/ +*.egg-info/ +.vault_pass +config/config.json diff --git a/agent_debian.py b/agent_debian.py new file mode 100644 index 0000000..95f8045 --- /dev/null +++ b/agent_debian.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Agent Debian — Administration système complète. +Contrôle apt, systemd, réseau, filesystem, processus, logs, conteneurs, utilisateurs. +""" +import os +import sys +import threading +import subprocess +import logging + +sys.path.insert(0, "/opt") + +from agents_core import BaseAgent, AgentContext, Message, MessageType + +logger = logging.getLogger(__name__) + + +class AgentDebian(BaseAgent): + AGENT_TYPE = "debian" + DESCRIPTION = ( + "Administration système Debian : paquets apt, services systemd, " + "réseau, filesystem, processus, logs, conteneurs Docker/LXC, utilisateurs" + ) + DEFAULT_CONFIG_PATH = "/opt/agent_debian/config/config.json" + + def get_skills_dir(self) -> str: + return os.path.join(os.path.dirname(__file__), "skills") + + def on_start(self): + """Au démarrage, signale à Nexus que l'agent est prêt.""" + self.mqtt.send_to("nexus", f"Agent Debian ({self.agent_id}) en ligne.") + # Lance la surveillance proactive + self._start_monitoring() + + def setup_extra_subscriptions(self): + """Souscrit aussi aux commandes de contrôle.""" + self.mqtt.subscribe( + f"agents/{self.agent_id}/control", + self._on_control_message, + ) + + def _on_control_message(self, msg, topic: str): + """Messages de contrôle (pause, resume, report...).""" + from agents_core.message_bus import Message as Msg + payload = msg.payload if isinstance(msg, Msg) else str(msg) + result = self._handle_system_command(payload) + if result and isinstance(msg, Msg): + self.mqtt.reply(msg, result) + + def handle_custom_command(self, cmd: str, args: str, source_msg=None): + """Commandes spécifiques à l'agent Debian.""" + if cmd == "report": + return self._build_report() + if cmd == "update": + return self._self_update() + return f"Commande inconnue : /{cmd}" + + def on_broadcast(self, msg: Message): + """Réagit aux broadcasts (ex: demande de statut globale).""" + if "status" in str(msg.payload).lower(): + self.mqtt.reply(msg, self._build_report()) + + def _build_report(self) -> str: + """Génère un rapport quotidien du système.""" + context = AgentContext(self) + lines = [f"── Rapport {self.agent_id} ──"] + stats = self.queue.daily_stats() + lines.append( + f"Tâches : {stats['total']} total / " + f"{stats['completed']} OK / {stats['failed']} erreurs / " + f"durée moy. {stats['avg_duration_s']}s" + ) + # Infos système rapides + try: + uptime = subprocess.check_output("uptime -p", shell=True, text=True).strip() + disk = subprocess.check_output("df -h / | tail -1 | awk '{print $3\"/\"$2\" (\"$5\" utilisé)\"}'", + shell=True, text=True).strip() + mem = subprocess.check_output( + "free -h | awk '/^Mem:/{print $3\"/\"$2}'", shell=True, text=True + ).strip() + lines.append(f"Uptime : {uptime} | RAM : {mem} | Disque / : {disk}") + except Exception: + pass + return "\n".join(lines) + + def _self_update(self) -> str: + """Git pull + redémarrage du service.""" + try: + out = subprocess.check_output( + "cd /opt/agent_debian && git pull", + shell=True, text=True, stderr=subprocess.STDOUT + ) + subprocess.Popen(["systemctl", "restart", self.agent_id]) + return f"Mise à jour effectuée :\n{out}\nRedémarrage en cours..." + except subprocess.CalledProcessError as e: + return f"Erreur mise à jour : {e.output}" + + def _start_monitoring(self): + """Lance la surveillance proactive en arrière-plan.""" + t = threading.Thread(target=self._monitor_loop, daemon=True) + t.start() + + def _monitor_loop(self): + """Vérifie périodiquement les ressources critiques et alerte si nécessaire.""" + import time + while self._running: + try: + self._check_disk_usage() + self._check_memory() + except Exception as e: + logger.debug(f"[Monitor] {e}") + time.sleep(300) # Toutes les 5 minutes + + def _check_disk_usage(self): + """Alerte si un disque dépasse 85%.""" + result = subprocess.run( + "df -h | awk 'NR>1 && $5+0 > 85 {print $0}'", + shell=True, capture_output=True, text=True + ) + if result.stdout.strip(): + self.mqtt.alert( + f"Espace disque critique :\n{result.stdout.strip()}", + severity="critical" + ) + + def _check_memory(self): + """Alerte si la RAM disponible < 10%.""" + result = subprocess.run( + "free | awk '/^Mem:/{if ($3/$2*100 > 90) print \"RAM utilisée à \"int($3/$2*100)\"%\"}'", + shell=True, capture_output=True, text=True + ) + if result.stdout.strip(): + self.mqtt.alert(result.stdout.strip(), severity="warning") + + +if __name__ == "__main__": + AgentDebian().run() diff --git a/agent_debian.service b/agent_debian.service new file mode 100644 index 0000000..6b36b1a --- /dev/null +++ b/agent_debian.service @@ -0,0 +1,18 @@ +[Unit] +Description=Agent Debian — Administration système +After=network.target mosquitto.service +Wants=mosquitto.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/agent_debian +ExecStart=/opt/agent_debian/venv/bin/python /opt/agent_debian/agent_debian.py +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=agent-debian + +[Install] +WantedBy=multi-user.target diff --git a/config/system_prompt.txt b/config/system_prompt.txt new file mode 100644 index 0000000..9272d07 --- /dev/null +++ b/config/system_prompt.txt @@ -0,0 +1,38 @@ +Tu es un agent d'administration système Debian. Tu contrôles pleinement ce serveur. +Tu reçois des instructions via MQTT (depuis Nexus) ou XMPP (directement). + +## Tes skills disponibles et quand les utiliser + +- **sysinfo** : informations système (CPU, RAM, disque, uptime, réseau) +- **apt** : gestion des paquets (install, remove, update, upgrade, search) +- **systemd** : gestion des services (start, stop, restart, status, logs, enable) +- **filesystem** : opérations fichiers (ls, cat, write, delete, find, grep, df, du) +- **network** : réseau (ip, ping, traceroute, DNS, ports, firewall) +- **process** : processus (list, kill, top, find) +- **journal** : logs système (tail, service, errors, since, grep) +- **user** : utilisateurs (add, delete, passwd, groups, sudo) +- **container** : Docker et LXC (ps, start, stop, logs, exec, stats) +- **cron** : tâches planifiées (list, add, remove) +- **script** : créer et exécuter des scripts bash +- **shell** : commande bash directe (fallback si aucun skill ne convient) +- **mqtt_send** : envoyer un message à un autre agent ou topic MQTT + +## Règles importantes + +1. Utilise toujours le skill le plus spécifique disponible +2. Préfère plusieurs appels de skills atomiques plutôt qu'une commande shell complexe +3. Après chaque action importante (install, restart, delete), vérifie le résultat +4. Si une tâche génère un script, utilise SKILL:script pour le créer et l'exécuter, + et le résultat sera automatiquement renvoyé via MQTT +5. En cas d'erreur, diagnostique avant de réessayer +6. Réponds toujours en français +7. Sois concis dans tes réponses — l'essentiel, pas tout le stdout brut + +## Communication MQTT + +Tu peux envoyer des messages à d'autres agents : + SKILL:mqtt_send ARGS:agents/nexus/inbox | {"type":"result","payload":"mon résultat"} + +Pour les scripts qui doivent retourner un résultat : + Les variables $MQTT_BROKER et $MQTT_REPLY_TOPIC sont disponibles dans l'environnement. + mosquitto_pub -h $MQTT_BROKER -t $MQTT_REPLY_TOPIC -m "résultat" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..59df9e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +agents_core @ file:///opt/agents_core +requests>=2.28 diff --git a/skills/apt.py b/skills/apt.py new file mode 100644 index 0000000..5b9e439 --- /dev/null +++ b/skills/apt.py @@ -0,0 +1,107 @@ +""" +Skill APT — gestion des paquets Debian. + +Usage LLM : + SKILL:apt ARGS:update + SKILL:apt ARGS:upgrade + SKILL:apt ARGS:install [paquet2...] + SKILL:apt ARGS:remove + SKILL:apt ARGS:purge + SKILL:apt ARGS:search + SKILL:apt ARGS:show + SKILL:apt ARGS:list-installed [filtre] + SKILL:apt ARGS:list-upgradable + SKILL:apt ARGS:autoremove + SKILL:apt ARGS:check-updates +""" +import subprocess + +DESCRIPTION = "Gestion des paquets Debian via apt/dpkg" +USAGE = "SKILL:apt ARGS:install | remove | update | upgrade | search | show | list-installed | list-upgradable | autoremove" + +ENV = {"DEBIAN_FRONTEND": "noninteractive", "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"} + + +def _run(cmd: str, timeout: int = 120) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout, env=ENV + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s) — commande trop longue." + 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 "" + extra = parts[1] if len(parts) > 1 else "" + + if action == "update": + return _run("apt-get update -q") + + if action == "upgrade": + return _run("apt-get upgrade -y -q", timeout=300) + + if action == "dist-upgrade": + return _run("apt-get dist-upgrade -y -q", timeout=300) + + if action == "install": + if not extra: + return "Précise le(s) paquet(s) à installer." + return _run(f"apt-get install -y -q {extra}", timeout=180) + + if action == "remove": + if not extra: + return "Précise le paquet à supprimer." + return _run(f"apt-get remove -y {extra}") + + if action == "purge": + if not extra: + return "Précise le paquet à purger." + return _run(f"apt-get purge -y {extra}") + + if action == "autoremove": + return _run("apt-get autoremove -y") + + if action == "search": + if not extra: + return "Précise le terme de recherche." + return _run(f"apt-cache search {extra} | head -30") + + if action == "show": + if not extra: + return "Précise le paquet." + return _run(f"apt-cache show {extra}") + + if action in ("list-installed", "list"): + cmd = f"dpkg -l | grep '^ii' | awk '{{print $2\" \"$3}}'" + if extra: + cmd += f" | grep {extra}" + return _run(cmd) + + if action == "list-upgradable": + return _run("apt list --upgradable 2>/dev/null") + + if action == "check-updates": + out = _run("apt-get -s upgrade 2>/dev/null | grep '^[0-9]'") + return out or "Système à jour." + + if action == "hold": + if not extra: + return "Précise le paquet." + return _run(f"apt-mark hold {extra}") + + if action == "unhold": + if not extra: + return "Précise le paquet." + return _run(f"apt-mark unhold {extra}") + + return ( + "Action inconnue. Disponible : update, upgrade, install, remove, purge, " + "search, show, list-installed, list-upgradable, autoremove, check-updates, hold, unhold" + ) diff --git a/skills/container.py b/skills/container.py new file mode 100644 index 0000000..5887f95 --- /dev/null +++ b/skills/container.py @@ -0,0 +1,160 @@ +""" +Skill CONTAINER — gestion des conteneurs Docker et LXC/LXD. + +Usage LLM : + SKILL:container ARGS:docker ps [all] + SKILL:container ARGS:docker start + SKILL:container ARGS:docker stop + SKILL:container ARGS:docker restart + SKILL:container ARGS:docker logs [N] + SKILL:container ARGS:docker stats + SKILL:container ARGS:docker images + SKILL:container ARGS:docker pull + SKILL:container ARGS:docker rm + SKILL:container ARGS:docker rmi + SKILL:container ARGS:docker exec + SKILL:container ARGS:docker inspect + SKILL:container ARGS:lxc list + SKILL:container ARGS:lxc start + SKILL:container ARGS:lxc stop + SKILL:container ARGS:lxc exec + SKILL:container ARGS:lxc info +""" +import subprocess + +DESCRIPTION = "Gestion conteneurs Docker et LXC/LXD : start, stop, logs, exec, stats, images" +USAGE = "SKILL:container ARGS:docker ps|start|stop|restart|logs|exec|stats|images|pull | lxc list|start|stop|exec|info" + + +def _run(cmd: str, timeout: int = 30) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def _docker(action: str, args: list) -> str: + a = args[0] if args else "" + + if action == "ps": + flag = "-a" if (a == "all" or a == "-a") else "" + return _run(f"docker ps {flag} --format 'table {{{{.Names}}}}\\t{{{{.Image}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'") + + if action == "start": + return _run(f"docker start {a}") if a else "Précise le nom du conteneur." + + if action == "stop": + return _run(f"docker stop {a}") if a else "Précise le nom du conteneur." + + if action == "restart": + return _run(f"docker restart {a}") if a else "Précise le nom du conteneur." + + if action == "logs": + if not a: + return "Précise le nom du conteneur." + n = args[1] if len(args) > 1 else "50" + return _run(f"docker logs --tail {n} {a}") + + if action == "stats": + return _run("docker stats --no-stream --format 'table {{{{.Name}}}}\\t{{{{.CPUPerc}}}}\\t{{{{.MemUsage}}}}'") + + if action == "images": + return _run("docker images --format 'table {{{{.Repository}}}}\\t{{{{.Tag}}}}\\t{{{{.Size}}}}\\t{{{{.CreatedSince}}}}'") + + if action == "pull": + return _run(f"docker pull {a}", timeout=120) if a else "Précise l'image." + + if action == "rm": + return _run(f"docker rm {a}") if a else "Précise le nom du conteneur." + + if action == "rm-stopped": + return _run("docker container prune -f") + + if action == "rmi": + return _run(f"docker rmi {a}") if a else "Précise l'image." + + if action == "exec": + if len(args) < 2: + return "Format : docker exec " + container = args[0] + cmd = " ".join(args[1:]) + return _run(f"docker exec {container} {cmd}") + + if action == "inspect": + return _run(f"docker inspect {a}") if a else "Précise le nom du conteneur." + + if action == "network": + return _run("docker network ls") + + if action == "volumes": + return _run("docker volume ls") + + if action == "compose-up": + return _run("docker compose up -d", timeout=120) if not a else _run(f"docker compose -f {a} up -d", timeout=120) + + if action == "compose-down": + return _run("docker compose down") + + return f"Action docker inconnue : {action}" + + +def _lxc(action: str, args: list) -> str: + a = args[0] if args else "" + + if action == "list": + return _run("lxc list --format table 2>/dev/null || lxc-ls -f 2>/dev/null") + + if action == "start": + return _run(f"lxc start {a}") if a else "Précise le nom." + + if action == "stop": + return _run(f"lxc stop {a}") if a else "Précise le nom." + + if action == "restart": + return _run(f"lxc restart {a}") if a else "Précise le nom." + + if action == "exec": + if len(args) < 2: + return "Format : lxc exec " + cmd = " ".join(args[1:]) + return _run(f"lxc exec {a} -- {cmd}") + + if action == "info": + return _run(f"lxc info {a}") if a else _run("lxc info") + + if action == "snapshot": + return _run(f"lxc snapshot {a}") if a else "Précise le nom." + + if action == "delete": + return _run(f"lxc delete {a} --force") if a else "Précise le nom." + + return f"Action lxc inconnue : {action}" + + +def run(args: str, context) -> str: + parts = args.strip().split() + if not parts: + return "Précise : docker ou lxc suivi d'une action." + + runtime = parts[0].lower() + action = parts[1].lower() if len(parts) > 1 else "ps" + rest = parts[2:] if len(parts) > 2 else [] + + if runtime == "docker": + return _docker(action, rest) + + if runtime in ("lxc", "lxd"): + return _lxc(action, rest) + + # Tentative de détection auto + if runtime in ("ps", "stats", "images", "logs", "start", "stop", "restart", "exec"): + return _docker(runtime, parts[1:]) + + return "Précise le runtime : docker ou lxc. Ex: SKILL:container ARGS:docker ps" diff --git a/skills/cron.py b/skills/cron.py new file mode 100644 index 0000000..6af70c6 --- /dev/null +++ b/skills/cron.py @@ -0,0 +1,93 @@ +""" +Skill CRON — gestion des tâches cron. + +Usage LLM : + SKILL:cron ARGS:list [utilisateur] + SKILL:cron ARGS:add + SKILL:cron ARGS:remove + SKILL:cron ARGS:system-list +""" +import subprocess +import tempfile +import os + +DESCRIPTION = "Gestion des tâches cron (crontab)" +USAGE = "SKILL:cron ARGS:list | add <* * * * *> | remove | system-list" + + +def _run(cmd: str, timeout: int = 10) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + return (result.stdout + result.stderr).strip() or "(aucune sortie)" + 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 "" + + if action == "list": + user = rest.strip() or "" + flag = f"-u {user}" if user else "" + result = _run(f"crontab {flag} -l 2>/dev/null") + return result if result else "Crontab vide." + + if action == "add": + # Format attendu : "* * * * * commande" + # On split les 5 premiers champs (expression cron) + le reste (commande) + words = rest.split() + if len(words) < 6: + return "Format : add \nEx: add 0 3 * * * /usr/bin/apt-get update" + cron_expr = " ".join(words[:5]) + command = " ".join(words[5:]) + entry = f"{cron_expr} {command}" + + # Récupère le crontab actuel, ajoute la ligne + current = _run("crontab -l 2>/dev/null") + if entry in current: + return f"Cette entrée existe déjà : {entry}" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".cron", delete=False) as f: + if current and "no crontab" not in current.lower(): + f.write(current + "\n") + f.write(entry + "\n") + tmpfile = f.name + + out = _run(f"crontab {tmpfile}") + os.unlink(tmpfile) + return f"Entrée ajoutée : {entry}\n{out}" + + if action == "remove": + if not rest: + return "Précise le pattern à supprimer." + current = _run("crontab -l 2>/dev/null") + lines = [l for l in current.splitlines() if rest not in l] + new_cron = "\n".join(lines) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".cron", delete=False) as f: + f.write(new_cron + "\n") + tmpfile = f.name + + out = _run(f"crontab {tmpfile}") + os.unlink(tmpfile) + removed = len(current.splitlines()) - len(lines) + return f"{removed} entrée(s) supprimée(s) contenant '{rest}'.\n{out}" + + if action == "clear": + return _run("crontab -r 2>/dev/null && echo 'Crontab effacé'") + + if action == "system-list": + # Crons système dans /etc/cron.* + out = [] + for d in ["/etc/cron.d", "/etc/cron.daily", "/etc/cron.weekly", "/etc/cron.monthly"]: + files = _run(f"ls {d} 2>/dev/null") + if files: + out.append(f"{d}:\n{files}") + return "\n\n".join(out) or "Aucun cron système trouvé." + + return "Action inconnue. Disponible : list, add, remove, clear, system-list" diff --git a/skills/filesystem.py b/skills/filesystem.py new file mode 100644 index 0000000..42460ed --- /dev/null +++ b/skills/filesystem.py @@ -0,0 +1,177 @@ +""" +Skill FILESYSTEM — opérations sur le système de fichiers. + +Usage LLM : + SKILL:filesystem ARGS:ls + SKILL:filesystem ARGS:cat + SKILL:filesystem ARGS:write | + SKILL:filesystem ARGS:append | + SKILL:filesystem ARGS:delete + SKILL:filesystem ARGS:mkdir + SKILL:filesystem ARGS:move | + SKILL:filesystem ARGS:copy | + SKILL:filesystem ARGS:chmod + SKILL:filesystem ARGS:chown + SKILL:filesystem ARGS:find + SKILL:filesystem ARGS:grep + SKILL:filesystem ARGS:df + SKILL:filesystem ARGS:du + SKILL:filesystem ARGS:stat + SKILL:filesystem ARGS:tail [N] + SKILL:filesystem ARGS:head [N] +""" +import os +import subprocess + +DESCRIPTION = "Opérations filesystem : ls, cat, write, delete, move, copy, chmod, find, grep, df, du" +USAGE = "SKILL:filesystem ARGS:ls | cat | write | | delete | find | grep | df | du | tail [N]" + +# Chemins interdits pour éviter les accidents +FORBIDDEN = ["/proc", "/sys", "/dev", "/run/systemd"] + + +def _safe_path(path: str) -> bool: + path = os.path.realpath(path) + return not any(path.startswith(f) for f in FORBIDDEN) + + +def _run(cmd: str, timeout: int = 15) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + # Sépare l'action du reste + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "" + rest = parts[1] if len(parts) > 1 else "" + + if action == "ls": + path = rest or "." + return _run(f"ls -lah {path}") + + if action == "cat": + if not rest: + return "Précise le fichier." + if not _safe_path(rest): + return f"Accès refusé : {rest}" + return _run(f"cat {rest}") + + if action == "tail": + parts2 = rest.split() + filepath = parts2[0] if parts2 else "" + n = parts2[1] if len(parts2) > 1 else "50" + return _run(f"tail -n {n} {filepath}") + + if action == "head": + parts2 = rest.split() + filepath = parts2[0] if parts2 else "" + n = parts2[1] if len(parts2) > 1 else "30" + return _run(f"head -n {n} {filepath}") + + if action == "write": + if "|" not in rest: + return "Format : write | " + filepath, content = rest.split("|", 1) + filepath = filepath.strip() + content = content.strip() + if not _safe_path(filepath): + return f"Accès refusé : {filepath}" + try: + os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True) + with open(filepath, "w") as f: + f.write(content) + return f"Fichier écrit : {filepath} ({len(content)} caractères)" + except Exception as e: + return str(e) + + if action == "append": + if "|" not in rest: + return "Format : append | " + filepath, content = rest.split("|", 1) + filepath = filepath.strip() + if not _safe_path(filepath): + return f"Accès refusé : {filepath}" + try: + with open(filepath, "a") as f: + f.write(content.strip() + "\n") + return f"Contenu ajouté à {filepath}" + except Exception as e: + return str(e) + + if action == "delete": + if not rest: + return "Précise le chemin." + if not _safe_path(rest): + return f"Accès refusé : {rest}" + # Confirmation implicite : on ne supprime pas récursivement sans -r explicite + if os.path.isdir(rest): + return _run(f"rm -rf {rest}") + return _run(f"rm -f {rest}") + + if action == "mkdir": + if not rest: + return "Précise le chemin." + return _run(f"mkdir -p {rest}") + + if action == "move": + if "|" not in rest: + return "Format : move | " + src, dst = rest.split("|", 1) + return _run(f"mv {src.strip()} {dst.strip()}") + + if action == "copy": + if "|" not in rest: + return "Format : copy | " + src, dst = rest.split("|", 1) + return _run(f"cp -r {src.strip()} {dst.strip()}") + + if action == "chmod": + parts2 = rest.split(None, 1) + if len(parts2) < 2: + return "Format : chmod " + return _run(f"chmod {parts2[0]} {parts2[1]}") + + if action == "chown": + parts2 = rest.split(None, 1) + if len(parts2) < 2: + return "Format : chown " + return _run(f"chown -R {parts2[0]} {parts2[1]}") + + if action == "find": + parts2 = rest.split(None, 1) + path = parts2[0] if parts2 else "." + pattern = parts2[1] if len(parts2) > 1 else "*" + return _run(f"find {path} -name '{pattern}' 2>/dev/null | head -50") + + if action == "grep": + parts2 = rest.split(None, 1) + if len(parts2) < 2: + return "Format : grep " + return _run(f"grep -n '{parts2[0]}' {parts2[1]} 2>/dev/null | head -50") + + if action == "df": + return _run("df -h") + + if action == "du": + path = rest or "." + return _run(f"du -sh {path}/* 2>/dev/null | sort -rh | head -20") + + if action == "stat": + if not rest: + return "Précise le chemin." + return _run(f"stat {rest}") + + return ( + "Action inconnue. Disponible : ls, cat, tail, head, write, append, delete, " + "mkdir, move, copy, chmod, chown, find, grep, df, du, stat" + ) diff --git a/skills/journal.py b/skills/journal.py new file mode 100644 index 0000000..bb48b21 --- /dev/null +++ b/skills/journal.py @@ -0,0 +1,108 @@ +""" +Skill JOURNAL — consultation des logs système via journalctl. + +Usage LLM : + SKILL:journal ARGS:tail [N] + SKILL:journal ARGS:service [N] + SKILL:journal ARGS:boot [N] + SKILL:journal ARGS:errors [N] + SKILL:journal ARGS:since (ex: "1h", "30min", "yesterday") + SKILL:journal ARGS:grep [service] + SKILL:journal ARGS:kernel [N] + SKILL:journal ARGS:disk-usage +""" +import subprocess + +DESCRIPTION = "Consultation des logs système via journalctl et /var/log" +USAGE = "SKILL:journal ARGS:tail [N] | service [N] | errors [N] | since | grep | boot | kernel" + + +def _run(cmd: str, timeout: int = 15) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + 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 "tail" + rest = parts[1] if len(parts) > 1 else "" + + if action == "tail": + n = rest.strip() or "50" + return _run(f"journalctl -n {n} --no-pager -o short-iso") + + if action == "service": + parts2 = rest.split() + service = parts2[0] if parts2 else "" + n = parts2[1] if len(parts2) > 1 else "50" + if not service: + return "Précise le service." + return _run(f"journalctl -u {service} -n {n} --no-pager -o short-iso") + + if action == "boot": + n = rest.strip() or "50" + return _run(f"journalctl -b -n {n} --no-pager -o short-iso") + + if action == "errors": + n = rest.strip() or "30" + return _run(f"journalctl -p err..emerg -n {n} --no-pager -o short-iso") + + if action == "warnings": + n = rest.strip() or "30" + return _run(f"journalctl -p warning..emerg -n {n} --no-pager -o short-iso") + + if action == "since": + if not rest: + return "Précise la durée (ex: '1h', '30min', 'yesterday', '2024-01-01')" + # Convertit "1h" → "1 hour ago", "30min" → "30 minutes ago" + since = rest.strip() + if since.endswith("h") and since[:-1].isdigit(): + since = f"{since[:-1]} hours ago" + elif since.endswith("min") and since[:-3].isdigit(): + since = f"{since[:-3]} minutes ago" + return _run(f"journalctl --since='{since}' --no-pager -o short-iso | tail -100") + + if action == "grep": + parts2 = rest.split(None, 1) + pattern = parts2[0] if parts2 else "" + service = parts2[1].strip() if len(parts2) > 1 else "" + if not pattern: + return "Précise le pattern." + cmd = f"journalctl --no-pager -o short-iso" + if service: + cmd += f" -u {service}" + cmd += f" | grep -i '{pattern}' | tail -50" + return _run(cmd) + + if action == "kernel": + n = rest.strip() or "30" + return _run(f"journalctl -k -n {n} --no-pager -o short-iso") + + if action == "disk-usage": + return _run("journalctl --disk-usage") + + if action == "vacuum": + # Nettoyage des vieux logs + size = rest.strip() or "500M" + return _run(f"journalctl --vacuum-size={size}") + + if action == "file": + # Lire un fichier de log classique + filepath = rest.strip() + if not filepath: + return "Précise le fichier." + return _run(f"tail -100 {filepath}") + + return ( + "Action inconnue. Disponible : tail, service, boot, errors, warnings, " + "since, grep, kernel, disk-usage, vacuum, file" + ) 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/network.py b/skills/network.py new file mode 100644 index 0000000..20ef2e4 --- /dev/null +++ b/skills/network.py @@ -0,0 +1,179 @@ +""" +Skill NETWORK — administration réseau. + +Usage LLM : + SKILL:network ARGS:ip [show|route|link] + SKILL:network ARGS:ping [count] + SKILL:network ARGS:traceroute + SKILL:network ARGS:dns + SKILL:network ARGS:ports [tcp|udp] + SKILL:network ARGS:connections + SKILL:network ARGS:firewall status + SKILL:network ARGS:firewall allow + SKILL:network ARGS:firewall deny + SKILL:network ARGS:firewall delete + SKILL:network ARGS:firewall list + SKILL:network ARGS:bandwidth [interface] + SKILL:network ARGS:hosts [add|remove] + SKILL:network ARGS:wget + SKILL:network ARGS:curl +""" +import subprocess + +DESCRIPTION = "Administration réseau : ip, ping, traceroute, DNS, ports, firewall ufw/iptables" +USAGE = "SKILL:network ARGS:ip | ping | traceroute | dns | ports | connections | firewall status|allow|deny|list | wget " + + +def _run(cmd: str, timeout: int = 20) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + 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 "" + rest = parts[1] if len(parts) > 1 else "" + + if action == "ip": + sub = rest.strip().lower() or "show" + if sub == "show" or sub == "addr": + return _run("ip -br addr show") + if sub == "route": + return _run("ip route show") + if sub == "link": + return _run("ip -br link show") + if sub == "full": + return _run("ip addr show") + return _run(f"ip {rest}") + + if action == "ping": + parts2 = rest.split() + host = parts2[0] if parts2 else "" + count = parts2[1] if len(parts2) > 1 else "4" + if not host: + return "Précise l'hôte." + return _run(f"ping -c {count} {host}", timeout=int(count) * 3 + 5) + + if action == "traceroute": + host = rest.strip() + if not host: + return "Précise l'hôte." + return _run(f"traceroute -m 15 {host}", timeout=60) + + if action == "dns": + host = rest.strip() + if not host: + return "Précise le nom à résoudre." + return _run(f"dig +short {host} && dig +short -x $(dig +short {host} | head -1) 2>/dev/null || nslookup {host}") + + if action == "ports": + proto = rest.strip().lower() + if proto == "udp": + return _run("ss -ulnp") + return _run("ss -tlnp") + + if action == "connections": + return _run("ss -tunp | head -50") + + if action == "netstat": + return _run("ss -s") + + if action == "firewall": + parts2 = rest.split(None, 1) + sub = parts2[0].lower() if parts2 else "status" + arg = parts2[1] if len(parts2) > 1 else "" + + # Détecte ufw ou iptables + ufw_available = _run("which ufw") != "" + + if sub == "status": + if ufw_available: + return _run("ufw status verbose") + return _run("iptables -L -n -v --line-numbers") + + if sub == "allow": + if not arg: + return "Précise le port/service." + if ufw_available: + return _run(f"ufw allow {arg}") + return _run(f"iptables -A INPUT -p tcp --dport {arg} -j ACCEPT") + + if sub == "deny": + if not arg: + return "Précise le port/service." + if ufw_available: + return _run(f"ufw deny {arg}") + return _run(f"iptables -A INPUT -p tcp --dport {arg} -j DROP") + + if sub == "delete": + if not arg: + return "Précise la règle ou le numéro." + if ufw_available: + return _run(f"ufw delete {arg}") + return _run(f"iptables -D INPUT {arg}") + + if sub == "list": + if ufw_available: + return _run("ufw status numbered") + return _run("iptables -L INPUT -n -v --line-numbers") + + if sub == "enable": + return _run("ufw --force enable") if ufw_available else "ufw non disponible." + + if sub == "disable": + return _run("ufw disable") if ufw_available else "ufw non disponible." + + return f"Sous-commande firewall inconnue : {sub}" + + if action == "bandwidth": + iface = rest.strip() or "eth0" + # ifstat ou /proc/net/dev si pas dispo + result = _run(f"cat /proc/net/dev | grep {iface}") + if not result: + return f"Interface {iface} introuvable." + return result + + if action == "hosts": + parts2 = rest.split() + sub = parts2[0].lower() if parts2 else "list" + if sub == "list" or not parts2: + return _run("cat /etc/hosts") + if sub == "add" and len(parts2) >= 3: + ip, name = parts2[1], parts2[2] + return _run(f"echo '{ip} {name}' >> /etc/hosts && echo 'Ajouté : {ip} {name}'") + if sub == "remove" and len(parts2) >= 2: + name = parts2[1] + return _run(f"sed -i '/{name}/d' /etc/hosts && echo 'Supprimé : {name}'") + return "Usage : hosts list | add | remove " + + if action == "wget": + url = rest.strip() + if not url: + return "Précise l'URL." + return _run(f"wget -q --spider {url} && echo 'URL accessible' || echo 'URL inaccessible'") + + if action == "curl": + url = rest.strip() + if not url: + return "Précise l'URL." + return _run(f"curl -sI {url} | head -10") + + if action == "arp": + return _run("arp -n") + + if action == "hostname": + return _run("hostname -f") + + return ( + "Action inconnue. Disponible : ip, ping, traceroute, dns, ports, connections, " + "firewall, hosts, wget, curl, arp, hostname, netstat" + ) diff --git a/skills/process.py b/skills/process.py new file mode 100644 index 0000000..ecfba66 --- /dev/null +++ b/skills/process.py @@ -0,0 +1,99 @@ +""" +Skill PROCESS — gestion des processus. + +Usage LLM : + SKILL:process ARGS:list [filtre] + SKILL:process ARGS:top [N] + SKILL:process ARGS:kill [signal] + SKILL:process ARGS:killall + SKILL:process ARGS:nice + SKILL:process ARGS:info + SKILL:process ARGS:tree + SKILL:process ARGS:find +""" +import subprocess + +DESCRIPTION = "Gestion des processus : list, top, kill, killall, nice, find" +USAGE = "SKILL:process ARGS:list [filtre] | top [N] | kill [signal] | killall | info | tree | find " + + +def _run(cmd: str, timeout: int = 10) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + 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 "" + + if action == "list": + cmd = "ps aux --sort=-%cpu | head -30" + if rest: + cmd = f"ps aux | grep -i {rest} | grep -v grep" + return _run(cmd) + + if action == "top": + n = rest.strip() or "15" + return _run( + f"ps aux --sort=-%cpu | head -{n} | " + "awk 'NR==1{print} NR>1{printf \"%-10s %-6s %-5s %-5s %s\\n\",$1,$2,$3,$4,$11}'" + ) + + if action == "kill": + parts2 = rest.split() + if not parts2: + return "Précise le PID." + pid = parts2[0] + signal = parts2[1] if len(parts2) > 1 else "15" + return _run(f"kill -{signal} {pid}") + + if action == "kill9": + if not rest: + return "Précise le PID." + return _run(f"kill -9 {rest.strip()}") + + if action == "killall": + if not rest: + return "Précise le nom du processus." + return _run(f"killall {rest.strip()}") + + if action == "nice": + parts2 = rest.split() + if len(parts2) < 2: + return "Format : nice " + pid, prio = parts2[0], parts2[1] + return _run(f"renice {prio} -p {pid}") + + if action == "info": + if not rest: + return "Précise le PID." + pid = rest.strip() + out = _run(f"ps -p {pid} -o pid,ppid,user,%cpu,%mem,vsz,rss,stat,start,time,comm --no-headers") + cmdline = _run(f"cat /proc/{pid}/cmdline 2>/dev/null | tr '\\0' ' '") + return f"Process {pid}:\n{out}\nCmdline: {cmdline}" + + if action == "tree": + return _run("pstree -p | head -50") + + if action == "find": + if not rest: + return "Précise le nom du processus." + return _run(f"pgrep -a -i {rest.strip()}") + + if action == "lsof": + # Fichiers ouverts par un processus + if rest: + return _run(f"lsof -p {rest.strip()} | head -30") + return _run("lsof | wc -l && echo fichiers ouverts au total") + + return "Action inconnue. Disponible : list, top, kill, kill9, killall, nice, info, tree, find, lsof" diff --git a/skills/script.py b/skills/script.py new file mode 100644 index 0000000..9baf4f0 --- /dev/null +++ b/skills/script.py @@ -0,0 +1,132 @@ +""" +Skill SCRIPT — créer et exécuter un script bash, avec renvoi du résultat via MQTT. + +L'environnement du script expose automatiquement : + MQTT_BROKER, MQTT_REPLY_TOPIC, AGENT_ID + +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:run | + SKILL:script ARGS:save | + SKILL:script ARGS:exec [args] + SKILL:script ARGS:list + SKILL:script ARGS:show + SKILL:script ARGS:delete +""" +import os +import subprocess +import stat + +DESCRIPTION = "Créer/exécuter des scripts bash avec renvoi du résultat via MQTT" +USAGE = "SKILL:script ARGS:run| | save | | exec | list | show " + +SCRIPTS_DIR = "/opt/agent_debian/scripts" + + +def _ensure_dir(): + os.makedirs(SCRIPTS_DIR, exist_ok=True) + + +def _run(cmd: str, env: dict = None, timeout: int = 60) -> 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)" + except Exception as e: + return str(e) + + +def _build_env(context) -> dict: + """Environnement injecté dans chaque script.""" + 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 + return env + + +def run(args: str, context) -> str: + _ensure_dir() + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "run" + rest = parts[1] if len(parts) > 1 else "" + + if action == "run": + # Exécution directe d'un script inline + if not rest: + return "Précise le contenu du script." + content = rest.replace("\\n", "\n") + # Fichier temporaire + import tempfile + 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) + out = _run(tmpfile, env=env, timeout=60) + os.unlink(tmpfile) + return out + + if action == "save": + if "|" not in rest: + return "Format : save | " + name, content = rest.split("|", 1) + name = name.strip().replace("/", "_") # Sécurité + content = content.strip().replace("\\n", "\n") + path = os.path.join(SCRIPTS_DIR, name + ".sh") + with open(path, "w") as f: + f.write("#!/bin/bash\n" + content) + os.chmod(path, stat.S_IRWXU) + return f"Script sauvegardé : {path}" + + if action == "exec": + parts2 = rest.split(None, 1) + name = parts2[0] if parts2 else "" + sargs = parts2[1] if len(parts2) > 1 else "" + if not name: + return "Précise le nom du script." + path = os.path.join(SCRIPTS_DIR, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable dans {SCRIPTS_DIR}" + env = _build_env(context) + return _run(f"{path} {sargs}", env=env, timeout=120) + + if action == "list": + files = [f for f in os.listdir(SCRIPTS_DIR) if f.endswith(".sh")] + return "\n".join(files) if files else "Aucun script sauvegardé." + + if action == "show": + name = rest.strip() + if not name: + return "Précise le nom du script." + path = os.path.join(SCRIPTS_DIR, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable." + with open(path) as f: + return f.read() + + if action == "delete": + name = rest.strip() + if not name: + return "Précise le nom du script." + path = os.path.join(SCRIPTS_DIR, name + ".sh") + if os.path.exists(path): + os.unlink(path) + return f"Script '{name}' supprimé." + return f"Script '{name}' introuvable." + + return "Action inconnue. Disponible : run, save, exec, list, show, delete" diff --git a/skills/shell.py b/skills/shell.py new file mode 100644 index 0000000..15f0846 --- /dev/null +++ b/skills/shell.py @@ -0,0 +1,62 @@ +""" +Skill SHELL — exécution de commandes shell arbitraires. +Skill de dernier recours quand aucun skill spécialisé ne convient. + +Usage LLM : SKILL:shell ARGS: +""" +import subprocess + +DESCRIPTION = "Exécution de commandes shell arbitraires (fallback général)" +USAGE = "SKILL:shell ARGS:" + +# Commandes bloquées pour éviter les accidents critiques +BLOCKED = [ + "rm -rf /", + "dd if=/dev/zero of=/dev/", + "mkfs", + "> /dev/sda", + ":(){ :|:& };:", # fork bomb +] + + +def run(args: str, context) -> str: + cmd = args.strip() + if not cmd: + return "Commande vide." + + # Vérification des commandes dangereuses + for blocked in BLOCKED: + if blocked in cmd: + return f"Commande bloquée pour sécurité : {blocked}" + + try: + result = subprocess.run( + cmd, + shell=True, + text=True, + capture_output=True, + timeout=60, + executable="/bin/bash", + ) + stdout = result.stdout.strip() + stderr = result.stderr.strip() + returncode = result.returncode + + output = "" + if stdout: + output += stdout + if stderr: + output += ("\n" if output else "") + f"[stderr] {stderr}" + if not output: + output = f"(Commande exécutée, code retour : {returncode})" + + # Tronqué à 4000 caractères + if len(output) > 4000: + output = output[:4000] + f"\n... [tronqué, {len(output)} caractères total]" + + return output + + except subprocess.TimeoutExpired: + return "Timeout (60s) — commande trop longue." + except Exception as e: + return f"Erreur : {e}" diff --git a/skills/sysinfo.py b/skills/sysinfo.py new file mode 100644 index 0000000..837881c --- /dev/null +++ b/skills/sysinfo.py @@ -0,0 +1,63 @@ +""" +Skill SYSINFO — informations système complètes. + +Usage LLM : SKILL:sysinfo ARGS:all | cpu | mem | disk | uptime | load | net +""" +import subprocess + +DESCRIPTION = "Informations système : CPU, RAM, disque, uptime, charge, réseau" +USAGE = "SKILL:sysinfo ARGS:all | cpu | mem | disk | uptime | load | net" + + +def _run(cmd: str) -> str: + try: + return subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.STDOUT).strip() + except subprocess.CalledProcessError as e: + return e.output.strip() or str(e) + + +def run(args: str, context) -> str: + what = args.strip().lower() or "all" + + sections = [] + + if what in ("all", "uptime"): + uptime = _run("uptime -p") + since = _run("uptime -s") + sections.append(f"Uptime : {uptime} (depuis {since})") + + if what in ("all", "load"): + load = _run("cat /proc/loadavg") + cpus = _run("nproc") + sections.append(f"Charge système : {load} ({cpus} CPU)") + + if what in ("all", "cpu"): + cpu_info = _run("lscpu | grep -E 'Model name|CPU\\(s\\)|MHz'") + cpu_usage = _run( + "top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4\"%\"}'" + ) + sections.append(f"CPU :\n{cpu_info}\nUtilisation : {cpu_usage}") + + if what in ("all", "mem"): + mem = _run("free -h") + sections.append(f"Mémoire :\n{mem}") + + if what in ("all", "disk"): + disk = _run("df -h --output=source,size,used,avail,pcent,target | column -t") + sections.append(f"Disques :\n{disk}") + + if what in ("all", "net"): + ifaces = _run("ip -br addr show") + sections.append(f"Interfaces réseau :\n{ifaces}") + + if what == "os": + osinfo = _run("cat /etc/os-release | grep -E '^(NAME|VERSION)='") + kernel = _run("uname -r") + sections.append(f"OS :\n{osinfo}\nKernel : {kernel}") + + if not sections: + return ( + "Option inconnue. Utilise : all, cpu, mem, disk, uptime, load, net, os" + ) + + return "\n\n".join(sections) diff --git a/skills/systemd.py b/skills/systemd.py new file mode 100644 index 0000000..997fb5f --- /dev/null +++ b/skills/systemd.py @@ -0,0 +1,133 @@ +""" +Skill SYSTEMD — gestion des services systemd. + +Usage LLM : + SKILL:systemd ARGS:status + SKILL:systemd ARGS:start + SKILL:systemd ARGS:stop + SKILL:systemd ARGS:restart + SKILL:systemd ARGS:reload + SKILL:systemd ARGS:enable + SKILL:systemd ARGS:disable + SKILL:systemd ARGS:logs [lignes] + SKILL:systemd ARGS:list [pattern] + SKILL:systemd ARGS:failed + SKILL:systemd ARGS:daemon-reload +""" +import subprocess + +DESCRIPTION = "Gestion des services systemd (start/stop/restart/status/logs/enable/disable)" +USAGE = "SKILL:systemd ARGS:status | start | stop | restart | enable | disable | logs [N] | list | failed" + + +def _run(cmd: str, timeout: int = 30) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split() + action = parts[0].lower() if parts else "" + service = parts[1] if len(parts) > 1 else "" + + if action == "status": + if not service: + return "Précise le service." + return _run(f"systemctl status {service} --no-pager -l") + + if action == "start": + if not service: + return "Précise le service." + out = _run(f"systemctl start {service}") + status = _run(f"systemctl is-active {service}") + return f"Démarrage de {service}... Statut : {status}\n{out}" + + if action == "stop": + if not service: + return "Précise le service." + out = _run(f"systemctl stop {service}") + status = _run(f"systemctl is-active {service}") + return f"Arrêt de {service}... Statut : {status}\n{out}" + + if action == "restart": + if not service: + return "Précise le service." + out = _run(f"systemctl restart {service}") + status = _run(f"systemctl is-active {service}") + return f"Redémarrage de {service}... Statut : {status}\n{out}" + + if action == "reload": + if not service: + return "Précise le service." + return _run(f"systemctl reload {service}") + + if action == "enable": + if not service: + return "Précise le service." + return _run(f"systemctl enable {service}") + + if action == "disable": + if not service: + return "Précise le service." + return _run(f"systemctl disable {service}") + + if action == "mask": + if not service: + return "Précise le service." + return _run(f"systemctl mask {service}") + + if action == "unmask": + if not service: + return "Précise le service." + return _run(f"systemctl unmask {service}") + + if action == "logs": + if not service: + return "Précise le service." + n = parts[2] if len(parts) > 2 else "50" + try: + n = int(n) + except ValueError: + n = 50 + return _run(f"journalctl -u {service} -n {n} --no-pager -o short-iso") + + if action == "list": + pattern = parts[1] if len(parts) > 1 else "" + cmd = "systemctl list-units --type=service --no-pager" + if pattern: + cmd += f" | grep {pattern}" + return _run(cmd) + + if action == "list-all": + return _run("systemctl list-units --type=service --all --no-pager") + + if action == "failed": + return _run("systemctl list-units --state=failed --no-pager") + + if action == "daemon-reload": + return _run("systemctl daemon-reload") + + if action == "is-active": + if not service: + return "Précise le service." + return _run(f"systemctl is-active {service}") + + if action == "is-enabled": + if not service: + return "Précise le service." + return _run(f"systemctl is-enabled {service}") + + return ( + "Action inconnue. Disponible : status, start, stop, restart, reload, " + "enable, disable, mask, unmask, logs, list, list-all, failed, " + "daemon-reload, is-active, is-enabled" + ) diff --git a/skills/user.py b/skills/user.py new file mode 100644 index 0000000..18253e9 --- /dev/null +++ b/skills/user.py @@ -0,0 +1,127 @@ +""" +Skill USER — gestion des utilisateurs et groupes. + +Usage LLM : + SKILL:user ARGS:list + SKILL:user ARGS:add [--sudo] + SKILL:user ARGS:delete + SKILL:user ARGS:passwd + SKILL:user ARGS:info + SKILL:user ARGS:groups + SKILL:user ARGS:addgroup + SKILL:user ARGS:removegroup + SKILL:user ARGS:lock + SKILL:user ARGS:unlock + SKILL:user ARGS:whoami + SKILL:user ARGS:logged + SKILL:user ARGS:sudoers +""" +import subprocess + +DESCRIPTION = "Gestion utilisateurs et groupes : add, delete, passwd, groups, lock/unlock, sudoers" +USAGE = "SKILL:user ARGS:list | add [--sudo] | delete | passwd | info | groups | addgroup | lock | unlock | logged" + + +def _run(cmd: str, timeout: int = 15) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:3000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split() + action = parts[0].lower() if parts else "list" + rest = parts[1:] if len(parts) > 1 else [] + + if action == "list": + return _run("getent passwd | awk -F: '$3>=1000 || $3==0 {print $1\" (uid=\"$3\", shell=\"$7\")\"}'") + + if action == "add": + if not rest: + return "Précise le nom d'utilisateur." + name = rest[0] + sudo = "--sudo" in rest or "-s" in rest + cmds = [ + f"adduser --gecos '' --disabled-password {name}", + ] + if sudo: + cmds.append(f"usermod -aG sudo {name}") + return "\n".join(_run(c) for c in cmds) + + if action == "delete": + if not rest: + return "Précise le nom d'utilisateur." + return _run(f"deluser --remove-home {rest[0]}") + + if action == "passwd": + if not rest: + return "Précise le nom d'utilisateur." + # Génère un mot de passe aléatoire + pwd = _run("openssl rand -base64 12").strip() + out = _run(f"echo '{rest[0]}:{pwd}' | chpasswd") + return f"{out}\nNouveauMDP : {pwd}" + + if action == "info": + if not rest: + return "Précise le nom d'utilisateur." + name = rest[0] + return _run(f"id {name} && getent passwd {name}") + + if action == "groups": + if not rest: + return "Précise le nom d'utilisateur." + return _run(f"groups {rest[0]}") + + if action == "addgroup": + if len(rest) < 2: + return "Format : addgroup " + return _run(f"usermod -aG {rest[1]} {rest[0]}") + + if action == "removegroup": + if len(rest) < 2: + return "Format : removegroup " + return _run(f"gpasswd -d {rest[0]} {rest[1]}") + + if action == "lock": + if not rest: + return "Précise le nom d'utilisateur." + return _run(f"usermod -L {rest[0]}") + + if action == "unlock": + if not rest: + return "Précise le nom d'utilisateur." + return _run(f"usermod -U {rest[0]}") + + if action == "whoami": + return _run("whoami && id") + + if action == "logged": + return _run("who && echo '---' && last -n 10") + + if action == "sudoers": + return _run("getent group sudo | cut -d: -f4") + + if action == "ssh-key": + # Ajouter une clé SSH pour un utilisateur + if len(rest) < 2: + return "Format : ssh-key " + name = rest[0] + key = " ".join(rest[1:]) + return _run( + f"mkdir -p /home/{name}/.ssh && " + f"echo '{key}' >> /home/{name}/.ssh/authorized_keys && " + f"chmod 700 /home/{name}/.ssh && " + f"chmod 600 /home/{name}/.ssh/authorized_keys && " + f"chown -R {name}:{name} /home/{name}/.ssh && " + f"echo 'Clé ajoutée pour {name}'" + ) + + return "Action inconnue. Disponible : list, add, delete, passwd, info, groups, addgroup, removegroup, lock, unlock, whoami, logged, sudoers, ssh-key"