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
269 lines
9.8 KiB
Python
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",
|
|
),
|
|
]
|