erplibre/script/vpn/presets.py
Mathieu Benoit cfbdd9406e [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 14:40:38 -04:00

182 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)