[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
This commit is contained in:
Mathieu Benoit 2026-09-04 09:51:07 +00:00
parent 50187ece85
commit 7b749e43bb
4 changed files with 561 additions and 0 deletions

View file

@ -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
}
]

View file

@ -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 :
<HostName> → le libellé affiché. Un nom de service, pas un hôte,
malgré ce que la balise dit.
<HostAddress> → le nom d'hôte réel du concentrateur.
<UserGroup> → 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 `<HostEntry>` 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 `<HostEntry>` : 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}")

190
script/vpn/presets.py Normal file
View file

@ -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/<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 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)

View file

@ -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 `<UserGroup>`, qui est un
CHEMIN D'URL et décide quel service du concentrateur on joint, et
`<HostName>`, 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 = """<?xml version="1.0" encoding="UTF-8"?>
<AnyConnectProfile xmlns="http://schemas.xmlsoap.org/encoding/">
<ClientInitialization>
<AuthenticationTimeout>12</AuthenticationTimeout>
<LocalLanAccess UserControllable="true">true</LocalLanAccess>
</ClientInitialization>
<ServerList>
<HostEntry>
<HostName>CampusLab</HostName>
<HostAddress>ssl.vpn.example-campus.net</HostAddress>
<UserGroup>SSLProfileLab</UserGroup>
</HostEntry>
</ServerList>
</AnyConnectProfile>
"""
def with_entries(*blocks):
inner = "".join(blocks)
return (
'<?xml version="1.0" encoding="UTF-8"?>'
'<AnyConnectProfile xmlns="http://schemas.xmlsoap.org/encoding/">'
f"<ServerList>{inner}</ServerList></AnyConnectProfile>"
)
def entry(name="CampusLab", address="ssl.vpn.example-campus.net", group=""):
parts = []
if name is not None:
parts.append(f"<HostName>{name}</HostName>")
if address is not None:
parts.append(f"<HostAddress>{address}</HostAddress>")
if group is not None:
parts.append(f"<UserGroup>{group}</UserGroup>")
return f"<HostEntry>{''.join(parts)}</HostEntry>"
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):
"""`<UserGroup>` 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
`<HostAddress>` 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("<AnyConnectProfile><ServerList>")
def test_a_file_that_is_not_a_profile_is_named_as_such(self):
with self.assertRaises(ProfileXmlError) as caught:
parse("<?xml version='1.0'?><something><else/></something>")
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()