erplibre/script/vpn/profiles.py

202 lines
7.2 KiB
Python
Raw Permalink Normal View History

#!/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