string, 'Adresse_netbird' => string]. // ---------------------------------------------------------------------------- function Lister_boitiers_connus($db) { $requete = "SELECT Adresse_mac, Adresse_netbird FROM TB_Cartes WHERE Adresse_netbird IS NOT NULL AND Adresse_netbird <> ''"; $resultat = $db->query($requete); $boitiers = []; while ($ligne = $resultat->fetch_assoc()) $boitiers[] = $ligne; return $boitiers; } // ---------------------------------------------------------------------------- // Interroge GET /archives sur le boîtier d'un bateau. Renvoie : // ['ok' => true, 'archives' => [...]] en cas de succès // ['ok' => false, 'raison' => "..."] sinon (bateau injoignable, réponse // invalide...) - jamais une erreur // fatale, le cron doit continuer // avec les autres bateaux. // Le tri chronologique est déjà garanti côté API par le nom de fichier // (cf synthèse §32.4) ; retrié ici aussi par précaution, sans coût réel. // ---------------------------------------------------------------------------- function Lister_archives_en_attente($adresse_netbird) { if (empty($adresse_netbird)) { return ['ok' => false, 'raison' => "aucune adresse NetBird enregistrée pour ce boîtier"]; } $url = "http://" . $adresse_netbird . ":" . PORT_API_CM5 . "/archives"; $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, TIMEOUT_CONNEXION_CM5); curl_setopt($ch, CURLOPT_TIMEOUT, TIMEOUT_REQUETE_CM5); $reponse_brute = curl_exec($ch); $erreur_curl = curl_error($ch); $code_http = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($reponse_brute === false) { return ['ok' => false, 'raison' => "injoignable (" . $erreur_curl . ")"]; } if ($code_http !== 200) { return ['ok' => false, 'raison' => "réponse HTTP " . $code_http]; } $donnees = json_decode($reponse_brute, true); if (!is_array($donnees) || ($donnees['status'] ?? '') !== 'ok' || !isset($donnees['archives'])) { return ['ok' => false, 'raison' => "réponse JSON invalide ou inattendue"]; } $archives = $donnees['archives']; usort($archives, fn($a, $b) => strcmp($a['nom'], $b['nom'])); return ['ok' => true, 'archives' => $archives]; } // ---------------------------------------------------------------------------- // Extrait l'empreinte (16 hex) portée par le nom de fichier - même format, // exactement, que la validation déjà faite côté shipheart_api (§33.1). // Renvoie false si le nom ne correspond pas au format attendu. // ---------------------------------------------------------------------------- function Extraire_empreinte_nom($nom_fichier) { if (!preg_match('/^[0-9A-Fa-f]{12}_\d{6}_\d{6}_([0-9A-Fa-f]{16})\.sql\.gz\.enc$/', $nom_fichier, $m)) return false; return strtolower($m[1]); } // ---------------------------------------------------------------------------- // GET /archives/ - récupère le contenu brut d'une archive précise. // Même forme de retour que Lister_archives_en_attente() (['ok' => bool, ...]). // ---------------------------------------------------------------------------- function Recuperer_archive($adresse_netbird, $nom_fichier) { $url = "http://" . $adresse_netbird . ":" . PORT_API_CM5 . "/archives/" . rawurlencode($nom_fichier); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, TIMEOUT_CONNEXION_CM5); curl_setopt($ch, CURLOPT_TIMEOUT, TIMEOUT_REQUETE_CM5); $contenu = curl_exec($ch); $erreur_curl = curl_error($ch); $code_http = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($contenu === false) return ['ok' => false, 'raison' => "injoignable (" . $erreur_curl . ")"]; if ($code_http !== 200) return ['ok' => false, 'raison' => "réponse HTTP " . $code_http]; return ['ok' => true, 'contenu' => $contenu]; } // ---------------------------------------------------------------------------- // Vérifie que le contenu récupéré correspond bien à l'empreinte portée par // son nom - détecte une corruption de transfert (cf synthèse §32.4/33.1). // Ne jamais supprimer l'archive côté CM5 si cette vérification échoue - une // nouvelle tentative au prochain cycle récupérera à nouveau le même fichier. // ---------------------------------------------------------------------------- function Integrite_confirmee($nom_fichier, $contenu) { $empreinte_attendue = Extraire_empreinte_nom($nom_fichier); if ($empreinte_attendue === false) return false; $empreinte_calculee = substr(hash('sha256', $contenu), 0, 16); return $empreinte_calculee === $empreinte_attendue; } // ---------------------------------------------------------------------------- // ---------------------------------------------------------------------------- // Copie de sauvegarde des archives mises en quarantaine (MMSI ou affectation absente) - avant de // confirmer la suppression côté CM5, pour ne jamais perdre le contenu pendant que la quarantaine est en // attente (cf synthèse §38, correction d'un manque identifié en testant le mécanisme). // ---------------------------------------------------------------------------- // Met une archive en quarantaine (divergence MMSI) : sauvegarde son contenu // CHIFFRÉ tel que reçu (pour ne rien perdre pendant que la quarantaine est en // attente - correction d'un manque identifié en testant le mécanisme, cf // synthèse §38), enregistre la ligne dans TB_Archives_traitees, puis confirme // la suppression côté CM5 (son intégrité a déjà été vérifiée avant cet appel). // ---------------------------------------------------------------------------- // ---------------------------------------------------------------------------- // Met une archive en quarantaine faute d'affectation active (boîtier jamais // affecté, ou retiré - cf synthèse §38). Contrairement à la quarantaine MMSI, // aucun déchiffrement n'est nécessaire : sans bateau à qui l'attribuer, ni // l'en-tête ni le contenu ne servent à rien à ce stade. // ---------------------------------------------------------------------------- function Mettre_en_quarantaine_sans_affectation($db_principale, $adresse_netbird, $nom_fichier, $contenu_chiffre, $adresse_mac) { if (!is_dir(REPERTOIRE_QUARANTAINE)) @mkdir(REPERTOIRE_QUARANTAINE, 0750, true); file_put_contents(REPERTOIRE_QUARANTAINE . "/" . $nom_fichier, $contenu_chiffre); $statut = 'en_quarantaine_sans_affectation'; $requete = "INSERT INTO TB_Archives_traitees (Nom_fichier, Adresse_mac, Statut) VALUES (?, ?, ?)"; $stmt = $db_principale->prepare($requete); $stmt->bind_param('sss', $nom_fichier, $adresse_mac, $statut); $stmt->execute(); $stmt->close(); Confirmer_suppression_archive($adresse_netbird, $nom_fichier); } // ---------------------------------------------------------------------------- // Rejette une archive suite à une erreur technique NON transitoire (cf // synthèse §38) : déchiffrement, version inconnue, fichier SQL introuvable, // échec de création de génération, échec de chargement. Sauvegarde le // contenu chiffré dans un répertoire dédié (suivi manuel, reprise éventuelle // une fois la cause corrigée), enregistre Statut='rejetee' avec le détail de // l'erreur, puis confirme la suppression côté CM5 - inutile de laisser le // CM5 retransmettre indéfiniment un fichier dont le contenu ne changera // jamais. // ---------------------------------------------------------------------------- function Rejeter_archive($db_principale, $adresse_netbird, $nom_fichier, $contenu_chiffre, $adresse_mac, $id_bateau, $detail_erreur, $mmsi_recu = null, $mmsi_attendu = null) { if (!is_dir(REPERTOIRE_ERREURS)) @mkdir(REPERTOIRE_ERREURS, 0750, true); file_put_contents(REPERTOIRE_ERREURS . "/" . $nom_fichier, $contenu_chiffre); $statut = 'rejetee'; $requete = "INSERT INTO TB_Archives_traitees (Nom_fichier, Adresse_mac, ID_Bateau_affecte, ID_Bateau_final, Statut, Detail_erreur, MMSI_recu, MMSI_attendu, Date_resolution) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())"; $stmt = $db_principale->prepare($requete); $stmt->bind_param('ssiissss', $nom_fichier, $adresse_mac, $id_bateau, $id_bateau, $statut, $detail_erreur, $mmsi_recu, $mmsi_attendu); $stmt->execute(); $stmt->close(); Confirmer_suppression_archive($adresse_netbird, $nom_fichier); } function Mettre_en_quarantaine_mmsi($db_principale, $adresse_netbird, $nom_fichier, $contenu_chiffre, $adresse_mac, $id_bateau, $mmsi_recu, $mmsi_attendu) { if (!is_dir(REPERTOIRE_QUARANTAINE)) @mkdir(REPERTOIRE_QUARANTAINE, 0750, true); file_put_contents(REPERTOIRE_QUARANTAINE . "/" . $nom_fichier, $contenu_chiffre); $statut = 'en_quarantaine_mmsi'; $requete = "INSERT INTO TB_Archives_traitees (Nom_fichier, Adresse_mac, ID_Bateau_affecte, Statut, MMSI_recu, MMSI_attendu) VALUES (?, ?, ?, ?, ?, ?)"; $stmt = $db_principale->prepare($requete); $stmt->bind_param('ssisss', $nom_fichier, $adresse_mac, $id_bateau, $statut, $mmsi_recu, $mmsi_attendu); $stmt->execute(); $stmt->close(); Confirmer_suppression_archive($adresse_netbird, $nom_fichier); } // ---------------------------------------------------------------------------- // Vérifie si cette archive a déjà été traitée (quel que soit le résultat) - // garde-fou anti-double-traitement, en complément de l'index UNIQUE déjà // présent sur TB_Archives_traitees.Nom_fichier (cf synthèse §32.6/34.3). // ---------------------------------------------------------------------------- function Archive_deja_traitee($db_principale, $nom_fichier) { $requete = "SELECT 1 FROM TB_Archives_traitees WHERE Nom_fichier=?"; $stmt = $db_principale->prepare($requete); $stmt->bind_param('s', $nom_fichier); $stmt->execute(); $stmt->store_result(); $trouve = $stmt->num_rows > 0; $stmt->close(); return $trouve; } // ---------------------------------------------------------------------------- // Enregistre le résultat du traitement d'une archive dans TB_Archives_traitees // (cf synthèse §34.3). $id_generation peut être null (non encore résolu, ex : // bateau sans aucune affectation). // ---------------------------------------------------------------------------- function Enregistrer_archive_traitee($db_principale, $nom_fichier, $adresse_mac, $id_bateau, $id_generation, $statut, $mmsi_recu = null, $mmsi_attendu = null) { // MMSI_recu/MMSI_attendu toujours renseignés quand connus, même en cas de concordance parfaite - // trace d'historique, et par principe : un NULL ne doit jamais pouvoir signifier à la fois "MMSI // inconnu" et "MMSI connu mais non indiqué ici" (cf synthèse §39). $requete = "INSERT INTO TB_Archives_traitees (Nom_fichier, Adresse_mac, ID_Bateau_affecte, ID_Bateau_final, ID_Generation, Statut, MMSI_recu, MMSI_attendu, Date_resolution) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())"; $stmt = $db_principale->prepare($requete); $stmt->bind_param('ssiiisss', $nom_fichier, $adresse_mac, $id_bateau, $id_bateau, $id_generation, $statut, $mmsi_recu, $mmsi_attendu); $ok = $stmt->execute(); $stmt->close(); return $ok; } // ---------------------------------------------------------------------------- // DELETE /archives/ - confirme le traitement auprès du CM5, qui peut // alors supprimer le fichier local (cf synthèse §33.1). // ---------------------------------------------------------------------------- function Confirmer_suppression_archive($adresse_netbird, $nom_fichier) { $url = "http://" . $adresse_netbird . ":" . PORT_API_CM5 . "/archives/" . rawurlencode($nom_fichier); $ch = curl_init($url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, TIMEOUT_CONNEXION_CM5); curl_setopt($ch, CURLOPT_TIMEOUT, TIMEOUT_REQUETE_CM5); $reponse = curl_exec($ch); $code_http = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return ($reponse !== false && $code_http === 200); } // ---------------------------------------------------------------------------- // Bloc principal - pour l'instant, se limite à afficher ce qui serait traité. // Les blocs suivants remplaceront cet affichage par le traitement réel. // ---------------------------------------------------------------------------- function Executer_cron_archivage() { // Ce script tourne nativement sur le VPS (pas dans le conteneur ship2net-php) - contrairement au site // web, il ne peut pas résoudre "ship2net-db" (nom DNS interne à Docker). Le port de ship2net-db a été // publié sur 127.0.0.1:3307 uniquement (jamais exposé publiquement, ni même via NetBird) - le 3306 // natif du port est déjà pris par l'instance MariaDB native du VPS, un usage différent (cf synthèse §37). Ouverture_bdd("127.0.0.1:3307", BDD_USER, BDD_PASSWORD, BDD_DATABASE); $db = $_SESSION['db']; $boitiers = Lister_boitiers_connus($db); echo count($boitiers) . " boîtier(s) connu(s) avec une adresse NetBird renseignée.\n\n"; // Option --bateau=N (synthèse §50, traitement à la demande du portail) : seul le boîtier actuellement affecté au // bateau N est traité. Sans option : tous les boîtiers (passage régulier du timer). $bateau_filtre = 0; foreach ($GLOBALS['argv'] ?? array() as $argument) { if (preg_match('/^--bateau=([1-9][0-9]{0,9})$/', $argument, $m) === 1) $bateau_filtre = (int)$m[1]; } if ($bateau_filtre > 0) echo "Traitement limité au bateau {$bateau_filtre}.\n\n"; foreach ($boitiers as $boitier) { $adresse_mac = $boitier['Adresse_mac']; if ($bateau_filtre > 0) { $affectation = Resoudre_affectation_active($db, $adresse_mac); if (!$affectation['existe'] || (int)$affectation['id_bateau'] != $bateau_filtre) continue; } $verrou = Verrouiller_boitier($adresse_mac); if (!$verrou['ok']) { echo "Boîtier {$adresse_mac} : " . $verrou['raison'] . " - ignoré pour ce cycle.\n"; continue; } try { $resultat = Lister_archives_en_attente($boitier['Adresse_netbird']); if (!$resultat['ok']) { echo "Boîtier {$adresse_mac} : " . $resultat['raison'] . " - ignoré pour ce cycle.\n"; continue; } $nb = count($resultat['archives']); if ($nb === 0) continue; // rien à signaler pour ce boîtier ce cycle-ci // Résolu UNE SEULE FOIS par boîtier (pas par archive) : toutes les archives d'un même passage // partagent la même adresse MAC, et l'affectation ne change jamais en cours d'exécution du // script - seul un geste administratif explicite la modifie (cf synthèse §38). $affectation = Resoudre_affectation_active($db, $adresse_mac); $libelle = $affectation['existe'] ? "Bateau {$affectation['id_bateau']}" : "Boîtier {$adresse_mac} (aucune affectation active)"; echo "{$libelle} : {$nb} archive(s) en attente.\n"; foreach ($resultat['archives'] as $archive) { $nom = $archive['nom']; if (Archive_deja_traitee($db, $nom)) { echo " - {$nom} : déjà traitée - confirmation de suppression seulement.\n"; Confirmer_suppression_archive($boitier['Adresse_netbird'], $nom); continue; } $recuperation = Recuperer_archive($boitier['Adresse_netbird'], $nom); if (!$recuperation['ok']) { echo " - {$nom} : ÉCHEC de récupération (" . $recuperation['raison'] . ") - nouvelle tentative au prochain cycle.\n"; continue; } if (!Integrite_confirmee($nom, $recuperation['contenu'])) { echo " - {$nom} : empreinte invalide, archive potentiellement corrompue - nouvelle tentative au prochain cycle.\n"; continue; } // Aucune affectation active (jamais affecté, ou retiré) : quarantaine directe, sans // déchiffrement - ni l'en-tête ni le contenu ne servent à rien sans bateau à qui l'attribuer // (cf synthèse §38) : if (!$affectation['existe']) { echo " - {$nom} : aucune affectation active pour ce boîtier - mise en quarantaine.\n"; Mettre_en_quarantaine_sans_affectation($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac); continue; } $id_bateau = $affectation['id_bateau']; $dechiffrement = Decrypter_archive($recuperation['contenu']); if (!$dechiffrement['ok']) { echo " - {$nom} : ÉCHEC de déchiffrement (" . $dechiffrement['raison'] . ") - rejetée.\n"; Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Échec de déchiffrement : " . $dechiffrement['raison']); continue; } $origine_bdd = $dechiffrement['entete']['Origine_bdd'] ?? 'normale'; $mmsi_recu = $dechiffrement['entete']['No_mmsi'] ?? null; $mmsi_attendu_connu = null; // reste null si No_mmsi absent de l'en-tête - sinon toujours renseigné (cf synthèse §39) if ($mmsi_recu !== null) { $verification_mmsi = Verifier_mmsi($db, $id_bateau, $mmsi_recu); $mmsi_attendu_connu = $verification_mmsi['attendu']; if ($verification_mmsi['statut'] === 'quarantaine') { echo " - {$nom} : MMSI reçu ({$mmsi_recu}) différent de celui attendu ({$verification_mmsi['attendu']}) - mise en quarantaine.\n"; Mettre_en_quarantaine_mmsi($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, $mmsi_recu, $verification_mmsi['attendu']); continue; } } $version_cible = $dechiffrement['entete']['Version_soft_no_package'] ?? null; $generation_active = Resoudre_generation_active($db, $id_bateau); $nouvelle_generation = Nouvelle_generation_necessaire($origine_bdd, $generation_active); echo " - {$nom} : récupérée et intègre (" . strlen($recuperation['contenu']) . " octets), origine=" . $origine_bdd . "\n"; $fichier_sql = Resoudre_fichier_sql($db, $version_cible); if ($fichier_sql === false) { echo " ÉCHEC : version logicielle '{$version_cible}' inconnue de TB_Versions - rejetée.\n"; Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Version logicielle '{$version_cible}' inconnue de TB_Versions", $mmsi_recu, $mmsi_attendu_connu); continue; } $contenu_fichier_sql = @file_get_contents(REPERTOIRE_VERSIONS_SQL . "/" . $fichier_sql); if ($contenu_fichier_sql === false) { echo " ÉCHEC : fichier {$fichier_sql} introuvable sur le VPS - rejetée.\n"; Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Fichier {$fichier_sql} introuvable sur le VPS", $mmsi_recu, $mmsi_attendu_connu); continue; } if ($nouvelle_generation) { $zone_usine = Extraire_zone_usine($contenu_fichier_sql); if ($zone_usine === false) { echo " ÉCHEC : marqueur Factory_structure introuvable dans {$fichier_sql} - rejetée.\n"; Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Marqueur Factory_structure introuvable dans {$fichier_sql}", $mmsi_recu, $mmsi_attendu_connu); continue; } // 'factory'/'restauration' est toujours un fait rapporté honnêtement par le bateau - à // conserver tel quel pour le diagnostic, qu'une génération existait déjà côté serveur ou // non. Seul 'normale' sans aucune génération existante (un bateau vraiment neuf, qui n'a // jamais rien envoyé) devient 'creation_initiale' (cf synthèse §37). if ($origine_bdd === 'factory' || $origine_bdd === 'restauration') { $motif = $origine_bdd; } else { $motif = 'creation_initiale'; } $resultat_generation = Creer_generation($db, $id_bateau, $motif, $zone_usine, $version_cible); if (!$resultat_generation['ok']) { echo " ÉCHEC de création de génération : " . $resultat_generation['raison'] . " - rejetée.\n"; Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Échec de création de génération : " . $resultat_generation['raison'], $mmsi_recu, $mmsi_attendu_connu); continue; } $nom_base_cible = $resultat_generation['nom_base']; echo " Nouvelle génération créée : {$nom_base_cible} (motif={$motif}).\n"; } else { $nom_base_cible = $generation_active['nom_base']; // Migration du schéma AVANT chargement si la version logicielle a évolué (mise à jour du // bateau) - les données de l'archive ont été produites avec le nouveau schéma (synthèse §46) : $migration = Migrer_generation($db, $generation_active, $version_cible, $fichier_sql, $contenu_fichier_sql); if (!$migration['ok']) { echo " ÉCHEC : " . $migration['raison'] . " - rejetée.\n"; Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, $migration['raison'], $mmsi_recu, $mmsi_attendu_connu); continue; } echo " Génération existante : {$nom_base_cible} (" . $migration['message'] . ").\n"; } $chargement = Charger_contenu_archive($nom_base_cible, $dechiffrement['sql']); if (!$chargement['ok']) { echo " ÉCHEC de chargement dans {$nom_base_cible} : " . $chargement['raison'] . " - rejetée.\n"; Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Échec de chargement dans {$nom_base_cible} : " . $chargement['raison'], $mmsi_recu, $mmsi_attendu_connu); continue; } echo " Contenu chargé dans {$nom_base_cible}.\n"; // Résolution de la vraie Cle_primaire de la génération (celle juste créée, ou l'existante) : $generation_pour_enregistrement = Resoudre_generation_active($db, $id_bateau); Enregistrer_archive_traitee($db, $nom, $adresse_mac, $id_bateau, $generation_pour_enregistrement['cle'], 'traitee', $mmsi_recu, $mmsi_attendu_connu); Mettre_a_jour_derniere_archive($db, $id_bateau, $nom); if (Confirmer_suppression_archive($boitier['Adresse_netbird'], $nom)) { echo " Archive confirmée et supprimée côté CM5.\n"; } else { echo " AVERTISSEMENT : confirmation de suppression échouée (le fichier restera côté CM5, sans conséquence - cf synthèse §32.5).\n"; } } } finally { Liberer_boitier($verrou['poignee']); } } Fermeture_bdd(); } Executer_cron_archivage();