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
200 lines
7.2 KiB
Python
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
|