erplibre/script/vpn/drivers/wireguard.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

270 lines
9.8 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""WireGuard : une configuration, une commande, et c'est monté.
Le plus simple des pilotes — et c'est justement là qu'il faut se méfier.
WireGuard n'a pas de session : `wg-quick up` réussit et l'interface apparaît
même si la clé du pair est fausse, même si l'endpoint est injoignable. Rien
ne dit non, parce qu'il n'y a personne à qui dire non.
Ce pilote attend donc une POIGNÉE DE MAIN avant de déclarer le tunnel monté.
Sans cette attente, « ✓ Tunnel monté » voudrait dire « l'interface existe »,
ce qui n'est pas la même chose et ne se découvre qu'au premier paquet perdu.
Les routes viennent d'`AllowedIPs` et c'est `wg-quick` qui les pose — y
compris, en « tout le trafic », l'astuce de marquage (fwmark) qui garde
l'endpoint joignable. On ne double donc PAS son travail : un `ip route` de
plus ici entrerait en conflit avec le sien.
"""
from __future__ import annotations
import shlex
from script.vpn import valid
from script.vpn.drivers.base import VpnDriver, interface_addresses
class WireguardDriver(VpnDriver):
name = "wireguard"
label = "WireGuard"
binaries = ("wg", "wg-quick", "ip")
secret_fields = (
("wg_private_key", "WireGuard private key of this machine", True),
("wg_preshared_key", "WireGuard pre-shared key (optional)", False),
)
iface_kind = "wireguard"
hint = "When you control both ends: the fastest and the simplest"
defaults = {
"port": 51820,
"wg_address": "",
"wg_peer_key": "",
"wg_dns": "",
"wg_keepalive": 25,
}
form_fields = (
(
"wg_address",
"Address of this machine inside the tunnel (10.7.0.2/32)",
"text",
False,
),
("wg_peer_key", "Public key of the peer", "text", False),
("port", "WireGuard endpoint port", "int", True),
("wg_dns", "DNS server inside the tunnel (optional)", "text", True),
("wg_keepalive", "PersistentKeepalive, in seconds", "int", True),
)
# ------------------------------------------------------------------
@property
def iface(self):
"""Nom de l'interface, et donc du fichier de configuration.
`wg-quick` DÉDUIT le nom de l'interface du nom du fichier : le
fichier doit s'appeler `<interface>.conf`. Tronqué à 15 caractères,
limite du noyau pour un nom d'interface.
"""
return f"wg-{self.name_tag}"[:15]
@property
def config_file(self):
return f"{self.secret_dir}/{self.iface}.conf"
@property
def allowed_ips(self):
"""`AllowedIPs` : ce qui entre dans le tunnel.
C'est le champ le plus mal compris de WireGuard — il sert à LA FOIS
de filtre de trafic et de table de routage. `0.0.0.0/0` veut donc
dire « tout le trafic », et rien d'autre n'est nécessaire pour cela.
"""
if self.profile.get("default_route"):
return "0.0.0.0/0"
return ", ".join(self.profile.get("routes", []))
# ------------------------------------------------------------------
@classmethod
def validate_profile(cls, profile):
valid.ip_interface(
profile, "wg_address", "Adresse de cette machine dans le tunnel"
)
valid.wg_key(profile, "wg_peer_key", "Clé publique du pair")
valid.port(profile, "port", "Port de l'endpoint WireGuard")
valid.ip_address(
profile, "wg_dns", "Serveur DNS dans le tunnel", required=False
)
valid.integer(profile, "wg_keepalive", "PersistentKeepalive", 0, 65535)
def config_body(self):
p = self.profile
lines = [
"# Généré par ERPLibre (script/vpn). tmpfs, 0600, effacé au down.",
"[Interface]",
f"PrivateKey = {self.secrets.get('wg_private_key', '')}",
f"Address = {p['wg_address']}",
f"MTU = {p['mtu']}",
"",
"[Peer]",
f"PublicKey = {p['wg_peer_key']}",
]
preshared = self.secrets.get("wg_preshared_key")
if preshared:
lines.append(f"PresharedKey = {preshared}")
lines += [
f"Endpoint = {p['server']}:{p['port']}",
f"AllowedIPs = {self.allowed_ips}",
]
if p.get("wg_keepalive"):
# Indispensable derrière du NAT : sans trafic, la traduction
# expire et le pair ne sait plus où nous joindre.
lines.append(f"PersistentKeepalive = {p['wg_keepalive']}")
# Pas de « DNS = » : wg-quick le confie à `resolvconf`, absent de
# beaucoup d'installations systemd-resolved, et la configuration
# ENTIÈRE échoue alors. On appelle resolvectl nous-mêmes.
return "\n".join(lines)
# ------------------------------------------------------------------
def up(self, runner):
p = self.profile
if not self.ensure_ready(runner):
return False
if not self.allowed_ips:
runner.fail(
"Aucun réseau à router : AllowedIPs serait vide et le"
" tunnel ne porterait rien."
)
return False
self.prepare_dirs(runner)
runner.write(
self.config_file,
self.config_body() + "\n",
mode="0600",
secret=True,
)
code, _ = runner.cmd(
f"monter {self.iface}",
f"wg-quick up {shlex.quote(self.config_file)}",
timeout=60,
)
if code != 0 and not runner.dry_run:
runner.fail(
"wg-quick a refusé. Causes usuelles : clé mal formée,"
" adresse déjà prise, module wireguard absent du noyau."
)
return False
self.write_state(runner, "iface", self.iface)
if not self._wait_for_handshake(runner):
return False
if runner.dry_run:
return True
addresses = (
", ".join(interface_addresses(self.iface)) or "sans adresse"
)
runner.ok(f"interface {self.iface} : {addresses}")
# Les routes appartiennent à wg-quick, via AllowedIPs. Rien à
# ajouter ici — voir l'en-tête du fichier.
if p.get("wg_dns"):
self.set_resolved_dns(runner, self.iface, [p["wg_dns"]])
return True
def _wait_for_handshake(self, runner):
"""Attend une poignée de main avec le pair.
`wg show … latest-handshakes` rend un horodatage par pair, à zéro
tant que rien n'a abouti. C'est le SEUL signe que la clé et
l'endpoint sont bons : l'interface, elle, monte de toute façon.
"""
script = (
"for i in $(seq 1 20); do"
f" wg show {shlex.quote(self.iface)} latest-handshakes"
" | awk '$2 > 0 { found = 1 } END { exit !found }'"
" && exit 0; sleep 0.5; done; exit 1"
)
code, _ = runner.cmd(
"attendre la poignée de main du pair",
f"sh -c {shlex.quote(script)}",
check=False,
timeout=30,
)
if code == 0 or runner.dry_run:
return True
runner.fail(
"Aucune poignée de main en 10 s. L'interface est montée — c'est"
" toujours le cas avec WireGuard — mais le pair n'a pas"
" répondu : clé publique du pair, PSK, endpoint ou UDP"
f" {self.profile['port']} filtré."
)
return False
# ------------------------------------------------------------------
def down(self, runner):
runner.cmd(
f"démonter {self.iface}",
f"wg-quick down {shlex.quote(self.config_file)}",
check=False,
timeout=60,
)
# Filet : après un redémarrage du CLI, le fichier de configuration
# peut avoir disparu du tmpfs alors que l'interface tient toujours.
# `wg-quick down` échoue alors, et l'interface resterait là.
runner.cmd(
f"filet : retirer {self.iface} si elle est restée",
"sh -c {}".format(
shlex.quote(
f"ip link show {shlex.quote(self.iface)} >/dev/null 2>&1"
f" && ip link del {shlex.quote(self.iface)} || true"
)
),
check=False,
)
runner.remove(self.secret_dir)
self.clear_state(runner, "iface")
runner.ok("Tunnel démonté, secrets effacés.")
return True
# ------------------------------------------------------------------
def status(self, runner):
iface = self.recorded_iface() or self.iface
code, out = runner.cmd(
"poignée de main WireGuard",
f"wg show {shlex.quote(iface)} latest-handshakes",
check=False,
capture=True,
)
stamps = [
int(part)
for line in out.splitlines()
for part in line.split()[1:2]
if part.isdigit()
]
latest = max(stamps) if stamps else 0
extra = [
(
"poignée de main",
bool(latest) if code == 0 else None,
(
f"horodatage {latest}"
if latest
else (out.strip().splitlines() or ["wg muet (sudo ?)"])[
-1
][:120]
),
)
]
return self.standard_status(runner, extra=extra)
def log_commands(self):
return [
(
f"état complet de {self.iface}",
f"wg show {shlex.quote(self.iface)}",
),
(
"journal du noyau (module wireguard)",
"journalctl -n 30 --no-pager -k -g wireguard",
),
]