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