[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)
|
|
|
|
|
"""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.
|
|
|
|
|
"""
|
[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 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
|