"""
Client réseau : dialogue avec le serveur de signalisation et orchestre les mises à jour
WireGuard locales à chaque changement de la liste de pairs.

Flux :
1. create_network() / join_network()  -> REST, une seule fois (n'installe pas le tunnel)
2. start_listening()                   -> découvre notre endpoint public via STUN, installe
                                           le tunnel WireGuard, PUIS ouvre la WebSocket
                                           (qui reçoit les listes de pairs et met à jour
                                           WireGuard à chaque changement)

IMPORTANT — ordre STUN avant tunnel : une fois le tunnel WireGuard actif, l'interface
occupe exclusivement WIREGUARD_LISTEN_PORT (côté noyau sur Linux, côté service sur
Windows). La découverte STUN se fait donc une seule fois, AVANT l'installation du
tunnel, en utilisant ce même port local (pour que le mapping NAT observé corresponde à
celui que WireGuard utilisera ensuite). Elle n'est pas répétée après coup : le port n'est
plus disponible pour un bind() Python une fois le tunnel monté.

En plus de l'endpoint public STUN, chaque pair signale aussi son IP locale LAN au
serveur (_detect_local_ip). Quand deux pairs partagent le même sous-réseau
(_same_subnet, heuristique /24), _pick_endpoint préfère l'IP LAN directe plutôt que
l'endpoint public — évite de dépendre du hairpin NAT du routeur, peu fiable en
pratique pour deux pairs derrière le même routeur (voir README).

Ce module n'a aucune dépendance à l'UI : la GUI (gui.py) s'y connecte via des callbacks.
"""

from __future__ import annotations

import ipaddress
import json
import logging
import socket
import threading
import time
from dataclasses import dataclass
from typing import Callable, Optional

import requests
import websocket  # package "websocket-client"

import config
import licence_client
import licence_settings
import local_addresses
import local_addresses_common
import lan_verification
import firewall_manager
import reconnect_policy
import service_types
import stun_client
import tunnel_probe
import upnp_client
import windows_reserved_ports
import wireguard_manager
import ws_relay_transport

logger = logging.getLogger("meshamoto-client.network")

# Doit rester identique à `habilitation.ENTETE_JETON` côté serveur — recopié parce que
# les deux arbres ne peuvent pas partager de module (`config.py` homonymes). Un test
# lit l'autre fichier pour empêcher la divergence : elle rendrait tous les clients
# inhabilités sans qu'aucun message ne l'explique.
HABILITATION_ENTETE = "x-meshamoto-licence"

# Sentinelle : « ce pair n'a jamais été vu », à distinguer de « vu, sans endpoint public
# connu » (`None`, cas d'un pair dont ni STUN ni UPnP n'a encore abouti). Confondre les
# deux ferait passer la toute première diffusion pour un déménagement.
_ENDPOINT_JAMAIS_VU = object()

# Callback appelé à chaque mise à jour de la liste de pairs : list[dict] -> None
PeerListCallback = Callable[[list[dict]], None]
# Callback d'erreur : str -> None (affiché dans la GUI, ex: perte de connexion au serveur)
ErrorCallback = Callable[[str], None]
# (message, definitif) : `definitif` distingue une coupure passagere, dont il faut
# seulement informer, d'un refus du serveur qui exige une action de l'utilisateur.
ConnectionStateCallback = Callable[[str, bool], None]

# Le serveur repousse la liste de pairs plusieurs fois de suite pendant une connexion
# (accept() de la WebSocket, écho de notre propre endpoint_update, puis celui de
# l'autre pair...). Regrouper les mises à jour rapprochées dans cette fenêtre évite
# une réinstallation du tunnel par diffusion (donc un prompt pkexec/UAC par diffusion).
PEER_APPLY_DEBOUNCE_SECONDS = 1.0


def _server_urls(host: str, port: int, use_tls: bool) -> tuple[str, str]:
    """
    URL REST et WebSocket du serveur de signalisation.

    Le schéma était écrit en dur en `http://`/`ws://`, ce qui rendait TLS impossible :
    le mot de passe d'un réseau circulait donc en clair dès que le serveur était exposé
    sur Internet. Le hachage PBKDF2 côté serveur protège la base en cas de fuite, pas le
    mot de passe en transit.
    """
    return (
        f"{'https' if use_tls else 'http'}://{host}:{port}",
        f"{'wss' if use_tls else 'ws'}://{host}:{port}",
    )


def _detect_local_ip() -> Optional[str]:
    """
    Détecte l'IP locale (LAN) de cette machine, via l'astuce classique du socket UDP
    "connecté" sans envoi réel de paquet — identique sur Windows et Linux, pas besoin
    de backend spécifique à l'OS.
    """
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.connect(("8.8.8.8", 80))
        return sock.getsockname()[0]
    except OSError:
        return None
    finally:
        sock.close()


def _prefix_len_of(virtual_cidr: str) -> int:
    """
    Préfixe du réseau virtuel, tel qu'annoncé par le serveur.

    C'est lui qui détermine la route que l'interface WireGuard s'approprie. Il était
    écrit en dur à /8 dans le rendu du .conf, ce qui ne valait que pour l'ancienne plage
    25.0.0.0/8 : avec 192.168.192.0/24, un /8 aurait fait capter tout 192.0.0.0/8 au
    tunnel, LAN physique de l'utilisateur compris. Repli sur le défaut si le CIDR est
    illisible — un préfixe trop étroit est sans danger, contrairement à un trop large.
    """
    try:
        return ipaddress.ip_network(virtual_cidr).prefixlen
    except ValueError:
        logger.warning("CIDR de reseau illisible (%s), repli sur /%d",
                       virtual_cidr, wireguard_manager.DEFAULT_ADDRESS_PREFIX_LEN)
        return wireguard_manager.DEFAULT_ADDRESS_PREFIX_LEN


def local_ip_conflicts_with_virtual_cidr(local_ip: Optional[str], virtual_cidr: str) -> bool:
    """
    Vrai si l'IP physique de cette machine tombe dans la plage virtuelle du réseau.

    Dans ce cas, l'interface WireGuard capte une plage qui recouvre le LAN réel : les
    machines locales dont l'adresse tombe dedans (box, NAS, imprimante) deviennent
    injoignables, et les symptômes n'évoquent en rien un conflit d'adressage. Le cas est
    devenu plausible depuis le passage de la plage virtuelle à 192.168.192.0/24 : un /8
    en 25.x ne recouvrait jamais un réseau domestique, un /24 en 192.168.x le peut.

    Détecté pour pouvoir l'annoncer clairement — la parade est de relancer le serveur
    avec MESHAMOTO_VNET_CIDR sur une autre plage, puis de recréer le réseau.
    Logique pure, testable sans réseau.
    """
    if local_ip is None:
        return False
    try:
        return ipaddress.ip_address(local_ip) in ipaddress.ip_network(virtual_cidr)
    except ValueError:
        return False


def _same_subnet(ip_a: str, ip_b: str, prefix_len: int = 24) -> bool:
    """
    Heuristique volontairement simple pour l'MVP : considère deux IP comme étant sur
    le même LAN si elles partagent le même préfixe /24 (hypothèse standard pour un
    réseau domestique/petit bureau — ne lit pas le vrai masque de sous-réseau de
    l'interface, ce qui nécessiterait un accès OS-spécifique).
    """
    try:
        return (
            ipaddress.ip_interface(f"{ip_a}/{prefix_len}").network
            == ipaddress.ip_interface(f"{ip_b}/{prefix_len}").network
        )
    except ValueError:
        return False


class NetworkAlreadyExistsError(Exception):
    """
    Levée par NetworkClient.create_network() quand le réseau existe déjà (HTTP 409) —
    cas normal (un autre pair l'a créé avant nous), à distinguer d'une vraie panne
    serveur (injoignable, erreur 5xx...) qui doit, elle, être remontée à l'utilisateur.
    """


class MauvaisMotDePasseError(Exception):
    """
    Levée par NetworkClient.rotate_password() sur un HTTP 401 : le mot de passe
    courant est faux, ou le réseau n'existe pas. À distinguer d'une panne réseau,
    parce que les deux appellent des actions opposées — corriger une saisie d'un
    côté, vérifier son serveur de l'autre.
    """


@dataclass
class SessionState:
    server_base_url: str
    network_id: int
    peer_id: int
    virtual_ip: str
    cidr: str
    private_key: str
    public_key: str


