"""Restauration garantie de l'état réseau à la fermeture de l'application. Le tunnel WireGuard modifie deux choses globales au système : la table de routage et la résolution DNS. Tant que l'application ne les rend pas, la machine reste dans un état où « il n'y a plus internet » — c'est vrai sous Linux (entrée resolvconf orpheline) comme sous Windows (service de tunnel resté en place et règle NRPT résiduelle). Ce module branche la même routine de restauration sur *toutes* les sorties : * clic sur « Quitter » dans le systray ou fermeture de la fenêtre ; * signal SIGINT / SIGTERM (Ctrl-C, arrêt de session, `kill`) ; * SIGBREAK sous Windows (fermeture de la console) ; * `atexit`, qui couvre `sys.exit()` et la fin normale de l'interpréteur ; * exception non rattrapée, via `sys.excepthook`. La restauration est idempotente : la déclencher plusieurs fois est sans effet. """ from __future__ import annotations import atexit import signal import sys import threading from typing import Callable from app.core import wireguard as wg_core from app.core.config import Config _lock = threading.Lock() _restored = False _cfg: Config | None = None _report: Callable[[str, str], None] | None = None _installed = False def _log(msg: str, kind: str = "info") -> None: if _report is not None: try: _report(msg, kind) except Exception: pass def restore_network(reason: str = "") -> list[str]: """Rend le réseau à son état d'origine. Idempotent et sans exception. Retourne la liste des actions effectuées (vide si rien n'était à faire). """ global _restored with _lock: if _restored or _cfg is None: return [] _restored = True cfg = _cfg actions: list[str] = [] try: if wg_core.is_connected(cfg): ok, msg = wg_core.disconnect(cfg) actions.append("tunnel arrêté" if ok else f"échec arrêt tunnel : {msg}") _log(f"Fermeture{(' (' + reason + ')') if reason else ''} :" f" {actions[-1]}", "info" if ok else "error") else: # Le tunnel n'est plus là, mais sa configuration DNS peut avoir # survécu : c'est précisément le cas qui coupe la résolution. actions += wg_core.force_cleanup(cfg) if actions: _log("Résidus réseau nettoyés à la fermeture : " + ", ".join(actions), "warning") except Exception as e: # noqa: BLE001 _log(f"Restauration réseau incomplète : {e}", "error") return actions def _signal_handler(signum, _frame): restore_network(f"signal {signum}") # Réinstaller le comportement par défaut puis se renvoyer le signal : # le code de sortie reste celui attendu par l'appelant (shell, systemd). try: signal.signal(signum, signal.SIG_DFL) except (ValueError, OSError): pass sys.exit(128 + int(signum)) def _excepthook(exc_type, exc, tb): restore_network("exception non rattrapée") sys.__excepthook__(exc_type, exc, tb) def install(cfg: Config, report: Callable[[str, str], None] | None = None, app=None) -> None: """Branche la restauration sur toutes les voies de sortie du programme.""" global _cfg, _report, _installed _cfg = cfg _report = report if _installed: return _installed = True atexit.register(restore_network, "atexit") sys.excepthook = _excepthook for name in ("SIGINT", "SIGTERM", "SIGBREAK", "SIGHUP"): sig = getattr(signal, name, None) if sig is None: continue try: signal.signal(sig, _signal_handler) except (ValueError, OSError): # Signal indisponible sur la plateforme ou hors thread principal. pass if app is not None: app.aboutToQuit.connect(lambda: restore_network("aboutToQuit")) # Qt bloque dans du code C : sans réveil périodique, les gestionnaires # de signaux Python ne s'exécutent jamais pendant la boucle d'événements. from PyQt6.QtCore import QTimer heartbeat = QTimer(app) heartbeat.timeout.connect(lambda: None) heartbeat.start(400) app._wgs_signal_heartbeat = heartbeat # garder une référence vivante def arm() -> None: """Réarme la restauration après une reconnexion volontaire.""" global _restored with _lock: _restored = False