diff --git a/Makefile b/Makefile index 392a73b..cf61d0d 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,12 @@ BUILD := build ICON := /tmp/wgsecure_icon_build.png # Options PyInstaller communes aux deux plateformes +# `--clean` vide le répertoire de travail au démarrage. Linux et Windows +# portant le même `--name`, ils partageaient `build/wgsecure/` : lancés à la +# suite, chacun effaçait les fichiers intermédiaires de l'autre en plein +# travail. Résultat observé le 8 septembre 2026 — un .exe Windows gonflé de +# 44 Mo et un binaire Linux tronqué dont l'archive ne se décompressait plus +# (« decompression resulted in return code -3 »). D'où un workpath par cible. PI_OPTS := \ --noconfirm \ --clean \ @@ -26,7 +32,7 @@ PI_OPTS := \ --hidden-import cryptography.hazmat.primitives.asymmetric.x25519 \ --collect-submodules PyQt6 -.PHONY: all linux windows helper windows-release check-windows-release installer-deps release publish version changelog install install-gnome uninstall-gnome run run-admin icon reset-password setup-sudoers check-privileges clean clean-all help venv +.PHONY: all linux windows helper windows-release check-windows-release installer-deps release publish publish-updates version changelog install install-gnome uninstall-gnome run run-admin icon reset-password setup-sudoers check-privileges clean clean-all help venv # ── Cible par défaut ──────────────────────────────────────────────────────── all: linux @@ -66,6 +72,7 @@ linux: install icon @echo " 🐧 Compilation Linux (PyInstaller onefile)…" @echo "" $(PYTHON) -m PyInstaller $(PI_OPTS) \ + --workpath build/linux \ --onefile \ --icon=$(ICON) \ --add-data "CHANGELOG.md:." \ @@ -92,6 +99,7 @@ windows: icon || (echo " ❌ qwindows.dll introuvable dans $(WINE_QT_PLUGINS)/platforms"; \ echo " → Installez PyQt6 dans Wine avec : make setup-wine"; exit 1) @wine $(WINE_PYTHON) -m PyInstaller $(PI_OPTS) \ + --workpath build/windows \ --onefile \ --windowed \ --icon=$(ICON) \ @@ -129,6 +137,7 @@ helper: @wine $(WINE_PYTHON) -m PyInstaller \ --noconfirm \ --clean \ + --workpath build/helper \ --name wgsecure-helper \ --onefile \ --hidden-import win32timezone \ @@ -248,6 +257,51 @@ publish: release done; \ bash "$(GITEA_RELEASE_SH)" --version $(VERSION) $$ASSETS +# ── Publication des artefacts vers le serveur de mise à jour ───────────────── +# Le client va chercher ses mises à jour sur le serveur WGSecure, pas sur +# Gitea : le dépôt est privé, et un jeton embarqué dans une application +# distribuée est un jeton donné à tous ses utilisateurs. Cette cible dépose +# les binaires et le manifeste que la route /api/version publie. +# +# Le condensat est calculé ici, une fois, et non par le serveur à chaque +# appel : un SHA-256 sur 125 Mo à chaque interrogation bloquerait l'API. +UPDATE_HOST := root@192.168.1.206 +UPDATE_DIR := /opt/WGSecure-Serveur/artifacts + +# ANNOUNCE permet d'annoncer les artefacts sous un autre numéro que celui +# qu'ils portent : `make publish-updates ANNOUNCE=9.9.9` fait croire au parc +# qu'une version existe, sans avoir à en compiler une seconde. Sert à exercer +# la chaîne de mise à jour — détection, téléchargement, empreinte, bascule — +# avec les binaires déjà construits. +ANNOUNCE ?= $(VERSION) + +publish-updates: + @test -f "$(DIST)/$(APP)-$(VERSION)-linux-x86_64" \ + || (echo " ❌ Artefact Linux absent — lancez d'abord : make release"; exit 1) + @echo "" + @echo " 📤 Publication des artefacts $(VERSION) vers $(UPDATE_HOST)…" + @FILES="$(DIST)/$(APP)-$(VERSION)-linux-x86_64"; \ + [ -f "$(DIST)/$(APP)-$(VERSION)-setup.exe" ] \ + && FILES="$$FILES $(DIST)/$(APP)-$(VERSION)-setup.exe" \ + || echo " ⚠️ Installeur Windows absent : seuls les postes Linux seront servis"; \ + scp -q $$FILES $(UPDATE_HOST):$(UPDATE_DIR)/ + @$(PYTHON) -c "import json, hashlib, os, sys; \ + v='$(VERSION)'; a='$(ANNOUNCE)'; d='$(DIST)'; app='$(APP)'; \ + spec=[('linux-x86_64', f'{app}-{v}-linux-x86_64'), ('windows-setup', f'{app}-{v}-setup.exe')]; \ + arts=[{'key': k, 'filename': n, 'size': os.path.getsize(f'{d}/{n}'), \ + 'sha256': hashlib.sha256(open(f'{d}/{n}','rb').read()).hexdigest()} \ + for k, n in spec if os.path.isfile(f'{d}/{n}')]; \ + notes=open('CHANGELOG.md', encoding='utf-8').read().split('## [')[1]; \ + json.dump({'version': a, 'notes': '## [' + notes.split('\n---')[0].strip(), \ + 'artifacts': arts}, open('/tmp/wgs-manifest.json','w', encoding='utf-8'), \ + ensure_ascii=False, indent=2); \ + print(f' 🔑 {len(arts)} artefact(s) de {v}, annonces comme {a}')" + @scp -q /tmp/wgs-manifest.json $(UPDATE_HOST):$(UPDATE_DIR)/manifest.json + @ssh $(UPDATE_HOST) "chown -R wgsecure:wgsecure $(UPDATE_DIR)" + @rm -f /tmp/wgs-manifest.json + @echo " ✅ Mise à jour $(ANNOUNCE) proposée aux clients" + @echo "" + # ── Version courante ────────────────────────────────────────────────────────── version: @echo "$(VERSION)" diff --git a/app/core/updater.py b/app/core/updater.py new file mode 100644 index 0000000..21d9f4f --- /dev/null +++ b/app/core/updater.py @@ -0,0 +1,243 @@ +"""Mise à jour du client, servie par le serveur depuis l'intérieur du tunnel. + +Les binaires ne peuvent pas venir du dépôt Gitea : il est privé, et un jeton +embarqué dans une application distribuée est un jeton donné à tous ses +utilisateurs. Ils viennent donc du serveur WGSecure, où l'accès est déjà +tranché — l'API n'est joignable que par le tunnel, et on y parvient parce que +sa clé WireGuard est déclarée. Aucun secret à embarquer ici. + +Rien ne s'installe sans un clic : la vérification est automatique, la suite ne +l'est pas. Cette application monte un VPN, et une version défaillante qui se +propagerait seule couperait l'accès de tout un parc sans que personne ne l'ait +demandé. + +L'archive téléchargée est vérifiée contre le condensat SHA-256 annoncé avant +d'être installée. Le canal est déjà authentifié par WireGuard ; le condensat +couvre ce que le chiffrement ne couvre pas — un fichier tronqué, un disque +plein, un artefact mal publié. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +import tempfile +import urllib.error +import urllib.request +from typing import Callable, NamedTuple + +from app.core.config import Config +from app.core.vpn_session import _OPENER, api_base_url +from app.utils.helper_ipc import call_helper +from app.utils.platform_utils import is_windows + +# Le sondage part au montage du tunnel, dans un thread que l'arrêt de +# l'application attend : bref, donc, comme celui de l'état d'accès. +_CHECK_TIMEOUT = 5 +# Le téléchargement, lui, porte sur 125 Mo : la borne couvre une liaison lente, +# pas un serveur muet — l'absence de premier octet est tranchée par le sondage. +_DOWNLOAD_TIMEOUT = 600 +_CHUNK = 256 * 1024 + + +class Update(NamedTuple): + """Une version disponible et l'artefact qui convient à ce poste.""" + + version: str + notes: str + key: str + filename: str + size: int + sha256: str + + +def _parse_version(raw: str) -> tuple[int, ...]: + """« 0.10.0 » → (0, 10, 0), pour comparer autrement qu'alphabétiquement. + + Comparées comme des chaînes, « 0.9.1 » passait pour plus récent que + « 0.10.0 » : le neuvième caractère décidait de tout. + """ + parts = [] + for chunk in (raw or "").strip().lstrip("vV").split("."): + digits = "".join(c for c in chunk if c.isdigit()) + parts.append(int(digits) if digits else 0) + return tuple(parts) or (0,) + + +def artifact_key() -> str: + """Artefact attendu par ce poste. + + Sous Windows, c'est l'installeur et non l'exécutable nu : le `.exe` en + cours ne peut pas être remplacé pendant qu'il tourne, alors que + l'installeur sait arrêter, remplacer et relancer. + """ + return "windows-setup" if is_windows() else "linux-x86_64" + + +def check(cfg: Config, current: str) -> Update | None: + """Une version plus récente que `current` est-elle publiée ? + + `None` couvre tous les cas où il n'y a rien à proposer : serveur muet, + serveur qui ne distribue pas de mise à jour, version identique ou plus + ancienne, artefact absent pour cette plateforme. Aucun n'est une panne, et + aucun ne doit se voir — une vérification qui échoue ne doit jamais + interrompre quelqu'un qui voulait juste ouvrir son VPN. + """ + base = api_base_url(cfg) + if not base: + return None + request = urllib.request.Request(f"{base}/api/version", method="GET") + try: + with _OPENER.open(request, timeout=_CHECK_TIMEOUT) as response: + body = json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, OSError, ValueError): + return None + + latest = (body.get("version") or "").strip() + if not latest or _parse_version(latest) <= _parse_version(current): + return None + + key = artifact_key() + entry = next((a for a in body.get("artifacts", []) + if a.get("key") == key), None) + if not entry or not entry.get("sha256"): + return None + return Update(version=latest, notes=body.get("notes", ""), key=key, + filename=entry.get("filename", key), + size=int(entry.get("size") or 0), + sha256=entry["sha256"].lower()) + + +def download(cfg: Config, update: Update, + progress: Callable[[int, int], None] | None = None, + dest_dir: str = "") -> str: + """Récupère l'artefact et rend son chemin, ou lève. + + Écrit dans un fichier temporaire vérifié avant d'être présenté : un + téléchargement interrompu ne doit jamais laisser derrière lui quelque + chose qui ressemble à une mise à jour valide. + + `dest_dir` place ce temporaire là où le fichier finira. Il allait dans + `/tmp`, souvent un autre système de fichiers que `~/.local/bin` : la mise + en place se faisait alors par copie — donc par écriture sur l'exécutable + en cours — et le noyau la refusait (« Fichier texte occupé »). Sur le même + système de fichiers, c'est un simple renommage, que rien n'interdit. + """ + base = api_base_url(cfg) + if not base: + raise RuntimeError("Adresse du serveur de mise à jour inconnue.") + + request = urllib.request.Request( + f"{base}/api/version/download/{update.key}", method="GET") + digest = hashlib.sha256() + received = 0 + fd, tmp = tempfile.mkstemp(prefix=".wgsecure-update-", + suffix=os.path.splitext(update.filename)[1], + dir=dest_dir or None) + try: + with _OPENER.open(request, timeout=_DOWNLOAD_TIMEOUT) as response, \ + os.fdopen(fd, "wb") as out: + while True: + chunk = response.read(_CHUNK) + if not chunk: + break + out.write(chunk) + digest.update(chunk) + received += len(chunk) + if progress: + progress(received, update.size) + except BaseException: + os.unlink(tmp) + raise + + if digest.hexdigest() != update.sha256: + os.unlink(tmp) + raise RuntimeError( + "Le fichier téléchargé ne correspond pas à l'empreinte annoncée " + "par le serveur : il est incomplet ou altéré. Rien n'a été " + "installé.") + if update.size and received != update.size: + os.unlink(tmp) + raise RuntimeError(f"Taille inattendue : {received} octets reçus pour " + f"{update.size} annoncés.") + return tmp + + +def current_binary() -> str: + """Chemin de l'exécutable à remplacer, "" hors binaire figé. + + Lancée depuis les sources (`python main.py`), l'application n'a pas de + binaire à remplacer : la mise à jour ne s'y applique pas, et le proposer + remplacerait l'interpréteur Python du système. + """ + return sys.executable if getattr(sys, "frozen", False) else "" + + +def apply_update(archive: str) -> str: + """Installe l'artefact téléchargé. Rend le message à montrer, ou lève. + + Deux systèmes, deux gestes. Sous Windows, l'installeur est lancé et + l'application doit quitter : un `.exe` en cours d'exécution est verrouillé + par le système, et se remplacer soi-même y est impossible. Sous Linux, + l'ancien binaire est simplement délié puis le nouveau mis à sa place — + le noyau garde le fichier ouvert vivant jusqu'à la fermeture du programme, + qui continue donc de tourner normalement jusqu'à son redémarrage. + """ + if is_windows(): + raise RuntimeError("Sous Windows, la mise à jour passe par " + "`apply_via_helper`, pas par ce chemin.") + + target = current_binary() + if not target: + raise RuntimeError( + "Mise à jour impossible : l'application tourne depuis les sources " + "et non depuis un binaire installé.") + try: + os.chmod(archive, 0o755) + # `os.replace` et non `shutil.move` : le premier est un renommage pur, + # qui échoue plutôt que de se rabattre sur une copie. C'est ce repli + # qui cassait la mise à jour — copier revient à écrire dans le fichier + # exécuté, ce que Linux refuse (ETXTBSY). Le renommage, lui, ne touche + # qu'une entrée de répertoire : l'ancien inode reste vivant pour le + # processus en cours, qui continue de tourner jusqu'à sa fermeture. + os.replace(archive, target) + except OSError as exc: + try: + os.unlink(archive) + except OSError: + pass + raise RuntimeError( + f"Remplacement de {target} impossible : {exc}") from exc + return ("Mise à jour installée. Elle prendra effet au prochain démarrage " + "de WGSecure.") + + +def apply_via_helper(cfg: Config) -> str: + """Fait installer la mise à jour par le service, sans élévation. + + Le service tourne en LocalSystem : il écrit dans Program Files sans qu'une + invite UAC n'apparaisse. Rien ne lui est transmis d'autre que l'adresse de + l'API — ni fichier, ni chemin, ni empreinte. Il lit le manifeste, + télécharge et vérifie lui-même. Lui passer un binaire déjà téléchargé + serait plus rapide et ouvrirait à tout compte du poste le moyen de faire + exécuter n'importe quoi avec les privilèges du système : le pipe est + ouvert aux utilisateurs interactifs. + + Le service peut être absent — l'installeur le dépose sans l'enregistrer. + L'appelant reçoit alors de quoi le dire, plutôt qu'un échec opaque. + """ + base = api_base_url(cfg) + if not base: + raise RuntimeError("Adresse du serveur de mise à jour inconnue.") + reponse = call_helper({"cmd": "apply_update", "api_base": base}, + timeout_ms=900_000) + if reponse is None: + raise RuntimeError( + "Le service WGSecure Helper ne répond pas. Sans lui, la mise à " + "jour demande les droits administrateur : lancez l'installeur " + "téléchargé depuis le serveur.") + if not reponse.get("ok"): + raise RuntimeError(str(reponse.get("message") or "Mise à jour refusée.")) + return str(reponse.get("message") or "Mise à jour installée.") diff --git a/app/service/helper_service.py b/app/service/helper_service.py index 8137714..10762e0 100644 --- a/app/service/helper_service.py +++ b/app/service/helper_service.py @@ -18,16 +18,22 @@ strictement ses paramètres avant d'exécuter quoi que ce soit. from __future__ import annotations import ipaddress +import hashlib import json import logging import os import re import subprocess import sys +import tempfile +import urllib.error +import urllib.request from logging.handlers import RotatingFileHandler import pywintypes import servicemanager +import win32api +import win32con import win32event import win32file import win32pipe @@ -54,6 +60,20 @@ _MAX_DOMAINS = 20 _LOG_DIR = os.path.join(os.environ.get("PROGRAMDATA", r"C:\ProgramData"), "WGSecure") _LOG_FILE = os.path.join(_LOG_DIR, "helper.log") +# SID du compte autorisé à piloter le tunnel, retenu au premier usage. Le pipe +# est ouvert à tout utilisateur interactif : sans cet ancrage, n'importe quel +# compte du poste pouvait arrêter le tunnel d'un autre, installer le sien, ou +# poser une règle DNS valable pour toute la machine. +_OWNER_FILE = os.path.join(_LOG_DIR, "owner.sid") + +# Directives refusées dans un `.conf` : elles font exécuter des commandes par +# le service, donc en LocalSystem. `wireguard.exe` les ignore sous Windows, +# mais le fichier n'a aucune raison d'en porter — les refuser coûte une ligne +# et ferme la porte si un jour il les honore. +_CONF_FORBIDDEN = ("postup", "predown", "postdown", "preup") +# Un `.conf` WireGuard tient en quelques centaines d'octets ; au-delà, ce +# n'est pas une configuration. +_CONF_MAX_BYTES = 64 * 1024 def _get_logger() -> logging.Logger: @@ -74,6 +94,116 @@ class ValidationError(Exception): pass +def _caller_sid(handle) -> str: + """SID de l'appelant du pipe, sous forme textuelle. + + Lève plutôt que de rendre une valeur par défaut : une identité qu'on ne + peut pas établir ne doit jamais valoir autorisation. + """ + try: + win32security.ImpersonateNamedPipeClient(handle) + except pywintypes.error as e: + raise ValidationError(f"Identité de l'appelant non vérifiable : {e}") from None + try: + token = win32security.OpenThreadToken( + win32api.GetCurrentThread(), win32con.TOKEN_QUERY, True) + sid, _ = win32security.GetTokenInformation(token, win32security.TokenUser) + return win32security.ConvertSidToStringSid(sid) + except pywintypes.error as e: + raise ValidationError(f"Identité de l'appelant illisible : {e}") from None + finally: + win32security.RevertToSelf() + + +def _caller_is_admin(handle) -> bool: + """L'appelant est-il administrateur local ? Faux si indéterminable.""" + try: + win32security.ImpersonateNamedPipeClient(handle) + except pywintypes.error: + return False + try: + admins = win32security.CreateWellKnownSid(win32security.WinBuiltinAdministratorsSid) + token = win32security.OpenThreadToken( + win32api.GetCurrentThread(), win32con.TOKEN_QUERY, True) + for sid, attrs in win32security.GetTokenInformation(token, win32security.TokenGroups): + if sid == admins and attrs & win32security.SE_GROUP_ENABLED: + return True + return False + except pywintypes.error: + return False + finally: + win32security.RevertToSelf() + + +def _authorize(handle, logger: logging.Logger) -> str: + """Autorise une commande qui modifie l'état de la machine. + + Le premier appelant devient le propriétaire, et son SID est retenu : sur + un poste où une seule personne configure le VPN — le cas courant — cela se + fait sans rien demander. Ensuite, seuls ce compte et les administrateurs + sont servis. + + Ce n'est pas une barrière contre le propriétaire lui-même, qui reste libre + d'installer le tunnel qu'il veut : c'en est une contre *les autres comptes + du poste*, qui pouvaient jusqu'ici couper son tunnel ou détourner la + résolution de noms de toute la machine sans être administrateurs. + """ + sid = _caller_sid(handle) + try: + owner = open(_OWNER_FILE, encoding="utf-8").read().strip() + except OSError: + owner = "" + + if not owner: + try: + os.makedirs(_LOG_DIR, exist_ok=True) + with open(_OWNER_FILE, "w", encoding="utf-8") as f: + f.write(sid) + logger.info("Propriétaire du helper enregistré : %s", sid) + except OSError as e: + raise ValidationError(f"Propriétaire non enregistrable : {e}") from None + return sid + + if sid != owner and not _caller_is_admin(handle): + logger.warning("Commande refusée : %s n'est ni le propriétaire (%s) " + "ni administrateur", sid, owner) + raise ValidationError( + "Ce compte n'est pas autorisé à piloter le tunnel WGSecure.") + return sid + + +def _validate_conf_content(path: str) -> None: + """Refuse un `.conf` qui porterait des directives exécutables. + + Le chemin était vérifié, jamais le contenu : un utilisateur pouvait faire + installer par LocalSystem un tunnel dont il choisissait l'endpoint, les + clés et les routes. Le contrôle reste modeste — il ne dit pas *quel* + serveur est légitime, information que ce service n'a pas — mais il barre + l'exécution de commandes, qui est le pas de trop. + """ + try: + size = os.path.getsize(path) + except OSError as e: + raise ValidationError(f"Configuration illisible : {e}") from None + if size > _CONF_MAX_BYTES: + raise ValidationError(f"Configuration anormalement volumineuse ({size} octets)") + try: + with open(path, encoding="utf-8", errors="replace") as f: + lignes = f.read().splitlines() + except OSError as e: + raise ValidationError(f"Configuration illisible : {e}") from None + + for ligne in lignes: + cle = ligne.split("=", 1)[0].strip().lower() + if cle in _CONF_FORBIDDEN: + raise ValidationError( + f"Directive « {cle} » interdite dans une configuration " + f"installée par le service : elle ferait exécuter des " + f"commandes avec les privilèges du système.") + if not any(l.strip().lower() == "[interface]" for l in lignes): + raise ValidationError("Configuration sans section [Interface]") + + def _validate_interface_name(name) -> str: if not isinstance(name, str) or not _INTERFACE_NAME_RE.match(name): raise ValidationError(f"Nom d'interface invalide : {name!r}") @@ -170,20 +300,23 @@ def _run(cmd: list[str], timeout: int = 30) -> tuple[int, str]: return -1, str(e) -def _handle_install_tunnel(req: dict, handle) -> dict: +def _handle_install_tunnel(req: dict, handle, logger) -> dict: + _authorize(handle, logger) name = _validate_interface_name(req.get("name")) config_path = _validate_config_path(req.get("config_path"), name, handle) + _validate_conf_content(config_path) code, msg = _run([wireguard_exe(), "/installtunnelservice", config_path], timeout=90) return {"ok": code == 0, "message": msg} -def _handle_uninstall_tunnel(req: dict, handle) -> dict: +def _handle_uninstall_tunnel(req: dict, handle, logger) -> dict: + _authorize(handle, logger) name = _validate_interface_name(req.get("name")) code, msg = _run([wireguard_exe(), "/uninstalltunnelservice", name], timeout=60) return {"ok": code == 0, "message": msg} -def _handle_start_tunnel_service(req: dict, handle) -> dict: +def _handle_start_tunnel_service(req: dict, handle, logger) -> dict: """Démarre le service d'un tunnel déjà installé. Complète install/uninstall : dans le fonctionnement courant le tunnel @@ -191,18 +324,21 @@ def _handle_start_tunnel_service(req: dict, handle) -> dict: validé, et le préfixe « WireGuardTunnel$ » est ajouté ici — l'appelant ne choisit donc jamais quel service piloter. """ + _authorize(handle, logger) name = _validate_interface_name(req.get("name")) code, msg = _run(["sc.exe", "start", f"WireGuardTunnel${name}"], timeout=45) return {"ok": code == 0, "message": msg} -def _handle_stop_tunnel_service(req: dict, handle) -> dict: +def _handle_stop_tunnel_service(req: dict, handle, logger) -> dict: + _authorize(handle, logger) name = _validate_interface_name(req.get("name")) code, msg = _run(["sc.exe", "stop", f"WireGuardTunnel${name}"], timeout=45) return {"ok": code == 0, "message": msg} -def _handle_set_split_dns(req: dict, handle) -> dict: +def _handle_set_split_dns(req: dict, handle, logger) -> dict: + _authorize(handle, logger) server = _validate_server(req.get("server")) domains = _validate_domains(req.get("domains")) rule_name = _validate_rule_name(req.get("rule_name")) @@ -211,14 +347,15 @@ def _handle_set_split_dns(req: dict, handle) -> dict: return {"ok": code == 0, "message": msg} -def _handle_clear_split_dns(req: dict, handle) -> dict: +def _handle_clear_split_dns(req: dict, handle, logger) -> dict: + _authorize(handle, logger) rule_name = _validate_rule_name(req.get("rule_name")) script = _nrpt_remove_split_dns_ps(rule_name) code, msg = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], timeout=20) return {"ok": code == 0, "message": msg} -def _handle_get_status(req: dict, handle) -> dict: +def _handle_get_status(req: dict, handle, logger) -> dict: """Lecture seule : "message" porte la sortie de `wg show`, pas une erreur — "ok" reflète juste si la commande a pu s'exécuter. `latest_handshakes` : ajoute le sous-argument `latest-handshakes` @@ -233,6 +370,103 @@ def _handle_get_status(req: dict, handle) -> dict: return {"ok": code == 0, "message": msg} +def _handle_apply_update(req: dict, handle, logger) -> dict: + """Installe la mise à jour publiée par le serveur. Sans aucun paramètre. + + L'appelant ne fournit ni chemin, ni binaire, ni empreinte : il déclenche, + rien de plus. C'est la seule forme acceptable ici. Une commande qui + installerait un fichier désigné par l'appelant donnerait à tout compte du + poste — le pipe est ouvert aux utilisateurs interactifs — le moyen de + faire exécuter ce qu'il veut avec les privilèges du système. Le service + lit donc lui-même le manifeste, télécharge lui-même, et vérifie lui-même. + + La confiance restante va au serveur WGSecure, joignable par le seul + tunnel : un serveur compromis diffuserait un binaire exécuté en + LocalSystem. C'est le propre de toute mise à jour automatique ; seule une + signature vérifiée contre une clé publique embarquée ici la lèverait. + """ + _authorize(handle, logger) + base = (req.get("api_base") or "").strip() + # L'URL vient de l'appelant, mais elle est bornée : uniquement http(s), + # et seule l'adresse du serveur dans le tunnel a un manifeste à servir. + # Ce qui compte est que ni le fichier ni son empreinte n'en dépendent — + # les deux viennent de la même source, lue par le service. + if not base.startswith(("http://", "https://")): + raise ValidationError("Adresse du serveur de mise à jour invalide") + + try: + with urllib.request.urlopen(f"{base}/api/version", timeout=10) as r: + manifest = json.loads(r.read().decode("utf-8")) + except (urllib.error.URLError, OSError, ValueError) as e: + return {"ok": False, "message": f"Manifeste illisible : {e}"} + + entry = next((a for a in manifest.get("artifacts", []) + if a.get("key") == "windows-setup"), None) + if not entry or not entry.get("sha256"): + return {"ok": False, "message": "Aucun installeur Windows publié."} + + digest = hashlib.sha256() + fd, tmp = tempfile.mkstemp(prefix="wgsecure-update-", suffix=".exe", + dir=_LOG_DIR) + try: + url = f"{base}/api/version/download/windows-setup" + with urllib.request.urlopen(url, timeout=600) as r, os.fdopen(fd, "wb") as out: + while chunk := r.read(262144): + out.write(chunk) + digest.update(chunk) + if digest.hexdigest() != entry["sha256"].lower(): + os.unlink(tmp) + logger.warning("Mise à jour refusée : empreinte non conforme") + return {"ok": False, + "message": "Empreinte non conforme — rien n'a été installé."} + except (urllib.error.URLError, OSError, ValueError) as e: + try: + os.unlink(tmp) + except OSError: + pass + return {"ok": False, "message": f"Téléchargement impossible : {e}"} + + # L'installeur est lancé *détaché*, et surtout pas attendu : son travail + # consiste à arrêter ce service et à remplacer son binaire. L'attendre + # reviendrait à demander à un processus de survivre à sa propre mise à + # mort — `subprocess.run` ne rendrait jamais la main, et Windows refuse de + # toute façon d'écraser l'image d'un processus vivant. + # + # DETACHED_PROCESS le sort de la console du service, CREATE_NEW_PROCESS_GROUP + # de son groupe, et CREATE_BREAKAWAY_FROM_JOB de son job éventuel : sans + # cela, l'arrêt du service emporterait l'installeur avec lui. + # + # Le fichier est déposé sous %PROGRAMDATA%\WGSecure, écrit par le seul + # LocalSystem : un compte local ne peut pas le remplacer entre la + # vérification et l'exécution. + DETACHED_PROCESS = 0x00000008 + CREATE_NEW_PROCESS_GROUP = 0x00000200 + CREATE_BREAKAWAY_FROM_JOB = 0x01000000 + flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB + try: + subprocess.Popen( + [tmp, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/NOCANCEL"], + creationflags=flags, close_fds=True, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + except OSError as e: + try: + os.unlink(tmp) + except OSError: + pass + return {"ok": False, "message": f"Installeur non lançable : {e}"} + + # Volontairement pas de `unlink` : le fichier est encore en cours + # d'exécution, et ce service va être arrêté avant d'avoir pu le nettoyer. + # L'installeur suivant écrasera ce reliquat, nommé de façon unique. + logger.info("Installeur %s lancé, arrêt du service imminent", + manifest.get("version")) + return {"ok": True, + "message": f"Version {manifest.get('version')} en cours " + f"d'installation. WGSecure va se fermer, le tunnel " + f"sera coupé, puis l'application redémarrera à jour."} + + _HANDLERS = { "install_tunnel": _handle_install_tunnel, "uninstall_tunnel": _handle_uninstall_tunnel, @@ -241,6 +475,7 @@ _HANDLERS = { "set_split_dns": _handle_set_split_dns, "clear_split_dns": _handle_clear_split_dns, "get_status": _handle_get_status, + "apply_update": _handle_apply_update, } @@ -278,7 +513,7 @@ def _handle_connection(handle, logger: logging.Logger) -> None: resp = {"ok": False, "message": f"Commande inconnue : {cmd!r}"} else: try: - resp = handler(req, handle) + resp = handler(req, handle, logger) except ValidationError as e: resp = {"ok": False, "message": str(e)} logger.info("%s -> ok=%s", cmd, resp.get("ok")) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 36fb552..43f9914 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,5 +1,8 @@ from __future__ import annotations +import os +import subprocess + from datetime import datetime, timedelta, timezone from PyQt6.QtWidgets import ( @@ -7,9 +10,9 @@ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QMessageBox, QInputDialog, QLineEdit, QListWidget, QListWidgetItem, - QComboBox, + QComboBox, QProgressBar, ) -from PyQt6.QtCore import Qt, QTimer, QDateTime, QEvent +from PyQt6.QtCore import Qt, QTimer, QDateTime, QEvent, pyqtSignal from PyQt6.QtGui import QFont, QCloseEvent, QColor from app.core.config import Config @@ -19,6 +22,9 @@ from app.core import log as conn_log from app.core import history as hist from app.core import shutdown as shutdown_guard from app.core import vpn_session +from app.core import updater +from app.utils.platform_utils import is_windows +from app import __version__ as app_version from app.ui.worker import TaskWorker, ValueWorker from app.ui.access_code_dialog import AccessCodeDialog from app.ui.admin_window import AdminWindow @@ -43,9 +49,22 @@ _ACCESS_UNKNOWN_BEFORE_CLOSE = 3 # devait être proposée. Le tunnel vient de monter : ses routes peuvent n'être # pas encore posées, et la fenêtre surgirait pour un code déjà valide. _PROBE_RETRY_MS = 3000 +# Intervalle entre deux interrogations du serveur de mise à jour. La question +# ne se posait qu'à l'instant précis où le tunnel montait : un serveur pas +# encore joignable à cette seconde-là — routes non posées, quarantaine en +# cours — et plus rien n'était proposé de toute la session, sans que rien ne +# le dise. Un intervalle unique plutôt que deux rythmes : `check` rend `None` +# aussi bien quand le serveur se tait que lorsqu'il n'a rien de neuf, et les +# distinguer demanderait de changer sa signature pour un gain de cadence nul. +_UPDATE_RECHECK = timedelta(minutes=30) class MainWindow(QMainWindow): + # Émis depuis le thread de téléchargement : passer par un signal est la + # seule façon sûre de toucher un widget depuis un autre thread que celui + # de l'interface. + update_progress = pyqtSignal(int, int) + def __init__(self, config: Config, parent=None): super().__init__(parent) self._cfg = config @@ -88,6 +107,16 @@ class MainWindow(QMainWindow): # Conservée après fermeture : la fenêtre reste parentée à celle-ci, et # son thread doit être attendu avant destruction (cf. prepare_quit). self._access_dialog: AccessCodeDialog | None = None + # Mise à jour : proposée, jamais imposée. `_update_dismissed` ne dure + # que la session — une version écartée d'un clic distrait se + # represente au prochain démarrage plutôt que d'être perdue. + self._update_worker: ValueWorker | None = None + self._update = None + self._update_dismissed = False + # Date du dernier échange abouti avec le serveur de mise à jour, et + # non de la dernière tentative : un échec doit être réessayé vite, une + # réponse obtenue peut attendre. + self._update_checked_at: datetime | None = None self._reconnect_failures = 0 self._quitting = False self._warned_foreign: set[str] = set() @@ -154,12 +183,251 @@ class MainWindow(QMainWindow): root.setSpacing(0) root.addWidget(self._build_header()) + root.addWidget(self._build_update_banner()) root.addWidget(self._build_status_action()) root.addWidget(self._build_details()) # ── Pied de page ───────────────────────────────────────────────── root.addWidget(self._build_footer()) + def _build_update_banner(self) -> QWidget: + """Bandeau proposant une mise à jour, masqué le reste du temps. + + Placé sous l'en-tête et non dans le panneau de détails : une version + disponible se rate si elle attend qu'on déplie quelque chose. Il + n'apparaît qu'une fois le serveur consulté, ne se réaffiche pas dans + la session si l'utilisateur l'écarte, et n'installe rien sans clic. + """ + self._update_banner = QWidget() + self._update_banner.setStyleSheet( + f"background: {theme.WARN_BG}; border-radius: 4px;") + # Non parenté au bandeau : il reçoit plus bas un layout vertical qui + # empile cette ligne et la barre de progression. + row = QHBoxLayout() + row.setContentsMargins(10, 6, 10, 6) + row.setSpacing(6) + self._update_label = QLabel("") + self._update_label.setWordWrap(True) + # Blanc plutôt que le jaune du bandeau : sur ce fond, le texte + # d'avertissement passait pour du décor et se lisait mal. + self._update_label.setStyleSheet( + f"color: {theme.TEXT}; font-size: 11px; background: transparent;") + row.addWidget(self._update_label, 1) + + self._btn_update = QPushButton("MAJ") + self._btn_update.setFixedHeight(24) + self._btn_update.setStyleSheet( + f"QPushButton {{ background: {theme.OK_SOLID}; color: white;" + " font-weight: bold; border: none; border-radius: 4px;" + " padding: 0 14px; }" + f"QPushButton:hover {{ background: {theme.OK_HOVER}; }}" + f"QPushButton:disabled {{ background: {theme.BG_RAISED};" + f" color: {theme.TEXT_FAINT}; }}") + self._btn_update.clicked.connect(self._install_update) + row.addWidget(self._btn_update) + + # Report : une croix, pas un mot. Elle dit « pas maintenant » sans + # entrer en concurrence de lecture avec l'action principale. + btn_later = QPushButton("✕") + btn_later.setFixedSize(24, 24) + btn_later.setToolTip("Reporter — la proposition reviendra au prochain " + "démarrage") + btn_later.setStyleSheet( + f"QPushButton {{ background: {theme.FAIL_SOLID}; color: white;" + " font-weight: bold; border: none; border-radius: 4px; }" + f"QPushButton:hover {{ background: {theme.FAIL_HOVER}; }}") + btn_later.clicked.connect(self._dismiss_update) + row.addWidget(btn_later) + + # Le téléchargement porte sur une centaine de mégaoctets : sans rien + # à l'écran, l'application paraissait figée et l'utilisateur ne + # pouvait pas distinguer une attente d'un échec. + self._update_progress = QProgressBar() + self._update_progress.setFixedHeight(6) + self._update_progress.setTextVisible(False) + self._update_progress.setStyleSheet( + f"QProgressBar {{ border: none; border-radius: 3px;" + f" background: {theme.BG}; }}" + f"QProgressBar::chunk {{ background: {theme.OK_SOLID};" + " border-radius: 3px; }") + self._update_progress.setVisible(False) + outer = QVBoxLayout() + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(4) + inner = QWidget() + inner.setLayout(row) + inner.setStyleSheet("background: transparent;") + outer.addWidget(inner) + outer.addWidget(self._update_progress) + self._update_banner.setLayout(outer) + + self.update_progress.connect(self._on_download_progress) + self._update_banner.setVisible(False) + return self._update_banner + + def _on_download_progress(self, recu: int, total: int): + """Avancement du téléchargement, reçu du thread qui le mène.""" + if total > 0: + self._update_progress.setRange(0, total) + self._update_progress.setValue(recu) + self._btn_update.setText(f"{100 * recu // total} %") + else: + # Total inconnu : barre en va-et-vient plutôt qu'une jauge figée + # à zéro, qui se lit comme une panne. + self._update_progress.setRange(0, 0) + + def _update_due(self) -> bool: + """Faut-il (re)demander au serveur s'il propose une version ? + + Deux rythmes : rapide tant que le serveur n'a rien répondu, lent une + fois qu'il a parlé. Une mise à jour déjà proposée, ou écartée pour la + session, ne se redemande pas. + """ + if self._update is not None or self._update_dismissed: + return False + if self._update_checked_at is None: + return True + return (datetime.now(timezone.utc) + - self._update_checked_at) >= _UPDATE_RECHECK + + def _check_update(self): + """Interroge le serveur, sans bloquer ni déranger si rien n'est trouvé.""" + if self._update_worker is not None or self._update_dismissed: + return + self._update_worker = ValueWorker(updater.check, self._cfg, + app_version, parent=self) + self._update_worker.done.connect(self._on_update_checked) + self._update_worker.finished.connect(self._update_worker.deleteLater) + self._update_worker.start() + + def _on_update_checked(self, found): + self._update_worker = None + self._update = found + self._update_checked_at = datetime.now(timezone.utc) + if found is None or self._update_dismissed: + return + taille = f"{found.size / 1048576:.0f} Mo" if found.size else "" + self._update_label.setText( + f"⬆️ Version {found.version} disponible" + + (f" ({taille})" if taille else "")) + self._update_banner.setVisible(True) + self._sync_window_height() + self._add_log(f"Version {found.version} disponible sur le serveur", + "info") + + def _dismiss_update(self): + """Écarte la proposition pour cette session, sans la refuser à jamais. + + Rien n'est écrit : au prochain démarrage la question se repose, et une + version ignorée par mégarde n'est pas perdue pour toujours. + """ + self._update_dismissed = True + self._update_banner.setVisible(False) + self._sync_window_height() + + def _install_update(self): + """Télécharge puis installe, après confirmation explicite.""" + if self._update is None or self._update_worker is not None: + return + reponse = QMessageBox.question( + self, "Mettre à jour WGSecure", + f"Installer la version {self._update.version} ?\n\n" + f"Le fichier sera téléchargé depuis le serveur, puis vérifié " + f"contre l'empreinte qu'il annonce avant d'être installé.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + if reponse != QMessageBox.StandardButton.Yes: + return + + self._btn_update.setEnabled(False) + self._btn_update.setText("…") + self._update_progress.setRange(0, 0) # va-et-vient tant que rien + self._update_progress.setVisible(True) # n'est encore reçu + self._sync_window_height() + self._update_worker = ValueWorker(self._download_and_apply, + self._update, parent=self) + self._update_worker.done.connect(self._on_update_installed) + self._update_worker.finished.connect(self._update_worker.deleteLater) + self._update_worker.start() + + def _download_and_apply(self, info): + """Exécuté hors du thread graphique : 125 Mo, puis remplacement. + + Rend le message à montrer, ou le relaie préfixé — `ValueWorker` avale + les exceptions en rendant `None`, ce qui ne dirait pas ce qui a échoué. + """ + try: + if is_windows(): + # Le service fait tout : il télécharge et vérifie lui-même, + # pour qu'aucun compte du poste ne puisse lui faire installer + # un fichier de son choix avec les privilèges du système. + return updater.apply_via_helper(self._cfg) + cible = updater.current_binary() + archive = updater.download( + self._cfg, info, + progress=lambda recu, total: self.update_progress.emit(recu, total), + dest_dir=os.path.dirname(cible) if cible else "") + return updater.apply_update(archive) + except Exception as exc: # noqa: BLE001 — remonté à l'UI + return f"ERREUR: {exc}" + + def _on_update_installed(self, message): + self._update_worker = None + self._btn_update.setEnabled(True) + self._btn_update.setText("MAJ") + self._update_progress.setVisible(False) + self._sync_window_height() + texte = message or "Échec inattendu de la mise à jour." + if texte.startswith("ERREUR: "): + texte = texte[8:] + self._add_log(f"Mise à jour : {texte}", "error") + QMessageBox.warning(self, "Mise à jour impossible", texte) + return + self._add_log(texte, "success") + self._update_banner.setVisible(False) + self._sync_window_height() + QMessageBox.information(self, "Mise à jour", texte) + + # Sous Windows, l'installeur tourne déjà et attend que les fichiers se + # libèrent : rester ouvert le ferait patienter jusqu'à son délai, puis + # échouer. On rend donc la main tout de suite — `prepare_quit` démonte + # le tunnel et restaure le DNS avant de fermer, ce qui laisse aussi le + # service du tunnel arrêtable par l'installeur. + if is_windows(): + self._add_log("Fermeture pour laisser l'installeur remplacer les " + "fichiers", "warning") + self.prepare_quit() + QApplication.quit() + return + + # Sous Linux, le binaire est déjà remplacé et l'application continue de + # tourner sur l'ancien, que le noyau garde en vie. Rien n'oblige à + # fermer — mais le dire sans rien proposer laissait l'utilisateur + # devant une version périmée sans savoir quoi faire. + cible = updater.current_binary() + if not cible: + return + if QMessageBox.question( + self, "Redémarrer WGSecure", + "La nouvelle version est en place. Redémarrer maintenant ?\n\n" + "Le tunnel sera coupé le temps du redémarrage.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes + ) != QMessageBox.StandardButton.Yes: + return + try: + # Lancé détaché : sans cela le nouveau processus mourrait avec + # celui-ci, qui s'apprête justement à disparaître. + subprocess.Popen([cible], start_new_session=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + except OSError as exc: + QMessageBox.warning(self, "Redémarrage impossible", str(exc)) + return + self.prepare_quit() + QApplication.quit() + # ── Section repliable : tout ce qui n'est pas statut/action ────────── def _build_details(self) -> QWidget: self._details = QWidget() @@ -763,6 +1031,11 @@ class MainWindow(QMainWindow): # hors de l'application. Demander au serveur avant de conclure # quoi que ce soit : lui seul sait si l'accès est encore ouvert. self._probe_access(prompt_if_closed=False) + # Le serveur de mise à jour n'est joignable que par le + # tunnel : la question ne se pose qu'une fois celui-ci monté. + QTimer.singleShot(2000, self._check_update) + elif self._update_due(): + self._check_update() elif self._access_open and self._access_stale(): # Accès cru ouvert : le reconfirmer périodiquement, une # révocation côté serveur n'étant annoncée par rien. @@ -1231,7 +1504,8 @@ class MainWindow(QMainWindow): # Laisser les threads en cours se terminer avant de démonter le tunnel : # une connexion et une déconnexion simultanées laisseraient un état mixte. - for worker in (self._worker, self._ping_worker, self._probe_worker): + for worker in (self._worker, self._ping_worker, self._probe_worker, + self._update_worker): try: if worker is not None and worker.isRunning(): worker.wait(15000) @@ -1246,6 +1520,7 @@ class MainWindow(QMainWindow): self._worker = None self._ping_worker = None self._probe_worker = None + self._update_worker = None if self._session_id is not None: hist.end_session(self._session_id, self._prev_rx or 0, diff --git a/wgsecure.iss b/wgsecure.iss index 2bf57e4..0a0f8b7 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -24,19 +24,31 @@ SolidCompression=yes ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequired=admin +; Mise à jour d'une installation existante : les deux exécutables à remplacer +; sont en cours d'exécution — wgsecure.exe côté utilisateur, wgsecure-helper.exe +; en service. Windows verrouille l'image d'un processus vivant : sans ce qui +; suit, la copie échoue et l'installeur repart en ayant l'air d'avoir réussi. +; +; Le Restart Manager ferme l'application qui détient les fichiers ; le service, +; lui, est arrêté explicitement avant la copie (cf. StopForUpgrade). Les +; fichiers reçoivent en secours le drapeau `restartreplace`, qui programme le +; remplacement au prochain démarrage si un verrou subsiste malgré tout. +CloseApplications=yes +CloseApplicationsFilter=*.exe +RestartApplications=no [Languages] Name: "french"; MessagesFile: "compiler:Languages\French.isl" [Files] -Source: "dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion restartreplace ; Service privilégié optionnel (cf. app/service/, `make helper`) : installe/ ; désinstalle le tunnel et pose/retire les règles NRPT (split-DNS) en ; LocalSystem, pour éviter une invite d'authentification à chaque connexion/ ; déconnexion. skipifsourcedoesntexist : le build peut être absent (composant ; optionnel, pas encore généré sur toutes les machines de build) — dans ce ; cas l'app retombe simplement sur son chemin élevé existant. -Source: "dist\wgsecure-helper.exe"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist +Source: "dist\wgsecure-helper.exe"; DestDir: "{app}"; Flags: ignoreversion restartreplace skipifsourcedoesntexist ; VC++ Redistributable x64 — téléchargé automatiquement par `make installer-deps` : ; https://aka.ms/vs/17/release/vc_redist.x64.exe → installer\VC_redist.x64.exe Source: "installer\VC_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall @@ -119,8 +131,47 @@ begin ewWaitUntilTerminated, ResultCode); end; +// Arrêt de tout ce qui tient les fichiers, avant la copie. Le service se +// remplace lui-même : il doit donc être à l'arrêt, et `sc stop` rend la main +// avant que le processus n'ait fini de mourir — d'où l'attente qui suit. +// Les tunnels sont arrêtés au passage : leur service tourne en SYSTEM et +// n'est pas remplacé ici, mais laisser le VPN monté pendant que son pilote +// disparaît de l'écran n'aurait aucun sens pour l'utilisateur. +procedure StopForUpgrade; +var + ResultCode, I: Integer; + Names: TArrayOfString; + J: Integer; +begin + // Tunnels WireGuard posés par WGSecure : le nom d'interface est réglable, + // on interroge donc le registre des services plutôt que de deviner « wgs0 ». + if RegGetSubkeyNames(HKEY_LOCAL_MACHINE, 'SYSTEM\CurrentControlSet\Services', Names) then + begin + for J := 0 to GetArrayLength(Names) - 1 do + if Pos('WireGuardTunnel$', Names[J]) = 1 then + Exec(ExpandConstant('{sys}\sc.exe'), 'stop ' + AddQuotes(Names[J]), + '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + end; + + Exec(ExpandConstant('{sys}\sc.exe'), 'stop WGSecureHelper', '', SW_HIDE, + ewWaitUntilTerminated, ResultCode); + // `sc stop` demande l'arrêt sans l'attendre : copier tout de suite + // retomberait sur un binaire encore verrouillé. + for I := 1 to 20 do + begin + if not FileExists(ExpandConstant('{app}\wgsecure-helper.exe')) then + Break; + if RenameFile(ExpandConstant('{app}\wgsecure-helper.exe'), + ExpandConstant('{app}\wgsecure-helper.exe')) then + Break; + Sleep(500); + end; +end; + procedure CurStepChanged(CurStep: TSetupStep); begin + if CurStep = ssInstall then + StopForUpgrade; if CurStep = ssPostInstall then InstallHelperService; end;