erplibre/script/vpn/profiles.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

200 lines
7.2 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Profils VPN : tout ce qui n'est PAS un secret.
Le partage est net et c'est le cœur du dispositif : l'hôte, l'utilisateur
PPP, les routes et le MTU vivent ici, en JSON lisible ; la clé PSK et les
mots de passe vivent dans le coffre KeePassXC (voir `secrets.py`). Un profil
peut donc être lu, montré, comparé, versionné chez un client — sans jamais
exposer de quoi monter le tunnel.
Le fichier d'écriture est `private/todo/todo_override_private.json`, le SEUL
des trois fichiers fusionnés par `ConfigFile.get_config` qui soit gitignored,
et que `set_config_value` écrit en 0600 atomique. La lecture, elle, passe par
la fusion : un profil peut aussi venir de `script/todo/todo.json` (partagé
par l'équipe) ou de `private/todo/todo_override.json`.
Toute valeur est VALIDÉE avant d'être écrite : elle finira dans un fichier de
configuration et dans une ligne de commande lancée par sudo. Un nom d'hôte
avec une espace ou un point-virgule n'y arrivera pas.
"""
from __future__ import annotations
import json
import os
# Le MODULE, pas la constante : `CONFIG_OVERRIDE_PRIVATE_FILE` importée par
# valeur figerait le chemin à l'import, et les tests — qui le déplacent dans
# un répertoire temporaire — écriraient dans le vrai fichier de l'utilisateur.
from script.config import config_file as config_module
from script.config.config_file import ConfigFile
from script.vpn import valid
from script.vpn.valid import NAME_RE, SERVER_RE, ProfileError # noqa: F401
# La clé de section, dans les trois fichiers de configuration.
CONFIG_KEY = "vpn"
# Valeurs par défaut COMMUNES à toutes les technologies. Ce qui n'appartient
# qu'à une seule vit dans les `defaults` de son pilote : un profil WireGuard
# n'a rien à faire d'un « port L2TP local », et une liste de champs qui les
# additionne tous devient illisible au troisième pilote.
#
# `default_route` est FAUX par défaut : un tunnel qui capte tout le trafic
# coupe la session SSH en cours et n'est pas ce qu'un déploiement ERPLibre
# distant demande. C'est un choix explicite.
DEFAULTS = {
"driver": "l2tp_ipsec",
"server": "",
"routes": [],
"default_route": False,
"mtu": 1280,
# Adresse TÉMOIN, joignable uniquement à travers le tunnel. Vide,
# « ça marche » reste une impression ; remplie, le diagnostic peut
# le PROUVER.
"probe": "",
}
def secret_title(name: str) -> str:
"""Titre de l'entrée KeePassXC qui porte les secrets du profil.
Dérivé du nom plutôt que stocké : deux sources de vérité pour un même
lien finissent toujours par diverger, et un profil renommé chercherait
un secret sous l'ancien titre sans le dire."""
return f"ERPLibre VPN / {name}"
def load_all(config=None) -> list[dict]:
"""Tous les profils, dans l'ordre de fusion. Jamais None."""
cfg = config or ConfigFile()
data = cfg.get_config(CONFIG_KEY)
if not isinstance(data, list):
return []
return [p for p in data if isinstance(p, dict) and p.get("name")]
def load(name: str, config=None) -> dict | None:
"""Le profil `name`, complété par les défauts, ou None."""
for profile in load_all(config):
if profile.get("name") == name:
return with_defaults(profile)
return None
def with_defaults(profile: dict) -> dict:
"""Copie du profil où chaque clé connue a une valeur.
Les défauts du PILOTE sont ajoutés à ceux du format : c'est ce qui
permet à chaque technologie d'avoir ses propres réglages sans que le
format les connaisse. Un pilote inconnu ne fait pas échouer la lecture —
`validate` le dira, avec la liste des pilotes connus.
"""
from script.vpn.drivers import get_driver
full = dict(DEFAULTS)
driver = get_driver(str(profile.get("driver") or DEFAULTS["driver"]))
if driver is not None:
full.update(driver.defaults)
full.update({k: v for k, v in profile.items() if v is not None})
return full
def names(config=None) -> list[str]:
return [p["name"] for p in load_all(config)]
def _load_private() -> dict:
"""Contenu brut du fichier privé, {} s'il est absent ou illisible."""
path = config_module.CONFIG_OVERRIDE_PRIVATE_FILE
if not os.path.exists(path):
return {}
try:
with open(path) as fh:
data = json.load(fh)
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def private_profiles() -> list[dict]:
"""Les profils du fichier privé SEULS.
L'écriture doit repartir de cette liste et non de la fusion : réécrire
la fusion recopierait dans le fichier privé les profils venus de
`todo.json`, qui se retrouveraient alors en double à la lecture
suivante (la fusion étend les listes, elle ne les déduplique pas).
"""
data = _load_private().get(CONFIG_KEY)
return (
[p for p in data if isinstance(p, dict)]
if isinstance(data, list)
else []
)
def save(profile: dict, config=None) -> dict:
"""Valide puis écrit le profil dans le fichier privé. Rend le profil
normalisé. Lève ProfileError si quelque chose ne va pas."""
clean = validate(profile)
cfg = config or ConfigFile()
profiles = [
p for p in private_profiles() if p.get("name") != clean["name"]
]
profiles.append(clean)
cfg.set_config_value([CONFIG_KEY], profiles)
return clean
def delete(name: str, config=None) -> bool:
"""Retire le profil du fichier privé. Rend False s'il n'y était pas —
un profil venu de `todo.json` n'est pas supprimable d'ici, et le dire
vaut mieux que de faire semblant."""
profiles = private_profiles()
kept = [p for p in profiles if p.get("name") != name]
if len(kept) == len(profiles):
return False
cfg = config or ConfigFile()
cfg.set_config_value([CONFIG_KEY], kept)
return True
def validate(profile: dict) -> dict:
"""Profil normalisé, ou ProfileError.
Deux étages : ce qui vaut pour toute technologie est jugé ici, le reste
par `validate_profile` du pilote — qui normalise ses champs en place.
"""
from script.vpn.drivers import driver_names, get_driver
full = with_defaults(profile)
valid.text(full, "name", "Nom de profil", pattern=NAME_RE)
driver_name = str(full.get("driver") or "").strip()
driver = get_driver(driver_name)
if driver is None:
raise ProfileError(
f"Pilote inconnu : « {driver_name} »."
f" Connus : {', '.join(driver_names())}."
)
full["driver"] = driver_name
valid.text(full, "server", "Adresse du serveur", pattern=SERVER_RE)
full["routes"] = valid.cidr_list(full.get("routes"))
valid.flag(full, "default_route")
valid.integer(full, "mtu", "MTU", 576, 1500)
valid.ip_address(full, "probe", "Adresse témoin")
if (
driver.needs_routes
and not full["routes"]
and not full["default_route"]
):
raise ProfileError(
"Un tunnel sans route ne sert à rien : déclarer au moins un"
" réseau à joindre, ou demander la route par défaut."
)
driver.validate_profile(full)
return full