[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)
|
|
|
|
|
"""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.
|
|
|
|
|
"""
|
[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 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}")
|