self::MEMORY_LIMIT); // Register REST API $restController = new RestController(); $restController->register(); // Actions de sécurité tenues par l'agent : réaffirmées à chaque démarrage, sinon // elles cessent d'exister (même famille que le piège n°16). Chacune sous filet : // un durcissement n'a aucune raison de pouvoir fermer le site d'un client. foreach ([ [\Sentinelo\Security\HardeningService::class, 'enforce'], [\Sentinelo\Security\LoginBlocker::class, 'register'], ] as $bootstrap) { try { $bootstrap(); } catch (\Throwable $e) { Logger::error('security', 'Démarrage de ' . $bootstrap[0] . ' abandonné : ' . $e->getMessage()); } } // Updates of this plugin are delivered by WordPress.org. Preloading the // post-swap path on `upgrader_pre_install` keeps a failed update from // leaving the site WITHOUT an agent, whoever serves the zip. add_filter('upgrader_pre_install', static function ($return) { PostUpdatePreload::run(); return $return; }, 1); // A site coming from the privately distributed edition still carries an // update-check event that nothing handles any more. if (wp_next_scheduled('SENTINELO_check_self_update_event')) { wp_clear_scheduled_hook('SENTINELO_check_self_update_event'); } // Admin if (is_admin()) { new SettingsPage(); new AdminNotices(); } // Cron hooks, bound from the shared task catalogue. $this->bindScheduledTasks(new WpScheduler()); // WordPress event listeners $this->registerWpListeners(); } /** * Handler for each task the catalogue declares. */ /** Ce qu'une sauvegarde de plusieurs Go demandait déjà par ini_set(). */ public const MEMORY_LIMIT = '1024M'; private const HANDLERS = [ TaskCatalogue::HEARTBEAT => 'handleHeartbeat', TaskCatalogue::SYNC => 'handleSyncToSaas', TaskCatalogue::SECURITY_SCAN => 'handleSecurityScan', TaskCatalogue::WEBHOOK_QUEUE => 'handleWebhookQueue', TaskCatalogue::CLEANUP_LOGS => 'handleCleanupLogs', TaskCatalogue::CLEANUP_BACKUPS => 'handleCleanupExpiredBackups', TaskCatalogue::RESUME_ARCHIVE => 'handleResumeBackupArchive', TaskCatalogue::RESUME_UPLOAD => 'handleResumeBackupUpload', ]; /** * Les tâches du catalogue que cet agent n'exécute PAS, et l'assume. * * La sauvegarde quotidienne est déclenchée par la PLATEFORME, jamais par * l'agent. Deux planificateurs pour un même travail, c'est deux fois le * travail et aucune autorité : mesuré le 2026-09-02 sur api.auramur.fr, le * cron du site a démarré sa sauvegarde à 03:00:07 et la plateforme s'est vu * refuser la sienne en 409 CONFLICT une minute plus tard. * * Surtout, la sauvegarde automatique est désormais activable PAR SITE depuis * la plateforme (`Site::automaticBackupsEnabled`). Un * agent qui continuerait de se déclencher seul rendrait ce réglage faux : * l'interface annoncerait « sauvegardes automatiques désactivées » pendant * que le site en produirait toutes les nuits. * * Le déclenchement manuel et la reprise ne sont pas concernés — ils passent * par les routes HTTP et par RESUME_ARCHIVE / RESUME_UPLOAD, qui restent liés. */ private const DELIBERATELY_UNBOUND = [TaskCatalogue::DAILY_BACKUP]; /** * Bind every catalogue task to its handler, and complain loudly about any task * that has none. * * A scheduled task with no handler fires forever and does nothing — the most * invisible failure mode this agent has. Deriving the bindings from the catalogue * means adding a task cannot silently skip this step. */ private function bindScheduledTasks(Scheduler $scheduler): void { foreach (TaskCatalogue::all() as $task) { // Refus déclaré : on ne lie pas, et on ne s'en plaint pas non plus. // Un hook non lié qui n'est PAS dans cette liste reste une erreur. // // On DÉplanifie au passage : toute la flotte porte cette tâche, planifiée // par une version antérieure. Ne pas le faire laisserait un réveil // quotidien qui n'exécute rien — la panne la plus invisible de cet agent, // à ceci près qu'elle serait cette fois volontaire et donc jamais relue. if (in_array($task->hook, self::DELIBERATELY_UNBOUND, true)) { if ($scheduler instanceof WpScheduler) { // `unschedule()` ne retire que les événements SANS arguments, // puisqu'il boucle sur `wp_next_scheduled()`. Les deux familles // doivent partir, sinon un fantôme laissé par une ancienne // version survivrait à la purge censée le retirer. $ghosts = $scheduler->unscheduleArgBearingEvents($task->hook); $planned = null !== $scheduler->nextRun($task->hook); if ($planned) { $scheduler->unschedule($task->hook); } if ($planned || $ghosts > 0) { Logger::info('cron', sprintf( 'Tâche « %s » déplanifiée (%d fantôme(s)) : elle est déclenchée par la plateforme.', $task->hook, $ghosts )); } } continue; } $handler = self::HANDLERS[$task->hook] ?? null; if ($handler === null) { Logger::error('cron', sprintf( 'Tâche planifiée « %s » (%s) sans handler : elle se déclenchera sans rien faire.', $task->hook, $task->label )); continue; } $scheduler->onDue($task->hook, [$this, $handler]); // Et on la planifie si elle ne l'est pas. // // Le crochet d'activation ne se déclenche PAS quand WordPress met à // jour une extension : une tâche ajoutée par une nouvelle version ne // serait donc jamais planifiée sur un site déjà installé — c'est-à-dire // sur toute la flotte. Ce qui suit la rattrape. // // `schedule()` est idempotent (il n'agit que si rien n'est planifié), // donc le coût par requête est une lecture d'option par tâche. // Purge des evenements fantomes laisses par d'anciennes versions. // Invisibles a `wp_next_scheduled()`, ils se declenchaient a chaque // reveil de wp-cron — un par passage, indefiniment. if ($scheduler instanceof WpScheduler) { $ghosts = $scheduler->unscheduleArgBearingEvents($task->hook); if ($ghosts > 0) { Logger::info('cron', sprintf( '%d evenement(s) fantome(s) retire(s) pour « %s ».', $ghosts, $task->hook )); } } $scheduler->schedule( $task->hook, $task->intervalSeconds, $task->firstRunAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))) ); } } /** * Legacy cron interval names. * * KEPT for backward compatibility: installs upgraded from an earlier build still * have events scheduled under 'SENTINELO_5min' / 'SENTINELO_hourly'. Removing these * definitions would leave WordPress unable to reschedule those recurring events — * the heartbeat and the webhook queue would simply stop, silently. New schedules * come from WpScheduler, which registers its own interval names. */ public function addCronIntervals(array $schedules): array { if (!isset($schedules['SENTINELO_5min'])) { $schedules['SENTINELO_5min'] = [ 'interval' => 300, 'display' => __('Every 5 minutes', 'sentinelo'), ]; } if (!isset($schedules['SENTINELO_hourly'])) { $schedules['SENTINELO_hourly'] = [ 'interval' => 3600, 'display' => __('Every hour', 'sentinelo'), ]; } return $schedules; } public function handleHeartbeat(): void { $saasUrl = SENTINELO_DEFAULT_SAAS_URL; $siteUuid = Config::get('site_uuid'); $apiKey = Config::getApiKey(); if (!$saasUrl || !$siteUuid || !$apiKey) { return; } // `agent_version` est lu par le backend et stocké dans `sites.agent_version` : // c'est ce qui alimente la liste des agents périmés, et sur les cibles Composer // c'est TOUTE la compensation à l'absence d'auto-mise à jour. L'agent WordPress // ne l'envoyait pas — le seul déployé chez des clients était donc le seul dont // la version restait inconnue, et la colonne restait vide sur toute la flotte. // // `application_version` est la clé neutre du contrat ; `wp_version` reste // envoyée parce qu'un backend plus ancien ne connaît qu'elle, et que le lecteur // côté backend accepte déjà les deux. $payload = wp_json_encode([ 'site_uuid' => $siteUuid, 'timestamp' => time(), 'agent_version' => defined('SENTINELO_VERSION') ? SENTINELO_VERSION : null, 'application_version' => get_bloginfo('version'), 'wp_version' => get_bloginfo('version'), 'php_version' => phpversion(), ]); wp_remote_post( trailingslashit($saasUrl) . 'api/sites/' . $siteUuid . '/heartbeat', [ 'headers' => [ 'Content-Type' => 'application/json', 'X-Sentinelo-API-Key' => $apiKey, 'X-Sentinelo-Site-UUID' => $siteUuid, ], 'body' => $payload, 'timeout' => 10, 'blocking' => false, ] ); // UTC, like every other timestamp the agent persists. The admin screen reads // this back through strtotime(), which WordPress runs in UTC — a site-local // string was therefore re-read as UTC and the "last sync" line was off by the // site's offset, permanently. Config::set('last_heartbeat', Timestamps::store(new \DateTimeImmutable('now', new \DateTimeZone('UTC')))); $this->maybeCheckSslExpiry(); } /** * Once per day, read the site's own TLS certificate and fire an alert webhook * if it expires within the configured threshold (default 21 days). Throttled via * a transient so the every-5-min heartbeat doesn't open a socket each tick. */ private function maybeCheckSslExpiry(): void { if (get_transient('SENTINELO_ssl_checked') !== false) { return; } set_transient('SENTINELO_ssl_checked', 1, DAY_IN_SECONDS); $info = $this->readSslCertInfo(); if ($info === null || $info['days_remaining'] === null) { return; } $threshold = (int) Config::get('ssl_alert_days', 21); if ($info['days_remaining'] <= $threshold) { $sender = new \Sentinelo\Webhook\WebhookSender(); $sender->send('alert.ssl_expiring', \Sentinelo\Webhook\Events\SslExpiringSoonEvent::toPayload( $info['days_remaining'], $info['expiry_date'], $info['issuer'] )); Logger::warning('ssl', "Certificat TLS expire dans {$info['days_remaining']} jours"); } } /** * @return array{days_remaining:int, expiry_date:string, issuer:string}|null */ private function readSslCertInfo(): ?array { // Use the site's configured scheme, NOT is_ssl(): the heartbeat runs from // WP-Cron over a loopback request where is_ssl() is false even on a fully // HTTPS site, which would skip the check exactly in the unattended path. if (!str_starts_with((string) home_url(), 'https://')) { return null; } $host = parse_url(site_url(), PHP_URL_HOST) ?: sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'] ?? 'localhost')); $context = stream_context_create(['ssl' => ['capture_peer_cert' => true]]); $socket = @stream_socket_client('ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context); if (!$socket) { return null; } $params = stream_context_get_params($socket); $certResource = $params['options']['ssl']['peer_certificate'] ?? null; fclose($socket); if (!$certResource) { return null; } $cert = openssl_x509_parse($certResource); if (!$cert) { return null; } $validTo = $cert['validTo_time_t'] ?? 0; return [ 'days_remaining' => max(0, (int) round(($validTo - time()) / 86400)), 'expiry_date' => $validTo ? gmdate('c', $validTo) : '', 'issuer' => is_array($cert['issuer'] ?? null) ? ($cert['issuer']['O'] ?? 'Unknown') : 'Unknown', ]; } public function handleSyncToSaas(): void { $saasUrl = SENTINELO_DEFAULT_SAAS_URL; $siteUuid = Config::get('site_uuid'); $apiKey = Config::getApiKey(); if (!$saasUrl || !$siteUuid || !$apiKey) { return; } wp_remote_post( trailingslashit($saasUrl) . 'api/sites/' . $siteUuid . '/request-sync', [ 'headers' => [ 'Content-Type' => 'application/json', 'X-Sentinelo-API-Key' => $apiKey, ], 'timeout' => 120, 'blocking' => false, ] ); } public function handleSecurityScan(?string $type = null): void { $scanner = new \Sentinelo\Scanner\SecurityScanner(); if ($type === 'quick') { $scanner->runQuickScan(); } else { $scanner->runFullScan(); } } public function handleWebhookQueue(): void { $queue = new \Sentinelo\Webhook\WebhookQueue(); $queue->processQueue(); } public function handleCleanupLogs(): void { $retentionDays = (int) Config::get('log_retention_days', 30); $cutoff = strtotime("-{$retentionDays} days"); if (!is_dir(SENTINELO_LOG_DIR)) { return; } $files = glob(SENTINELO_LOG_DIR . 'sentinelo-*.log'); if (!$files) { return; } foreach ($files as $file) { if (filemtime($file) < $cutoff) { unlink($file); } } } /** * Reprend l'envoi d'une sauvegarde interrompue, s'il y en a une. * * Le cas ordinaire est qu'il n'y ait rien à faire : la méthode sort alors * immédiatement. Elle n'existe que pour les archives trop grosses pour * partir en une seule requête — sur un hébergement lent, c'est-à-dire la * plupart d'entre elles. */ public function handleResumeBackupUpload(): void { // Une tache planifiee qui leve ne remonte a personne : WordPress avale // l'exception et la ligne « en cours » reste ouverte pour toujours. // Le coeur clot desormais la sauvegarde lui-meme ; ce filet-ci couvre ce // qui se produit AVANT lui (construction, lecture de la ligne courante). try { $manager = new \Sentinelo\Backup\BackupManager(); $manager->resumePendingUpload(); } catch (\Throwable $e) { Logger::error('backup', 'Fatal error in handleResumeBackupUpload: ' . $e->getMessage()); } } /** * Reprend la construction d'archive en cours, s'il en reste une. * * Comme {@see self::handleResumeBackupUpload()}, le cas ordinaire est qu'il * n'y ait rien à faire. Elle n'existe que pour les sites dont le contenu est * trop volumineux pour qu'une archive se construise en une seule requête — * sur un hébergement mutualisé, c'est-à-dire dès quelques centaines de Mo. */ public function handleResumeBackupArchive(): void { // Meme filet que handleResumeBackupUpload() — et c'est par ici que la // panne est arrivee : la verification d'archive a leve, personne n'a // rattrape, et le site n'a plus jamais sauvegarde. try { $manager = new \Sentinelo\Backup\BackupManager(); $manager->resumePendingArchive(); } catch (\Throwable $e) { Logger::error('backup', 'Fatal error in handleResumeBackupArchive: ' . $e->getMessage()); } } public function handleCleanupExpiredBackups(): void { $policy = new \Sentinelo\Backup\RetentionPolicy(); $policy->cleanExpired(); } private function registerWpListeners(): void { // Dernière connexion, pour la liste des comptes WordPress (orphelins). Horodatée // en UTC ISO 8601, comme tout ce qui franchit la frontière agent ↔ plateforme. // La date de début du suivi est posée une fois : sans elle, « jamais vu » ne // voudrait rien dire sur un site où l'agent vient d'arriver. if (get_option('SENTINELO_login_tracking_since') === false) { update_option('SENTINELO_login_tracking_since', gmdate('c')); } add_action('wp_login', static function (string $login, $user): void { try { if (is_object($user) && isset($user->ID)) { update_user_meta((int) $user->ID, 'SENTINELO_last_login', gmdate('c')); } } catch (\Throwable) { // Un suivi de connexion ne doit jamais empêcher une connexion. } }, 10, 2); // Login failed tracking add_action('wp_login_failed', static function (string $username): void { $ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? 'unknown')); $transientKey = 'SENTINELO_login_fails_' . md5($ip); $fails = (int) get_transient($transientKey); $fails++; set_transient($transientKey, $fails, 300); if ($fails >= 10) { $sender = new \Sentinelo\Webhook\WebhookSender(); $sender->send('login.failed_burst', \Sentinelo\Webhook\Events\LoginFailedEvent::toPayload($fails, $ip)); delete_transient($transientKey); } }); // Plugin updated OUTSIDE our REST endpoint (wp-admin click, WP auto-update, // WP-CLI). When the endpoint drove the update it already emitted the event with // the real old version and a clean slug, and this hook fires in that same // request — reporting again produced a duplicate MaintenanceLog. // // Ce crochet se declenche a l'interieur de `WP_Upgrader::run()`, APRES le // remplacement des fichiers et AVANT `maintenance_mode(false)`. Tout ce qui // s'y passe retarde la reouverture du site, et tout ce qui y meurt laisse // le fichier `.maintenance` derriere : WordPress ne l'ignore qu'au bout de // dix minutes, pendant lesquelles le site rend 503 a TOUS ses visiteurs. // Mesure le 24 aout 2026 sur api.webntricks.com — onze minutes. // // D'ou les deux regles ci-dessous, qui valent pour les deux crochets : // on EMPILE le webhook (une ecriture locale) au lieu de l'envoyer (un // aller-retour reseau, 94 Ko/s sur un mutualise), et on enveloppe tout — // notre notification n'a aucune raison de pouvoir fermer un site client. add_action('upgrader_process_complete', static function (\WP_Upgrader $upgrader, array $options): void { try { if (\Sentinelo\Core\UpdateOrigin::isSelfDriven()) { return; } if (($options['type'] ?? '') !== 'plugin' || ($options['action'] ?? '') !== 'update') { return; } // WP passes 'plugin' for a single update and 'plugins' for a bulk one. $plugins = $options['plugins'] ?? array_filter([$options['plugin'] ?? null]); foreach ((array) $plugins as $plugin) { if (!$plugin) { continue; } $data = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin); // Slug, not basename: the backend keys elements by slug, and sending // "akismet/akismet.php" created a second, unmatchable element. $slug = dirname((string) $plugin) === '.' ? basename((string) $plugin, '.php') : dirname((string) $plugin); \Sentinelo\Webhook\WebhookQueue::enqueue( 'update.applied', \Sentinelo\Webhook\Events\PluginUpdatedEvent::toPayload($slug, '', $data['Version'] ?? '', 'site') ); } } catch (\Throwable $e) { \Sentinelo\Core\Logger::error('update', 'Signalement post-mise a jour abandonne : ' . $e->getMessage()); } }, 10, 2); // Core updated outside our REST endpoint — same reasoning as above. add_action('_core_updated_successfully', static function (string $wpVersion): void { try { if (\Sentinelo\Core\UpdateOrigin::isSelfDriven()) { return; } \Sentinelo\Webhook\WebhookQueue::enqueue( 'update.applied', \Sentinelo\Webhook\Events\CoreUpdatedEvent::toPayload($wpVersion, 'site') ); } catch (\Throwable $e) { \Sentinelo\Core\Logger::error('update', 'Signalement post-mise a jour du coeur abandonne : ' . $e->getMessage()); } }); } }