erplibre/script/vpn/presets.py
Mathieu Benoit 9ac66f97f4 [REM] vpn : retirer la recherche de préréglage que personne n'appelle
`presets.load(identifiant)` n'avait aucun appelant hors de son propre
test : une interface publique inventée pour un besoin qui ne s'est pas
présenté, et un test qui n'existait que parce qu'elle existait. Le menu
liste par `load_all` et choisit par rang.

Elle reviendra le jour où un CLI prendra un préréglage par son nom, avec
le test que ce besoin justifiera. Vérifié : 48 tests des préréglages
verts, et aucun appel résiduel dans script/ ni test/.

--- EN ---

`presets.load(identifier)` had no caller outside its own test: a public
interface invented for a need that never arose, and a test that existed
only because the interface did. The menu lists through `load_all` and
picks by rank.

It comes back the day a CLI takes a preset by name, with the test that
need will justify. Checked: 48 preset tests green, and no remaining call
in script/ or test/.

Assisted-by: Claude Opus 5
2026-09-08 09:41:29 -04:00

181 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)
"""Préréglages de site : un profil déjà rempli, sauf ce qui est personnel.
Un préréglage porte ce qu'un établissement publie et qui est le même pour
tout le monde — passerelle, protocole, groupe d'authentification, port,
limites du concentrateur. Il ne porte JAMAIS d'identifiant ni de secret :
c'est ce partage qui lui permet de se distribuer.
C'est donc un profil PARTIEL, sans `name` : le nom appartient à celui qui
crée le profil, parce qu'il aura plusieurs profils sur la même passerelle
(un par identité) et que c'est lui qui les distingue. Un préréglage n'est
pas validé au chargement, seulement quand on l'applique — `profiles.save`
tranche, avec ses messages.
Où ils sont lus, dans l'ordre
-----------------------------
1. `conf/vpn_presets/` — livré avec le dépôt. Il n'y a rien d'identifiant
dans un dépôt public : ce répertoire ne contient que des gabarits, avec
des passerelles inventées.
2. `private/vpn/presets/` — ignoré par git. C'est le point de montage d'un
dépôt privé : les préréglages qui nomment un établissement y vivent, et
se versionnent là où le dépôt est privé.
3. Les répertoires listés sous `vpn_preset_paths` dans la configuration —
pour un dépôt privé cloné ailleurs qu'à cet endroit.
Le PLUS TARDIF gagne sur un même identifiant. C'est ce qui permet de
corriger un gabarit du dépôt — une passerelle qui a déménagé, un groupe
renommé — sans modifier un fichier suivi par git, donc sans conflit au
prochain `git pull`.
Un fichier illisible ne fait pas échouer le chargement : il est retenu dans
une liste d'erreurs que l'appelant AFFICHE. Un préréglage fautif rendrait
autrement tous les autres inatteignables, et la panne se lirait « aucun
préréglage » alors qu'il y en a dix.
"""
from __future__ import annotations
import json
import os
import re
from script.config.config_file import ConfigFile
from script.vpn import profiles
from script.vpn.valid import NAME_RE
# Les répertoires livrés, dans l'ordre de lecture. Relatifs à la racine du
# checkout, comme les chemins de `script/config/config_file.py`.
PRESET_DIRS = ("./conf/vpn_presets", "./private/vpn/presets")
# Le seul répertoire où l'outil ÉCRIT. `conf/vpn_presets/` est suivi par
# git et ne reçoit que des gabarits écrits à la main.
PRIVATE_DIR = "./private/vpn/presets"
# Clé de configuration donnant des répertoires SUPPLÉMENTAIRES, lus après
# ceux du dessus.
CONFIG_KEY = "vpn_preset_paths"
# Clés qui décrivent le préréglage lui-même et ne sont pas des champs de
# profil : elles sont retirées par `apply`.
META_KEYS = ("preset", "label", "hint")
def slug_stem(name: str) -> str:
"""Nom de fichier sûr tiré de `name`, « anyconnect » en dernier repli.
Le nom vient du fichier que l'utilisateur désigne : il finit dans un
chemin, et « ../../etc/quelque-chose » n'y arrivera pas.
"""
cleaned = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")[:31]
return cleaned or "anyconnect"
def save(items: list[dict], stem: str) -> str:
"""Écrit `items` dans `private/vpn/presets/<stem>.json`. Rend le chemin.
Ce répertoire et pas un autre : `conf/vpn_presets/` est suivi par git,
et un préréglage importé nomme un établissement, sa passerelle et son
groupe de connexion. Écrit là, il partirait sur un fork public.
0600 : rien de tout cela n'est secret, mais rien n'oblige non plus à le
donner à lire aux autres comptes de la machine.
"""
os.makedirs(PRIVATE_DIR, mode=0o700, exist_ok=True)
path = os.path.join(PRIVATE_DIR, f"{stem}.json")
temporary = f"{path}.tmp"
handle = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(handle, "w") as fh:
json.dump(items, fh, indent=4, ensure_ascii=False)
fh.write("\n")
os.replace(temporary, path)
return path
def preset_dirs(config=None) -> list[str]:
"""Les répertoires à lire, dans l'ordre. Les inexistants restent dans
la liste : c'est `load_all` qui les saute, et les nommer ici garde
l'ordre lisible."""
dirs = list(PRESET_DIRS)
cfg = config or ConfigFile()
extra = cfg.get_config(CONFIG_KEY)
if isinstance(extra, str):
extra = [extra]
if isinstance(extra, list):
dirs += [str(d) for d in extra if isinstance(d, (str, os.PathLike))]
return dirs
def _read_file(path: str) -> tuple[list[dict], str]:
"""(préréglages, erreur). L'un des deux est vide.
Un fichier porte un préréglage (objet) ou plusieurs (liste) : un site
qui en distribue trois n'a pas à ouvrir trois fichiers.
"""
try:
with open(path) as fh:
data = json.load(fh)
except (OSError, ValueError) as error:
return [], f"{path} : {error}"
items = data if isinstance(data, list) else [data]
found = []
for item in items:
if not isinstance(item, dict):
return [], f"{path} : un préréglage doit être un objet JSON."
identifier = str(item.get("preset") or "").strip()
if not NAME_RE.match(identifier):
return [], (
f"{path} : « preset » manquant ou refusé"
f" (« {identifier} »). Attendu : minuscules, chiffres,"
" « - » ou « _ »."
)
found.append(dict(item, preset=identifier))
return found, ""
def load_all(config=None) -> tuple[list[dict], list[str]]:
"""(préréglages, erreurs).
Les préréglages sont rendus dans l'ordre où leur identifiant est
apparu pour la première fois, et non dans celui du dernier fichier qui
l'a redéfini : une liste dont les lignes changent de place selon qu'un
site a surchargé un gabarit se lit mal.
"""
by_id: dict[str, dict] = {}
errors: list[str] = []
for directory in preset_dirs(config):
if not os.path.isdir(directory):
continue
for entry in sorted(os.listdir(directory)):
if not entry.endswith(".json"):
continue
found, error = _read_file(os.path.join(directory, entry))
if error:
errors.append(error)
continue
for item in found:
by_id[item["preset"]] = item
return list(by_id.values()), errors
def label(preset: dict) -> str:
"""Ce qu'on affiche. Le `label` s'il est là, l'identifiant sinon : un
préréglage sans libellé reste choisissable."""
return str(preset.get("label") or preset["preset"])
def apply(preset: dict, name: str) -> dict:
"""Profil complété par les défauts, prêt pour le formulaire.
Les clés de description (`META_KEYS`) sont retirées : elles nomment le
préréglage et n'ont rien à faire dans un profil, où `profiles.validate`
les ignorerait en silence — et où elles resteraient à traîner dans la
configuration écrite.
Un préréglage ne porte ni identifiant ni secret. Ce qui manque est donc
ce que le formulaire demande ensuite, et c'est voulu.
"""
draft = {k: v for k, v in preset.items() if k not in META_KEYS}
draft["name"] = name
return profiles.with_defaults(draft)