[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-03 23:42:49 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""OpenVPN : on part du fichier `.ovpn` que le client a fourni.
|
|
|
|
|
|
|
|
|
|
Ce pilote ne fabrique PAS de configuration OpenVPN. Un `.ovpn` porte une
|
|
|
|
|
autorité de certification, un certificat, une clé privée, des directives de
|
|
|
|
|
compression et de chiffrement : le modéliser dans un profil JSON serait
|
|
|
|
|
recopier un format qui existe déjà, et le recopier moins bien. Le profil
|
|
|
|
|
pointe donc vers le fichier, et ce pilote y ajoute ce que le fichier ne peut
|
|
|
|
|
pas contenir sans devenir un secret de plus : les identifiants, lus dans le
|
|
|
|
|
coffre et posés dans un tmpfs.
|
|
|
|
|
|
|
|
|
|
Deux choses qu'on aurait tort de croire évidentes :
|
|
|
|
|
|
|
|
|
|
· **`--cd`.** Un `.ovpn` référence ses fichiers voisins en relatif (`ca.crt`,
|
|
|
|
|
`client.key`). Lancé depuis la racine du dépôt, openvpn ne les trouve pas
|
|
|
|
|
et se plaint d'un certificat manquant, pas d'un répertoire. On se place
|
|
|
|
|
donc dans le répertoire du fichier.
|
|
|
|
|
· **L'ordre des options.** Ce qui suit `--config` sur la ligne de commande
|
|
|
|
|
l'emporte sur le contenu du fichier. Notre `--auth-user-pass <fichier>`
|
|
|
|
|
doit donc venir APRÈS, sinon un `auth-user-pass` nu dans le `.ovpn` fait
|
|
|
|
|
attendre une saisie qui ne viendra jamais — le démon est détaché.
|
|
|
|
|
|
|
|
|
|
Le tunnel scindé se demande à OpenVPN par `--route-nopull` : ignorer les
|
|
|
|
|
routes poussées, puis poser les nôtres. C'est un gros marteau — il ignore
|
|
|
|
|
aussi le DNS poussé — et le pilote le dit quand il le prend.
|
|
|
|
|
"""
|
[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 13:30:31 -04:00
|
|
|
|
[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-03 23:42:49 -04:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import shlex
|
|
|
|
|
|
|
|
|
|
from script.vpn import valid
|
|
|
|
|
from script.vpn.drivers.base import (
|
|
|
|
|
STATE_DIR,
|
|
|
|
|
VpnDriver,
|
|
|
|
|
interface_addresses,
|
|
|
|
|
interfaces,
|
|
|
|
|
wait_for_new_interface,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OpenvpnDriver(VpnDriver):
|
|
|
|
|
name = "openvpn"
|
|
|
|
|
label = "OpenVPN"
|
|
|
|
|
binaries = ("openvpn", "ip")
|
|
|
|
|
# Pas obligatoire : beaucoup de `.ovpn` s'authentifient par certificat
|
|
|
|
|
# seul. `up` exige le mot de passe seulement si un utilisateur est
|
|
|
|
|
# déclaré.
|
|
|
|
|
secret_fields = (("password", "OpenVPN password", False),)
|
|
|
|
|
iface_kind = "tun"
|
|
|
|
|
# Faux : un profil sans route reste utilisable — il joint l'hôte
|
|
|
|
|
# distant, et l'adresse qu'on y obtient dit quel réseau ajouter. Le
|
|
|
|
|
# site ne donne souvent qu'une passerelle et des identifiants.
|
|
|
|
|
needs_routes = False
|
|
|
|
|
hint = "When the site handed you a .ovpn file"
|
|
|
|
|
user_field = "ovpn_user"
|
|
|
|
|
# Le MTU vient du serveur (ou du .ovpn), pas du profil.
|
|
|
|
|
uses_mtu = False
|
|
|
|
|
defaults = {"ovpn_config": "", "ovpn_user": ""}
|
|
|
|
|
form_fields = (
|
|
|
|
|
(
|
|
|
|
|
"ovpn_config",
|
|
|
|
|
"Path to the .ovpn file provided by the site",
|
|
|
|
|
"path",
|
|
|
|
|
False,
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"ovpn_user",
|
|
|
|
|
"OpenVPN user (empty if the file authenticates by certificate)",
|
|
|
|
|
"text",
|
|
|
|
|
False,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
@property
|
|
|
|
|
def auth_file(self):
|
|
|
|
|
return f"{self.secret_dir}/auth.txt"
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def log_file(self):
|
|
|
|
|
"""Journal en tmpfs, lisible sans sudo : c'est lui qui dit pourquoi
|
|
|
|
|
une connexion a échoué, et `diagnose` doit pouvoir le montrer."""
|
|
|
|
|
return f"{STATE_DIR}/{self.name_tag}.log"
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def validate_profile(cls, profile):
|
|
|
|
|
valid.path(profile, "ovpn_config", "Fichier .ovpn")
|
|
|
|
|
valid.text(profile, "ovpn_user", "Utilisateur OpenVPN", required=False)
|
|
|
|
|
|
|
|
|
|
def auth_body(self):
|
|
|
|
|
"""Le format attendu par `--auth-user-pass` : deux lignes."""
|
|
|
|
|
return "{}\n{}\n".format(
|
|
|
|
|
self.profile.get("ovpn_user", ""),
|
|
|
|
|
self.secrets.get("password", ""),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def command(self):
|
|
|
|
|
"""La ligne de commande, sans aucun secret : le mot de passe est
|
|
|
|
|
dans le fichier d'authentification, pas ici."""
|
|
|
|
|
p = self.profile
|
|
|
|
|
config = p["ovpn_config"]
|
|
|
|
|
parts = [
|
|
|
|
|
"openvpn",
|
|
|
|
|
f"--config {shlex.quote(config)}",
|
|
|
|
|
f"--cd {shlex.quote(os.path.dirname(config) or '.')}",
|
|
|
|
|
f"--daemon erplibre-{self.name_tag}",
|
|
|
|
|
f"--writepid {shlex.quote(self.pid_file)}",
|
|
|
|
|
f"--log {shlex.quote(self.log_file)}",
|
|
|
|
|
]
|
|
|
|
|
if p.get("ovpn_user"):
|
|
|
|
|
parts.append(f"--auth-user-pass {shlex.quote(self.auth_file)}")
|
|
|
|
|
# Le fichier est relu à chaque renégociation : rien à garder en
|
|
|
|
|
# mémoire, et un secret de moins qui traîne dans le processus.
|
|
|
|
|
parts.append("--auth-nocache")
|
|
|
|
|
if not p.get("default_route") and p.get("routes"):
|
|
|
|
|
parts.append("--route-nopull")
|
|
|
|
|
return " ".join(parts)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
def up(self, runner):
|
|
|
|
|
p = self.profile
|
|
|
|
|
if not self.ensure_ready(runner):
|
|
|
|
|
return False
|
|
|
|
|
if p.get("ovpn_user") and not self.secrets.get("password"):
|
|
|
|
|
report = runner.warn if runner.dry_run else runner.fail
|
|
|
|
|
report(
|
|
|
|
|
f"Le profil déclare l'utilisateur « {p['ovpn_user']} » mais"
|
|
|
|
|
" aucun mot de passe n'est dans le coffre."
|
|
|
|
|
)
|
|
|
|
|
if not runner.dry_run:
|
|
|
|
|
return False
|
|
|
|
|
self._warn_about_config_file(runner)
|
|
|
|
|
|
|
|
|
|
before = (
|
|
|
|
|
runner.call(
|
|
|
|
|
"relever les interfaces tun/tap existantes",
|
|
|
|
|
lambda: interfaces("tun"),
|
|
|
|
|
dry_safe=True,
|
|
|
|
|
)
|
|
|
|
|
or set()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.prepare_dirs(runner)
|
|
|
|
|
if p.get("ovpn_user"):
|
|
|
|
|
runner.write(
|
|
|
|
|
self.auth_file, self.auth_body(), mode="0600", secret=True
|
|
|
|
|
)
|
|
|
|
|
if not p.get("default_route") and p.get("routes"):
|
|
|
|
|
runner.warn(
|
|
|
|
|
"Tunnel scindé par --route-nopull : les routes ET le DNS"
|
|
|
|
|
" poussés par le serveur sont ignorés. Seules les routes du"
|
|
|
|
|
" profil sont posées."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
code, _ = runner.cmd("lancer openvpn", self.command(), timeout=60)
|
|
|
|
|
if code != 0 and not runner.dry_run:
|
|
|
|
|
runner.fail(
|
|
|
|
|
"openvpn n'a pas démarré. Le journal dit pourquoi :"
|
|
|
|
|
f" {self.log_file}"
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if not self._wait_for_init(runner):
|
|
|
|
|
return False
|
|
|
|
|
if runner.dry_run:
|
|
|
|
|
runner.info(" (à blanc : l'interface serait nommée ici)")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
iface = wait_for_new_interface(before, "tun", timeout=10)
|
|
|
|
|
if not iface:
|
|
|
|
|
runner.fail(
|
|
|
|
|
"OpenVPN dit s'être initialisé, mais aucune interface"
|
|
|
|
|
" tun/tap n'est apparue. Cas rare : configuration en mode"
|
|
|
|
|
" pont (tap) sans interface propre."
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
addresses = ", ".join(interface_addresses(iface)) or "sans adresse"
|
|
|
|
|
runner.ok(f"interface {iface} : {addresses}")
|
|
|
|
|
self.write_state(runner, "iface", iface)
|
|
|
|
|
if not p.get("default_route"):
|
|
|
|
|
self.add_routes(runner, iface)
|
|
|
|
|
self.suggest_routes(runner, iface)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _wait_for_init(self, runner):
|
|
|
|
|
"""Attend « Initialization Sequence Completed » dans le journal.
|
|
|
|
|
|
|
|
|
|
C'est LE signal de succès d'OpenVPN. Le processus détaché existe
|
|
|
|
|
bien avant : se contenter de son pid ferait dire « monté » à un
|
|
|
|
|
client encore en train de se faire refuser ses certificats.
|
|
|
|
|
"""
|
|
|
|
|
log = shlex.quote(self.log_file)
|
|
|
|
|
script = (
|
|
|
|
|
"for i in $(seq 1 120); do"
|
|
|
|
|
f" grep -q 'Initialization Sequence Completed' {log}"
|
|
|
|
|
" 2>/dev/null && exit 0; sleep 0.5; done; exit 1"
|
|
|
|
|
)
|
|
|
|
|
code, _ = runner.cmd(
|
|
|
|
|
"attendre l'initialisation d'OpenVPN",
|
|
|
|
|
f"sh -c {shlex.quote(script)}",
|
|
|
|
|
check=False,
|
|
|
|
|
timeout=75,
|
|
|
|
|
)
|
|
|
|
|
if code == 0 or runner.dry_run:
|
|
|
|
|
return True
|
|
|
|
|
runner.fail(
|
|
|
|
|
"OpenVPN ne s'est pas initialisé en 60 s. Les dernières lignes"
|
|
|
|
|
f" de {self.log_file} disent laquelle des trois étapes a"
|
|
|
|
|
" échoué : TLS, authentification, ou pose des routes."
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def _warn_about_config_file(self, runner):
|
|
|
|
|
"""Un `.ovpn` embarque souvent la clé privée du client.
|
|
|
|
|
|
|
|
|
|
Le fichier appartient à l'utilisateur, pas à nous : on ne le
|
|
|
|
|
déplace pas, on ne le réécrit pas. Mais lisible par tout le monde,
|
|
|
|
|
il vaut la peine d'être signalé — c'est une clé privée.
|
|
|
|
|
"""
|
|
|
|
|
config = self.profile.get("ovpn_config", "")
|
|
|
|
|
try:
|
|
|
|
|
mode = os.stat(config).st_mode
|
|
|
|
|
except OSError:
|
|
|
|
|
runner.warn(
|
|
|
|
|
f"Fichier de configuration introuvable : {config}."
|
|
|
|
|
" Le montage échouera."
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
if mode & 0o077:
|
|
|
|
|
runner.warn(
|
|
|
|
|
f"{config} est lisible au-delà de son propriétaire"
|
|
|
|
|
f" (mode {oct(mode & 0o777)}). Un .ovpn embarque souvent la"
|
|
|
|
|
" clé privée du client : chmod 600 est de rigueur."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
def down(self, runner):
|
|
|
|
|
self.kill_pidfile(runner, "arrêter openvpn")
|
|
|
|
|
runner.remove(self.secret_dir)
|
|
|
|
|
# Le journal SURVIT au démontage, volontairement : c'est juste
|
|
|
|
|
# après un « down » qu'on cherche pourquoi ça n'allait pas. Il est
|
|
|
|
|
# en tmpfs, donc il part au redémarrage de la machine.
|
|
|
|
|
self.clear_state(runner, "iface", "pid")
|
|
|
|
|
runner.ok(
|
|
|
|
|
f"Tunnel démonté, secrets effacés. Journal gardé : {self.log_file}"
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
def status(self, runner):
|
|
|
|
|
extra = [self.check_daemon("processus openvpn")]
|
|
|
|
|
try:
|
|
|
|
|
with open(self.log_file) as fh:
|
|
|
|
|
lines = [line.strip() for line in fh if line.strip()]
|
|
|
|
|
except OSError:
|
|
|
|
|
lines = []
|
|
|
|
|
initialised = any(
|
|
|
|
|
"Initialization Sequence Completed" in line for line in lines
|
|
|
|
|
)
|
|
|
|
|
extra.append(
|
|
|
|
|
(
|
|
|
|
|
"initialisation OpenVPN",
|
|
|
|
|
initialised if lines else None,
|
|
|
|
|
lines[-1][:120] if lines else "aucun journal",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return self.standard_status(runner, extra=extra)
|
|
|
|
|
|
|
|
|
|
def log_commands(self):
|
|
|
|
|
return [
|
|
|
|
|
(
|
|
|
|
|
f"journal OpenVPN ({self.log_file})",
|
|
|
|
|
f"tail -n 40 {shlex.quote(self.log_file)}",
|
|
|
|
|
)
|
|
|
|
|
]
|