affectation active). // L'adresse source d'une connexion NetBird ne peut pas être usurpée (chaque pair a sa clé WireGuard) : c'est // l'identification de l'émetteur, sans mot de passe ni jeton. // Retour : ['id_bateau' => int, 'adresse_mac' => string] ou null (module inconnu, ou non affecté à un bateau) // ---------------------------------------------------------------------------- function Bateau_par_adresse_netbird($db, $adresse) { $stmt = $db->prepare("SELECT A.ID_Bateau, C.Adresse_mac FROM TB_Cartes C JOIN TB_Affectations_bateaux A ON A.Adresse_mac = C.Adresse_mac AND A.Date_fin IS NULL JOIN TB_Bateaux B ON B.Cle_primaire = A.ID_Bateau AND B.Validite = 1 WHERE C.Adresse_netbird = ?"); $stmt->bind_param('s', $adresse); $stmt->execute(); $ligne = $stmt->get_result()->fetch_row(); $stmt->close(); return $ligne ? array('id_bateau' => (int)$ligne[0], 'adresse_mac' => $ligne[1]) : null; } // ---------------------------------------------------------------------------- // Valeur entière d'un réglage de TB_Configuration (base principale), ou $defaut. // ---------------------------------------------------------------------------- function Reglage_entier($db, $nom, $defaut) { $stmt = $db->prepare("SELECT Valeur FROM TB_Configuration WHERE Nom=? AND Validite=1"); $stmt->bind_param('s', $nom); $stmt->execute(); $ligne = $stmt->get_result()->fetch_row(); $stmt->close(); $valeur = $ligne ? (int)$ligne[0] : 0; return ($valeur > 0) ? $valeur : $defaut; } // ---------------------------------------------------------------------------- // (Ré)arme le chien de garde d'un bateau : une tâche ponctuelle systemd (de l'utilisateur koriolan), qui lancera // gardien.php dans $secondes - sauf si elle est réarmée entre-temps par un nouveau signal. Aucune interrogation : une // échéance repoussée à chaque signe de vie. // Retour : true si l'échéance est programmée // ---------------------------------------------------------------------------- function Rearmer_gardien($id_bateau, $secondes) { $unite = PREFIXE_UNITE_GARDIEN.(int)$id_bateau; $environnement = Environnement_systemd_utilisateur(); // L'échéance précédente est annulée (sans erreur si elle n'existait pas ou était déjà échue) : exec($environnement."systemctl --user stop ".escapeshellarg($unite.".timer")." 2>/dev/null"); exec($environnement."systemctl --user reset-failed ".escapeshellarg($unite.".service")." 2>/dev/null"); exec($environnement."systemd-run --user --quiet --collect --unit=".escapeshellarg($unite) ." --on-active=".(int)$secondes."s /usr/bin/php ".escapeshellarg(__DIR__."/gardien.php")." ".(int)$id_bateau." 2>&1", $sortie, $code); if ($code !== 0) error_log("Rearmer_gardien(".$id_bateau.") : ".implode(" | ", $sortie)); return ($code === 0); } // ---------------------------------------------------------------------------- // Variables nécessaires à "systemctl --user" / "systemd-run --user" depuis un service système exécuté sous koriolan // (le gestionnaire systemd de l'utilisateur tourne en permanence grâce à "loginctl enable-linger koriolan"). // ---------------------------------------------------------------------------- function Environnement_systemd_utilisateur() { $uid = posix_getuid(); return "XDG_RUNTIME_DIR=/run/user/".$uid." DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/".$uid."/bus "; } // ---------------------------------------------------------------------------- // Dépose une demande de traitement des alertes (et des archives) pour un bateau - traitée par ship2net-alertes.path. // ---------------------------------------------------------------------------- function Demander_traitement_alertes($id_bateau) { if (!is_dir(REPERTOIRE_DEMANDES_ALERTES)) @mkdir(REPERTOIRE_DEMANDES_ALERTES, 02775, true); return (@file_put_contents(REPERTOIRE_DEMANDES_ALERTES."/".(int)$id_bateau, gmdate('Y-m-d H:i:s')." UTC\n") !== false); } // ============================================================================ // ÉTAPE D - Traitement des alertes (synthèse §51) // ============================================================================ const PORT_API_CM5_ALERTES = 8080; const EXPEDITEUR_ALERTES = "do_not_answer@koriolan.tech"; const NOM_EXPEDITEUR_ALERTES = "ShipHeart"; // ---------------------------------------------------------------------------- // Réglage texte de TB_Configuration (base principale), ou $defaut. // ---------------------------------------------------------------------------- function Reglage_texte($db, $nom, $defaut) { $stmt = $db->prepare("SELECT Valeur FROM TB_Configuration WHERE Nom=? AND Validite=1"); $stmt->bind_param('s', $nom); $stmt->execute(); $ligne = $stmt->get_result()->fetch_row(); $stmt->close(); return ($ligne && trim($ligne[0]) !== '') ? trim($ligne[0]) : $defaut; } // ---------------------------------------------------------------------------- // Date UTC ('Y-m-d H:i:s') affichée dans le fuseau des alertes (TB_Configuration.Fuseau_alertes, Europe/Paris par // défaut) - les destinataires d'un e-mail ou d'un SMS n'ont pas de navigateur pour la convertir. // ---------------------------------------------------------------------------- function Date_alerte($utc, $fuseau) { try { $d = new DateTime($utc, new DateTimeZone('UTC')); $d->setTimezone(new DateTimeZone($fuseau)); } catch (Exception $e) { return $utc." UTC"; } return $d->format('d/m/Y H:i'); } function Duree_alerte($secondes) { $secondes = max(0, (int)$secondes); if ($secondes < 60) return $secondes." s"; if ($secondes < 3600) return intdiv($secondes, 60)." min"; if ($secondes < 86400) return intdiv($secondes, 3600)." h ".sprintf("%02d", intdiv($secondes % 3600, 60))." min"; return intdiv($secondes, 86400)." j ".intdiv($secondes % 86400, 3600)." h"; } // ---------------------------------------------------------------------------- // Destinataires d'un bateau : ShipMembers ayant un accès ACTIF, compte valide, et au moins un moyen choisi. Un SM ayant // plusieurs accès au même bateau n'apparaît qu'une fois (moyens cumulés). // Retour : tableau de ['id', 'nom', 'email', 'telephone', 'email_choisi', 'sms_choisi', 'sms_possible'] // ---------------------------------------------------------------------------- function Destinataires_alertes($db, $id_bateau) { $stmt = $db->prepare("SELECT M.ID, TRIM(CONCAT(IFNULL(M.Prenom,''), ' ', IFNULL(M.Nom,''))), M.Email, M.Telephone, M.Telephone_verifie, MAX(L.Alertes_email), MAX(L.Alertes_sms) FROM TB_Lien_bateau_shipmember L JOIN TB_Shipmembers M ON M.ID = L.ID_Shipmember WHERE L.ID_Bateau=? AND L.Statut='actif' AND L.Fin_validite >= NOW() AND M.Validite=1 AND (L.Alertes_email=1 OR L.Alertes_sms=1) GROUP BY M.ID"); $stmt->bind_param('i', $id_bateau); $stmt->execute(); $resultat = $stmt->get_result(); $destinataires = array(); while ($l = $resultat->fetch_row()) { $destinataires[] = array('id' => (int)$l[0], 'nom' => $l[1], 'email' => $l[2], 'telephone' => $l[3], 'email_choisi' => ((int)$l[5] == 1), 'sms_choisi' => ((int)$l[6] == 1), // SMS seulement vers un numéro vérifié, au format international : 'sms_possible' => ((int)$l[4] == 1 && preg_match('/^\+[1-9][0-9]{6,14}$/', (string)$l[3]) === 1)); } $stmt->close(); return $destinataires; } // ---------------------------------------------------------------------------- // Enregistre une alerte, avec tout ce qu'il faut pour l'envoyer (objet, corps HTML de l'e-mail, texte du SMS, moyens // prévus). Statut 'a_envoyer' pour une alerte à envoyer : elle ne passera à 'envoyee' qu'APRÈS les envois // (Envoyer_alerte) - un arrêt entre les deux ne la perd pas, elle sera reprise (Reprendre_alertes_a_envoyer). // La clé unique (bateau, event, type, date) empêche de traiter deux fois le même événement. // Retour : l'ID de l'alerte créée, ou 0 si elle existait déjà (rien à faire) // ---------------------------------------------------------------------------- function Creer_alerte($db, $id_bateau, $id_event, $nom_event, $type, $date_evenement, $statut, $objet = null, $html = null, $texte_sms = null, $moyen_email = false, $moyen_sms = false) { $email = $moyen_email ? 1 : 0; $sms = $moyen_sms ? 1 : 0; $stmt = $db->prepare("INSERT IGNORE INTO TB_Alertes (ID_Bateau, ID_Event, Nom_event, Type, Date_evenement, Statut, Objet, Texte, Texte_sms, Moyen_email, Moyen_sms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); $stmt->bind_param('iisssssssii', $id_bateau, $id_event, $nom_event, $type, $date_evenement, $statut, $objet, $html, $texte_sms, $email, $sms); $stmt->execute(); $id = ($stmt->affected_rows == 1) ? (int)$db->insert_id : 0; $stmt->close(); return $id; } function Statut_alerte($db, $id_alerte, $statut) { $stmt = $db->prepare("UPDATE TB_Alertes SET Statut=? WHERE ID=?"); $stmt->bind_param('si', $statut, $id_alerte); $stmt->execute(); $stmt->close(); } // ---------------------------------------------------------------------------- // Envoi d'un SMS d'alerte (étape F) : par l'API OVH (config/fonctions_sms.php). Le texte y est ramené à l'alphabet GSM // et à 160 caractères. $reference : identifiant de l'envoi chez OVH. // ---------------------------------------------------------------------------- function Envoyer_sms($numero, $texte, &$erreur, &$reference = null) { return Envoyer_sms_ovh($numero, $texte, $erreur, $reference); } // ---------------------------------------------------------------------------- // Envoie une alerte 'a_envoyer' à ses destinataires, d'après son contenu ENREGISTRÉ, sur les moyens prévus pour elle ET // choisis par chacun ; enregistre chaque envoi ; puis passe l'alerte à 'envoyee' (ou 'sans_destinataire'). // Reprise sans doublon : un destinataire déjà servi sur un moyen (envoi enregistré, réussi ou non) ne l'est pas à // nouveau. (Un arrêt entre l'envoi d'un e-mail et son enregistrement pourrait, très rarement, le faire repartir : on // préfère un message en double à un message perdu.) // Retour : nombre de destinataires visés // ---------------------------------------------------------------------------- function Envoyer_alerte($db, $id_alerte, $destinataires) { $stmt = $db->prepare("SELECT Objet, Texte, Texte_sms, Moyen_email, Moyen_sms FROM TB_Alertes WHERE ID=? AND Statut='a_envoyer'"); $stmt->bind_param('i', $id_alerte); $stmt->execute(); $alerte = $stmt->get_result()->fetch_assoc(); $stmt->close(); if (!$alerte) return 0; $texte_sms = (string)$alerte['Texte_sms']; // ramené à 160 caractères GSM par Envoyer_sms_ovh $deja = function($id_sm, $canal) use ($db, $id_alerte) { $stmt = $db->prepare("SELECT 1 FROM TB_Alertes_envois WHERE ID_Alerte=? AND ID_Shipmember=? AND Canal=?"); $stmt->bind_param('iis', $id_alerte, $id_sm, $canal); $stmt->execute(); $trouve = ($stmt->get_result()->num_rows > 0); $stmt->close(); return $trouve; }; $enregistrer = function($id_sm, $canal, $destinataire, $ok, $erreur, $reference = null) use ($db, $id_alerte) { $statut = $ok ? 'envoye' : 'echec'; $stmt = $db->prepare("INSERT IGNORE INTO TB_Alertes_envois (ID_Alerte, ID_Shipmember, Canal, Destinataire, Statut, Reference_fournisseur, Detail_erreur) VALUES (?, ?, ?, ?, ?, ?, ?)"); $stmt->bind_param('iisssss', $id_alerte, $id_sm, $canal, $destinataire, $statut, $reference, $erreur); $stmt->execute(); $stmt->close(); }; $vises = 0; foreach ($destinataires as $d) { $vise = false; if ($alerte['Moyen_email'] && $d['email_choisi'] && filter_var($d['email'], FILTER_VALIDATE_EMAIL)) { $vise = true; if (!$deja($d['id'], 'email')) { $erreur = null; $ok = Envoi_email($d['nom'], $d['email'], (string)$alerte['Objet'], (string)$alerte['Texte'], NOM_EXPEDITEUR_ALERTES, EXPEDITEUR_ALERTES, array(), $erreur); $enregistrer($d['id'], 'email', $d['email'], $ok, $ok ? null : (string)$erreur); } } if ($alerte['Moyen_sms'] && $d['sms_choisi'] && $d['sms_possible']) { $vise = true; if (!$deja($d['id'], 'sms')) { $erreur = null; $reference = null; $ok = Envoyer_sms($d['telephone'], $texte_sms, $erreur, $reference); $enregistrer($d['id'], 'sms', $d['telephone'], $ok, $ok ? null : (string)$erreur, $reference); } } if ($vise) $vises++; } Statut_alerte($db, $id_alerte, ($vises > 0) ? 'envoyee' : 'sans_destinataire'); return $vises; } // ---------------------------------------------------------------------------- // Reprise des alertes d'un bateau restées 'a_envoyer' (arrêt du script entre leur enregistrement et leur envoi). // Retour : nombre d'alertes reprises // ---------------------------------------------------------------------------- function Reprendre_alertes_a_envoyer($db, $id_bateau, $destinataires) { $stmt = $db->prepare("SELECT ID FROM TB_Alertes WHERE ID_Bateau=? AND Statut='a_envoyer' ORDER BY ID"); $stmt->bind_param('i', $id_bateau); $stmt->execute(); $ids = array_map(fn($l) => (int)$l[0], $stmt->get_result()->fetch_all()); $stmt->close(); foreach ($ids as $id) Envoyer_alerte($db, $id, $destinataires); return count($ids); } // ---------------------------------------------------------------------------- // Lecture des déclenchements et fins d'alarmes sur le CM5 (API, route GET /alertes - étape B), à partir d'une date. // Retour : tableau trié par date (['date', 'type' (1 déclenchement, 2 fin), 'id_event', 'nom', 'alarme_email', // 'alarme_sms', 'texte_email', 'texte_sms']), ou null si le CM5 n'a pas répondu (ou ne connaît pas la route). // ---------------------------------------------------------------------------- function Lire_alarmes_cm5($adresse_netbird, $depuis_utc) { $ch = curl_init("http://".$adresse_netbird.":".PORT_API_CM5_ALERTES."/alertes?depuis=".rawurlencode($depuis_utc)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_TIMEOUT, 15); $reponse = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($reponse === false || $code !== 200) return null; $donnees = json_decode($reponse, true); if (!is_array($donnees) || ($donnees['status'] ?? '') !== 'ok' || !is_array($donnees['evenements'] ?? null)) return null; $evenements = $donnees['evenements']; usort($evenements, fn($a, $b) => strcmp((string)$a['date'], (string)$b['date'])); return $evenements; } // ---------------------------------------------------------------------------- // Fusible anti-rafales : nombre de déclenchements ET de fins d'un event dans les deux fenêtres (réglables) se terminant à // $date_utc. Retour : le motif si un seuil est franchi, null sinon. // ---------------------------------------------------------------------------- function Fusible_franchi($db, $id_bateau, $id_event, $date_utc) { foreach (array(array('Rafale_courte_nombre', 5, 'Rafale_courte_duree', 60), array('Rafale_longue_nombre', 15, 'Rafale_longue_duree', 3600)) as $s) { $nombre = Reglage_entier($db, $s[0], $s[1]); $duree = Reglage_entier($db, $s[2], $s[3]); $stmt = $db->prepare("SELECT SUM(Type='declenchement'), SUM(Type='fin') FROM TB_Alertes WHERE ID_Bateau=? AND ID_Event=? AND Type IN ('declenchement','fin') AND Date_evenement > ? - INTERVAL ? SECOND AND Date_evenement <= ?"); $stmt->bind_param('iisis', $id_bateau, $id_event, $date_utc, $duree, $date_utc); $stmt->execute(); $l = $stmt->get_result()->fetch_row(); $stmt->close(); if ((int)$l[0] >= $nombre && (int)$l[1] >= $nombre) return (int)$l[0]." déclenchements et ".(int)$l[1]." fins en ".Duree_alerte($duree); } return null; } function Event_suspendu($db, $id_bateau, $id_event) { $stmt = $db->prepare("SELECT 1 FROM TB_Suspensions_alertes WHERE ID_Bateau=? AND ID_Event=? AND Date_reactivation IS NULL"); $stmt->bind_param('ii', $id_bateau, $id_event); $stmt->execute(); $suspendu = ($stmt->get_result()->num_rows > 0); $stmt->close(); return $suspendu; }