From 7b749e43bb8eba28dfaa0cdf37e226d1e924e8bd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 09:51:07 +0000 Subject: [PATCH] =?UTF-8?q?[ADD]=20vpn=20:=20pr=C3=A9r=C3=A9glages=20de=20?= =?UTF-8?q?site,=20import=C3=A9s=20d'un=20profil=20AnyConnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- conf/vpn_presets/example-ssl-vpn.json | 16 ++ script/vpn/anyconnect_xml.py | 151 +++++++++++++++++++ script/vpn/presets.py | 190 ++++++++++++++++++++++++ test/test_vpn_anyconnect_xml.py | 204 ++++++++++++++++++++++++++ 4 files changed, 561 insertions(+) create mode 100644 conf/vpn_presets/example-ssl-vpn.json create mode 100644 script/vpn/anyconnect_xml.py create mode 100644 script/vpn/presets.py create mode 100644 test/test_vpn_anyconnect_xml.py diff --git a/conf/vpn_presets/example-ssl-vpn.json b/conf/vpn_presets/example-ssl-vpn.json new file mode 100644 index 0000000..5fb2fa2 --- /dev/null +++ b/conf/vpn_presets/example-ssl-vpn.json @@ -0,0 +1,16 @@ +[ + { + "preset": "example_ssl_vpn", + "label": "Example campus SSL VPN (template)", + "hint": "Cisco AnyConnect gateway with an authentication group", + "driver": "openconnect", + "server": "ssl.vpn.example-campus.net", + "port": 443, + "oc_protocol": "anyconnect", + "oc_authgroup": "CampusSSL", + "oc_sso": false, + "oc_password_len": 0, + "routes": [], + "default_route": false + } +] diff --git a/script/vpn/anyconnect_xml.py b/script/vpn/anyconnect_xml.py new file mode 100644 index 0000000..ede186c --- /dev/null +++ b/script/vpn/anyconnect_xml.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Lire un profil AnyConnect (`.xml`) et en tirer des préréglages. + +Un site qui exploite un concentrateur Cisco distribue un fichier +`AnyConnectProfile` que son client dépose dans +`/opt/cisco/secureclient/vpn/profile/`. Tout ce dont openconnect a besoin +pour joindre le bon service y est déjà écrit, et le retaper à la main est +l'occasion de se tromper sur le seul champ qui compte. + +Trois balises sont lues, et RIEN d'autre : + + → le libellé affiché. Un nom de service, pas un hôte, + malgré ce que la balise dit. + → le nom d'hôte réel du concentrateur. + → le chemin d'URL, donc `--usergroup`. C'est le champ + qui décide QUEL service du concentrateur on joint. + +Le reste du fichier décrit le comportement du client graphique de Cisco — +sélection de certificat, reconnexion automatique, mise à jour, exclusion +PPP. Rien de cela n'a d'équivalent chez openconnect, et prétendre le +traduire donnerait des champs que rien ne lit. + +Ce que le fichier ne porte PAS, et que le formulaire demande ensuite : +l'identifiant, et la méthode d'authentification. Le profil AnyConnect ne +dit pas si le service authentifie par mot de passe ou par SAML — c'est le +concentrateur qui l'annonce à la connexion. + +L'espace de noms n'est pas ignoré mais il n'est pas EXIGÉ non plus : les +fichiers vus dans le parc déclarent +`xmlns="http://schemas.xmlsoap.org/encoding/"`, et un site peut en +distribuer un sans. Les balises sont donc cherchées sur leur nom local. +""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET + +from script.vpn.valid import NAME_RE + +# Ce que le pilote openconnect a besoin de savoir et que le fichier ne dit +# pas. Repris tel quel dans chaque préréglage produit, pour qu'il soit +# complet dès sa lecture plutôt que complété au petit bonheur. +BASE = { + "driver": "openconnect", + "oc_protocol": "anyconnect", + "port": 443, + "routes": [], + "default_route": False, +} + + +class ProfileXmlError(ValueError): + """Fichier refusé. Le message est destiné à l'utilisateur.""" + + +def _local(tag: str) -> str: + """Nom de balise sans son espace de noms : `{uri}HostName` → `HostName`.""" + return tag.rsplit("}", 1)[-1] + + +def _text(node, name: str) -> str: + """Texte de l'enfant direct `name`, "" s'il est absent ou vide. + + Enfant DIRECT et non descendant : un `` n'imbrique pas ses + champs, et chercher en profondeur ferait remonter la valeur d'une + entrée voisine dans un fichier mal formé. + """ + for child in node: + if _local(child.tag) == name: + return (child.text or "").strip() + return "" + + +def slug(label: str, address: str) -> str: + """Identifiant de préréglage tiré du libellé, sinon de l'hôte. + + Le libellé est le nom que le site a choisi et celui que l'utilisateur + reconnaît. Réduit à l'alphabet de `NAME_RE`, et replié sur le premier + élément du nom d'hôte quand il n'en reste rien d'utilisable — un + libellé entièrement accentué ou vide ne doit pas rendre le fichier + inimportable. + """ + for candidate in (label, address.split(".")[0]): + cleaned = re.sub(r"[^a-z0-9]+", "_", candidate.lower()).strip("_") + cleaned = cleaned[:31] + if cleaned and NAME_RE.match(cleaned): + return cleaned + return "anyconnect" + + +def parse(text: str) -> list[dict]: + """Les préréglages décrits par ce XML. Lève ProfileXmlError. + + Un fichier peut porter plusieurs `` : un site en distribue + souvent un par service. Chacun devient un préréglage, et les + identifiants sont rendus uniques par un suffixe — deux entrées peuvent + porter le même libellé. + """ + try: + root = ET.fromstring(text) + except ET.ParseError as error: + raise ProfileXmlError(f"XML illisible : {error}") + + entries = [node for node in root.iter() if _local(node.tag) == "HostEntry"] + if not entries: + raise ProfileXmlError( + "Aucun « HostEntry » : ce fichier n'est pas un profil" + " AnyConnect, ou il ne déclare aucun serveur." + ) + + presets: list[dict] = [] + seen: set[str] = set() + for entry in entries: + address = _text(entry, "HostAddress") + label = _text(entry, "HostName") + if not address: + # Une entrée sans adresse ne mène nulle part. Sautée plutôt que + # fatale : les autres entrées du fichier restent utilisables. + continue + identifier = slug(label, address) + if identifier in seen: + suffix = 2 + while f"{identifier}_{suffix}" in seen: + suffix += 1 + identifier = f"{identifier}_{suffix}" + seen.add(identifier) + presets.append( + dict( + BASE, + preset=identifier, + label=label or address, + server=address, + oc_usergroup=_text(entry, "UserGroup"), + ) + ) + if not presets: + raise ProfileXmlError( + "Chaque « HostEntry » est sans « HostAddress » : aucun serveur" + " à joindre." + ) + return presets + + +def parse_file(path: str) -> list[dict]: + try: + with open(path, encoding="utf-8") as fh: + return parse(fh.read()) + except OSError as error: + raise ProfileXmlError(f"{path} : {error}") diff --git a/script/vpn/presets.py b/script/vpn/presets.py new file mode 100644 index 0000000..1dc8859 --- /dev/null +++ b/script/vpn/presets.py @@ -0,0 +1,190 @@ +#!/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 load(identifier: str, config=None) -> dict | None: + """Le préréglage `identifier`, ou None.""" + found, _ = load_all(config) + for item in found: + if item["preset"] == identifier: + return item + return None + + +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) diff --git a/test/test_vpn_anyconnect_xml.py b/test/test_vpn_anyconnect_xml.py new file mode 100644 index 0000000..624ae63 --- /dev/null +++ b/test/test_vpn_anyconnect_xml.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Lecture d'un profil AnyConnect : les trois balises qui comptent. + +Ni root, ni réseau. Le fichier est construit dans le test. + +Ce que ce fichier protège : la distinction entre ``, qui est un +CHEMIN D'URL et décide quel service du concentrateur on joint, et +``, qui n'est qu'un libellé d'affichage. Les confondre ne donne +pas une erreur de syntaxe — cela donne le formulaire d'authentification +d'un autre service, donc un refus sur des identifiants justes. +""" + +import os +import sys +import tempfile +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.vpn.anyconnect_xml import ( # noqa: E402 + ProfileXmlError, + parse, + parse_file, + slug, +) + +# Passerelle INVENTÉE : la règle du dépôt interdit d'illustrer avec un vrai +# site, et un test fige pour toujours l'exemple qu'il choisit. +XML = """ + + + 12 + true + + + + CampusLab + ssl.vpn.example-campus.net + SSLProfileLab + + + +""" + + +def with_entries(*blocks): + inner = "".join(blocks) + return ( + '' + '' + f"{inner}" + ) + + +def entry(name="CampusLab", address="ssl.vpn.example-campus.net", group=""): + parts = [] + if name is not None: + parts.append(f"{name}") + if address is not None: + parts.append(f"{address}") + if group is not None: + parts.append(f"{group}") + return f"{''.join(parts)}" + + +class ParseProfile(unittest.TestCase): + def test_the_three_tags_that_matter(self): + (preset,) = parse(XML) + self.assertEqual(preset["label"], "CampusLab") + self.assertEqual(preset["server"], "ssl.vpn.example-campus.net") + self.assertEqual(preset["oc_usergroup"], "SSLProfileLab") + + def test_the_usergroup_is_not_the_authgroup(self): + """`` est un chemin d'URL (`--usergroup`), pas une valeur + de menu déroulant (`--authgroup`). Le mettre dans le mauvais champ + mène au formulaire d'un autre service.""" + (preset,) = parse(XML) + self.assertEqual(preset.get("oc_authgroup", ""), "") + + def test_the_hostname_is_a_label_not_a_host(self): + """La balise dit « HostName » et ne porte pas un nom d'hôte : c'est + `` qui le porte. Le raccourci se paie en résolution + DNS impossible.""" + (preset,) = parse(XML) + self.assertNotEqual(preset["label"], preset["server"]) + self.assertNotIn(".", preset["label"]) + + def test_the_openconnect_driver_is_filled_in(self): + """Le fichier ne dit pas quel client l'utilisera : le pilote et le + protocole sont ajoutés pour que le préréglage soit complet dès sa + lecture.""" + (preset,) = parse(XML) + self.assertEqual(preset["driver"], "openconnect") + self.assertEqual(preset["oc_protocol"], "anyconnect") + self.assertEqual(preset["port"], 443) + + def test_the_client_behaviour_is_not_translated(self): + """`ClientInitialization` décrit le client graphique de Cisco. + Rien n'y a d'équivalent chez openconnect, et prétendre le traduire + donnerait des champs que rien ne lit.""" + (preset,) = parse(XML) + for absent in ("AuthenticationTimeout", "LocalLanAccess", "mtu"): + self.assertNotIn(absent, preset) + + def test_a_file_without_a_namespace_is_read_too(self): + """Les fichiers du parc en déclarent un, mais rien n'y oblige un + site : les balises sont cherchées sur leur nom local.""" + naked = XML.replace( + ' xmlns="http://schemas.xmlsoap.org/encoding/"', "" + ) + (preset,) = parse(naked) + self.assertEqual(preset["oc_usergroup"], "SSLProfileLab") + + def test_several_host_entries_become_several_presets(self): + text = with_entries( + entry("CampusLab", group="SSLProfileLab"), + entry("CampusStaff", group="SSLProfileStaff"), + ) + found = parse(text) + self.assertEqual( + [p["preset"] for p in found], ["campuslab", "campusstaff"] + ) + + def test_two_entries_with_the_same_label_stay_distinct(self): + """Deux entrées peuvent porter le même libellé, et deux préréglages + ne peuvent pas porter le même identifiant.""" + text = with_entries( + entry("Campus", group="SSLProfileA"), + entry("Campus", group="SSLProfileB"), + ) + found = parse(text) + self.assertEqual([p["preset"] for p in found], ["campus", "campus_2"]) + + def test_an_entry_without_an_address_is_skipped_not_fatal(self): + """Elle ne mène nulle part ; les autres restent utilisables.""" + text = with_entries( + entry("Broken", address=None, group="SSLProfileX"), + entry("CampusLab", group="SSLProfileLab"), + ) + found = parse(text) + self.assertEqual([p["preset"] for p in found], ["campuslab"]) + + def test_a_usergroup_is_optional(self): + """Un site peut n'en pas avoir : le concentrateur n'héberge alors + qu'un service, et la racine suffit.""" + (preset,) = parse(with_entries(entry(group=""))) + self.assertEqual(preset["oc_usergroup"], "") + + def test_broken_xml_is_refused_with_a_message(self): + with self.assertRaises(ProfileXmlError): + parse("") + + def test_a_file_that_is_not_a_profile_is_named_as_such(self): + with self.assertRaises(ProfileXmlError) as caught: + parse("") + self.assertIn("HostEntry", str(caught.exception)) + + def test_every_entry_without_an_address_is_refused(self): + with self.assertRaises(ProfileXmlError): + parse(with_entries(entry(address=None))) + + +class Slug(unittest.TestCase): + """L'identifiant doit passer `NAME_RE`, quoi que porte le libellé.""" + + def test_the_label_gives_the_identifier(self): + self.assertEqual(slug("CampusLab", "ssl.vpn.example.net"), "campuslab") + + def test_spaces_and_punctuation_fold_to_underscores(self): + self.assertEqual( + slug("Campus Lab (SSL)", "ssl.vpn.example.net"), "campus_lab_ssl" + ) + + def test_an_unusable_label_falls_back_to_the_host(self): + """Un libellé entièrement accentué ou vide ne doit pas rendre le + fichier inimportable.""" + self.assertEqual(slug("", "ssl.vpn.example.net"), "ssl") + self.assertEqual(slug("—", "gw1.vpn.example.net"), "gw1") + + def test_a_digit_leading_host_still_yields_a_valid_name(self): + self.assertEqual(slug("", "1gw.vpn.example.net"), "1gw") + + +class ParseFromDisk(unittest.TestCase): + def test_a_real_file_round_trips(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "campus.xml") + with open(path, "w") as fh: + fh.write(XML) + (preset,) = parse_file(path) + self.assertEqual(preset["oc_usergroup"], "SSLProfileLab") + + def test_a_missing_file_is_refused_with_its_path(self): + with self.assertRaises(ProfileXmlError) as caught: + parse_file("/nowhere/campus.xml") + self.assertIn("campus.xml", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main()