feat(update): mise à jour 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ù le contrôle d'accès
existe déjà — 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.
Le serveur expose GET /api/version, qui rend la version publiée et l'empreinte
de chaque artefact, et /api/version/download/{clé} qui les sert. Le manifeste
est déposé par `make publish-updates`, avec les condensats calculés une fois à
la publication : les recalculer à chaque appel bloquerait l'API sur 125 Mo.
`ANNOUNCE=` permet d'annoncer les artefacts sous un autre numéro, pour exercer
la chaîne sans compiler une seconde version.
Rien ne s'installe sans un clic. La vérification est automatique — au montage
du tunnel puis toutes les demi-heures, car ne la faire qu'au montage laissait
un serveur momentanément injoignable annuler toute proposition pour la session
— mais le téléchargement attend le bouton. Cette application monte un VPN, et
une version défaillante qui se propagerait seule couperait l'accès d'un parc
entier sans que personne ne l'ait demandé.
L'artefact est vérifié contre son empreinte SHA-256 avant d'être mis en place,
et le fichier temporaire est détruit à la moindre anomalie. Le canal est déjà
authentifié par WireGuard ; le condensat couvre ce qu'il ne couvre pas — un
téléchargement tronqué, un disque plein, un artefact mal publié.
Sous Linux, le binaire est remplacé par renommage, sur le même système de
fichiers que sa destination. Un déplacement depuis /tmp se rabattait sur une
copie, donc sur une écriture dans l'exécutable en cours, que le noyau refuse
(ETXTBSY). Un renommage ne touche qu'une entrée de répertoire : l'ancien inode
reste vivant pour le processus, qui continue jusqu'à sa fermeture — et
l'application propose désormais de redémarrer plutôt que de le laisser deviner.
Sous Windows, l'installation passe par le service, qui tourne en LocalSystem et
écrit dans Program Files sans invite d'élévation. La commande `apply_update` ne
prend aucun paramètre : le service lit le manifeste, télécharge et vérifie
lui-même. Lui passer un fichier déjà téléchargé aurait donné à 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.
L'installeur orchestre le remplacement, ce qu'il ne faisait pas : les deux
exécutables à remplacer tournent au moment de la mise à jour, et Windows
verrouille l'image d'un processus vivant. Il ferme donc l'application par le
gestionnaire de redémarrage, arrête les tunnels et le service avant la copie —
en attendant la libération réelle du fichier, `sc stop` rendant la main avant
la fin de l'arrêt — et garde `restartreplace` en secours. Le service, lui,
lance l'installeur détaché et ne l'attend pas : il lui demande de se remplacer
lui-même.
Durcissement du service au passage. Le premier appelant devient propriétaire et
son SID est retenu ; ensuite seuls ce compte et les administrateurs sont
servis. Le pipe étant ouvert à tout utilisateur interactif, n'importe quel
compte du poste pouvait jusqu'ici couper le tunnel d'un autre ou poser une
règle NRPT valable pour toute la machine. Le contenu d'un `.conf` est également
vérifié — seul son chemin l'était — et les directives exécutables y sont
refusées.
Corrige enfin un défaut de compilation : Linux et Windows partageaient le
répertoire de travail de PyInstaller, que `--clean` vide au démarrage. Lancées
à la suite, les deux cibles effaçaient mutuellement leurs fichiers
intermédiaires, produisant un .exe gonflé de 44 Mo et un binaire Linux tronqué
dont l'archive ne se décompressait plus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
414144560e
commit
e520e30716
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user