[ADD] vpn : préréglages de site, importés d'un profil AnyConnect
Un site publie sa passerelle, son protocole et son groupe de connexion.
Les retaper sur chaque poste, c'est se tromper un jour sur le seul champ
qui décide du service joint. Un préréglage est un profil PARTIEL, sans
identifiant ni secret : c'est ce qui lui permet de circuler.
Les répertoires sont lus dans l'ordre et le plus tardif gagne sur un même
identifiant, pour qu'un site corrige un gabarit livré sans modifier de
fichier suivi, donc sans conflit au prochain pull. Un fichier illisible
est signalé et sauté : sinon la panne se lit « aucun préréglage » alors
qu'il y en a dix. Un profil AnyConnect porte déjà les trois balises
utiles, il est donc lu directement. Vérifié : 19 tests unitaires.
--- EN ---
A site publishes its gateway, protocol and connection group. Retyping
them on every machine means getting the one field that decides which
service you reach wrong some day. A preset is a PARTIAL profile, with no
username and no secret: that is what lets it be handed around.
Directories are read in order and the latest wins on the same identifier,
so a site can fix a shipped template without editing a tracked file, and
without a conflict on the next pull. An unreadable file is reported and
skipped: otherwise the fault reads as "no preset" when ten exist. An
AnyConnect profile already carries the three useful tags, so it is read
directly. Checked: 19 unit tests.
Assisted-by: Claude Opus 5
2026-09-04 05:51:07 -04:00
|
|
|
#!/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.
|
|
|
|
|
"""
|
[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 : préréglages de site, importés d'un profil AnyConnect
Un site publie sa passerelle, son protocole et son groupe de connexion.
Les retaper sur chaque poste, c'est se tromper un jour sur le seul champ
qui décide du service joint. Un préréglage est un profil PARTIEL, sans
identifiant ni secret : c'est ce qui lui permet de circuler.
Les répertoires sont lus dans l'ordre et le plus tardif gagne sur un même
identifiant, pour qu'un site corrige un gabarit livré sans modifier de
fichier suivi, donc sans conflit au prochain pull. Un fichier illisible
est signalé et sauté : sinon la panne se lit « aucun préréglage » alors
qu'il y en a dix. Un profil AnyConnect porte déjà les trois balises
utiles, il est donc lu directement. Vérifié : 19 tests unitaires.
--- EN ---
A site publishes its gateway, protocol and connection group. Retyping
them on every machine means getting the one field that decides which
service you reach wrong some day. A preset is a PARTIAL profile, with no
username and no secret: that is what lets it be handed around.
Directories are read in order and the latest wins on the same identifier,
so a site can fix a shipped template without editing a tracked file, and
without a conflict on the next pull. An unreadable file is reported and
skipped: otherwise the fault reads as "no preset" when ten exist. An
AnyConnect profile already carries the three useful tags, so it is read
directly. Checked: 19 unit tests.
Assisted-by: Claude Opus 5
2026-09-04 05:51:07 -04:00
|
|
|
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)
|