loranloran

Sentinel Plugins Essentiels for WordPress (MU)

Aug 18th, 2025 (edited)
101
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
PHP 16.11 KB | Source Code | 0 0
  1. <?php
  2. /**
  3.  * Plugin Name: Sentinel Plugins Essentiels for WordPress (MU)
  4.  * Description: MU-plugin résilient qui surveille, réactive et alerte sur l’état des plugins essentiels. Maintenance-aware, logs détaillés, rotation, rate limiting, cron.
  5.  * Version: 1.0.0
  6.  * Author: @loran750 CoeurDuWeb.com
  7.  *
  8.  * --------------------------------------------------------------------------------
  9.  *
  10.  * -- RÔLE DU PLUGIN --
  11.  *
  12.  * "Sentinel" est un gardien indésactivable pour vos plugins WordPress critiques.
  13.  * Il surveille en permanence leur état, les réactive automatiquement s'ils sont désactivés
  14.  * et vous alerte par email en cas de problème, tout en tenant un journal d'événements détaillé.
  15.  *
  16.  * -- FONCTIONNALITÉS PRINCIPALES --
  17.  *
  18.  * - Surveillance Multi-Vecteurs : Le check s'exécute au chargement (muplugins_loaded),
  19.  *   périodiquement (cron), en fin de script (shutdown) et lors de modifications.
  20.  * - Réactivation Automatique : Tente de réactiver immédiatement tout plugin surveillé trouvé inactif.
  21.  * - Logs Détaillés & Séparés : Crée des logs pour les actions (sentinel-plugins.log) et les
  22.  *   emails (sentinel-mails.log) dans /wp-content/logs/, avec rotation automatique.
  23.  * - Emails d'Alerte avec Anti-Spam (Rate Limiting) : Prévient l'inondation de votre boîte
  24.  *   mail en espaçant les alertes de même type (15 min par défaut).
  25.  * - Ignore le Mode Maintenance : Se met en pause durant les mises à jour de WordPress pour
  26.  *   éviter tout conflit.
  27.  * - Endpoint de Diagnostic JSON : Fournit une URL pour un bilan de santé instantané.
  28.  *
  29.  * -- CONFIGURATION RAPIDE --
  30.  *
  31.  * 1. Placer ce fichier dans le dossier /wp-content/mu-plugins/ (créez-le si besoin).
  32.  * 2. Adapter la section // ===== CONFIG ===== ci-dessous (liste des plugins, email...).
  33.  *
  34.  * -- UTILISATION & TESTS --
  35.  *
  36.  * - DIAGNOSTIC RAPIDE (ENDPOINT) :
  37.  *   Connectez-vous en tant qu'administrateur et visitez l'URL suivante :
  38.  *   https://votresite.com/?sentinel_status=1
  39.  *   Le statut (existence et activation) de chaque plugin surveillé s'affichera en JSON.
  40.  *
  41.  * - DÉSACTIVATION VOLONTAIRE D'UN PLUGIN :
  42.  *   1. Éditez ce fichier (via FTP/SFTP).
  43.  *   2. Commentez la ligne du plugin à désactiver dans la section CONFIG (ajoutez // au début).
  44.  *   3. Désactivez le plugin depuis l'administration WordPress.
  45.  *   4. IMPORTANT : Après votre intervention, n'oubliez pas de réactiver le plugin et de
  46.  *      retirer le commentaire (//) pour reprendre la surveillance.
  47.  *
  48.  * - TEST DE RÉACTIVITÉ :
  49.  *   Pour vérifier que tout fonctionne, désactivez un plugin surveillé depuis l'admin.
  50.  *   Il doit se réactiver seul quasi-instantanément. Vérifiez ensuite les logs générés
  51.  *   dans le dossier /wp-content/logs/.
  52.  *
  53.  * --------------------------------------------------------------------------------
  54.  */
  55.  
  56. if (!defined('ABSPATH')) { exit; }
  57.  
  58. if (!class_exists('Sentinel_Plugins_Essentiels')) {
  59. final class Sentinel_Plugins_Essentiels {
  60.  
  61.   // ===== CONFIG =====
  62.   private $config = [
  63.     // Slugs exacts "dossier/fichier.php" => "Nom"
  64.     'essential_plugins' => [
  65.       'elementor/elementor.php'           => 'Elementor',
  66.       'elementor-pro/elementor-pro.php'   => 'Elementor Pro',
  67.       'jet-engine/jet-engine.php'         => 'JetEngine',
  68.       'astra-addon/astra-addon.php'       => 'Astra Pro',
  69.     ],
  70.  
  71.     // Emails
  72.     'email_to'                => null,         // null = admin_email
  73.     'email_prefix'            => '[Alerte Sentinelle : '.parse_url(home_url(), PHP_URL_HOST).']',
  74.     'enable_email'            => true,
  75.     'mail_rate_limit_seconds' => 900,          // 15 min par type d’alerte
  76.  
  77.     // Logging
  78.     'log_dir_primary'         => WP_CONTENT_DIR . '/logs',
  79.     'log_dir_fallback'        => null,         // Défini vers uploads/logs dynamiquement
  80.     'log_file'                => 'sentinel-plugins.log',
  81.     'mail_log_file'           => 'sentinel-mails.log',
  82.     'log_max_size_bytes'      => 5 * 1024 * 1024, // 5 Mo
  83.  
  84.     // Réaction
  85.     'enable_auto_reactivate'  => true,
  86.  
  87.     // Cron healthcheck
  88.     'enable_cron'             => true,
  89.     'cron_recurrence'         => 'hourly',     // hourly, twicedaily, daily
  90.  
  91.     // Multisite
  92.     'multisite_scope_network' => true,         // surveiller activations réseau
  93.   ];
  94.   // ===== FIN CONFIG =====
  95.  
  96.   private $cid;
  97.   private $log_dir;
  98.   private $cron_hook = 'sentinel_plugins_healthcheck';
  99.   private $last_mail_option = 'sentinel_last_mail_times';
  100.  
  101.   public function __construct() {
  102.     $this->cid = $this->correlation_id();
  103.     $this->prepare_log_dir();
  104.  
  105.     // Scans principaux
  106.     add_action('muplugins_loaded', [$this, 'early_scan'], 1);
  107.     add_action('shutdown',         [$this, 'shutdown_scan'], 999);
  108.     add_action('init',             [$this, 'check_paused_plugins'], 20);
  109.  
  110.     // Surveiller les changements d’active_plugins
  111.     add_action('update_option_active_plugins', [$this, 'on_active_plugins_change'], 10, 3);
  112.  
  113.     // Multisite réseau
  114.     if (is_multisite() && $this->config['multisite_scope_network']) {
  115.       add_action('update_site_option_active_sitewide_plugins', [$this, 'on_network_plugins_change'], 10, 3);
  116.     }
  117.  
  118.     // Cron healthcheck périodique
  119.     if ($this->config['enable_cron']) {
  120.       add_action($this->cron_hook, [$this, 'periodic_healthcheck']);
  121.       $this->ensure_cron();
  122.     }
  123.  
  124.     // Endpoint de statut simple (admin uniquement)
  125.     add_action('init', [$this, 'status_endpoint'], 1);
  126.   }
  127.  
  128.   // ===== Utils =====
  129.   private function correlation_id() {
  130.     try {
  131.       $r = function_exists('random_bytes') ? bin2hex(random_bytes(4)) : substr(md5(mt_rand()), 0, 8);
  132.     } catch (Throwable $e) {
  133.       $r = substr(md5(mt_rand()), 0, 8);
  134.     }
  135.     return strtoupper($r);
  136.   }
  137.  
  138.   private function prepare_log_dir() {
  139.     if (!$this->config['log_dir_fallback'] && function_exists('wp_upload_dir')) {
  140.       $up = @wp_upload_dir();
  141.       if (is_array($up) && !empty($up['basedir'])) {
  142.         $this->config['log_dir_fallback'] = trailingslashit($up['basedir']) . 'logs';
  143.       }
  144.     }
  145.     $candidates = [$this->config['log_dir_primary'], $this->config['log_dir_fallback']];
  146.     foreach ($candidates as $dir) {
  147.       if (!$dir) continue;
  148.       if (!is_dir($dir)) { @wp_mkdir_p($dir); }
  149.       if (is_dir($dir) && is_writable($dir)) { $this->log_dir = $dir; break; }
  150.     }
  151.     if (!$this->log_dir && is_writable(WP_CONTENT_DIR)) {
  152.       $this->log_dir = WP_CONTENT_DIR;
  153.     }
  154.   }
  155.  
  156.   private function log_path_main() { return trailingslashit($this->log_dir) . $this->config['log_file']; }
  157.   private function log_path_mail() { return trailingslashit($this->log_dir) . $this->config['mail_log_file']; }
  158.  
  159.   private function rotate_if_needed($file) {
  160.     if (!$file || !@file_exists($file)) return;
  161.     clearstatcache(true, $file);
  162.     $size = @filesize($file);
  163.     if ($size !== false && $size >= $this->config['log_max_size_bytes']) {
  164.       @rename($file, $file . '.' . date('Ymd-His'));
  165.     }
  166.   }
  167.  
  168.   private function log($message, $context = [], $is_mail_log = false) {
  169.     $time = function_exists('wp_date') ? wp_date('Y-m-d H:i:s') : date('Y-m-d H:i:s');
  170.     $url  = $_SERVER['REQUEST_URI'] ?? '-';
  171.     $ip   = $_SERVER['REMOTE_ADDR'] ?? (php_sapi_name() ?: 'CLI');
  172.     $ua   = $_SERVER['HTTP_USER_AGENT'] ?? '-';
  173.     $user = 'anon';
  174.     if (function_exists('wp_get_current_user')) {
  175.       $u = @wp_get_current_user();
  176.       if ($u && $u->ID) $user = $u->user_login . " (ID {$u->ID})";
  177.     }
  178.     $ctx = '';
  179.     if (is_array($context) && !empty($context)) {
  180.       $safe = [];
  181.       foreach ($context as $k => $v) { $safe[$k] = is_scalar($v) ? $v : @json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); }
  182.       $ctx = ' | ' . json_encode($safe, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  183.     }
  184.     $line = "[$time] [CID:{$this->cid}] user=$user ip=$ip url=$url ua=\"$ua\" :: $message$ctx" . PHP_EOL;
  185.     $file = $is_mail_log ? $this->log_path_mail() : $this->log_path_main();
  186.     if ($this->log_dir) {
  187.       $this->rotate_if_needed($file);
  188.       if (@file_put_contents($file, $line, FILE_APPEND | LOCK_EX) === false) { @error_log('[sentinel] ' . $line); }
  189.     } else {
  190.       @error_log('[sentinel] ' . $line);
  191.     }
  192.   }
  193.  
  194.   private function mail_rate_allowed($key) {
  195.     $limit = (int)$this->config['mail_rate_limit_seconds'];
  196.     if ($limit <= 0) return true;
  197.     $map = get_option($this->last_mail_option, []);
  198.     $last = isset($map[$key]) ? (int)$map[$key] : 0;
  199.     if ((time() - $last) < $limit) return false;
  200.     $map[$key] = time();
  201.     update_option($this->last_mail_option, $map, false);
  202.     return true;
  203.   }
  204.  
  205.   private function send_mail($subject, $body, $context = [], $rate_key = null) {
  206.     $to = $this->config['email_to'] ?? get_option('admin_email');
  207.     $site = function_exists('home_url') ? home_url() : ($_SERVER['HTTP_HOST'] ?? 'site');
  208.     $subject_full = trim($this->config['email_prefix'] . ' ' . $subject);
  209.     $body_full = "Site: $site\nCID: {$this->cid}\nTime: " . (function_exists('wp_date') ? wp_date('c') : date('c')) . "\n\n" . $body;
  210.     $sent = false; $err  = null;
  211.     if ($this->config['enable_email'] && function_exists('wp_mail') && !empty($to)) {
  212.       $rk = $rate_key ?: md5($subject_full);
  213.       if ($this->mail_rate_allowed($rk)) {
  214.         try {
  215.           $sent = @wp_mail($to, $subject_full, $body_full);
  216.         } catch (Throwable $e) { $err = 'Throwable: ' . $e->getMessage(); }
  217.       } else { $err = 'Rate-limited'; }
  218.     } else { $err = 'Email disabled or unavailable'; }
  219.     $this->log('Mail attempt: ' . ($sent ? 'SENT' : 'FAILED') . " | subj=\"$subject_full\" to=$to", ['error' => $err, 'context' => $context], true);
  220.     return $sent;
  221.   }
  222.  
  223.   private function is_plugin_active_any($plugin_file) {
  224.     if (!function_exists('is_plugin_active')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; }
  225.     if (is_multisite() && function_exists('is_plugin_active_for_network') && @is_plugin_active_for_network($plugin_file)) { return true; }
  226.     return @is_plugin_active($plugin_file);
  227.   }
  228.  
  229.   private function activate_safely($plugin_file) {
  230.     require_once ABSPATH . 'wp-admin/includes/plugin.php';
  231.     try {
  232.       $res = @activate_plugin($plugin_file, '', false, true);
  233.       if (is_wp_error($res)) {
  234.         $this->log("Activation error for $plugin_file", ['wp_error' => $res->get_error_message()]);
  235.         return $res;
  236.       }
  237.       return true;
  238.     } catch (Throwable $e) {
  239.       $this->log("Activation throwable for $plugin_file", ['ex' => $e->getMessage()]);
  240.       return new WP_Error('sentinel_activation_throwable', $e->getMessage());
  241.     }
  242.   }
  243.  
  244.   // ===== Scans =====
  245.   public function early_scan() {
  246.     if (file_exists(ABSPATH . '.maintenance')) { return; }
  247.     $ess = $this->config['essential_plugins'];
  248.     if (empty($ess)) return;
  249.     $missing = []; $inactive = [];
  250.     foreach ($ess as $file => $name) {
  251.       if (!file_exists(WP_PLUGIN_DIR . '/' . $file)) { $missing[$file] = $name; continue; }
  252.       if (!$this->is_plugin_active_any($file)) { $inactive[$file] = $name; }
  253.     }
  254.     if ($missing) {
  255.       $lines = []; foreach ($missing as $f => $n) $lines[] = "$n ($f)";
  256.       $msg = 'Plugins manquants: ' . implode(', ', $lines);
  257.       $this->log($msg);
  258.       $this->send_mail('Plugins essentiels manquants', $msg . "\nAction requise: restaurer les fichiers.", [], 'missing');
  259.     }
  260.     if ($inactive) {
  261.       $lines = []; foreach ($inactive as $f => $n) $lines[] = "$n ($f)";
  262.       $msg = 'Plugins inactifs détectés: ' . implode(', ', $lines);
  263.       $this->log($msg);
  264.       if ($this->config['enable_auto_reactivate']) {
  265.         foreach ($inactive as $f => $n) {
  266.           $res = $this->activate_safely($f);
  267.           if ($res === true) { $this->log("Réactivation automatique OK: $n ($f)"); }
  268.           else { $err = is_wp_error($res) ? $res->get_error_message() : 'unknown'; $this->log("Réactivation automatique ÉCHEC: $n ($f)", ['error' => $err]); }
  269.         }
  270.         $this->send_mail('Réactivation automatique effectuée', $msg . "\nVérifiez le fonctionnement du site.", [], 'reactivated');
  271.       } else {
  272.         $this->send_mail('Plugins essentiels inactifs', $msg . "\nAuto-réactivation désactivée.", [], 'inactive');
  273.       }
  274.     }
  275.   }
  276.  
  277.   public function shutdown_scan() {
  278.     $ess = $this->config['essential_plugins'];
  279.     $missing = [];
  280.     foreach ($ess as $file => $name) { if (!file_exists(WP_PLUGIN_DIR . '/' . $file)) $missing[$file] = $name; }
  281.     if ($missing) {
  282.       $lines = []; foreach ($missing as $f => $n) $lines[] = "$n ($f)";
  283.       $msg = 'Plugins manquants détectés en shutdown: ' . implode(', ', $lines);
  284.       $this->log($msg);
  285.       $this->send_mail('Plugins essentiels manquants (shutdown)', $msg, [], 'missing_shutdown');
  286.     }
  287.   }
  288.  
  289.   public function on_active_plugins_change($old, $new, $option) {
  290.     $removed = array_values(array_diff((array)$old, (array)$new));
  291.     $added   = array_values(array_diff((array)$new, (array)$old));
  292.     if (!$removed && !$added) return;
  293.     $stack = function_exists('debug_backtrace') ? @debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10) : [];
  294.     $frames = []; foreach ((array)$stack as $f) { $frames[] = (isset($f['class']) ? $f['class'].'::' : '') . ($f['function'] ?? ''); }
  295.     $user = 'anon'; if (function_exists('wp_get_current_user')) { $u = @wp_get_current_user(); if ($u && $u->ID) $user = $u->user_login . " (ID {$u->ID})"; }
  296.     $msg = "Modification active_plugins; +[" . implode(', ', $added) . "] -[" . implode(', ', $removed) . "]; by=$user; trace=" . implode(' > ', array_filter($frames));
  297.     $this->log($msg);
  298.     $this->send_mail('Modification de la liste des plugins actifs', $msg, [], 'active_plugins_change');
  299.   }
  300.  
  301.   public function check_paused_plugins() {
  302.     try {
  303.       $paused = get_option('paused_plugins');
  304.       if (is_array($paused) && !empty($paused)) {
  305.         $ess = array_keys($this->config['essential_plugins']);
  306.         $paused_ess = array_values(array_intersect($ess, array_keys($paused)));
  307.         if (!empty($paused_ess)) {
  308.           $this->log('Plugins en pause (Recovery Mode)', ['paused' => $paused_ess]);
  309.           $this->send_mail('Plugins essentiels en pause (Recovery Mode)', "Plugins: " . implode(', ', $paused_ess) . "\nVérifiez debug.log.", [], 'paused');
  310.         }
  311.       }
  312.     } catch (Throwable $e) { /* silence */ }
  313.   }
  314.  
  315.   public function on_network_plugins_change($old, $new, $option) {
  316.     $old_keys = array_keys((array)$old); $new_keys = array_keys((array)$new);
  317.     $removed = array_values(array_diff($old_keys, $new_keys));
  318.     $added   = array_values(array_diff($new_keys, $old_keys));
  319.     if (!$removed && !$added) return;
  320.     $msg = "Modification plugins réseau; +[" . implode(', ', $added) . "] -[" . implode(', ', $removed) . "]";
  321.     $this->log($msg);
  322.     $this->send_mail('Modification des plugins réseau', $msg, [], 'network_change');
  323.   }
  324.  
  325.   public function periodic_healthcheck() {
  326.     if (file_exists(ABSPATH . '.maintenance')) { return; }
  327.     $this->log('Cron healthcheck démarré');
  328.     $this->early_scan();
  329.     $this->log('Cron healthcheck terminé');
  330.   }
  331.  
  332.   private function ensure_cron() {
  333.     if (!function_exists('wp_next_scheduled') || !function_exists('wp_schedule_event')) return;
  334.     if (!wp_next_scheduled($this->cron_hook)) {
  335.       @wp_schedule_event(time(), $this->config['cron_recurrence'], $this->cron_hook);
  336.     }
  337.   }
  338.  
  339.   public function status_endpoint() {
  340.     if (isset($_GET['sentinel_status']) && current_user_can('manage_options')) {
  341.       $ess = $this->config['essential_plugins'];
  342.       $status = [];
  343.       foreach ($ess as $file => $name) {
  344.         $exists = file_exists(WP_PLUGIN_DIR . '/' . $file);
  345.         $active = $exists ? $this->is_plugin_active_any($file) : false;
  346.         $status[] = [ 'plugin' => $name, 'file' => $file, 'exists' => (int)$exists, 'active' => (int)$active ];
  347.       }
  348.       header('Content-Type: application/json; charset=utf-8');
  349.       echo json_encode(['cid' => $this->cid, 'time' => (function_exists('wp_date') ? wp_date('c') : date('c')), 'log_dir' => $this->log_dir, 'status' => $status], JSON_PRETTY_PRINT);
  350.       exit;
  351.     }
  352.   }
  353. } // fin classe
  354.  
  355. // Instanciation
  356. new Sentinel_Plugins_Essentiels();
  357. } // fin if class_exists guard
Advertisement
Add Comment
Please, Sign In to add comment