erplibre/script/vpn/drivers/wireguard.py
Mathieu Benoit 197d19d61e [ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.

Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.

--- EN ---

The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.

What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.

Assisted-by: Claude Opus 5
2026-09-04 03:42:49 +00:00

269 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",
),
]