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