diff --git a/README.md b/README.md index 19f82b7..3e48bd8 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ systemctl enable --now agent_ansible | `galaxy` | Installation de rôles et collections (`ansible-galaxy`) | | `vault` | Chiffrement/déchiffrement de secrets (`ansible-vault`) | | `shell` | Commandes shell locales (utile pour diagnostics) | -| `script` | Bibliothèque de scripts bash (save/list/show/exec/run/delete) | +| `script` | Bibliothèque de scripts bash (save/list/show/edit/exec/run/delete) | | `agents_status` | Statut des agents du système | | `mqtt_send` | Publication sur un topic MQTT | | `mqtt_subscribe` | Souscription dynamique à un topic MQTT | @@ -38,6 +38,8 @@ systemctl enable --now agent_ansible Les scripts bash sont stockés dans `/opt/agent_ansible/scripts/`. Ils peuvent encapsuler des appels `ansible-playbook` ou des opérations de maintenance sur l'infra. +Les noms sont normalisés automatiquement (extensions strip, extensions système interdites). Le contenu doit contenir au moins une commande réelle. + ## Structure ``` diff --git a/inventory/hosts b/inventory/hosts index d9881fc..aaf1531 100644 --- a/inventory/hosts +++ b/inventory/hosts @@ -15,3 +15,33 @@ localhost ansible_connection=local [all] 192.168.7.100 debian.local + +[net7] +192.168.7.79 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa +192.168.7.80 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa +192.168.7.90 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa +192.168.7.91 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa +192.168.7.112 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa +192.168.7.119 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa +192.168.7.120 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa +192.168.7.120 +192.168.7.119 +192.168.7.112 +192.168.7.91 +192.168.7.90 +192.168.7.80 +192.168.7.79 +192.168.7.76 +192.168.7.75 +192.168.7.74 +192.168.7.73 +192.168.7.72 +192.168.7.55 +192.168.7.54 +192.168.7.53 +192.168.7.52 +192.168.7.51 +192.168.7.19 +192.168.7.15 + +[network] diff --git a/skills/cron.py b/skills/cron.py new file mode 100644 index 0000000..cf8d52a --- /dev/null +++ b/skills/cron.py @@ -0,0 +1,109 @@ +""" +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 _confirm_or_execute(context, description: str, action_fn) -> str: + """Demande confirmation si requête XMPP directe, sinon exécute immédiatement.""" + sender = getattr(context.agent, '_last_xmpp_sender', '') + if not sender: + return action_fn() + context.agent._pending_confirmations[sender] = {"description": description, "fn": action_fn} + return f"⚠️ Confirmation requise :\n{description}\n\nRéponds **oui** pour confirmer ou **non** pour annuler." + + +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": + 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}" + + current = _run("crontab -l 2>/dev/null") + if entry in current: + return f"Cette entrée existe déjà : {entry}" + + def _do_add(): + 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}" + + return _confirm_or_execute(context, f"Ajouter cron : {entry}", _do_add) + + 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] + removed_count = len(current.splitlines()) - len(lines) + if removed_count == 0: + return f"Aucune entrée contenant '{rest}' trouvée." + + def _do_remove(): + 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) + return f"{removed_count} entrée(s) supprimée(s) contenant '{rest}'.\n{out}" + + return _confirm_or_execute(context, f"Supprimer {removed_count} cron contenant '{rest}'", _do_remove) + + if action == "clear": + return _confirm_or_execute( + context, + "Effacer TOUT le crontab root (action irréversible)", + lambda: _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/script.py b/skills/script.py index b4f828e..112307a 100644 --- a/skills/script.py +++ b/skills/script.py @@ -14,6 +14,7 @@ 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 @@ -25,11 +26,12 @@ import subprocess import tempfile from datetime import datetime -DESCRIPTION = "Bibliothèque de scripts bash : sauvegarder, lister, afficher, exécuter" +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 " @@ -53,9 +55,18 @@ def _ensure_dir(context) -> str: 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.""" - return os.path.basename(name.strip().replace("/", "_")) + """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: @@ -137,6 +148,20 @@ def run(args: str, context) -> str: name_raw, content = rest.split("|", 1) name = _safe_name(name_raw) content = content.strip().replace("\\n", "\n") + + 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) @@ -148,6 +173,35 @@ def run(args: str, context) -> str: 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)