class NetworkClient:
    def __init__(
        self,
        server_host: str = config.DEFAULT_SERVER_HOST,
        server_port: int = config.DEFAULT_SERVER_PORT,
        use_tls: bool = config.DEFAULT_SERVER_TLS,
    ) -> None:
        self._server_host = server_host
        self._server_port = server_port
        self._use_tls = use_tls
        self.base_url, self.ws_base = _server_urls(server_host, server_port, use_tls)
        self.session: Optional[SessionState] = None
        self._initial_peers: list[dict] = []
        self._last_applied_peers: Optional[list[dict]] = None
        self._own_local_ip: Optional[str] = None
        # Notre adresse publique (UPnP ou STUN), sans le port : sert à distinguer un pair
        # réellement sur notre LAN d'un pair distant qui utilise la même plage privée.
        self._own_public_ip: Optional[str] = None
        # Redirection UPnP : passerelle trouvée, et minuterie de renouvellement du bail.
        self._upnp_igd: Optional[upnp_client.InternetGatewayDevice] = None
        self._upnp_timer: Optional[threading.Timer] = None
        self._ws_app: Optional[websocket.WebSocketApp] = None
        # Habilitation. Vide sur une machine qui héberge son propre serveur — c'est
        # le cas par défaut, et aucun chemin ne réclame de clé quand elle l'est.
        self._licence_path = config.LICENCE_SETTINGS_PATH
        self._licence = licence_settings.charger(self._licence_path)
        # Posé quand le serveur refuse un jeton PÉRIMÉ : la boucle réactive alors au
        # lieu d'abandonner. Distinct de `_refuse_par_le_serveur`, qui est définitif.
        self._refus_reessayable = False
        self._ws_thread: Optional[threading.Thread] = None
        self._debounce_timer: Optional[threading.Timer] = None
        self._debounce_lock = threading.Lock()
        # Sérialise les réinstallations de tunnel entre elles : le debounce ne regroupe
        # que la PLANIFICATION, il n'empêche pas un nouveau timer de se déclencher
        # pendant qu'une réinstallation précédente est encore en cours (une invite
        # pkexec dure le temps que l'utilisateur tape son mot de passe, soit plusieurs
        # secondes, largement de quoi laisser passer un tour de timer). Sans ce verrou,
        # le `down` d'une application et le `up` de la suivante s'entrelacent.
        self._apply_lock = threading.Lock()
        # Écho de vivacité servi aux autres pairs (tunnel_probe). Créé seulement
        # une fois le tunnel monté : il se lie à l'IP virtuelle.
        self._echo: tunnel_probe.ServeurEcho | None = None
        # Suivi de l'état du tunnel pour la période de grâce des pings : pendant une
        # réinstallation, l'interface est down par construction, tous les pings
        # échouent — sans ce garde-fou ces échecs alimentaient le compteur du repli
        # sur relais (voir tunnel_recently_changed).
        self._tunnel_busy = False
        self._tunnel_settled_at = 0.0
        # Horodatage du dernier CHANGEMENT de liste de pairs, et compteur de diffusions
        # reçues. La grâce ci-dessus ne surveillait que NOTRE tunnel ; ce qui déstabilise
        # le réseau après une reconnexion de masse est que ceux des AUTRES changent
        # (point 76bis). Voir tunnel_recently_changed.
        self._last_peer_list_change = 0.0
        self._peer_list_broadcasts = 0
        # Résolution mémorisée de l'hôte du serveur, pour l'endpoint du relais
        # (voir _adresse_ipv4_du_serveur). Couple (hôte, ipv4).
        self._server_ipv4_cache: Optional[tuple[str, str]] = None
        self._on_apply_error: Optional[ErrorCallback] = None
        # IDs des pairs pour lesquels la connexion directe (LAN/publique) a été
        # détectée comme non fonctionnelle (voir gui.py, basé sur le ping) : on
        # préfère alors le relais UDP de secours pour ce pair, tant que ça reste vrai.
        self._relay_preferred_peer_ids: set[int] = set()
        # Dernier endpoint public connu par pair, pour repérer un déménagement (voir
        # _perimer_les_escalades_dont_le_chemin_a_change). Distinct de
        # `_last_applied_peers` : celui-ci ne retient que ce qui a été MONTÉ, alors
        # qu'un endpoint peut changer plusieurs fois entre deux montages.
        self._derniers_endpoints: dict[int, Optional[str]] = {}
        # Dernière décision prise face à un `relay_port` annoncé, par pair — sert
        # UNIQUEMENT à ne journaliser que les changements (voir
        # _journaliser_decision_de_relais). `_pick_endpoint` est appelé à chaque
        # application de liste de pairs : journaliser inconditionnellement noierait la
        # transition, qui est précisément ce qu'on cherche à lire après coup.
        self._derniere_decision_de_relais: dict[int, str] = {}
        # Toutes nos adresses privées, renseignées à la connexion (voir start_listening).
        self._own_local_addresses: list[str] = []
        # Dernières adresses locales annoncées par chaque pair, pour repérer un
        # déménagement : une preuve d'adjacence porte sur un CHEMIN, pas sur un pair
        # (leçon du point 87).
        self._dernieres_adresses_annoncees: dict[str, tuple[str, ...]] = {}
        # Mode réseau restreint : décoché par défaut, positionné par la GUI au
        # démarrage (voir ws_transport_settings). Tant qu'il est faux, `_porteurs_ws`
        # n'ouvre jamais rien — pas une seule socket.
        self._mode_reseau_restreint = False
        self._porteurs_ws = ws_relay_transport.GestionnaireDePorteurs()
        self._verificateur_lan = lan_verification.VerificateurLan(
            on_verification=self._sur_verification_lan
        )
        # Dernières configurations de pairs RENDUES, et dernière liste REÇUE.
        # Les deux existent à cause du point 93-(e) : voir _execute_apply_peers_locked
        # et _sur_verification_lan.
        self._dernieres_configs_appliquees: list[tuple] | None = None
        self._derniere_liste_recue: list[dict] = []
        # Dernier instant où le serveur a donné signe de vie sur la WebSocket, quel
        # que soit le type de trame. Voir `_surveiller_le_silence`.
        self._dernier_signe_du_serveur = 0.0
        # (endpoint public, endpoint local, toutes les adresses locales), redécouvert
        # à chaque connexion — voir `_decouvrir_nos_adresses`.
        self._nos_adresses: tuple = (None, None, [])
        self._adresses_fraiches = False
        # Reconnexion automatique de la WebSocket (point 70). `_arret_demande` distingue
        # une fermeture VOULUE (l'utilisateur quitte, change de réseau) d'une coupure
        # subie : se reconnecter après la première serait un bug, pas une robustesse.
        # L'`Event` sert à interrompre l'attente entre deux tentatives — sans lui,
        # fermer l'application pendant un recul de 30 s attendrait 30 s.
        self._arret_demande = False
        self._arret_event = threading.Event()
        # Numéro de génération de la boucle WebSocket. `start_listening` peut être
        # rappelé sur une session déjà en cours — il suffit de recliquer « Connecter »
        # sans fermer l'application — et créait alors un SECOND thread sans arrêter le
        # premier (point 70bis, observé en production). Tant qu'il n'y avait pas de
        # reconnexion, l'orpheline mourait à la première fermeture ; depuis, elle
        # survivait indéfiniment et se reconnectait pour toujours.
        # Un compteur plutôt qu'un simple `join` : une boucle en train de se rendormir
        # entre deux tentatives ne peut pas être interrompue de façon fiable par un
        # délai d'attente, alors qu'une comparaison de génération la fait sortir à coup
        # sûr au tour suivant, quel que soit son état.
        self._ws_generation = 0
        self._dernier_code_fermeture: Optional[int] = None
        self._ws_etait_ouverte = False
        # Posé par le message `refuse` du serveur : plus fiable que le code de
        # fermeture, que websocket-client rapporte à None dans ce cas précis.
        self._refuse_par_le_serveur = False

    def set_server(self, server_host: str, server_port: int, use_tls: bool = False) -> None:
        """Change le serveur cible avant de rejoindre un réseau (voir gui.py)."""
        self._server_host = server_host
        self._server_port = server_port
        self._use_tls = use_tls
        self.base_url, self.ws_base = _server_urls(server_host, server_port, use_tls)

    @property
    def server_host(self) -> str:
        """Nom ou adresse du serveur visé, sans schéma ni port. Exposé pour le
        diagnostic d'échec de connexion (voir `server_reachability`)."""
        return self._server_host

    @property
    def server_port(self) -> int:
        """Port visé. Exposé parce qu'un échec de connexion vient bien plus souvent
        du PORT que de l'adresse — le serveur documenté est lié à `127.0.0.1`
        derrière un reverse proxy, donc son port applicatif n'est joignable depuis
        aucune autre machine. Le diagnostic doit pouvoir le nommer."""
        return self._server_port

    @property
    def use_tls(self) -> bool:
        return self._use_tls

    # --- Habilitation ----------------------------------------------------

    def definir_cle_licence(self, cle: str) -> None:
        """Mémorise une nouvelle clé, et jette le jeton de l'ancienne."""
        self._licence = licence_settings.enregistrer_cle(self._licence_path, cle)

    @property
    def licence_configuree(self) -> bool:
        return self._licence.configuree

    def _entetes_habilitation(self) -> list[str]:
        """
        En-tête portant le jeton, ou liste vide sans licence configurée.

        Un EN-TÊTE et non un paramètre d'URL : le jeton est un porteur
        d'autorisation, et une URL se retrouve dans les journaux d'accès de tout
        reverse proxy traversé. Une réactivation qui échoue ne fait pas échouer la
        connexion — le serveur dira lui-même ce qu'il en pense, et c'est son message
        qui doit remonter, pas le nôtre.
        """
        try:
            jeton = self._jeton()
        except licence_client.LicenceRefuseeError as erreur:
            logger.warning("Habilitation indisponible : %s", erreur)
            return []
        return [f"{HABILITATION_ENTETE}: {jeton}"] if jeton else []

    def _jeton(self, forcer: bool = False) -> Optional[str]:
        """
        Jeton d'habilitation à présenter au serveur, ou `None` si aucune clé n'est
        configurée — cas de l'auto-hébergement, où le serveur n'en demande pas.

        **Un échec NON définitif ne jette pas le jeton en cache.** Si le service de
        licence est injoignable, un jeton encore valable doit continuer de servir :
        c'est exactement ce pour quoi il est mis en cache. Ne renoncer que lorsque le
        service a répondu, et qu'il a dit non.
        """
        if not self._licence.configuree:
            return None
        if not forcer and not licence_client.doit_reactiver(self._licence.jeton):
            return self._licence.jeton

        try:
            activation = licence_client.activer(self.base_url, self._licence.cle)
        except licence_client.LicenceRefuseeError as erreur:
            if erreur.definitif:
                raise
            logger.warning("Réactivation impossible (%s) — on garde le jeton en cache", erreur)
            return self._licence.jeton or None

        licence_settings.enregistrer_jeton(self._licence_path, activation.jeton)
        self._licence = licence_settings.Licence(
            cle=self._licence.cle, jeton=activation.jeton
        )
        logger.info(
            "Habilitation renouvelée (palier %s, valable %d jours)",
            activation.palier,
            max(0, (activation.expire_le - int(time.time())) // 86400),
        )
        return activation.jeton

    # --- REST -----------------------------------------------------------

    def create_network(self, name: str, password: str) -> dict:
        """
        Crée un nouveau réseau virtuel côté serveur.
        Lève NetworkAlreadyExistsError si le nom existe déjà (cas normal — voir la
        docstring de cette exception), ou requests.RequestException pour toute autre
        panne (serveur injoignable, timeout, erreur 5xx...).
        """
        resp = requests.post(f"{self.base_url}/networks/create", json={"name": name, "password": password, "jeton": self._jeton()}, timeout=10)
        if resp.status_code == 409:
            raise NetworkAlreadyExistsError(name)
        resp.raise_for_status()
        return resp.json()

    def rotate_password(self, name: str, ancien: str, nouveau: str) -> None:
        """
        Change le mot de passe du réseau — donc **révoque les codes d'invitation
        déjà distribués**, qui le transportent en clair.

        Lève `MauvaisMotDePasseError` sur un 401 (mot de passe courant faux, ou
        réseau inconnu) : le distinguer d'une panne réseau est indispensable, ces
        deux cas appelant des actions opposées de la part de l'utilisateur.
        """
        resp = requests.post(
            f"{self.base_url}/networks/rotate-password",
            json={"name": name, "password": ancien, "new_password": nouveau},
            timeout=10,
        )
        if resp.status_code == 401:
            raise MauvaisMotDePasseError(name)
        resp.raise_for_status()

    def kick_peer(self, name: str, password: str, peer_id: int) -> None:
        """
        Expulse un pair du réseau. Lève `MauvaisMotDePasseError` sur un 401.

        Un 404 (le pair n'est plus là) est traité comme un SUCCÈS : le résultat
        voulu est obtenu, et remonter une erreur pour un pair déjà parti ferait
        croire à un échec alors qu'il n'y a plus rien à faire.
        """
        resp = requests.post(
            f"{self.base_url}/networks/kick",
            json={"name": name, "password": password, "peer_id": peer_id},
            timeout=10,
        )
        if resp.status_code == 401:
            raise MauvaisMotDePasseError(name)
        if resp.status_code == 404:
            return
        resp.raise_for_status()

    def delete_network(self, name: str, password: str) -> None:
        """Supprime le réseau et tous ses pairs. Irréversible."""
        resp = requests.post(
            f"{self.base_url}/networks/delete",
            json={"name": name, "password": password},
            timeout=10,
        )
        if resp.status_code == 401:
            raise MauvaisMotDePasseError(name)
        resp.raise_for_status()

    def join_network(self, name: str, password: str, display_name: str) -> SessionState:
        """
        Rejoint un réseau (le crée d'abord si besoin, côté appelant). Charge notre
        identité WireGuard persistée (ou la crée au premier lancement) et l'enregistre
        auprès du serveur.

        Un `display_name` vide est transmis comme `None` et NON comme un littéral : le
        serveur conserve alors le pseudo déjà connu au lieu de l'écraser (point 69).
        Envoyer « peer » faute de mieux détruisait le nom choisi, pour tous les pairs,
        et sans retour possible.
        """
        keypair = wireguard_manager.load_own_keypair()
        resp = requests.post(
            f"{self.base_url}/networks/join",
            json={
                "name": name,
                "password": password,
                "public_key": keypair.public_key_b64,
                "display_name": display_name.strip() or None,
                "os_family": config.OS_FAMILY,
                "jeton": self._jeton(),
            },
            timeout=10,
        )
        if resp.status_code == 401:
            # Le serveur a RÉPONDU : ce n'est pas une panne de connexion, et il ne
            # faut surtout pas le laisser remonter comme un `HTTPError` brut. Le
            # 2026-07-31, un mot de passe faux sur un PC Windows s'est retrouvé
            # présenté comme un problème de port, parce que la couche au-dessus
            # ne distinguait pas « injoignable » de « joint, et il a dit non ».
            raise MauvaisMotDePasseError(name)
        resp.raise_for_status()
        data = resp.json()

        self.session = SessionState(
            server_base_url=self.base_url,
            network_id=data["network_id"],
            peer_id=data["peer_id"],
            virtual_ip=data["virtual_ip"],
            cidr=data["cidr"],
            private_key=keypair.private_key_b64,
            public_key=keypair.public_key_b64,
        )

        # Le tunnel n'est pas installé ici : start_listening() doit d'abord faire la
        # découverte STUN, qui a besoin du port WIREGUARD_LISTEN_PORT encore libre.
        self._initial_peers = data["peers"]
        return self.session

    # --- WireGuard --------------------------------------------------------

    def _perimer_les_escalades_dont_le_chemin_a_change(self, peers: list[dict]) -> None:
        """
        Oublie qu'on a réclamé un relais pour un pair dès que SON ENDPOINT CHANGE.

        **Une escalade est une preuve sur un CHEMIN, pas sur un PAIR.** `_pick_endpoint`
        garde délibérément un relais qu'on a soi-même demandé, parce qu'on a alors mesuré
        des échecs vers ce pair et qu'ignorer le relais rouvrirait la boucle échecs →
        demande → relais ignoré → échecs. Mais cette mesure ne vaut que pour l'endpoint
        auquel on l'a faite : quand le pair déménage, elle ne dit plus rien de son
        nouveau chemin, et la garder revient à le condamner au relais indéfiniment.

        Mesuré en conditions réelles le 2026-08-05, et c'est ce qui a motivé ce
        correctif. Un pair en CGNAT cellulaire avait fait escalader légitimement à
        14:58:33 ; il est ensuite revenu sur le LAN, joignable en **0,389 ms**, et restait
        acheminé par le relais du VPS à **50,73 ms** — un détour de 130× que rien
        n'arrêtait, la marque n'étant purgée nulle part. Même défaut que le marqueur de
        relais du point 71, au même endroit du raisonnement.

        Purge sur l'endpoint PUBLIC seul : c'est lui qui décide de la preuve de réseau
        local (`_preuve_de_reseau_local_partage`) et donc du chemin réellement emprunté.
        L'adresse LAN d'un pair peut changer sans que rien ne bouge pour nous — il est
        alors toujours aussi injoignable — et purger là-dessus rouvrirait la boucle que
        le garde-fou existe pour fermer.
        """
        for peer in peers:
            peer_id = peer.get("id")
            if peer_id is None:
                continue
            endpoint = peer.get("endpoint")
            precedent = self._derniers_endpoints.get(peer_id, _ENDPOINT_JAMAIS_VU)
            if precedent is _ENDPOINT_JAMAIS_VU:
                self._derniers_endpoints[peer_id] = endpoint
                continue
            if endpoint == precedent:
                continue
            self._derniers_endpoints[peer_id] = endpoint
            if peer_id not in self._relay_preferred_peer_ids:
                # Cas courant : un endpoint qui bouge sans qu'on ait jamais escaladé.
                # Rien à périmer, et le journaliser noierait le cas qui compte.
                continue
            self._relay_preferred_peer_ids.discard(peer_id)
            logger.info(
                "Le pair %s a changé d'endpoint (%s -> %s) : l'échec qui nous avait fait "
                "demander un relais ne vaut plus pour ce chemin, le direct redevient "
                "éligible",
                peer.get("display_name", peer_id), precedent, endpoint,
            )

    def _perimer_les_preuves_lan_dont_les_adresses_ont_change(self, peers: list[dict]) -> None:
        """
        Oublie la preuve d'adjacence LAN d'un pair dès que ses adresses annoncées
        changent.

        Même raisonnement qu'au point 87 : la sonde a prouvé qu'une machine répondait
        à une adresse donnée à un instant donné. Si le pair annonce autre chose, il a
        déménagé, débranché un câble ou changé de réseau — la preuve ne dit plus rien
        de son nouveau chemin, et la conserver le figerait sur une adresse périmée.
        """
        for peer in peers:
            virtual_ip = peer.get("virtual_ip")
            if not virtual_ip:
                continue
            annoncees = tuple(peer.get("local_endpoints") or ())
            precedentes = self._dernieres_adresses_annoncees.get(virtual_ip)
            self._dernieres_adresses_annoncees[virtual_ip] = annoncees
            if precedentes is not None and precedentes != annoncees:
                self._verificateur_lan.oublier(virtual_ip)

    def _sur_verification_lan(self) -> None:
        """
        Une adjacence vient d'être prouvée : réappliquer la liste courante.

        La vérification est asynchrone, donc la liste a déjà été appliquée avec
        l'endpoint public ou le relais. Sans cette réapplication, la preuve ne
        servirait qu'à la diffusion SUIVANTE — c'est-à-dire potentiellement jamais,
        un réseau stable n'en produisant plus.

        **On repart de la dernière liste REÇUE, pas de la dernière APPLIQUÉE**, et la
        nuance a coûté un diagnostic (point 93-e). Repartir de l'appliquée avait deux
        défauts qui se composaient : le garde-fou de `_execute_apply_peers_locked`
        comparait exactement cette liste, donc le rappel ne pouvait RIEN faire ; et
        `_apply_peers` annule l'application en attente, donc une liste plus fraîche
        déjà programmée était remplacée par une plus ancienne — l'invariant du
        point 78 rouvert par un autre chemin.
        """
        peers = self._derniere_liste_recue or self._last_applied_peers
        if peers:
            self._apply_peers(list(peers))

    def _apply_peers(self, peers: list[dict]) -> None:
        """
        Programme l'application de cette liste après PEER_APPLY_DEBOUNCE_SECONDS
        plutôt que tout de suite (voir la constante) — un appel qui arrive pendant le
        délai remplace le précédent, pas de cumul, seule la dernière liste reçue est
        effectivement appliquée.

        **INVARIANT À NE PAS CASSER : la DERNIÈRE liste reçue gagne, toujours.**
        Il existait ici un court-circuit qui sortait sans rien programmer quand la
        liste reçue était identique à `_last_applied_peers` — une micro-optimisation
        destinée à ne pas armer de minuterie pour un écho. Elle avait un défaut de
        correction, trouvé en production le 2026-08-04 (point 78) : elle sortait sans
        **annuler l'application déjà en attente**, alors que celle-ci porte une liste
        PLUS ANCIENNE. Le tunnel régressait donc vers un état périmé, et un pair
        parfaitement connecté en disparaissait pendant dix-huit secondes.
        Le no-op est de toute façon assuré un cran plus bas, dans
        `_execute_apply_peers_locked`, qui refait la comparaison au moment d'agir —
        c'est-à-dire au seul moment où elle a un sens. Le coût de ce qu'on a retiré
        est une minuterie qui se réveille pour ne rien faire.
        """
        if self.session is None:
            return
        # Compteur de diffusions : c'est la mesure qui manquait aux points 75 et 76bis.
        # La rafale de diffusions y a été DÉDUITE du code et des symptômes, jamais
        # observée — le serveur ne la journalise pas, et le client non plus. Sans cette
        # trace, on ne peut pas distinguer « une seule diffusion » de « huit d'affilée »
        # après coup, alors que c'est toute la différence entre un réseau stable et la
        # fenêtre où plus personne n'est en état d'être jugé.
        self._peer_list_broadcasts += 1
        self._derniere_liste_recue = peers
        self._perimer_les_escalades_dont_le_chemin_a_change(peers)
        self._perimer_les_preuves_lan_dont_les_adresses_ont_change(peers)
        self._verificateur_lan.soumettre(
            peers, self._own_local_addresses,
            self.session.cidr if self.session else None,
        )
        if peers == self._last_applied_peers:
            # Rien à changer par rapport à ce qui tourne — mais on programme quand même
            # (voir l'invariant ci-dessus) : c'est ce qui ANNULE une application en
            # attente devenue périmée. Pas de grâce ici : rien ne bouge sur le réseau,
            # l'armer sur des échos tiendrait la fenêtre ouverte en permanence.
            logger.debug(
                "Diffusion n°%d : %d pair(s), identique à la configuration appliquée",
                self._peer_list_broadcasts,
                len(peers),
            )
        else:
            # Un changement réel : c'est lui qui ouvre la fenêtre de grâce, parce que
            # c'est lui qui fait réinstaller leur interface aux pairs Windows.
            self._last_peer_list_change = time.monotonic()
            logger.info(
                "Diffusion n°%d : %d pair(s), liste modifiée",
                self._peer_list_broadcasts,
                len(peers),
            )
        with self._debounce_lock:
            if self._debounce_timer is not None:
                self._debounce_timer.cancel()
            self._debounce_timer = threading.Timer(
                PEER_APPLY_DEBOUNCE_SECONDS, self._execute_apply_peers, args=(peers,)
            )
            self._debounce_timer.daemon = True
            self._debounce_timer.start()

    def _execute_apply_peers(self, peers: list[dict]) -> None:
        """Réinstalle effectivement le tunnel avec cette liste de pairs — appelé par
        le timer de _apply_peers, jamais directement depuis le thread WebSocket.
        Sérialisé par `_apply_lock` (voir __init__)."""
        with self._apply_lock:
            self._execute_apply_peers_locked(peers)

    def _execute_apply_peers_locked(self, peers: list[dict]) -> None:
        if self.session is None:
            return
        peer_configs = [
            wireguard_manager.PeerConfig(
                public_key=p["public_key"],
                allowed_ip=f"{p['virtual_ip']}/32",
                endpoint=self._pick_endpoint(p),
            )
            for p in peers
        ]
        # Ferme les porteurs des pairs revenus en direct. Fait APRÈS le rendu, seul
        # moment où l'on connaît la liste complète — et le retour au direct est un
        # évènement normal depuis la réévaluation périodique du point 104.
        if self._mode_reseau_restreint:
            self._porteurs_ws.synchroniser(
                {p["id"] for p in peers if p.get("relay_port") and p.get("id") is not None}
            )
        # **LA COMPARAISON PORTE SUR CE QU'ON VA APPLIQUER, pas sur ce qu'on a reçu.**
        # Elle portait sur les dictionnaires diffusés (`peers == _last_applied_peers`),
        # ce qui rendait ce garde-fou AVEUGLE à tout changement d'endpoint décidé
        # localement — et il y en a : une preuve d'adjacence LAN qui vient d'être
        # établie (point 91), une escalade périmée (point 87), un relais écarté
        # (point 86). Mesuré le 2026-08-05 : `Chemin LAN direct PROUVÉ vers
        # 192.168.192.4` suivi d'AUCUNE mise à jour du tunnel, le pair restant sur
        # 19,47 ms au lieu de 0,62 ms.
        # Pire, `_sur_verification_lan` passe justement `_last_applied_peers` : le
        # rappel ne pouvait donc RIEN appliquer, par construction.
        # C'est le principe du point 29 — « la configuration rendue fait seule foi » —
        # appliqué un cran plus haut. La comparaison finale du `.conf` rendu, dans
        # `restart_tunnel_with_peers`, reste en place et reste la dernière autorité.
        configs_rendues = [(c.public_key, c.allowed_ip, c.endpoint) for c in peer_configs]
        if configs_rendues == self._dernieres_configs_appliquees:
            return
        self._tunnel_busy = True
        try:
            wireguard_manager.restart_tunnel_with_peers(
                self.session.private_key,
                self.session.virtual_ip,
                peer_configs,
                _prefix_len_of(self.session.cidr),
            )
        except RuntimeError as exc:
            logger.error("Échec de mise à jour du tunnel : %s", exc)
            if self._on_apply_error is not None:
                self._on_apply_error(str(exc))
            return
        finally:
            self._tunnel_busy = False
            self._tunnel_settled_at = time.monotonic()
        self._last_applied_peers = peers
        self._dernieres_configs_appliquees = configs_rendues
        self._demarrer_echo()

    def _demarrer_echo(self) -> None:
        """
        Met l'écho de vivacité en écoute, une fois le tunnel monté.

        Ici et pas plus tôt : le serveur se lie à l'IP VIRTUELLE, qui n'existe
        qu'une fois l'interface créée. Rejouable — les applications de liste de
        pairs se répètent à chaque changement d'effectif, et il n'y a qu'un écho.

        Un échec n'interrompt rien (voir `ServeurEcho.demarrer`) : sans écho, les
        autres pairs retombent sur l'ICMP, c'est-à-dire sur le comportement
        d'avant. Remplacer une dégradation par une panne serait un mauvais
        échange.
        """
        if self.session is None:
            return
        if self._echo is not None:
            # RELIER QUAND L'ADRESSE CHANGE, et pas seulement au premier montage.
            # Le garde-fou d'origine sortait dès qu'un écho existait, ce qui évitait
            # bien de relier le socket à chaque changement d'effectif — mais
            # empêchait aussi la reliaison quand l'IP VIRTUELLE change : rejoindre un
            # autre réseau, ou se voir réattribuer une adresse. L'écho restait alors
            # lié à une adresse qui n'existe plus sur l'interface, et ne répondait
            # plus à personne.
            #
            # Mesuré le 2026-08-04, après avoir déplacé le parc sur un autre réseau :
            # deux machines répondaient parfaitement en ICMP et acceptaient une
            # poignée de main TCP à travers le tunnel, mais leur écho était muet
            # 0/8. C'est le pire cas possible — les autres pairs les voient
            # « injoignables » et les poussent sur le relais, ce qui est exactement
            # la panne que l'écho a été écrit pour supprimer (point 71).
            if (
                self._echo.virtual_ip == self.session.virtual_ip
                and self._echo.adresses_lan == self._own_local_addresses
            ):
                return
            logger.info(
                "Adresse virtuelle changée (%s -> %s) : écho de vivacité relié",
                self._echo.virtual_ip, self.session.virtual_ip,
            )
            self._echo.arreter()
            self._echo = None
        # AVANT le bind, et l'ordre est le fond du correctif : Windows n'affiche
        # son alerte de sécurité que si aucune règle ne couvre déjà le
        # programme. Poser la nôtre d'abord empêche donc l'alerte d'apparaître,
        # donc empêche la règle `Block Public` qu'elle fabrique — on supprime la
        # cause au lieu de réparer l'effet (voir firewall_backend_windows.py).
        # Sans objet sur Linux, où le pare-feu ne raisonne pas par application.
        firewall_manager.autoriser_application(self.session.cidr)
        # Les adresses LAN servent UNIQUEMENT à répondre « qui es-tu ? » : c'est ce
        # qui permet à un pair du même lien de PROUVER notre adjacence au lieu de la
        # déduire d'un sous-réseau partagé, déduction qui est exactement le bug du
        # point 43. Elles sont toutes privées (filtrées par `local_addresses`), jamais
        # publiques : l'écho n'a aucune raison d'être joignable depuis Internet.
        echo = tunnel_probe.ServeurEcho(
            self.session.virtual_ip, adresses_lan=self._own_local_addresses
        )
        if not echo.demarrer():
            return
        self._echo = echo
        self._signaler_blocages_applicatifs()
        # `ufw` est un système séparé du nôtre : on le CONSTATE, on n'y touche pas
        # (règle posée après le point 36). Formulation conditionnelle parce qu'on
        # ne peut pas lire ses règles sans être root — affirmer un blocage non
        # mesuré enverrait chercher au mauvais endroit.
        if tunnel_probe.ufw_actif():
            logger.info(
                "ufw est actif : si vos pairs vous voient « injoignable » alors que "
                "le tunnel fonctionne, autorisez l'écho de vivacité avec : %s",
                tunnel_probe.commande_ufw_a_suggerer(self.session.cidr),
            )

    def _signaler_blocages_applicatifs(self) -> None:
        """
        Nomme les règles de pare-feu qui bloquent notre propre exécutable.

        Purement diagnostique : rien n'est supprimé (ces règles ont été créées
        hors de Meshamoto), et rien n'attend le résultat. D'où le fil dédié —
        l'interrogation énumère toutes les règles entrantes de la machine et
        peut prendre plusieurs secondes, or on est ici sous `_apply_lock`, dans
        le chemin qui monte le tunnel. Faire attendre le tunnel pour un message
        de journal serait un mauvais échange.
        """
        def travail() -> None:
            blocages = firewall_manager.blocages_applicatifs()
            if not blocages:
                return
            logger.warning(
                "%d règle(s) de pare-feu bloquent Meshamoto en entrant (%s). Vos pairs "
                "vous verront « injoignable » même si le tunnel fonctionne : un blocage "
                "l'emporte toujours sur une autorisation. Pour les retirer : %s",
                len(blocages),
                # Le profil est indispensable au libellé : Windows nomme souvent
                # ses règles d'après le seul exécutable, donc plusieurs règles
                # distinctes portent le MÊME nom d'affichage. Sans le profil, la
                # liste ressemble à un doublon (« meshamoto, meshamoto ») et
                # laisse croire à un défaut de notre côté — observé en réel le
                # 2026-08-03 sur XEON.
                ", ".join(
                    f"{regle.get('Affichage') or regle.get('Nom')} [{regle.get('Profil') or '?'}]"
                    for regle in blocages
                ),
                firewall_manager.commande_retrait_a_suggerer(blocages),
            )

        threading.Thread(target=travail, name="meshamoto-blocages", daemon=True).start()

    @property
    def tunnel_ever_applied(self) -> bool:
        """
        Vrai si le tunnel a été monté au moins une fois depuis le lancement.

        Sert à ne pas confondre « le tunnel est tombé » avec « il n'est pas
        encore monté ». Au démarrage, la liste de pairs arrive avant le montage
        (débounce de PEER_APPLY_DEBOUNCE_SECONDS) : le contrôle de santé voyait
        donc des pairs sans interface et annonçait une panne — observé en
        conditions réelles le 2026-07-30, avec un message alarmant affiché à
        chaque lancement alors que tout allait bien.
        """
        return self._last_applied_peers is not None

    def peer_dans_tunnel_applique(self, virtual_ip: str) -> bool:
        """
        Vrai si le tunnel actuellement monté contient une route vers ce pair.

        Tant que c'est faux, sonder ce pair ne mesure RIEN : il n'existe aucune
        route vers son IP virtuelle, donc l'échec est certain et ne dit rien de
        sa joignabilité. C'est le cas de tout pair qui vient d'apparaître, entre
        l'arrivée de la liste et l'application du tunnel — fenêtre bien réelle,
        puisque l'application est retardée de `PEER_APPLY_DEBOUNCE_SECONDS`.

        **Ce que ça corrige, mesuré le 2026-08-03 (point 75).** `_on_peers_updated`
        déclenche un cycle de ping IMMÉDIAT à chaque diffusion, et l'arrivée d'un
        pair en produit plusieurs coup sur coup (jointure, WebSocket, puis
        `endpoint_update` quand il découvre son endpoint). Trois diffusions
        rapprochées suffisaient donc à franchir `RELAY_FALLBACK_FAILURE_THRESHOLD`
        en une seconde, sans qu'un seul cycle de 5 s ait eu lieu : trois machines
        ont demandé un relais **200 ms** après la connexion d'un pair, pour une
        liaison directe parfaitement viable.

        La fenêtre de grâce du point 28 ne couvrait pas ce cas : elle ne s'arme
        qu'au moment où le tunnel CHANGE, donc après le débounce — trop tard.
        Et le verdict de panne locale du point 48 ne se déclenche pas non plus,
        puisque les autres pairs, eux, répondent parfaitement.
        """
        if self._last_applied_peers is None:
            return False
        return any(p.get("virtual_ip") == virtual_ip for p in self._last_applied_peers)

    def repair_tunnel(self) -> bool:
        """
        Force la réinstallation du tunnel à partir de la dernière liste de pairs
        appliquée. Renvoie False s'il n'y a rien à réparer (pas de session).

        Sert quand l'interface a disparu sans que la liste de pairs ait changé :
        driver WireGuard remplacé par une mise à jour Windows, service arrêté,
        port d'écoute volé par un autre tunnel (point 32). Dans ce cas, les deux
        court-circuits qui évitent normalement les réinstallations inutiles
        travaillent contre nous — celui sur `_last_applied_peers` ici, et celui
        sur le `.conf` rendu dans `restart_tunnel_with_peers` (point 29). Le
        second a déjà sa porte de sortie (il remonte si l'interface est absente),
        le premier est neutralisé ci-dessous.

        Passe par `_apply_lock` comme tout le reste : une réparation ne doit pas
        s'entrelacer avec une application déclenchée par le serveur (point 25).
        """
        if self.session is None or self._last_applied_peers is None:
            return False
        with self._apply_lock:
            peers = self._last_applied_peers
            # Neutralise le court-circuit d'égalité, sans quoi la réparation ne
            # ferait rien du tout : la liste est justement inchangée.
            self._last_applied_peers = None
            self._execute_apply_peers_locked(peers)
        return True

    def tunnel_recently_changed(self, grace_seconds: float) -> bool:
        """
        Vrai si le tunnel est en cours de réinstallation, ou si le réseau a bougé —
        chez nous OU chez les autres — il y a moins de `grace_seconds`.

        Pendant un `wg-quick down`/`up` l'interface n'existe pas : tous les pings vers
        les pairs échouent, quelle que soit la qualité réelle de la liaison. Et cette
        fenêtre ne dure pas quelques millisecondes mais plusieurs secondes, le temps
        que l'utilisateur réponde à l'invite pkexec. Sans ce garde-fou, ces échecs
        mécaniques alimentaient le compteur du repli sur relais : au bout de
        RELAY_FALLBACK_FAILURE_THRESHOLD, le client demandait un relais pour un pair
        parfaitement joignable, ce qui modifiait la liste de pairs (ajout du
        `relay_port`) et déclenchait une SECONDE réinstallation — donc deux invites de
        mot de passe de plus, et un tunnel qui passe par le relais au lieu du lien
        direct. Boucle de rétroaction observée en conditions réelles le 2026-07-29.

        **La grâce surveillait le mauvais évènement (point 76bis, mesuré le
        2026-08-03).** Elle ne partait que de NOTRE réinstallation. Or après une
        reconnexion de masse — un redémarrage du serveur de signalisation suffit — ce
        sont les tunnels des AUTRES qui bougent : un pair Windows n'a pas d'équivalent
        à `wg syncconf` (point 54), donc il détruit et recrée son interface à chaque
        changement d'effectif, et disparaît le temps de le faire. Nos pings vers lui
        échouaient alors pour de bon, sans que notre propre tunnel ait bougé d'un
        pouce : la grâce ne s'armait pas, et le pair distant réclamait un relais avec
        TOUS ses pairs, pour des liaisons directes parfaitement viables.

        On ne peut pas observer l'état du tunnel d'un pair. Mais on sait quand la LISTE
        DE PAIRS change, et c'est précisément le signal qui les fait tous réinstaller :
        tant qu'elle s'agite, personne n'est en état d'être jugé. D'où le maximum des
        deux horodatages ci-dessous.

        Volontairement large : un changement qui ne touche pas la configuration rendue
        (un pseudo, par exemple — point 29) ouvre quand même la fenêtre. Retarder un
        repli légitime de quelques secondes coûte beaucoup moins cher que de basculer
        tout un réseau sur le relais, qui est une dégradation dont rien ne fait
        revenir (point 66).
        """
        return self.motif_de_grace(grace_seconds) is not None

    def motif_de_grace(self, grace_seconds: float) -> Optional[str]:
        """
        Ce qui justifie la grâce en ce moment, ou None si elle n'est pas active.

        Source unique de vérité : `tunnel_recently_changed` n'est qu'un test de
        non-nullité sur ce résultat. Deux fonctions calculant séparément la même
        fenêtre finiraient par diverger.

        Le motif existe pour rendre le point 77 MESURABLE. Observé le 2026-08-04 à
        04:10 : la grâce a bien supprimé six échecs de sonde, mais notre tunnel venait
        de changer ET la liste de pairs aussi — la trace ne pouvait donc pas dire
        laquelle des deux sources avait servi, et l'apport propre du point 77 (la
        liste de pairs, pour les pairs Windows qui recréent leur interface pendant que
        le nôtre ne bouge pas) restait invérifiable.
        """
        if self._tunnel_busy:
            return "réinstallation du tunnel en cours"
        maintenant = time.monotonic()
        tunnel = (maintenant - self._tunnel_settled_at) < grace_seconds
        liste = (maintenant - self._last_peer_list_change) < grace_seconds
        if tunnel and liste:
            return "tunnel réinstallé et liste de pairs modifiée"
        if tunnel:
            return "tunnel réinstallé"
        if liste:
            return "liste de pairs modifiée"
        return None

    def _adresse_ipv4_du_serveur(self) -> str:
        """
        Adresse IPv4 littérale du serveur de signalisation, pour l'endpoint du relais.

        **Pourquoi résoudre ICI plutôt que de laisser WireGuard le faire.** Le composant
        privilégié refuse par conception tout endpoint qui n'est pas une IPv4 littérale,
        « pour éviter toute résolution déclenchée en root à partir d'une valeur fournie
        par la session » (voir `_valid_endpoint` dans `meshamoto-helper`). Passer un nom
        de domaine fait donc échouer le montage du tunnel sur Linux. La résolution se
        fait ici, dans le processus non privilégié, et le helper garde sa règle stricte.

        **Le défaut n'a pas pu se manifester avant le 2026-08-04** : le relais n'avait
        jamais été exercé qu'avec un serveur joint en `127.0.0.1` — une IP littérale, qui
        traverse la validation sans rien changer (voir les journaux des points 49 et
        67bis). Déplacer la signalisation sur un VPS, donc sur un NOM, l'a révélé.

        Passe par `getaddrinfo` et non par une table à part, ce qui fait respecter au
        passage la substitution de résolution du point 51 : quand le serveur est joint
        par son adresse locale pour contourner le hairpin NAT, le relais — qui tourne sur
        la même machine — doit l'être aussi.

        En cas d'échec, renvoie l'hôte tel quel : Windows sait résoudre de son côté, et
        un message d'erreur explicite du helper vaut mieux qu'un pair sans endpoint,
        qui serait injoignable sans que rien ne le dise.
        """
        hote = self._server_host
        if self._server_ipv4_cache and self._server_ipv4_cache[0] == hote:
            return self._server_ipv4_cache[1]
        try:
            infos = socket.getaddrinfo(hote, None, family=socket.AF_INET)
            adresse = infos[0][4][0]
        except (socket.gaierror, OSError, IndexError):
            logger.warning(
                "Résolution IPv4 impossible pour %s : endpoint de relais laissé tel quel",
                hote,
            )
            return hote
        self._server_ipv4_cache = (hote, adresse)
        return adresse

    def _behind_same_nat(self, peer: dict) -> bool:
        """
        Vrai si ce pair sort par la MÊME adresse publique que nous — seule preuve qu'il
        est réellement sur notre réseau local.

        Un préfixe privé identique n'en est pas une : `192.168.0.0/24` est la plage par
        défaut de la quasi-totalité des box grand public, donc deux pairs distants ont
        toutes les chances d'y être tous les deux. Mesuré le 2026-07-30 entre Montréal
        (`192.168.0.142`) et Québec (`192.168.0.16`) : chacun visait l'IP privée de
        l'autre SUR SON PROPRE RÉSEAU, aucun handshake ne pouvait aboutir.

        Information manquante des deux côtés (endpoint public inconnu) : on répond vrai,
        c'est-à-dire qu'on garde l'ancien comportement. Le cas est devenu rare depuis que
        l'UPnP fournit un endpoint même quand STUN est sauté (voir point 30), et se
        tromper dans ce sens ne casse que le cas LAN, déjà couvert par le hairpin quand
        le routeur le gère.
        """
        peer_endpoint = peer.get("endpoint")
        if not peer_endpoint or not self._own_public_ip:
            return True
        return peer_endpoint.rsplit(":", 1)[0] == self._own_public_ip

    def _preuve_de_reseau_local_partage(self, peer: dict) -> bool:
        """
        Preuve POSITIVE que ce pair est sur notre réseau local : même /24 privé ET même
        adresse publique, les quatre informations étant CONNUES.

        Volontairement plus stricte que `_behind_same_nat`, qui répond vrai quand
        l'information manque — défaut permissif adapté à son usage (ne pas casser le cas
        LAN quand on ne peut pas trancher), mais dangereux ici : c'est cette preuve qui
        autorise à **ignorer un relais alloué**, et un pair réellement en CGNAT dont
        l'endpoint public serait inconnu se retrouverait privé de son seul chemin. Le
        doute doit profiter au relais, jamais au LAN.
        """
        local_endpoint = peer.get("local_endpoint")
        peer_endpoint = peer.get("endpoint")
        if not (local_endpoint and peer_endpoint and self._own_local_ip and self._own_public_ip):
            return False
        if not _same_subnet(self._own_local_ip, local_endpoint.rsplit(":", 1)[0]):
            return False
        return peer_endpoint.rsplit(":", 1)[0] == self._own_public_ip

    def _journaliser_decision_de_relais(
        self, peer: dict, decision: str, message: str, *args
    ) -> None:
        """
        Journalise ce qu'on fait d'un `relay_port` annoncé — mais SEULEMENT quand la
        décision change pour ce pair.

        **Le trou que ça comble a coûté un diagnostic entier, le 2026-08-05.** Le client
        ne parlait que lorsqu'il IGNORAIT un relais ; l'adopter était muet. Impossible,
        après coup, de savoir si une diffusion portait un `relay_port` — donc impossible
        de dire si la péremption du point 87 avait changé l'issue ou si le direct était
        de toute façon le seul choix. Quatre hypothèses tenaient debout, aucune n'était
        mesurable. Même asymétrie que `request_relay_fallback`, qui ne journalise que ses
        échecs et avait déjà fait conclure de travers au point 86.
        **Ce qui manquait n'était pas « qu'avons-nous adopté »** — l'endpoint effectif est
        déjà visible dans la ligne `sync-tunnel` du composant privilégié — **mais « un
        relais était-il proposé »**. D'où une trace sur les DEUX branches : proposé et
        adopté, proposé et écarté. Le silence signifie désormais qu'aucun relais n'était
        annoncé, ce qui est une information et non une absence d'information.

        **Dédupliqué parce que `_pick_endpoint` est appelé à chaque application de liste
        de pairs.** Journaliser inconditionnellement noierait la transition — précisément
        ce qu'on cherche à relire — sous une ligne par diffusion et par pair. Même
        raisonnement qu'au point 79b pour la fenêtre de grâce, et que le
        court-circuit du cas courant dans `_perimer_les_escalades_dont_le_chemin_a_change`.
        La clé porte l'endpoint, pas seulement « adopté » : changer de port de relais est
        un évènement qui mérite sa ligne.
        """
        peer_id = peer.get("id")
        if self._derniere_decision_de_relais.get(peer_id) == decision:
            return
        self._derniere_decision_de_relais[peer_id] = decision
        logger.info(message, *args)

    def _pick_endpoint(self, peer: dict) -> Optional[str]:
        """
        Priorité : (1) relais UDP de secours dès qu'un port de relais est annoncé pour ce
        pair — **sauf** si on peut prouver que ce pair est sur notre réseau local et que
        ce n'est pas nous qui avons demandé le relais (voir plus bas) ; (2) IP LAN du pair
        s'il est sur le même sous-réseau que nous ET derrière la même adresse publique —
        évite de dépendre du hairpin NAT du routeur pour joindre un pair pourtant sur le
        même réseau local, comportement peu fiable observé en conditions réelles (voir
        "Limites connues" dans le README) ; (3) endpoint public découvert par UPnP ou STUN.

        **Le LAN bat le relais, et l'exception est sûre pour deux raisons.**
        Mesuré le 2026-08-05 : PC-INTEL, sur le même commutateur que nous (`0,57 ms` en
        ping physique), transitait par le relais du VPS à Beauharnois (`27,2 ms`) — un
        détour de 47×, dont l'hôte paie le trafic deux fois. Rien ne l'en sortait, les
        sondes passant par le relais, le compteur d'échecs ne remontait jamais.
        (a) **Le critère est symétrique et déterministe.** L'argument qui impose d'adopter
        le relais dès qu'il est annoncé (ci-dessous) tient à une décision ASYMÉTRIQUE :
        un côté a atteint son seuil d'échecs, l'autre non. Ici les deux côtés lisent les
        mêmes champs de la même diffusion (`local_endpoint` et `endpoint` de chacun) et
        en tirent forcément la même conclusion — ils quittent le relais ensemble, au même
        cycle, sans coordination. Aucun risque de parler dans le vide.
        (b) **Un chemin LAN ne dépend d'aucune traversée de NAT.** Le relais existe pour
        les pairs qui n'ont aucun chemin direct possible ; deux machines du même
        sous-réseau en ont toujours un. Un relais alloué pour une telle paire est donc
        un faux positif par construction.

        **Le garde-fou anti-boucle, lui, est indispensable.** Si c'est NOUS qui avons
        demandé ce relais, c'est que nous avons mesuré des échecs répétés vers ce pair —
        donc le chemin local est suspect malgré la preuve d'appartenance au même réseau
        (isolation de clients sur un Wi-Fi invité, pare-feu d'hôte entre machines du LAN).
        Ignorer le relais dans ce cas produirait une boucle : échecs → demande de relais →
        relais ignoré → échecs. On accepte donc toujours un relais qu'on a soi-même
        réclamé, et on n'écarte que celui demandé par l'autre côté, pour qui nous n'avons
        aucune preuve d'échec local. Vérifié sur l'incident du 2026-08-05 : notre journal
        ne contient aucune demande, les deux allocations venaient des pairs Windows.

        **Le relais est adopté même si ce n'est PAS nous qui l'avons demandé**, et c'est
        la seule façon dont il puisse fonctionner. Mesuré le 2026-07-30, premier essai
        réel : le relais est aveugle et n'apparie que deux adresses (voir
        `relay.BlindPairRelayProtocol`) — tant qu'un seul côté s'est présenté, ses paquets
        sont **silencieusement jetés**. Or l'autre côté ne reçoit alors rien par le
        relais, donc rien ne l'incite à basculer : il ne le fait que s'il atteint
        indépendamment son propre seuil d'échecs. Œuf et poule, et le journal du relais le
        montrait noir sur blanc — `(1/2)` puis plus rien, pendant que notre côté parlait
        dans le vide.
        Le `relay_port` diffusé par le serveur EST le signal de coordination qui manquait :
        il n'est alloué que si quelqu'un l'a demandé pour cette paire précise. On l'écoute
        désormais des deux côtés.
        Compromis assumé : un pair qui aurait encore un chemin direct viable est poussé
        sur le relais parce que l'autre sens échoue. C'est le bon arbitrage — un tunnel
        dont un seul sens passe ne transporte rien.
        """
        prouvee = self._verificateur_lan.adresse_verifiee(peer.get("virtual_ip"))
        if prouvee:
            self._journaliser_decision_de_relais(
                peer, f"lan-prouvé:{prouvee}",
                "Chemin LAN direct retenu pour %s : %s, adjacence PROUVÉE par sonde "
                "d'identité — ni endpoint public ni relais",
                peer.get("display_name", "?"), prouvee,
            )
            return prouvee

        if peer.get("relay_port"):
            nous_l_avons_demande = peer.get("id") in self._relay_preferred_peer_ids
            if not nous_l_avons_demande and self._preuve_de_reseau_local_partage(peer):
                self._journaliser_decision_de_relais(
                    peer, "ignoré",
                    "Relais ignoré pour %s : ce pair est sur notre réseau local (%s, même "
                    "adresse publique %s) et nous n'avons constaté aucun échec vers lui — "
                    "chemin direct conservé",
                    peer.get("display_name", "?"),
                    peer["local_endpoint"].rsplit(":", 1)[0],
                    self._own_public_ip,
                )
                return peer["local_endpoint"]
            endpoint = f"{self._adresse_ipv4_du_serveur()}:{peer['relay_port']}"
            # **Mode réseau restreint** (ws_transport_settings) : le relais est atteint
            # par une WebSocket sur 443 au lieu de l'UDP, donc WireGuard vise un socket
            # local que nous tenons. Substitué ICI et nulle part ailleurs — c'est le
            # seul endroit qui décide d'un endpoint, et un second point de décision
            # finirait par diverger.
            # Le repli est délibéré : porteur injoignable -> on garde l'endpoint UDP,
            # c'est-à-dire le comportement d'avant, plutôt que de priver le pair du
            # seul chemin qu'il avait.
            if self._mode_reseau_restreint and peer.get("id") is not None:
                local = self._porteurs_ws.endpoint_pour(
                    peer["id"], self.base_url, peer["relay_port"]
                )
                if local:
                    endpoint = local
            self._journaliser_decision_de_relais(
                peer, f"adopté:{endpoint}",
                "Relais ADOPTÉ pour %s : le serveur annonce le port %s, le trafic passera "
                "par %s (demandé par %s) — le chemin direct n'est plus emprunté",
                peer.get("display_name", "?"),
                peer["relay_port"],
                endpoint,
                "nous" if nous_l_avons_demande else "l'autre côté",
            )
            return endpoint

        self._derniere_decision_de_relais.pop(peer.get("id"), None)

        local_endpoint = peer.get("local_endpoint")
        if local_endpoint and self._own_local_ip:
            peer_ip = local_endpoint.rsplit(":", 1)[0]
            if _same_subnet(self._own_local_ip, peer_ip):
                if self._behind_same_nat(peer):
                    return local_endpoint
                logger.info(
                    "Préférence LAN écartée pour %s : même plage privée (%s) mais adresse "
                    "publique différente (%s ≠ %s) — pair distant, pas local",
                    peer.get("display_name", "?"), peer_ip,
                    (peer.get("endpoint") or "?").rsplit(":", 1)[0], self._own_public_ip,
                )
        return peer.get("endpoint")

    def definir_mode_reseau_restreint(self, actif: bool) -> None:
        """
        Active ou coupe le porteur WebSocket (voir ws_transport_settings).

        **Couper ferme les porteurs ouverts tout de suite**, sans attendre la prochaine
        diffusion : décocher la case doit produire un effet observable, sinon
        l'utilisateur qui essaie de revenir en arrière croit que le réglage est bloqué.
        La reprise du chemin UDP, elle, suit à la prochaine application de liste.
        """
        if actif == self._mode_reseau_restreint:
            return
        self._mode_reseau_restreint = actif
        logger.info("Mode réseau restreint %s", "activé" if actif else "désactivé")
        if not actif:
            self._porteurs_ws.tout_arreter()
        # **RÉAPPLIQUER, sinon le réglage n'agit qu'à la prochaine diffusion.** Même
        # défaut que celui payé au point 94 pour la preuve d'adjacence : `_pick_endpoint`
        # n'est appelé qu'à l'application d'une liste de pairs, et un réseau stable n'en
        # produit plus. Le premier essai en conditions réelles n'a marché que parce
        # qu'une escalade suivait de peu et fournissait la diffusion manquante — donc
        # par chance de minutage.
        # Réapplique aussi lorsqu'on DÉCOCHE : sans ça le tunnel resterait pointé sur
        # des porteurs qu'on vient de fermer, et le pair deviendrait injoignable au lieu
        # de revenir sur le relais UDP.
        self._sur_verification_lan()

    def request_relay_fallback(self, target_peer_id: int) -> None:
        """
        Demande une session de relais UDP de secours vers ce pair — à appeler quand la
        connexion directe (LAN/publique) échoue de façon persistante (voir gui.py, basé
        sur des échecs de ping consécutifs). Le port de relais effectif arrive ensuite via
        la prochaine mise à jour de la liste de pairs (le serveur rediffuse dès
        l'allocation), qui déclenchera automatiquement _apply_peers avec le nouvel
        endpoint — **des deux côtés**, que l'autre ait demandé ou non (voir
        `_pick_endpoint`).
        """
        if self.session is None:
            return
        # Conservé pour le diagnostic et l'affichage : ce n'est plus ce qui décide de
        # l'endpoint (le `relay_port` diffusé par le serveur suffit), mais ça garde la
        # trace de QUI a constaté l'échec, information absente de la liste de pairs.
        self._relay_preferred_peer_ids.add(target_peer_id)
        # **Garde-fou anti-boucle de la preuve LAN.** Si le chemin qu'on vient de voir
        # échouer est justement celui que la sonde avait prouvé, la preuve ne vaut plus :
        # l'écho répond sur son port alors que WireGuard n'aboutit pas (isolation de
        # clients sur un Wi-Fi invité, pare-feu d'hôte qui ne filtre pas les deux ports
        # pareil). Sans cet oubli, `_pick_endpoint` réimposerait le LAN à chaque
        # diffusion et on tournerait en rond — même famille que la boucle que le
        # point 86 ferme du côté du relais.
        self._oublier_la_preuve_lan_du_pair(target_peer_id)
        try:
            requests.post(
                f"{self.base_url}/networks/{self.session.network_id}/relay-session",
                json={"peer_id": self.session.peer_id, "target_peer_id": target_peer_id},
                timeout=10,
            )
        except requests.RequestException:
            logger.exception("Échec de la demande de session de relais pour le pair %s", target_peer_id)

    def _oublier_la_preuve_lan_du_pair(self, peer_id: int) -> None:
        """Invalide la preuve d'adjacence LAN d'un pair désigné par son identifiant."""
        for peer in self._last_applied_peers or ():
            if peer.get("id") == peer_id and peer.get("virtual_ip"):
                self._verificateur_lan.oublier(peer["virtual_ip"])
                return

    def _setup_upnp(self, local_ip: Optional[str]) -> Optional[str]:
        """
        Demande à la box une redirection du port WireGuard, et retourne l'endpoint
        public ainsi GARANTI (`ip:port`), ou None.

        Préféré à STUN quand il aboutit, pour une raison de fond : STUN ne fait
        qu'OBSERVER le mapping que le NAT a choisi, et ce mapping peut changer à tout
        moment — c'est la fragilité même du perçage de NAT. Une redirection UPnP impose
        le port et le garantit pendant la durée du bail.

        Fonctionne aussi quand le tunnel est DÉJÀ monté, contrairement à STUN dont le
        bind échouerait (WireGuard détient le port) : c'est un gain net au relancement.
        """
        if not config.UPNP_ENABLED or local_ip is None:
            return None
        igd = upnp_client.discover(local_ip=local_ip)
        if igd is None:
            return None

        port = config.WIREGUARD_LISTEN_PORT
        if not igd.add_port_mapping(port, port, local_ip, "UDP", "Meshamoto"):
            logger.info("La passerelle a refusé la redirection du port %d", port)
            return None

        adresse = igd.external_ip()
        if adresse is None:
            # Redirection créée mais adresse externe illisible : on la retire plutôt que
            # de laisser une ouverture dont on ne sait rien faire.
            igd.delete_port_mapping(port, "UDP")
            return None

        self._upnp_igd = igd
        self._schedule_upnp_renewal()
        logger.info("Redirection UPnP active : %s:%d (port garanti, bail %ds)",
                    adresse, port, upnp_client.DEFAULT_LEASE_SECONDS)
        return f"{adresse}:{port}"

    def _schedule_upnp_renewal(self) -> None:
        """Renouvelle le bail à mi-parcours : une box qui redémarre ou qui purge ses
        redirections ne doit pas nous laisser sans port ouvert jusqu'à la prochaine
        connexion."""
        if self._upnp_igd is None:
            return
        delai = max(60.0, upnp_client.DEFAULT_LEASE_SECONDS / 2)
        self._upnp_timer = threading.Timer(delai, self._renew_upnp)
        self._upnp_timer.daemon = True
        self._upnp_timer.start()

    def _renew_upnp(self) -> None:
        if self._upnp_igd is None:
            return
        port = config.WIREGUARD_LISTEN_PORT
        if self._own_local_ip and self._upnp_igd.add_port_mapping(
            port, port, self._own_local_ip, "UDP", "Meshamoto"
        ):
            # En INFO et non en DEBUG : le renouvellement est un mécanisme dont la
            # panne est SILENCIEUSE — un pair devient injoignable des heures plus tard,
            # sans rien dans le journal. Prouver qu'il tourne a coûté une expérience
            # d'une heure le 2026-08-03 (retrait manuel de la redirection, condition de
            # validité sur l'heure de démarrage du processus) là où une ligne par heure
            # répond en une seconde. Une ligne par bail n'est pas du bruit.
            logger.info("Bail UPnP renouvelé pour le port %d", port)
        else:
            logger.info("Renouvellement de la redirection UPnP en échec")
        self._schedule_upnp_renewal()

    def _teardown_upnp(self) -> None:
        """Retire la redirection à la fermeture : une ouverture orpheline sur la box
        survivrait au bail entier sans que personne ne l'écoute."""
        if self._upnp_timer is not None:
            self._upnp_timer.cancel()
            self._upnp_timer = None
        if self._upnp_igd is None:
            return
        try:
            if self._upnp_igd.delete_port_mapping(config.WIREGUARD_LISTEN_PORT, "UDP"):
                logger.info("Redirection UPnP retirée")
        except Exception:
            logger.debug("Retrait de la redirection UPnP en échec", exc_info=True)
        finally:
            self._upnp_igd = None

    def _discover_own_endpoint(self) -> Optional[str]:
        """
        Découvre notre endpoint public via STUN, à appeler impérativement AVANT
        l'installation du tunnel (voir note en tête de module). Retourne None si tous
        les serveurs STUN échouent — le pair restera alors sans endpoint connu tant que
        la connectivité STUN n'aura pas fonctionné (nécessite de relancer l'app).

        Cas particulier : si un tunnel est DÉJÀ actif (relancer l'application alors que
        l'interface d'une session précédente est restée montée), WireGuard détient le
        port en exclusivité et le bind STUN échouerait avec `Address already in use`.
        Ce n'est pas un problème : tant que ce tunnel tourne, `PersistentKeepalive`
        entretient le mapping NAT de ce même port, donc l'endpoint public déjà connu du
        serveur reste valide. On saute la découverte au lieu de logger une erreur
        inquiétante, et on renvoie None — le serveur applique un COALESCE sur ce champ,
        un endpoint nul n'écrase donc jamais celui qu'il a déjà (voir
        database.update_peer_endpoint).
        """
        if wireguard_manager.is_tunnel_active():
            logger.info(
                "Tunnel déjà actif : découverte STUN ignorée (le port %d appartient à "
                "WireGuard, et l'endpoint public déjà signalé au serveur reste valide).",
                config.WIREGUARD_LISTEN_PORT,
            )
            return None

        result = stun_client.discover_public_endpoint(config.WIREGUARD_LISTEN_PORT)
        if result is None:
            logger.warning("Découverte STUN échouée : aucun endpoint public à signaler au serveur.")
            return None
        endpoint = f"{result[0]}:{result[1]}"
        logger.info("Endpoint public découvert : %s", endpoint)
        return endpoint

    # --- WebSocket (temps réel) --------------------------------------------

    def _decouvrir_nos_adresses(self) -> tuple[Optional[str], Optional[str], list[str]]:
        """
        Redécouvre nos adresses : `(endpoint public, endpoint local, toutes les
        adresses locales)`. Met à jour l'état interne dont dépend `_pick_endpoint`.

        **Extraite de `start_listening` parce qu'elle doit être rejouée**, et pas
        seulement au démarrage. Mesuré le 2026-08-05 sur XEON : après la désactivation
        de sa carte Ethernet, le client s'est reconnecté en 3 s et a réannoncé
        `203.0.113.7:51820 (local : 192.168.0.14:51821)` — une adresse publique et
        une interface **mortes**, calculées au lancement. Ses pairs visaient donc une
        adresse qui n'existait plus, échouaient, et escaladaient vers le relais. Les
        `local_endpoints` du point 91 étaient périmés de la même façon, ce qui rendait
        la preuve d'adjacence inopérante là où elle sert le plus.
        """
        # L'IP locale d'abord : la redirection UPnP en a besoin comme client interne.
        self._own_local_ip = _detect_local_ip()
        endpoint = self._setup_upnp(self._own_local_ip) or self._discover_own_endpoint()
        if endpoint is None and self._nos_adresses[0] is not None:
            # **UNE REDÉCOUVERTE NE DÉGRADE JAMAIS CE QU'ON SAIT**, et ce garde-fou a
            # été payé comptant : sans lui, la redécouverte du point 97 a rouvert le
            # bug du point 43 en production, moins d'une heure après avoir été écrite.
            #
            # La cause est une composition de deux comportements corrects. STUN est
            # délibérément SAUTÉ quand le tunnel est déjà monté (point 30 : le port
            # appartient à WireGuard, et l'endpoint déjà signalé reste valide). Or une
            # redécouverte a lieu APRÈS le montage, par définition — donc elle rend
            # `None`. Et `_behind_same_nat` répond VRAI quand l'information manque,
            # défaut permissif assumé. Résultat mesuré le 2026-08-05 : l'adresse privée
            # d'un pair situé à 900 km retenue comme endpoint.
            #
            # Même règle que le `COALESCE` du serveur (points 33 et 69) : c'est le
            # SILENCE qui est préservé, pas la valeur. Une découverte qui ne trouve
            # rien ne dit pas « je n'ai plus d'adresse publique », elle dit « je n'ai
            # rien pu mesurer ».
            endpoint = self._nos_adresses[0]
        self._own_public_ip = endpoint.rsplit(":", 1)[0] if endpoint else None
        local_endpoint = (
            f"{self._own_local_ip}:{config.WIREGUARD_LISTEN_PORT}" if self._own_local_ip else None
        )
        # TOUTES nos adresses privées, et non la seule route par défaut : sur une
        # machine bi-domiciliée ce n'est pas forcément le bon chemin (point 90).
        # L'adresse du TUNNEL est exclue — l'annoncer ferait « prouver » un chemin qui
        # n'existe que dans le tunnel lui-même (point 91).
        self._own_local_addresses = local_addresses.adresses_locales_avec_repli(
            self._own_local_ip,
            exclure_cidr=self.session.cidr if self.session is not None else None,
        )
        endpoints = local_addresses_common.endpoints_annonces(
            self._own_local_addresses, config.WIREGUARD_LISTEN_PORT
        )
        if local_endpoint:
            logger.info(
                "IP locale détectée : %s (adresses annoncées : %s)",
                local_endpoint, ", ".join(endpoints) or "aucune",
            )
        return endpoint, local_endpoint, endpoints

    def _annoncer_nos_adresses(self, ws, adresses) -> None:
        """Envoie au serveur l'endpoint public et les adresses locales."""
        endpoint, local_endpoint, endpoints = adresses
        if endpoint is None and local_endpoint is None:
            return
        try:
            ws.send(json.dumps({
                "type": "endpoint_update",
                "endpoint": endpoint,
                "local_endpoint": local_endpoint,
                "local_endpoints": endpoints,
            }))
            logger.info(
                "Endpoint signalé au serveur : %s (local : %s)", endpoint, local_endpoint
            )
        except Exception:
            logger.exception("Échec d'envoi de l'endpoint au serveur")

    def _redecouvrir_apres_reconnexion(self, ws) -> None:
        """
        Redécouvre nos adresses après une RECONNEXION, et les réannonce si elles ont
        changé.

        **Sur un fil dédié, et ce n'est pas du confort.** `_on_open` s'exécute dans le
        thread de la WebSocket : y faire la découverte bloquerait la boucle de lecture,
        donc le traitement des pings du serveur — et la découverte UPnP coûte **8,5 s**
        sur la box du parc, contre 37 ms pour STUN (mesuré au point 46).

        **La première ouverture est sautée** : `start_listening` vient de tout calculer,
        et refaire la découverte y coûterait ces 8,5 s pour un résultat identique.
        """
        if self._adresses_fraiches:
            self._adresses_fraiches = False
            return
        precedentes = self._nos_adresses
        try:
            nouvelles = self._decouvrir_nos_adresses()
        except Exception:
            logger.exception("Échec de la redécouverte de nos adresses")
            return
        self._nos_adresses = nouvelles
        if nouvelles == precedentes:
            return
        logger.info(
            "Nos adresses ont changé depuis la connexion précédente "
            "(public %s -> %s, local %s -> %s) : nouvelle annonce au serveur",
            precedentes[0], nouvelles[0], precedentes[1], nouvelles[1],
        )
        self._annoncer_nos_adresses(ws, nouvelles)

    def start_listening(
        self,
        on_peers_updated: PeerListCallback,
        on_error: ErrorCallback,
        on_connection_state: Optional[ConnectionStateCallback] = None,
    ) -> None:
        """
        Découvre notre endpoint STUN, installe le tunnel avec les pairs déjà présents,
        puis démarre la WebSocket de signalisation dans un thread dédié.

        `on_connection_state(message, definitif)` rapporte l'état du lien de
        signalisation. Il est SÉPARÉ de `on_error` délibérément : côté interface,
        `on_error` ouvre une modale, et faire surgir une boîte bloquante à chaque
        coupure passagère — que la reconnexion résout en une seconde — serait pire que
        le silence qu'on corrige. `definitif=True` marque le seul cas où l'utilisateur
        doit vraiment agir : le serveur ne veut plus de ce pair.
        """
        if self.session is None:
            raise RuntimeError("Appelez join_network() avant start_listening().")

        self._on_apply_error = on_error
        # AVANT tout le reste : le port du tunnel tombe-t-il dans une plage que
        # Windows s'est réservée au démarrage ? Sous Windows le tunnel est lié par
        # WireGuard et non par nous, donc l'échec ne se présente pas comme un refus
        # de bind mais comme un service qui meurt au démarrage (point 32) — rien ne
        # pointerait vers cette cause. Ici et pas dans le chemin du tunnel : c'est un
        # `subprocess`, et le point 72 a montré ce que coûte un appel lent sous
        # `_apply_lock`. Sans effet hors Windows, où la fonction sort sans rien lancer.
        avertissement = windows_reserved_ports.avertissement_si_port_reserve(
            config.WIREGUARD_LISTEN_PORT, "Le port d'écoute WireGuard", config.WIREGUARD_PORT_ENV_VAR
        )
        if avertissement:
            logger.warning("%s", avertissement)
        self._nos_adresses = self._decouvrir_nos_adresses()
        # Fraîches : la première ouverture n'a rien à redécouvrir (voir `_on_open`).
        self._adresses_fraiches = True
        self._apply_peers(self._initial_peers)

        url = f"{self.ws_base}/networks/{self.session.network_id}/ws?peer_id={self.session.peer_id}"

        # Repli silencieux : `start_listening` reste appelable sans ce callback (tests,
        # usages sans interface), sans quoi l'ajouter aurait été un changement cassant.
        on_etat: ConnectionStateCallback = on_connection_state or (lambda _m, _d: None)

        def _noter_signe_de_vie(*_args) -> None:
            """
            Toute trame reçue du serveur vaut signe de vie — y compris ses PINGS.

            C'est le point clé : le serveur en envoie toutes les 20 s de lui-même
            (uvicorn), donc le silence est mesurable même quand rien ne se passe sur
            le réseau. S'appuyer sur les seules diffusions de liste ne marcherait pas :
            un réseau stable n'en produit plus, et l'absence de nouvelles y serait
            normale.
            """
            self._dernier_signe_du_serveur = time.monotonic()

        def _on_open(ws: websocket.WebSocketApp) -> None:
            # Marque une connexion réellement établie : c'est ce qui fait repartir le
            # recul de zéro à la coupure suivante (voir _boucle_websocket).
            self._ws_etait_ouverte = True
            _noter_signe_de_vie()
            # Ce qu'on SAIT, tout de suite : le serveur ne doit pas attendre une
            # découverte pour savoir où nous joindre.
            self._annoncer_nos_adresses(ws, self._nos_adresses)
            # Puis la redécouverte, sur un fil dédié — après une reconnexion nos
            # adresses ont pu changer, et c'est justement le cas qui compte.
            threading.Thread(
                target=self._redecouvrir_apres_reconnexion, args=(ws,),
                name="meshamoto-endpoint", daemon=True,
            ).start()

        def _on_message(_ws: websocket.WebSocketApp, message: str) -> None:
            _noter_signe_de_vie()
            try:
                data = json.loads(message)
            except json.JSONDecodeError:
                return
            if data.get("type") == "refuse":
                # Le serveur ne veut plus de ce pair (expulsé, réseau supprimé). On ne
                # peut PAS se fier au code de fermeture : mesuré le 2026-08-01,
                # `websocket-client` rapporte `None` quand le serveur ferme aussitôt
                # après l'acceptation, alors que le code part bien sur le fil. Sans ce
                # message, le client réessayait indéfiniment.
                # `reessayable` distingue un jeton d'habilitation PÉRIMÉ — qui se
                # règle tout seul en réactivant la clé — d'un refus définitif
                # (expulsion, réseau supprimé, clé révoquée). Sans cette distinction,
                # une échéance ferait renoncer un client parfaitement en règle et
                # afficherait une erreur là où il n'y a rien à faire.
                if data.get("reessayable"):
                    self._refus_reessayable = True
                else:
                    self._refuse_par_le_serveur = True
                logger.info("Refus du serveur : %s", data.get("raison", ""))
                return
            if data.get("type") == "peer_list":
                peers = data["peers"]
                self._apply_peers(peers)  # asynchrone (debounce) ; erreurs -> _on_apply_error
                on_peers_updated(peers)

        def _on_error(_ws: websocket.WebSocketApp, error: Exception) -> None:
            # Journalisé seulement : c'est `_boucle_websocket` qui décide de ce qu'on
            # montre à l'utilisateur, sinon une coupure banale afficherait une erreur
            # alors que la reconnexion va la résoudre en une seconde.
            logger.warning("Erreur WebSocket : %s", error)

        def _on_close(_ws: websocket.WebSocketApp, code: Optional[int], _msg: str) -> None:
            self._dernier_code_fermeture = code
            logger.info("WebSocket fermée (code %s)", code)

        generation = self._demarrer_nouvelle_generation()
        self._ws_thread = threading.Thread(
            target=self._boucle_websocket,
            args=(generation, url, _on_open, _on_message, _on_error, _on_close, on_etat,
                  _noter_signe_de_vie),
            daemon=True,
        )
        self._ws_thread.start()

    def _demarrer_nouvelle_generation(self) -> int:
        """
        Congédie la boucle WebSocket en cours, s'il y en a une, et rend le numéro de la
        nouvelle.

        `start_listening` est rappelable sur une session déjà ouverte : il suffit de
        recliquer « Connecter » sans fermer l'application. Sans ce congé, un second
        thread démarrait à côté du premier — et depuis la reconnexion automatique
        l'orpheline ne mourait plus jamais, se reconnectait indéfiniment, et chaque clic
        en ajoutait une (point 70bis, observé en production sur les journaux dédoublés
        du serveur).
        """
        self._ws_generation += 1
        ancienne = self._ws_app
        if ancienne is not None:
            try:
                # Réveille `run_forever` ; la comparaison de génération fera sortir la
                # boucle juste après, sans qu'on ait à attendre le thread.
                ancienne.close()
            except Exception:
                logger.debug("Fermeture de l'ancienne WebSocket sans effet", exc_info=True)
        # L'arrêt éventuellement demandé par un `stop()` précédent ne doit pas empêcher
        # cette nouvelle session de vivre.
        self._arret_demande = False
        self._arret_event.clear()
        return self._ws_generation

    def _surveiller_le_silence(self, app, generation: int, arret: threading.Event) -> None:
        """
        Ferme la WebSocket si le serveur cesse de donner signe de vie.

        **Défense en profondeur, et elle est motivée par une panne réelle.** Le
        keepalive de `run_forever` fait ce travail et fonctionne — trois reproductions
        sous Linux le 2026-08-05, détection en 25 s à chaque fois, y compris à travers
        un trou noir en TLS. Mais sur une machine WINDOWS il n'a pas fonctionné : le
        client s'est cru connecté treize minutes après que le serveur l'eut retiré,
        sans une ligne de journal, et n'est revenu que sur une action humaine
        (point 93-d). La cause n'est pas établie et n'était pas accessible depuis la
        machine de développement.

        Ce surveillant ne cherche donc pas à expliquer la panne : il rend la détection
        indépendante de la bibliothèque. Fermer l'application WebSocket suffit —
        `run_forever` rend la main, et la boucle de reconnexion existante fait le
        reste, exactement comme pour une coupure ordinaire.
        """
        while not arret.wait(config.WEBSOCKET_PING_TIMEOUT_SECONDS):
            if self._arret_demande or generation != self._ws_generation:
                return
            silence = time.monotonic() - self._dernier_signe_du_serveur
            if silence < config.WEBSOCKET_SILENCE_MAX_SECONDS:
                continue
            logger.warning(
                "Aucun signe du serveur depuis %.0f s alors que la liaison paraît "
                "ouverte : fermeture forcée pour déclencher une reconnexion",
                silence,
            )
            try:
                app.close()
            except Exception:
                logger.debug("Fermeture forcée impossible", exc_info=True)
            return

    def _boucle_websocket(self, generation, url, on_open, on_message, on_ws_error,
                          on_close, on_etat, noter_signe_de_vie=None) -> None:
        """
        Maintient la WebSocket ouverte, et la rouvre quand elle tombe.

        Existe parce qu'il n'y avait AUCUNE reprise (point 70) : `run_forever` était
        appelé une fois, et à la première fermeture le thread se terminait. Un
        redémarrage du serveur ou une coupure Wi-Fi laissait le pair silencieusement
        dehors, jusqu'à ce que quelqu'un relance l'application.

        La politique est dans `reconnect_policy`, pure et testée à part ; ici il ne
        reste que la mécanique. `run_forever(reconnect=...)` de websocket-client aurait
        évité cette boucle mais ne permet pas de s'ARRÊTER sur un code applicatif —
        or c'est exactement ce qu'il faut faire quand le serveur répond « ce pair ne
        fait plus partie du réseau ».
        """
        tentative = 0
        # La comparaison de génération est ce qui congédie une boucle devenue
        # obsolète : elle sort au tour suivant, quel que soit son état.
        while not self._arret_demande and generation == self._ws_generation:
            self._dernier_code_fermeture = None
            self._refuse_par_le_serveur = False
            # Le jeton est relu À CHAQUE TENTATIVE et non capturé une fois : une
            # session peut durer plus longtemps qu'un jeton, et la reconnexion est
            # justement le moment où l'on présente une habilitation fraîche.
            entetes = self._entetes_habilitation()
            app = websocket.WebSocketApp(
                url, header=entetes, on_open=on_open, on_message=on_message,
                on_error=on_ws_error, on_close=on_close,
                # Les PINGS du serveur sont le battement sur lequel s'appuie
                # `_surveiller_le_silence` : uvicorn en envoie toutes les 20 s de
                # lui-même, donc le silence reste mesurable sur un réseau au repos.
                on_ping=noter_signe_de_vie, on_pong=noter_signe_de_vie,
            )
            if generation != self._ws_generation:
                return
            self._ws_app = app
            self._dernier_signe_du_serveur = time.monotonic()
            fin_surveillance = threading.Event()
            threading.Thread(
                target=self._surveiller_le_silence,
                args=(app, generation, fin_surveillance),
                name="meshamoto-ws-silence", daemon=True,
            ).start()
            try:
                # Le keepalive est ce qui permet de CONSTATER une coupure : sans lui, une
                # connexion à moitié ouverte (réseau du poste disparu, aucun RST ni FIN)
                # n'est détectée qu'à l'expiration TCP du système — onze minutes mesurées
                # sous Windows le 2026-08-05, pendant lesquelles le pair se croit
                # connecté et n'essaie rien. Voir config, WEBSOCKET_PING_*.
                app.run_forever(
                    ping_interval=config.WEBSOCKET_PING_INTERVAL_SECONDS,
                    ping_timeout=config.WEBSOCKET_PING_TIMEOUT_SECONDS,
                )
            except Exception:
                logger.exception("La WebSocket s'est interrompue sur une exception")
            finally:
                # Sans quoi un surveillant survivrait à chaque tour de boucle, et une
                # session longue en accumulerait un par reconnexion — la famille de
                # fuites des points 45, 70bis et 94.
                fin_surveillance.set()

            # La connexion avait-elle abouti ? Si oui, la coupure qui suit est un
            # incident neuf et le compteur de recul repart de zéro : sans ça, une
            # session de plusieurs heures qui tombe une seconde fois attendrait
            # d'emblée le délai maximum.
            if self._ws_etait_ouverte:
                tentative = 0
                self._ws_etait_ouverte = False

            if self._refus_reessayable:
                # Habilitation périmée : on en redemande une, puis on réessaie. Le
                # cas se termine tout seul — si la CLÉ est réellement échue, le
                # service de licence le dit, et l'échec devient définitif ci-dessous.
                self._refus_reessayable = False
                try:
                    self._jeton(forcer=True)
                except licence_client.LicenceRefuseeError as erreur:
                    on_etat(erreur.message, True)
                    return

            code = (
                reconnect_policy.CODE_PEER_INCONNU
                if self._refuse_par_le_serveur
                else self._dernier_code_fermeture
            )
            decision = reconnect_policy.decider(
                code, tentative + 1, self._arret_demande
            )
            if not decision.reessayer:
                if decision.message:
                    # Définitif : l'utilisateur doit agir, donc on l'interrompt.
                    on_etat(decision.message, True)
                return

            tentative += 1
            if tentative == 1:
                # Une seule fois par coupure : répéter le message à chaque tentative
                # transformerait une panne en avalanche de notifications.
                on_etat(decision.message, False)
            logger.info(
                "Reconnexion dans %.0fs (tentative %d)", decision.delai, tentative
            )
            # `wait` et non `sleep` : une fermeture demandée pendant l'attente doit
            # être prise en compte tout de suite, pas au bout de 30 secondes.
            if self._arret_event.wait(decision.delai):
                return

    def announce_services(self, services: list) -> None:
        """
        Déclare l'état COMPLET des services locaux hébergés, diffusé aux autres pairs
        via le serveur de signalisation pour qu'ils puissent s'y connecter directement
        (voir gui.py, "Mes services hébergés").

        `services` est une liste de `service_types.Service`. L'état est toujours envoyé
        en entier plutôt que par différence : une liste vide efface tout, et le serveur
        n'a aucun état à reconstituer — ce qui évite qu'un message perdu laisse les
        deux côtés en désaccord sans que rien ne le signale.
        """
        if self._ws_app is None:
            return
        self._ws_app.send(
            json.dumps(
                {
                    "type": "service_update",
                    "services": service_types.en_charge_utile(services),
                }
            )
        )

    def clear_service(self) -> None:
        """Efface tous les services déclarés (voir announce_services)."""
        self.announce_services([])

    def stop(self) -> None:
        """Arrête proprement la WebSocket et désinstalle le tunnel WireGuard."""
        # AVANT de fermer la WebSocket : sinon la boucle de reconnexion verrait une
        # fermeture qu'elle prendrait pour une coupure subie, et rouvrirait aussitôt
        # la connexion qu'on est en train de fermer.
        self._arret_demande = True
        self._arret_event.set()
        with self._debounce_lock:
            if self._debounce_timer is not None:
                self._debounce_timer.cancel()
                self._debounce_timer = None
        self._teardown_upnp()
        if self._echo is not None:
            self._echo.arreter()
            self._echo = None
        self._verificateur_lan.arreter()
        # Les porteurs WebSocket portent deux fils chacun : les laisser derrière soi
        # est la famille de fuites des points 45, 70bis et 94.
        self._porteurs_ws.tout_arreter()
        if self._ws_app is not None:
            self._ws_app.close()
        try:
            wireguard_manager.uninstall_tunnel()
        except Exception:
            logger.exception("Échec lors de la désinstallation du tunnel à l'arrêt")
