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