Merge branch 'VPN_cisco_personnalise'

[ADD] vpn : joindre un service SAML d'un site, du profil au tunnel

8 commits. L'outil montait cinq technologies mais ne joignait pas un
service qui authentifie par un fournisseur d'identité : openconnect
s'arrête sur « No SSO handler », les distributions le bâtissant sans
webview.

Le profil AnyConnect d'un site devient un préréglage, et les deux
« groupes » qu'expose Cisco sont distingués — les confondre donnait le
formulaire d'un autre service. L'étape web est déléguée à un greffon, le
tunnel monté par le pilote : nom d'interface, état et diagnostic restent
au profil. Les préréglages d'un site vivent dans un répertoire ignoré par
git. Vérifié : 269 tests, un tunnel monté puis démonté pour de vrai.

--- EN ---

8 commits. The tool could bring up five technologies but could not reach
a service authenticating through an identity provider: openconnect stops
on "No SSO handler", distributions building it without a webview.

A site's AnyConnect profile now becomes a preset, and the two "groups"
Cisco exposes are told apart — confusing them handed over another
service's login form. The web step is delegated to a helper, the tunnel
brought up by the driver: interface name, state and diagnosis stay with
the profile. A site's presets live in a git-ignored directory. Checked:
269 unit tests, and one tunnel really mounted then torn down.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-08 00:22:53 -04:00
commit 8231275e89
25 changed files with 4253 additions and 99 deletions

View file

@ -25,6 +25,32 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
<!-- [en] -->
## Added
<!-- [fr] -->
## Ajouté
<!-- [en] -->
- Site presets for the VPN: one `.json` carries a site's gateway, protocol and connection group, and neither a username nor a secret, so it can be handed around. Read from `conf/vpn_presets/`, then from a git-ignored `private/vpn/presets/`, then from any directory listed in `vpn_preset_paths`; on the same identifier the latest wins, so a site fixes a shipped template without touching a tracked file
- Import a Cisco AnyConnect `.xml` profile from the menu, browsing the client's own directories or typing the path, and get a preset from its `HostName`, `HostAddress` and `UserGroup`
- OpenConnect tells apart the two mechanisms that designate a service on one concentrator: the connection group in the URL and the value picked from a dropdown. Confusing them hands over another service's login form, so correct credentials are refused with nothing naming the group
- Reach a gateway that demands an embedded browser for SAML, which stops OpenConnect on « No SSO handler »: the web step is delegated to an `openconnect-sso` helper that the installer offers to set up, and the tunnel is then brought up by the driver itself, so the interface name, the state files and the diagnosis stay with the profile
- Declare a concentrator that compares only the first characters of a password: it is announced before the secret is stored, and nothing is ever truncated
- The VPN installer looks for `vpnc-script` — a file, not a binary on the `PATH` — and names the package to install per distribution family, instead of letting the tunnel fail on an interface that never appears
- A command launched by the VPN runner gets `/dev/null` on standard input, so a captured-output command can no longer be stopped by a SIGTTOU and freeze the machine's package manager
- The VPN profile list marks which profiles carry a live tunnel, so connecting one already up asks first and disconnecting one already down says so instead of looking like a mistake. A profile is judged on its interface, not on the state file a tunnel killed without `down` leaves behind — a state left over used to be reported as mounted on the same screen that declared the process gone
<!-- [fr] -->
- Des préréglages de site pour le VPN : un `.json` porte la passerelle d'un site, son protocole et son groupe de connexion, et ni identifiant ni secret, si bien qu'il peut circuler. Lus depuis `conf/vpn_presets/`, puis depuis un `private/vpn/presets/` ignoré par git, puis depuis tout répertoire listé dans `vpn_preset_paths` ; sur un même identifiant le plus tardif gagne, et un site corrige un gabarit livré sans toucher de fichier suivi
- Importer un profil Cisco AnyConnect `.xml` depuis le menu, en parcourant les répertoires du client ou en tapant le chemin, et en tirer un préréglage de ses balises `HostName`, `HostAddress` et `UserGroup`
- OpenConnect distingue les deux mécanismes qui désignent un service sur un même concentrateur : le groupe de connexion dans l'URL et la valeur choisie dans un menu déroulant. Les confondre donne le formulaire d'un autre service, et des identifiants justes sont refusés sans que rien ne nomme le groupe
- Joindre une passerelle qui exige un navigateur intégré pour le SAML, ce qui arrête OpenConnect sur « No SSO handler » : l'étape web est déléguée à un greffon `openconnect-sso` que l'installateur propose de poser, et le tunnel est ensuite monté par le pilote lui-même, si bien que le nom d'interface, les fichiers d'état et le diagnostic restent au profil
- Déclarer un concentrateur qui ne compare que les premiers caractères d'un mot de passe : il est annoncé avant le dépôt du secret, et rien n'est jamais tronqué
- L'installateur VPN cherche `vpnc-script` — un fichier, et non un binaire du `PATH` — et nomme le paquet à poser par famille de distribution, au lieu de laisser le tunnel échouer sur une interface qui n'apparaît jamais
- Une commande lancée par l'exécuteur VPN reçoit `/dev/null` sur son entrée standard, si bien qu'une commande à sortie capturée ne peut plus être arrêtée par un SIGTTOU et figer le gestionnaire de paquets de la machine
- La liste des profils VPN marque ceux qui portent un tunnel vivant, si bien que connecter un profil déjà monté demande confirmation et que déconnecter un profil déjà tombé le dit au lieu de ressembler à une erreur. Un profil est jugé sur son interface et non sur le fichier d'état qu'un tunnel tué sans `down` laisse derrière lui — un état laissé était annoncé monté sur l'écran même qui déclarait le processus mort
<!-- [common] -->
## [1.8.0] - 2026-09-04

View file

@ -9,6 +9,17 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## Ajouté
- Des préréglages de site pour le VPN : un `.json` porte la passerelle d'un site, son protocole et son groupe de connexion, et ni identifiant ni secret, si bien qu'il peut circuler. Lus depuis `conf/vpn_presets/`, puis depuis un `private/vpn/presets/` ignoré par git, puis depuis tout répertoire listé dans `vpn_preset_paths` ; sur un même identifiant le plus tardif gagne, et un site corrige un gabarit livré sans toucher de fichier suivi
- Importer un profil Cisco AnyConnect `.xml` depuis le menu, en parcourant les répertoires du client ou en tapant le chemin, et en tirer un préréglage de ses balises `HostName`, `HostAddress` et `UserGroup`
- OpenConnect distingue les deux mécanismes qui désignent un service sur un même concentrateur : le groupe de connexion dans l'URL et la valeur choisie dans un menu déroulant. Les confondre donne le formulaire d'un autre service, et des identifiants justes sont refusés sans que rien ne nomme le groupe
- Joindre une passerelle qui exige un navigateur intégré pour le SAML, ce qui arrête OpenConnect sur « No SSO handler » : l'étape web est déléguée à un greffon `openconnect-sso` que l'installateur propose de poser, et le tunnel est ensuite monté par le pilote lui-même, si bien que le nom d'interface, les fichiers d'état et le diagnostic restent au profil
- Déclarer un concentrateur qui ne compare que les premiers caractères d'un mot de passe : il est annoncé avant le dépôt du secret, et rien n'est jamais tronqué
- L'installateur VPN cherche `vpnc-script` — un fichier, et non un binaire du `PATH` — et nomme le paquet à poser par famille de distribution, au lieu de laisser le tunnel échouer sur une interface qui n'apparaît jamais
- Une commande lancée par l'exécuteur VPN reçoit `/dev/null` sur son entrée standard, si bien qu'une commande à sortie capturée ne peut plus être arrêtée par un SIGTTOU et figer le gestionnaire de paquets de la machine
- La liste des profils VPN marque ceux qui portent un tunnel vivant, si bien que connecter un profil déjà monté demande confirmation et que déconnecter un profil déjà tombé le dit au lieu de ressembler à une erreur. Un profil est jugé sur son interface et non sur le fichier d'état qu'un tunnel tué sans `down` laisse derrière lui — un état laissé était annoncé monté sur l'écran même qui déclarait le processus mort
## [1.8.0] - 2026-09-04

View file

@ -9,6 +9,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## Added
- Site presets for the VPN: one `.json` carries a site's gateway, protocol and connection group, and neither a username nor a secret, so it can be handed around. Read from `conf/vpn_presets/`, then from a git-ignored `private/vpn/presets/`, then from any directory listed in `vpn_preset_paths`; on the same identifier the latest wins, so a site fixes a shipped template without touching a tracked file
- Import a Cisco AnyConnect `.xml` profile from the menu, browsing the client's own directories or typing the path, and get a preset from its `HostName`, `HostAddress` and `UserGroup`
- OpenConnect tells apart the two mechanisms that designate a service on one concentrator: the connection group in the URL and the value picked from a dropdown. Confusing them hands over another service's login form, so correct credentials are refused with nothing naming the group
- Reach a gateway that demands an embedded browser for SAML, which stops OpenConnect on « No SSO handler »: the web step is delegated to an `openconnect-sso` helper that the installer offers to set up, and the tunnel is then brought up by the driver itself, so the interface name, the state files and the diagnosis stay with the profile
- Declare a concentrator that compares only the first characters of a password: it is announced before the secret is stored, and nothing is ever truncated
- The VPN installer looks for `vpnc-script` — a file, not a binary on the `PATH` — and names the package to install per distribution family, instead of letting the tunnel fail on an interface that never appears
- A command launched by the VPN runner gets `/dev/null` on standard input, so a captured-output command can no longer be stopped by a SIGTTOU and freeze the machine's package manager
- The VPN profile list marks which profiles carry a live tunnel, so connecting one already up asks first and disconnecting one already down says so instead of looking like a mistake. A profile is judged on its interface, not on the state file a tunnel killed without `down` leaves behind — a state left over used to be reported as mounted on the same screen that declared the process gone
## [1.8.0] - 2026-09-04

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

5
private/.gitignore vendored
View file

@ -4,3 +4,8 @@
# bump). They describe one specific database, never a shared default, so they
# must not be versioned. Shared defaults belong to script/odoo/migration/.
odoo/
# Mount point for a private repository of VPN site presets. Those files name
# an institution, its gateway and its authentication group, so they belong to
# a repository that is itself private, never to a public fork.
vpn/presets/

View file

@ -32,6 +32,9 @@ Pilotes connus :
sshuttle sshuttle (sur RHEL/Rocky/Alma : dépôt EPEL requis)
Sans argument : tous les pilotes.
--sso installe EN PLUS le greffon d'authentification par formulaire web
(openconnect-sso). Voir la section « greffon SSO » plus bas.
USAGE
}
@ -153,17 +156,209 @@ binaries_for() {
esac
}
# Le script que openconnect appelle pour poser routes et DNS. Il vient d'un
# paquet à part, dont le nom change de famille en famille, et il s'installe à
# des endroits différents : c'est un FICHIER qu'on cherche, pas un binaire du
# PATH, donc `binaries_for` ne peut pas le voir.
#
# Sans lui, la session s'ouvre, openconnect se lance, et l'interface tun
# n'apparaît jamais : le montage échoue trois étages plus haut, sur un
# symptôme qui n'accuse pas le paquet manquant.
VPNC_SCRIPT_PATHS="
/etc/vpnc/vpnc-script
/usr/share/vpnc-scripts/vpnc-script
/usr/libexec/openconnect/vpnc-script
/usr/lib/openconnect/vpnc-script
"
verify_vpnc_script() {
local fam="$1" path package
for path in ${VPNC_SCRIPT_PATHS}; do
if [ -x "$path" ]; then
log "vpnc-script : ${path}"
return 0
fi
done
case "$fam" in
rhel|suse) package="vpnc-script" ;;
*) package="vpnc-scripts" ;;
esac
die "vpnc-script introuvable — installer le paquet ${package}"
}
verify() {
local driver="$1" missing=""
local driver="$1" fam="$2" missing=""
for b in $(binaries_for "$driver"); do
command -v "$b" >/dev/null || missing="${missing} ${b}"
done
if [ -n "$missing" ]; then
die "toujours absents après installation :${missing}"
fi
if [ "$driver" = "openconnect" ]; then
verify_vpnc_script "$fam"
fi
log "vérifié : tout est en place pour ${driver}"
}
# ----------------------------------------------------------------------
# Greffon SSO — authentification par formulaire web (SAML)
#
# Un bloc à part, et supprimable d'un seul geste, parce qu'il porte une
# dette qu'aucun paquet de distribution ne porte pour nous.
#
# openconnect refuse les passerelles qui exigent un navigateur INTÉGRÉ
# (« No SSO handler ») : les distributions le bâtissent sans webview.
# openconnect-sso pilote un vrai navigateur et rend un cookie de session,
# que le pilote monte ensuite lui-même.
#
# Son amont est ARRÊTÉ depuis 2023. Trois conséquences qui ne se
# résoudront pas d'elles-mêmes, et que ce bloc assume :
#
# · ses épingles de version sont intenables sur un Python récent — lxml
# d'avant la 5 ne COMPILE pas — d'où `--no-deps` et des dépendances
# choisies à la main ;
# · Qt et lxml viennent de la DISTRIBUTION, pas de PyPI, qui n'a pas de
# roues pour les Python les plus récents ;
# · il appelle `asyncio.get_event_loop()`, qui lève depuis Python 3.12
# quand aucune boucle n'est courante. Le correctif ci-dessous est REJOUÉ
# à chaque installation, car toute réinstallation l'effacerait.
#
# Les noms de paquets ne sont VÉRIFIÉS que sur debian et ubuntu. Sur les
# autres familles ils sont donnés au mieux : une erreur ici se lit
# « paquet introuvable » et ne casse rien d'autre.
sso_packages_for() {
case "$1" in
debian) echo "python3-pyqt6 python3-pyqt6.qtwebengine python3-lxml libxcb-cursor0 python3-venv" ;;
arch) echo "python-pyqt6 python-pyqt6-webengine python-lxml xcb-util-cursor" ;;
rhel) echo "python3-pyqt6 python3-pyqt6-webengine python3-lxml xcb-util-cursor" ;;
suse) echo "python3-qt6 python3-lxml xcb-util-cursor" ;;
esac
}
# Les dépendances RÉELLES du greffon, ses épingles retirées. Qt et lxml
# sont volontairement absents : ils viennent du système, vus par le venv
# grâce à `--system-site-packages`.
SSO_PIP_DEPS="attrs colorama keyring prompt-toolkit pyxdg requests structlog toml PySocks pyotp"
install_sso() {
local fam="$1" user="${SUDO_USER:-}"
[ -n "$user" ] || die "greffon SSO : lancer par sudo, pas en root direct
(le greffon a besoin de l'affichage et du trousseau d'un UTILISATEUR,
que root n'a pas — d'où \$SUDO_USER)"
log "── greffon SSO (openconnect-sso) ──"
log "amont arrêté depuis 2023 : contournements assumés, voir le source"
# shellcheck disable=SC2086
install_packages "$fam" $(sso_packages_for "$fam")
# Le venv appartient à l'UTILISATEUR : root n'a ni son affichage ni son
# trousseau, et un greffon installé sous root ne lui servirait à rien.
log "installation sous l'utilisateur ${user}"
# TOUT le travail sous l'utilisateur dans UN seul bloc, et les
# dépendances passées par l'environnement. Le découper en deux appels
# obligeait à composer depuis le shell de root un chemin contenant
# `$HOME`, où il désigne le mauvais home ; et une chaîne coupée par une
# continuation de ligne ne se recolle PAS — l'indentation de la ligne
# suivante en fait un argument séparé, si bien que pip ne recevait plus
# aucun paquet à installer et que le greffon restait sans dépendances.
runuser -u "$user" -- env DEPS="$SSO_PIP_DEPS" sh -s <<'USERPART'
set -eu
VENV="$HOME/.local/share/openconnect-sso-venv"
# `--system-site-packages` : c'est ainsi que le venv voit le Qt et le lxml
# de la distribution, dont PyPI n'a pas de roues pour un Python récent.
[ -x "$VENV/bin/python" ] || /usr/bin/python3 -m venv --system-site-packages "$VENV"
"$VENV/bin/pip" install --quiet --upgrade pip
# `--no-deps` : les épingles du greffon sont intenables, on choisit nous-mêmes.
"$VENV/bin/pip" install --quiet --no-deps openconnect-sso
# `--no-warn-conflicts` : pip rapporterait un conflit sur lxml et keyring,
# préfixé « ERROR », alors qu'il est ATTENDU — ce sont les deux épingles
# qu'on relâche sciemment. Ce n'est pas masquer une erreur mais taire une
# fausse alerte : l'option ne porte que sur le rapport de conflits, et une
# vraie panne d'installation remonte toujours.
# shellcheck disable=SC2086
"$VENV/bin/pip" install --quiet --no-warn-conflicts $DEPS
USERPART
log "épingles de lxml et keyring relâchées : voulu, voir plus haut"
sso_patch_event_loop "$user"
sso_verify "$user"
}
sso_patch_event_loop() {
# `asyncio.get_event_loop()` ne crée plus de boucle implicite quand
# aucune n'est courante : depuis Python 3.12 il avertit, depuis 3.14 il
# lève. Le greffon l'appelle à quatre endroits, tous atteints après
# celui-ci — poser la boucle une fois ici les sert tous.
runuser -u "$1" -- /usr/bin/python3 - <<'PATCH'
import glob, os, sys
MARK = "# ERPLibre : boucle asyncio explicite"
OLD = """ if os.name == "nt":
asyncio.set_event_loop(asyncio.ProactorEventLoop())
auth_response, selected_profile = asyncio.get_event_loop().run_until_complete("""
NEW = """ if os.name == "nt":
asyncio.set_event_loop(asyncio.ProactorEventLoop())
else:
%s : depuis Python 3.12,
# get_event_loop() n'en crée plus une implicitement.
asyncio.set_event_loop(asyncio.new_event_loop())
auth_response, selected_profile = asyncio.get_event_loop().run_until_complete(""" % MARK
root = os.path.expanduser("~/.local/share/openconnect-sso-venv")
found = glob.glob(os.path.join(root, "lib", "python*", "site-packages",
"openconnect_sso", "app.py"))
if not found:
sys.exit("[VPN] ERREUR: app.py du greffon introuvable")
for path in found:
with open(path) as fh:
source = fh.read()
if MARK in source:
print("[VPN] correctif asyncio : déjà en place")
continue
if OLD not in source:
print("[VPN] correctif asyncio : motif absent, version changée —"
" à revoir si le greffon ne démarre pas")
continue
with open(path, "w") as fh:
fh.write(source.replace(OLD, NEW, 1))
print("[VPN] correctif asyncio : appliqué")
PATCH
}
sso_verify() {
local helper
helper="$(runuser -u "$1" -- sh -c 'echo "$HOME/.local/share/openconnect-sso-venv/bin/openconnect-sso"')"
runuser -u "$1" -- "$helper" --help >/dev/null 2>&1 \
|| die "greffon SSO installé mais il ne démarre pas : ${helper}"
log "vérifié : ${helper}"
log "le renseigner dans oc_sso_helper si le profil ne le trouve pas"
}
sso_is_wanted() {
# Proposé seulement quand il servirait : le pilote openconnect est
# demandé, et aucun greffon n'est déjà joignable. Proposer d'installer
# ce qui est installé fait douter de ce qu'on lit.
case " ${drivers} " in
*" openconnect "*) ;;
*) return 1 ;;
esac
if command -v openconnect-sso >/dev/null \
|| [ -x "${HOME:-/root}/.local/bin/openconnect-sso" ] \
|| [ -x "/home/${SUDO_USER:-nobody}/.local/bin/openconnect-sso" ] \
|| [ -x "/home/${SUDO_USER:-nobody}/.local/share/openconnect-sso-venv/bin/openconnect-sso" ]; then
return 1
fi
log "certaines passerelles exigent un navigateur intégré (SAML) :"
log "openconnect s'arrête sur « No SSO handler » sans greffon."
log "amont du greffon arrêté depuis 2023, voir le source et le README."
printf '[VPN] Installer le greffon SSO ? [o/N] '
read -r reponse
case "$reponse" in
[oOyY]*) return 0 ;;
*) return 1 ;;
esac
}
ALL_DRIVERS="l2tp_ipsec wireguard openvpn openconnect sshuttle"
main() {
@ -172,17 +367,39 @@ main() {
esac
check_root "$@"
detect_os
local fam drivers
local fam drivers with_sso=0 args=""
fam="$(family)"
# `--sso` retiré de la liste avant qu'elle ne serve de liste de
# pilotes : sans cela il serait pris pour un nom de pilote.
for arg in "$@"; do
case "$arg" in
--sso) with_sso=1 ;;
*) args="${args} ${arg}" ;;
esac
done
set -- ${args}
# Sans argument : tout. C'est ce que « [8] Installer les paquets
# client » demande quand on ne choisit pas de technologie.
drivers="${*:-${ALL_DRIVERS}}"
for driver in ${drivers}; do
log "── ${driver} ──"
install_packages "$fam" "$(packages_for "$driver" "$fam")"
verify "$driver"
verify "$driver" "$fam"
done
disable_autostart
# La question se posait dans le menu seulement, et l'invocation directe
# — celle que le README documente — n'offrait rien : on repartait sans
# le greffon sans avoir su qu'il existait.
#
# `[ -t 0 ]` : sans terminal il n'y a personne pour répondre, et un
# déploiement automatisé ne doit pas se bloquer sur une invite. Le
# drapeau reste alors le seul moyen de le demander.
if [ "$with_sso" -eq 0 ] && [ -t 0 ] && sso_is_wanted; then
with_sso=1
fi
if [ "$with_sso" -eq 1 ]; then
install_sso "$fam"
fi
log "Terminé. Monter un tunnel : ./script/vpn/vpn.py up --profile <nom>"
}

View file

@ -88,7 +88,17 @@ class KdbxManager:
# /chemin » — la question avant ce dont elle parle.
print(f"{t('kdbx_vault_is')} {kdbx_file_path}", flush=True)
for _ in range(attempts):
password = getpass.getpass(prompt=t("kdbx_ask_password"))
# Sans terminal, `getpass` lève — `termios.error` quand il ne
# peut pas couper l'écho, `EOFError` quand l'entrée standard
# est déjà fermée. Renoncer proprement plutôt que de laisser la
# trace tuer le CLI : l'appelant sait dire « coffre non
# joignable », et un script lancé sans terminal ne peut de
# toute façon pas répondre.
try:
password = getpass.getpass(prompt=t("kdbx_ask_password"))
except (EOFError, OSError):
print(t("kdbx_no_terminal"))
return None
if not password:
print(t("kdbx_give_up"))
return None

View file

@ -11688,6 +11688,206 @@ TRANSLATIONS = {
"fr": "Quelle technologie ?",
"en": "Which technology?",
},
"VPN - Create a profile from a site preset": {
"fr": "\U0001F3DB VPN - Créer un profil à partir d'un préréglage de site",
"en": "\U0001F3DB VPN - Create a profile from a site preset",
},
"VPN - Import an AnyConnect profile (.xml)": {
"fr": "\U0001F4E5 VPN - Importer un profil AnyConnect (.xml)",
"en": "\U0001F4E5 VPN - Import an AnyConnect profile (.xml)",
},
"An AnyConnect profile usually sits in"
" /opt/cisco/secureclient/vpn/profile/ (or .../anyconnect/profile/).": {
"fr": (
"Un profil AnyConnect se trouve d'ordinaire dans"
" /opt/cisco/secureclient/vpn/profile/ (ou"
" .../anyconnect/profile/)."
),
"en": (
"An AnyConnect profile usually sits in"
" /opt/cisco/secureclient/vpn/profile/ (or"
" .../anyconnect/profile/)."
),
},
"Path to the .xml profile": {
"fr": "Chemin du profil .xml",
"en": "Path to the .xml profile",
},
"Gateway": {
"fr": "Passerelle",
"en": "Gateway",
},
"Connection group": {
"fr": "Groupe de connexion",
"en": "Connection group",
},
"Presets written: ": {
"fr": "Préréglages écrits : ",
"en": "Presets written: ",
},
"Next step: create a profile from one of them. The .xml carries no"
" username and does not say whether the service authenticates by"
" password or by web form.": {
"fr": (
"Étape suivante : créer un profil à partir de l'un d'eux. Le"
" .xml ne porte aucun identifiant et ne dit pas si le service"
" authentifie par mot de passe ou par formulaire web."
),
"en": (
"Next step: create a profile from one of them. The .xml carries"
" no username and does not say whether the service"
" authenticates by password or by web form."
),
},
"SSO helper driving a real browser (empty: openconnect-sso from"
" the PATH)": {
"fr": (
"Greffon SSO pilotant un vrai navigateur (vide :"
" openconnect-sso trouvé dans le PATH)"
),
"en": (
"SSO helper driving a real browser (empty: openconnect-sso"
" from the PATH)"
),
},
"AnyConnect version announced to the gateway": {
"fr": "Version AnyConnect annoncée à la passerelle",
"en": "AnyConnect version announced to the gateway",
},
"kdbx_no_terminal": {
"fr": (
"Pas de terminal pour saisir le mot de passe du coffre :"
" coffre non ouvert."
),
"en": (
"No terminal to type the vault password: vault left closed."
),
},
"Some gateways demand an embedded browser (SAML): openconnect stops on"
" \u00ab No SSO handler \u00bb and a helper is needed for the web step. It is"
" optional, and its upstream is no longer maintained.": {
"fr": (
"Certaines passerelles exigent un navigateur intégré (SAML) :"
" openconnect s'arrête sur « No SSO handler » et un greffon est"
" nécessaire pour l'étape web. Il est facultatif, et son amont"
" n'est plus entretenu."
),
"en": (
"Some gateways demand an embedded browser (SAML): openconnect"
" stops on « No SSO handler » and a helper is needed for the web"
" step. It is optional, and its upstream is no longer"
" maintained."
),
},
"Found": {
"fr": "Trouvé",
"en": "Found",
},
"Browse it? (Y/n)": {
"fr": "Le parcourir ? (O/n)",
"en": "Browse it? (Y/n)",
},
"Install it as well? (y/N)": {
"fr": "L'installer aussi ? (o/N)",
"en": "Install it as well? (y/N)",
},
"This profile is already connected.": {
"fr": "Ce profil est déjà connecté.",
"en": "This profile is already connected.",
},
"Connect it again? (y/N)": {
"fr": "Le reconnecter quand même ? (o/N)",
"en": "Connect it again? (y/N)",
},
"This profile is not connected. Disconnecting still clears the state"
" a dead tunnel left behind.": {
"fr": (
"Ce profil n'est pas connecté. Le déconnecter efface tout de"
" même l'état qu'un tunnel mort a laissé."
),
"en": (
"This profile is not connected. Disconnecting still clears the"
" state a dead tunnel left behind."
),
},
"SSO helper": {
"fr": "greffon SSO",
"en": "SSO helper",
},
"Connection group in the URL \u2014 <UserGroup> of an AnyConnect"
" profile (optional)": {
"fr": (
"Groupe de connexion dans l'URL \u2014 <UserGroup> d'un profil"
" AnyConnect (facultatif)"
),
"en": (
"Connection group in the URL \u2014 <UserGroup> of an"
" AnyConnect profile (optional)"
),
},
"No site preset available.": {
"fr": "Aucun préréglage de site disponible.",
"en": "No site preset available.",
},
"Drop a .json file in conf/vpn_presets/ (shared, nothing identifying)"
" or in private/vpn/presets/ (git-ignored, where a site preset goes).": {
"fr": (
"Poser un fichier .json dans conf/vpn_presets/ (partagé, rien"
" d'identifiant) ou dans private/vpn/presets/ (ignoré par git,"
" c'est là qu'un préréglage de site va)."
),
"en": (
"Drop a .json file in conf/vpn_presets/ (shared, nothing"
" identifying) or in private/vpn/presets/ (git-ignored, where a"
" site preset goes)."
),
},
"Unreadable preset: ": {
"fr": "Préréglage illisible : ",
"en": "Unreadable preset: ",
},
"Preset number (0 to go back)": {
"fr": "Numéro du préréglage (0 pour revenir)",
"en": "Preset number (0 to go back)",
},
"An empty answer keeps the preset value.": {
"fr": "Une réponse vide garde la valeur du préréglage.",
"en": "An empty answer keeps the preset value.",
},
"This profile already exists: the preset refreshes what it declares,"
" everything personal is kept.": {
"fr": (
"Ce profil existe déjà : le préréglage rafraîchit ce qu'il"
" déclare, tout ce qui est personnel est gardé."
),
"en": (
"This profile already exists: the preset refreshes what it"
" declares, everything personal is kept."
),
},
"Cisco AnyConnect gateway with an authentication group": {
"fr": "Passerelle Cisco AnyConnect avec groupe d'authentification",
"en": "Cisco AnyConnect gateway with an authentication group",
},
"Password characters the server compares (0: no limit)": {
"fr": (
"Caractères du mot de passe que le serveur compare (0 : aucune"
" limite)"
),
"en": "Password characters the server compares (0: no limit)",
},
"This gateway compares only the first {limit} characters of"
" the password: store just those, a longer one is refused.": {
"fr": (
"Cette passerelle ne compare que les {limit} premiers caractères"
" du mot de passe : n'en déposer que ceux-là, un plus long est"
" refusé."
),
"en": (
"This gateway compares only the first {limit} characters of the"
" password: store just those, a longer one is refused."
),
},
"No VPN profile yet: create one first.": {
"fr": "Aucun profil VPN : en créer un d'abord.",
"en": "No VPN profile yet: create one first.",

View file

@ -19,11 +19,19 @@ Deux chemins d'exécution, pour une raison :
"""
import getpass
import os
import click
try:
from script.todo import todo_file_browser
except Exception:
# urwid peut manquer : le parcours devient indisponible, la saisie
# directe reste. Un menu qui ne s'ouvre plus serait pire.
todo_file_browser = None
from script.todo.todo_i18n import t
from script.vpn import profiles
from script.vpn import anyconnect_xml, presets, profiles
from script.vpn.drivers import DRIVERS, get_driver
from script.vpn.vault import VaultError, VpnVault, secrets_to_env
@ -48,6 +56,67 @@ NO_ROUTE_NOTE = (
# douteux : les tests unitaires, eux, sont là.
UNPROVEN_NOTE = "never mounted against a real server: only unit tests cover it"
# Où poser un préréglage quand il n'y en a aucun. Dit les DEUX répertoires,
# parce qu'ils ne servent pas au même usage : l'un est suivi par git et ne
# doit rien porter d'identifiant, l'autre est ignoré et existe pour ça.
PRESET_LOCATION_NOTE = (
"Drop a .json file in conf/vpn_presets/ (shared, nothing identifying)"
" or in private/vpn/presets/ (git-ignored, where a site preset goes)."
)
# Ce qu'on dit quand le nom tapé désigne un profil déjà là. Dit LEQUEL des
# deux gagne, champ par champ : sans cela, on ne sait pas si rejouer un
# préréglage remet la passerelle à jour ou efface les routes ajoutées.
PRESET_REPLAYED_NOTE = (
"This profile already exists: the preset refreshes what it declares,"
" everything personal is kept."
)
# Répertoires où chercher un profil AnyConnect, dans l'ordre. Les deux
# premiers sont ceux du client de Cisco — le nom a changé entre AnyConnect
# et Secure Client. Les deux derniers parce qu'un site le distribue aussi
# par courriel ou par son portail, et le fichier atterrit alors là.
ANYCONNECT_DIRS = (
"/opt/cisco/secureclient/vpn/profile",
"/opt/cisco/anyconnect/profile",
"~/Downloads",
"~/Téléchargements",
)
# Où le client de Cisco dépose les profils qu'un site distribue. Le dire
# évite d'avoir à le chercher, et c'est le seul endroit où il se trouve
# quand le client graphique a déjà servi sur la machine.
ANYCONNECT_LOCATION_NOTE = (
"An AnyConnect profile usually sits in"
" /opt/cisco/secureclient/vpn/profile/ (or .../anyconnect/profile/)."
)
# Ce que le fichier ne dit PAS, et qu'il reste donc à régler. Le profil
# AnyConnect ne déclare pas la méthode d'authentification : c'est le
# concentrateur qui l'annonce à la connexion.
ANYCONNECT_NEXT_STEP = (
"Next step: create a profile from one of them. The .xml carries no"
" username and does not say whether the service authenticates by"
" password or by web form."
)
# Ce qu'on dit avant de proposer le greffon SSO. Il faut que la réponse
# soit éclairée : le greffon n'est utile que pour une passerelle qui exige
# un navigateur intégré, et son amont n'est plus entretenu.
SSO_HELPER_NOTE = (
"Some gateways demand an embedded browser (SAML): openconnect stops on"
" « No SSO handler » and a helper is needed for the web step. It is"
" optional, and its upstream is no longer maintained."
)
# Ce qu'on dit avant de descendre un profil qui n'est pas monté. Le geste
# garde un sens — il nettoie ce qu'un tunnel mort a laissé dans /run — et
# le taire ferait croire à une erreur de choix.
NOT_CONNECTED_NOTE = (
"This profile is not connected. Disconnecting still clears the state"
" a dead tunnel left behind."
)
MASTER_PASSWORD_WARNING = (
"The vault MASTER password is stored in the configuration in clear"
" text. Remove it and type it on demand."
@ -61,6 +130,19 @@ MASTER_PASSWORD_WARNING = (
DRIVER_LETTERS = "abcdefghijklmnopqrstuvwxyz"
def _sso_helper_seen():
"""Le greffon SSO est-il déjà joignable sur cette machine ?
Interrogé au PILOTE, pour que le menu et le montage cherchent au même
endroit : deux recherches distinctes finiraient par diverger, et le
menu proposerait d'installer ce que le montage trouve déjà.
"""
driver_cls = DRIVERS.get("openconnect")
if driver_cls is None:
return True
return bool(driver_cls({"name": "check"}).sso_helper)
def match_driver(answer, names):
"""Le pilote désigné par `answer`.
@ -106,6 +188,16 @@ class VpnMenuMixin:
{"prompt_description": t("VPN - Disconnect a profile")},
{"prompt_description": t("VPN - Status and diagnosis")},
{"section": t("Profiles & secrets")},
{
"prompt_description": t(
"VPN - Create a profile from a site preset"
)
},
{
"prompt_description": t(
"VPN - Import an AnyConnect profile (.xml)"
)
},
{"prompt_description": t("VPN - Add or edit a profile")},
{"prompt_description": t("VPN - Store secrets in the vault")},
{
@ -132,16 +224,20 @@ class VpnMenuMixin:
elif status == "3":
self._vpn_diagnose()
elif status == "4":
self._vpn_edit_profile()
self._vpn_from_preset()
elif status == "5":
self._vpn_store_secrets()
self._vpn_import_anyconnect()
elif status == "6":
self._vpn_show_config()
self._vpn_edit_profile()
elif status == "7":
self._vpn_delete_profile()
self._vpn_store_secrets()
elif status == "8":
self._vpn_install()
self._vpn_show_config()
elif status == "9":
self._vpn_delete_profile()
elif status == "10":
self._vpn_install()
elif status == "11":
self._vpn_check()
else:
print(t("Command not found !"))
@ -183,6 +279,13 @@ class VpnMenuMixin:
name = self._vpn_select_profile()
if not name:
return
# Remonter un tunnel qui tient rejoue toute l'authentification —
# jusqu'à un formulaire web et un second facteur — pour aboutir à
# une interface qui existait déjà.
if self._vpn_is_up(profiles.load(name)):
print(f"\n! {t('This profile is already connected.')}")
if not self._is_yes(input(f"{t('Connect it again? (y/N)')} : ")):
return
# Lu UNE fois pour les deux exécutions qui suivent.
secrets_env = self._vpn_secrets_env(name)
# Le plan d'abord, l'exécution ensuite : monter un tunnel réécrit
@ -195,8 +298,14 @@ class VpnMenuMixin:
def _vpn_disconnect(self):
name = self._vpn_select_profile()
if name:
self._vpn_cli(f"down --profile {name}")
if not name:
return
# « down » reste utile sur un profil déjà tombé : c'est lui qui
# efface l'état laissé dans /run par un tunnel mort sans lui. On le
# dit, on ne l'empêche pas.
if not self._vpn_is_up(profiles.load(name)):
print(f"\n! {t(NOT_CONNECTED_NOTE)}")
self._vpn_cli(f"down --profile {name}")
def _vpn_diagnose(self):
name = self._vpn_select_profile()
@ -218,14 +327,36 @@ class VpnMenuMixin:
driver_cls = self._vpn_pick_driver(None)
if driver_cls is None:
return
# La question n'est posée que pour le pilote qui peut s'en servir,
# et seulement si le greffon n'est pas déjà là : proposer
# d'installer ce qui est installé fait douter de ce qu'on lit.
options = ""
if driver_cls is DRIVERS.get("openconnect") and not _sso_helper_seen():
print(f"\n{t(SSO_HELPER_NOTE)}")
if self._is_yes(input(f"{t('Install it as well? (y/N)')} : ")):
options = " --with-sso"
print(f"\n{t('The installation requires sudo.')}")
self._vpn_cli(f"install --driver {driver_cls.name}")
self._vpn_cli(f"install --driver {driver_cls.name}{options}")
# ------------------------------------------------------------------
# Profils
# ------------------------------------------------------------------
@staticmethod
def _vpn_is_up(profile):
"""Ce profil porte-t-il un tunnel vivant ? Faux si on ne peut pas
savoir — un pilote retiré de la configuration ne doit pas empêcher
de lister les profils."""
driver_cls = get_driver(profile.get("driver"))
return bool(driver_cls) and driver_cls(profile).is_up()
def _vpn_select_profile(self):
"""Nom du profil choisi, "" si l'utilisateur renonce."""
"""Nom du profil choisi, "" si l'utilisateur renonce.
L'état de chaque profil est affiché, parce que la liste sert autant
à connecter qu'à déconnecter : sans lui, on descend un tunnel déjà
mort ou on remonte celui qui tient, et la sortie du CLI est la
première chose qui le dit — trop tard.
"""
all_profiles = [profiles.with_defaults(p) for p in profiles.load_all()]
if not all_profiles:
print(t("No VPN profile yet: create one first."))
@ -236,8 +367,12 @@ class VpnMenuMixin:
if profile["default_route"]
else ", ".join(profile["routes"])
)
# Deux colonnes de large dans les deux cas : un emoji en occupe
# deux, et sans cela les lignes non connectées décaleraient tout
# ce qui suit.
marque = "🟢" if self._vpn_is_up(profile) else " "
print(
f"[{index}] {profile['name']:<20}"
f"[{index}] {marque} {profile['name']:<20}"
f" {profile['server']:<26} {target}"
)
answer = input(f"{t('Profile number (0 to go back)')} : ").strip()
@ -247,7 +382,140 @@ class VpnMenuMixin:
return ""
return all_profiles[int(answer) - 1]["name"]
def _vpn_edit_profile(self):
def _vpn_from_preset(self):
"""Crée un profil à partir d'un préréglage de site.
Le préréglage porte ce que l'établissement publie et qui est le même
pour tout le monde ; il ne reste à taper que l'identifiant. Le
formulaire est celui de `_vpn_edit_profile`, amorcé : dupliquer les
questions ici ferait vivre deux formulaires qui divergeraient au
prochain champ ajouté à un pilote.
"""
found, errors = presets.load_all()
for error in errors:
print(f"! {t('Unreadable preset: ')}{error}")
if not found:
print(t("No site preset available."))
print(f" {t(PRESET_LOCATION_NOTE)}")
return
for index, preset in enumerate(found, start=1):
print(
f"[{index}] {presets.label(preset):<34}"
f" {preset.get('server', ''):<28}"
f" {t(preset.get('hint', '') or '')}"
)
answer = input(f"{t('Preset number (0 to go back)')} : ").strip()
if not answer.isdigit() or not 1 <= int(answer) <= len(found):
if answer not in ("0", ""):
print(t("Unknown choice."))
return
preset = found[int(answer) - 1]
print(f"\n{t('An empty answer keeps the preset value.')}")
name = input(
f"{t('Profile name (lowercase, digits, - or _)')} : "
).strip()
if not name:
return
existing = profiles.load(name)
seed = presets.apply(preset, name)
if existing:
print(f"! {t(PRESET_REPLAYED_NOTE)}")
# Le PRÉRÉGLAGE gagne sur les champs qu'il déclare : rejouer un
# préréglage sur un profil existant sert à le remettre à jour
# après un déménagement de passerelle ou un groupe renommé, et
# garder l'ancienne valeur ne ferait rien de ce qu'on demande.
#
# Le reste vient du profil, parce que c'est ce qui est PERSONNEL
# et qu'aucun préréglage ne porte : l'identifiant, les routes
# ajoutées à la main, le certificat épinglé, l'adresse témoin.
#
# `k in seed` borne la reprise aux champs que le pilote du
# préréglage connaît : sur un profil qui change de technologie,
# recopier tout ferait suivre une clé WireGuard dans un profil
# OpenConnect, où rien ne la lirait jamais.
declared = set(preset) - set(presets.META_KEYS)
seed.update(
{
key: value
for key, value in existing.items()
if key in seed and key not in declared
}
)
self._vpn_edit_profile(seed=seed)
def _vpn_select_xml(self):
"""Chemin du profil `.xml`, "" si l'utilisateur renonce.
Le parcours d'abord, la saisie ensuite, parce que ni l'un ni l'autre
ne suffit : le parcours part des répertoires du client de Cisco et
n'aide pas si le fichier vient d'ailleurs ; le chemin tapé oblige à
le connaître, or personne ne retient
« /opt/cisco/secureclient/vpn/profile ».
Le parcours n'est PROPOSÉ que si un de ces répertoires existe :
l'ouvrir sur un chemin absent afficherait une liste vide, ce qui
ressemble à une panne.
"""
start = next(
(
path
for path in (os.path.expanduser(d) for d in ANYCONNECT_DIRS)
if os.path.isdir(path)
),
"",
)
if todo_file_browser is not None and start:
print(f" {t('Found')} : {start}")
if self._is_yes(input(f"{t('Browse it? (Y/n)')} : ") or "o"):
self._xml_path = ""
browser = todo_file_browser.FileBrowser(
start, self._on_xml_selected
)
browser.run_main_frame()
if self._xml_path and os.path.isfile(self._xml_path):
return self._xml_path
answer = input(f"{t('Path to the .xml profile')} : ").strip()
return os.path.expanduser(answer) if answer else ""
def _on_xml_selected(self, path):
self._xml_path = path
def _vpn_import_anyconnect(self):
"""Transforme un profil AnyConnect (`.xml`) en préréglages.
Le fichier qu'un site distribue porte déjà le nom d'hôte et le
groupe de connexion, et c'est ce dernier qui décide quel service du
concentrateur on joint. Le retaper à la main est l'occasion de se
tromper sur le seul champ qui compte.
Écrit dans `private/vpn/presets/`, jamais dans `conf/` : le fichier
nomme un établissement.
"""
print(t(ANYCONNECT_LOCATION_NOTE))
path = self._vpn_select_xml()
if not path:
return
try:
found = anyconnect_xml.parse_file(path)
except anyconnect_xml.ProfileXmlError as error:
print(f"\n✗ {error}")
return
print()
for preset in found:
print(f" {presets.label(preset)}")
print(f" {t('Gateway')} : {preset['server']}")
print(
f" {t('Connection group')} :"
f" {preset['oc_usergroup'] or t('none')}"
)
stem = os.path.splitext(os.path.basename(path))[0]
stem = presets.slug_stem(stem)
written = presets.save(found, stem)
print(f"\n✓ {t('Presets written: ')}{written}")
print(f" {t(ANYCONNECT_NEXT_STEP)}")
def _vpn_edit_profile(self, seed=None):
"""Crée ou modifie un profil, quelle que soit la technologie.
Les questions viennent du PILOTE (`form_fields`) : ce menu ne sait
@ -257,15 +525,30 @@ class VpnMenuMixin:
Une réponse vide garde la valeur actuelle : modifier une seule route
ne doit pas obliger à ressaisir tout le reste.
`seed` amorce le formulaire avec un profil déjà rempli — un
préréglage de site. Il porte alors le nom ET la technologie, donc les
deux questions correspondantes ne sont pas posées : le préréglage y a
déjà répondu, et redemander « quelle technologie ? » invite à
contredire le seul champ qu'on ne doit pas changer.
"""
name = input(f"{t('Profile name (lowercase, digits, - or _)')} : ")
name = name.strip()
if not name:
return
current = profiles.load(name) or {"name": name}
driver_cls = self._vpn_pick_driver(current.get("driver"))
if driver_cls is None:
return
if seed is not None:
current = dict(seed)
name = current["name"]
driver_cls = get_driver(current.get("driver"))
if driver_cls is None:
print(f"✗ {t('Unknown driver: ')}{current.get('driver')}")
return
else:
name = input(
f"{t('Profile name (lowercase, digits, - or _)')} : "
).strip()
if not name:
return
current = profiles.load(name) or {"name": name}
driver_cls = self._vpn_pick_driver(current.get("driver"))
if driver_cls is None:
return
# Les défauts DU PILOTE CHOISI, pour que chaque question ait un
# défaut sensé même sur un profil qui change de technologie.
@ -440,7 +723,12 @@ class VpnMenuMixin:
return
print(f"\n{t('Vault entry')} : {title}")
print(f"{t('An empty answer keeps the stored value.')}\n")
print(f"{t('An empty answer keeps the stored value.')}")
# Les contraintes du pilote AVANT la première invite : une borne de
# longueur annoncée après coup coûte une deuxième saisie.
for note in driver_cls(profile).secret_notes():
print(f"! {note}")
print()
values = {}
if driver_cls.user_field:
# Recopié pour que le coffre reste LISIBLE dans KeePassXC ; le

View file

@ -112,6 +112,395 @@ home et son mot de passe maître est le vôtre. Chaque étape privilégiée appe
| `/run/erplibre-vpn/<profil>.*` | l'état non secret (interface retenue, pid, journal), lisible sans sudo |
| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP seulement : un bloc marqué, retiré au `down` |
<!-- [en] -->
## Site presets
A preset is a **partial profile**: everything an institution publishes and
that is the same for everybody — gateway, protocol, authentication group,
port, concentrator limits. It carries **no username and no secret**, which is
exactly what lets it be handed around. Creating a profile from one leaves the
identity to type, and nothing else.
`VPN › Create a profile from a site preset` lists them, then runs the ordinary
form pre-filled: an empty answer keeps the preset value.
Presets are read from these directories, in order:
| Path | Use |
|------|-----|
| `conf/vpn_presets/` | shipped with the repository — templates only, invented gateways, **nothing identifying** |
| `private/vpn/presets/` | git-ignored: the mount point for a private repository of real site presets |
| any directory in `vpn_preset_paths` | a private repository cloned somewhere else |
The **latest wins** on the same identifier. That is what lets a site correct a
shipped template — a gateway that moved, a group that was renamed — without
editing a git-tracked file, so without a conflict on the next `git pull`.
One `.json` file holds one preset (an object) or several (a list). Beyond the
profile fields, three keys describe the preset itself: `preset` (identifier,
lowercase, digits, `-` or `_`), `label` and an optional `hint`. An unreadable
file is reported and skipped — a broken preset must not make the others
unreachable.
<!-- [fr] -->
## Préréglages de site
Un préréglage est un **profil partiel** : tout 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 **ni
identifiant ni secret**, et c'est précisément ce qui lui permet de se
distribuer. Créer un profil à partir d'un préréglage ne laisse à taper que
l'identité, et rien d'autre.
`VPN › Créer un profil à partir d'un préréglage de site` les liste, puis
déroule le formulaire ordinaire pré-rempli : une réponse vide garde la valeur
du préréglage.
Les préréglages sont lus dans ces répertoires, dans l'ordre :
| Chemin | Usage |
|--------|-------|
| `conf/vpn_presets/` | livré avec le dépôt — des gabarits seulement, passerelles inventées, **rien d'identifiant** |
| `private/vpn/presets/` | ignoré par git : le point de montage d'un dépôt privé de préréglages réels |
| tout répertoire de `vpn_preset_paths` | un dépôt privé cloné ailleurs |
Le **plus tardif gagne** sur un même identifiant. C'est ce qui permet à un
site de corriger un gabarit livré — 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 `.json` porte un préréglage (objet) ou plusieurs (liste). Outre les
champs de profil, trois clés décrivent le préréglage lui-même : `preset`
(identifiant, minuscules, chiffres, `-` ou `_`), `label` et un `hint`
facultatif. Un fichier illisible est signalé et sauté — un préréglage fautif
ne doit pas rendre les autres inatteignables.
<!-- [en] -->
## An SSL VPN (AnyConnect), distribution by distribution
The `openconnect` driver speaks AnyConnect (Cisco), Pulse/Juniper,
GlobalProtect, Fortinet, F5 and Array. One command installs its client:
<!-- [fr] -->
## Un VPN SSL (AnyConnect), distribution par distribution
Le pilote `openconnect` parle AnyConnect (Cisco), Pulse/Juniper,
GlobalProtect, Fortinet, F5 et Array. Une commande installe son client :
<!-- [common] -->
```bash
sudo bash script/install/install_vpn.sh openconnect
./.venv.erplibre/bin/python script/vpn/vpn.py check --driver openconnect
```
<!-- [en] -->
| Distribution | Packages | `vpnc-script` |
|--------------|----------|---------------|
| Debian, Ubuntu | `openconnect vpnc-scripts` | `/usr/share/vpnc-scripts/vpnc-script` |
| Arch, Manjaro | `openconnect` (pulls `vpnc-scripts`) | `/usr/share/vpnc-scripts/vpnc-script` |
| Fedora, RHEL, Rocky, Alma | `openconnect` (pulls `vpnc-script`) | `/etc/vpnc/vpnc-script` |
| openSUSE | `openconnect` (pulls `vpnc-script`) | `/etc/vpnc/vpnc-script` |
`vpnc-script` is the one prerequisite that is **not** a binary on the `PATH`,
so the installer looks for the file itself and names the package to install
when it is missing. Without it openconnect starts, the session opens, and the
tun interface never appears — a failure three stages above the missing
package, on a symptom that does not accuse it.
Two fields decide almost everything else. `oc_authgroup` is the
**authentication group** the site tells you to select; a typo in it comes back
as “login failed”, with nothing pointing at the group. `oc_password_len`
declares that the concentrator **compares only the first N characters** of the
password — some do, a legacy directory limit. Zero means no limit. The field
never truncates: it says so before you store the secret, and it compares
lengths when the tunnel comes up. Store just those N characters.
On the first connection openconnect refuses an unpinned server certificate and
**prints** the `--servercert sha256:…` line to copy into `oc_servercert`. That
refusal is the expected first step, not a failure.
<!-- [fr] -->
| Distribution | Paquets | `vpnc-script` |
|--------------|---------|---------------|
| Debian, Ubuntu | `openconnect vpnc-scripts` | `/usr/share/vpnc-scripts/vpnc-script` |
| Arch, Manjaro | `openconnect` (tire `vpnc-scripts`) | `/usr/share/vpnc-scripts/vpnc-script` |
| Fedora, RHEL, Rocky, Alma | `openconnect` (tire `vpnc-script`) | `/etc/vpnc/vpnc-script` |
| openSUSE | `openconnect` (tire `vpnc-script`) | `/etc/vpnc/vpnc-script` |
`vpnc-script` est le seul prérequis qui **n'est pas** un binaire du `PATH` :
l'installateur cherche donc le fichier lui-même et nomme le paquet à installer
quand il manque. Sans lui, openconnect démarre, la session s'ouvre, et
l'interface tun n'apparaît jamais — une panne trois étages au-dessus du paquet
absent, sur un symptôme qui ne l'accuse pas.
Deux champs décident de presque tout le reste. `oc_authgroup` est le **groupe
d'authentification** que le site demande de choisir ; une faute de frappe
dedans revient en « identifiants refusés », sans que rien ne désigne le
groupe. `oc_password_len` déclare que le concentrateur **ne compare que les N
premiers caractères** du mot de passe — certains le font, reste d'une limite
d'annuaire. Zéro veut dire aucune limite. Le champ ne tronque jamais : il le
dit avant qu'on dépose le secret, et il compare les longueurs au montage. N'en
déposer que ces N caractères.
À la première connexion, openconnect refuse un certificat serveur non épinglé
et **imprime** la ligne `--servercert sha256:…` à recopier dans
`oc_servercert`. Ce refus est la première étape attendue, pas une panne.
<!-- [en] -->
## The two “groups” of an AnyConnect gateway
One concentrator hosts several services, and two entirely different
mechanisms select one. Confusing them does not raise a syntax error: it
hands you **another service's login form**, so correct credentials are
refused and nothing points at the group.
| Profile field | openconnect | What it is |
|---------------|-------------|------------|
| `oc_usergroup` | `--usergroup=X` | the **URL path**: `--usergroup=X` and `https://host/X` are the same thing |
| `oc_authgroup` | `--authgroup=X` | a value to pick in a **dropdown** the server presents |
A site that hands you an `.xml` profile designates its service by the path;
a site that shows you a list to choose from in a screenshot designates its
own by the dropdown.
In a Cisco `AnyConnectProfile` file, `<UserGroup>` is the path and
`<HostName>` is only a display label — despite the tag name, it is not a
hostname; `<HostAddress>` is. `VPN › Import an AnyConnect profile (.xml)`
reads those three tags and writes presets into `private/vpn/presets/`, so
the field that actually decides which service you reach is never retyped.
<!-- [fr] -->
## Les deux « groupes » d'une passerelle AnyConnect
Un même concentrateur héberge plusieurs services, et deux mécanismes tout à
fait différents servent à en désigner un. Les confondre ne donne pas une
erreur de syntaxe : cela donne **le formulaire d'un autre service**, donc un
refus sur des identifiants justes, sans que rien ne désigne le groupe.
| Champ du profil | openconnect | Ce que c'est |
|-----------------|-------------|--------------|
| `oc_usergroup` | `--usergroup=X` | le **chemin d'URL** : `--usergroup=X` et `https://hôte/X` sont la même chose |
| `oc_authgroup` | `--authgroup=X` | une valeur à choisir dans un **menu déroulant** que le serveur présente |
Un site qui remet un profil `.xml` désigne son service par le chemin ; un
site qui décrit une liste à choisir dans une capture d'écran désigne le sien
par le menu déroulant.
Dans un fichier `AnyConnectProfile` de Cisco, `<UserGroup>` est le chemin et
`<HostName>` n'est qu'un libellé d'affichage — malgré son nom, ce n'est pas
un nom d'hôte ; c'est `<HostAddress>` qui l'est. `VPN › Importer un profil
AnyConnect (.xml)` lit ces trois balises et écrit des préréglages dans
`private/vpn/presets/`, pour que le champ qui décide vraiment du service
joint ne soit jamais retapé.
<!-- [en] -->
## SSO / SAML: what openconnect can and cannot do
When a gateway authenticates through an identity provider (Okta, Azure AD,
Duo), there is no password to send — a web page has to be completed. Cisco
signals this in **two** different ways, and only one of them works from a
plain CLI:
| Server announces | openconnect needs | Works with a distribution package |
|------------------|-------------------|-----------------------------------|
| `single-sign-on-external-browser` | `--external-browser=<cmd>` | **yes** — set `oc_external_browser` |
| `sso-v2` (embedded browser) | a built-in webview (libwebkit2gtk) | **no** — Debian, Ubuntu, Fedora and Arch all build without it |
The gateway decides which one, per tunnel group. When it asks for the
embedded browser and openconnect has no webview, it stops on:
<!-- [fr] -->
## SSO / SAML : ce qu'openconnect sait faire, et ce qu'il ne sait pas
Quand une passerelle authentifie par un fournisseur d'identité (Okta, Azure
AD, Duo), il n'y a pas de mot de passe à envoyer — il faut compléter une page
web. Cisco l'annonce de **deux** façons différentes, et une seule des deux
fonctionne depuis un CLI nu :
| Le serveur annonce | openconnect exige | Marche avec un paquet de distribution |
|--------------------|-------------------|---------------------------------------|
| `single-sign-on-external-browser` | `--external-browser=<cmd>` | **oui** — remplir `oc_external_browser` |
| `sso-v2` (navigateur intégré) | une webview compilée (libwebkit2gtk) | **non** — Debian, Ubuntu, Fedora et Arch la compilent sans |
C'est la passerelle qui choisit, groupe de connexion par groupe de
connexion. Quand elle réclame le navigateur intégré et qu'openconnect n'a
pas de webview, il s'arrête sur :
<!-- [common] -->
```
Please complete the authentication process in the AnyConnect Login window.
No SSO handler
Failed to complete authentication
```
<!-- [en] -->
`--external-browser` does **not** help there: openconnect only takes that
path when the server announced the external-browser method. Which one a
gateway wants can be read without sending any secret:
<!-- [fr] -->
`--external-browser` n'y change **rien** : openconnect ne prend ce chemin
que si le serveur a annoncé la méthode « navigateur externe ». Laquelle
une passerelle veut se lit sans envoyer aucun secret :
<!-- [common] -->
```bash
openconnect --protocol=anyconnect --usergroup=<GROUPE> \
--authenticate --dump-http-traffic <passerelle> 2>&1 \
| grep -E 'sso-v2|external-browser|No SSO handler'
```
<!-- [en] -->
### Installing the helper
`VPN › Install the client packages` offers it when the driver is
OpenConnect and no helper is found — and stays quiet otherwise. From the
command line:
<!-- [fr] -->
### Installer le greffon
`VPN › Installer les paquets client` le propose quand le pilote est
OpenConnect et qu'aucun greffon n'est trouvé — et se taît sinon. En ligne
de commande :
<!-- [common] -->
```bash
sudo bash script/install/install_vpn.sh openconnect --sso
```
<!-- [en] -->
Read what that step carries before accepting it. The helper's upstream has
been **unmaintained since 2023**, so the installer holds workarounds that
will not resolve on their own: its version pins are unsatisfiable on a
recent Python (pre-5 `lxml` does not build), Qt and `lxml` come from the
distribution rather than PyPI, and a call to `asyncio.get_event_loop()`
raises from Python 3.12 on — patched on every install, since any
reinstallation erases it. pip reports a pin conflict on `lxml` and
`keyring`; it is expected, those are the two pins deliberately relaxed.
Package names are **verified on Debian and Ubuntu only**; on the other
families they are best-effort, and a mistake there reads as "package not
found" without breaking anything else.
The venv belongs to the **user**, not root: the helper needs a display and
a keyring, which root does not have. The installer therefore refuses to run
without `sudo`, from which it reads who to install for.
<!-- [fr] -->
Lire ce que cette étape porte avant de l'accepter. L'amont du greffon n'est
**plus entretenu depuis 2023**, si bien que l'installateur porte des
contournements qui ne se résoudront pas d'eux-mêmes : ses épingles de
version sont intenables sur un Python récent (`lxml` d'avant la 5 ne
compile pas), Qt et `lxml` viennent de la distribution et non de PyPI, et
un appel à `asyncio.get_event_loop()` lève depuis Python 3.12 — corrigé à
chaque installation, puisque toute réinstallation l'effacerait. pip signale
un conflit d'épingles sur `lxml` et `keyring` : il est attendu, ce sont les
deux qu'on relâche sciemment.
Les noms de paquets ne sont **vérifiés que sur Debian et Ubuntu** ; sur les
autres familles ils sont donnés au mieux, et une erreur s'y lit « paquet
introuvable » sans rien casser d'autre.
Le venv appartient à l'**utilisateur**, pas à root : le greffon a besoin
d'un affichage et d'un trousseau, que root n'a pas. L'installateur refuse
donc de tourner sans `sudo`, dont il lit pour qui installer.
<!-- [en] -->
### Delegating the web form, keeping the tunnel
For a gateway that insists on the embedded browser, set `oc_sso_helper` to
an `openconnect-sso` executable. The driver then splits the work:
| Step | Who | Runs as | Carries |
|------|-----|---------|---------|
| SAML / MFA in a real browser | the helper, `--authenticate json` | **you** (needs your display and keyring) | returns `{host, cookie, fingerprint}` |
| bringing the tunnel up | this driver, `--cookie-on-stdin` | root, via `sudo` | the cookie, on standard input only |
That split is the whole point. The helper does *only* the SAML dance; the
**profile** stays the source of truth for the interface name, the added
routes, the state files and the diagnosis. A tunnel opened by the helper
itself would be called `tun0`, would leave nothing in `/run`, and `status`,
`diagnose` and `down` would not see it.
Two details make the cookie fail if you neglect them, and the driver handles
both: the **announced identity** must match on both steps (`oc_ac_version`
goes to the helper *and* to openconnect — a cookie issued to one client
version is refused to another), and the **fingerprint** the helper reports
wins over `oc_servercert`, because it is the one it authenticated against.
Many of these gateways present a chain the system store does not validate
(`signer not found`), and `--non-inter` would refuse it without a pin.
The cookie never touches a file: it lives in a variable, leaves by standard
input, and is masked from every display the moment it exists. On a machine
with no usable GPU — a virtual machine, typically — the embedded Chromium
falls back to Vulkan and the window dies mid-authentication; the driver
therefore forces software rendering unless those variables are already set.
Which path is taken is decided in one place, and reads in this order:
<!-- [fr] -->
### Déléguer le formulaire web, garder le tunnel
Pour une passerelle qui exige le navigateur intégré, renseigner
`oc_sso_helper` avec un exécutable `openconnect-sso`. Le pilote partage
alors le travail :
| Étape | Qui | Sous quel compte | Ce qui circule |
|-------|-----|------------------|----------------|
| SAML / MFA dans un vrai navigateur | le greffon, `--authenticate json` | **vous** (il lui faut votre affichage et votre trousseau) | rend `{host, cookie, fingerprint}` |
| montage du tunnel | ce pilote, `--cookie-on-stdin` | root, par `sudo` | le cookie, par l'entrée standard seulement |
Ce partage est tout le dispositif. Le greffon ne fait *que* la danse SAML ;
le **profil** reste la source de vérité pour le nom d'interface, les routes
ajoutées, les fichiers d'état et le diagnostic. Un tunnel ouvert par le
greffon lui-même s'appellerait `tun0`, ne laisserait rien dans `/run`, et
`status`, `diagnose` et `down` ne le verraient pas.
Deux détails font échouer le cookie si on les néglige, et le pilote s'en
charge : l'**identité annoncée** doit être la même aux deux étapes
(`oc_ac_version` va au greffon *et* à openconnect — un cookie délivré à une
version de client est refusé à une autre), et l'**empreinte** que rend le
greffon prime sur `oc_servercert`, parce que c'est celle contre laquelle il
a authentifié. Beaucoup de ces passerelles présentent une chaîne que le
magasin du système ne valide pas (`signer not found`), et `--non-inter` la
refuserait sans épinglage.
Le cookie ne touche aucun fichier : il vit dans une variable, part par
l'entrée standard, et est masqué de tout affichage dès qu'il existe. Sur une
machine sans accélération exploitable — une machine virtuelle, typiquement —
le Chromium embarqué se rabat sur Vulkan et la fenêtre meurt au milieu de
l'authentification ; le pilote force donc le rendu logiciel, sauf si ces
variables sont déjà posées.
Le chemin retenu se décide en UN endroit, et se lit dans cet ordre :
<!-- [common] -->
| `oc_sso` | `oc_sso_helper` resolves | Path |
|---|---|---|
| yes | yes | helper authenticates, this driver mounts |
| yes | declared but not executable | **refused, and says so** — never a silent fallback |
| yes | no | `--external-browser`, openconnect alone |
| no | — | password from the vault |
<!-- [en] -->
A declared helper that cannot run is an error, not an invitation to take the
other path: falling back quietly would make the mount fail on `No SSO
handler`, three stages above the real cause — a wrong path.
`vpn.py status` and `diagnose` carry a `SSO helper` line: the resolved path,
`absent`, or `not applicable` when the profile authenticates by password.
<!-- [fr] -->
Un greffon déclaré qui ne peut pas s'exécuter est une erreur, pas une
invitation à prendre l'autre chemin : se replier sans bruit ferait échouer le
montage sur `No SSO handler`, trois étages au-dessus de la vraie cause — un
chemin fautif.
`vpn.py status` et `diagnose` portent une ligne `greffon SSO` : le chemin
résolu, `absent`, ou `sans objet` quand le profil authentifie par mot de
passe.
<!-- [en] -->
## The three security rules

View file

@ -54,6 +54,208 @@ home et son mot de passe maître est le vôtre. Chaque étape privilégiée appe
| `/run/erplibre-vpn/<profil>.*` | l'état non secret (interface retenue, pid, journal), lisible sans sudo |
| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP seulement : un bloc marqué, retiré au `down` |
## Préréglages de site
Un préréglage est un **profil partiel** : tout 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 **ni
identifiant ni secret**, et c'est précisément ce qui lui permet de se
distribuer. Créer un profil à partir d'un préréglage ne laisse à taper que
l'identité, et rien d'autre.
`VPN › Créer un profil à partir d'un préréglage de site` les liste, puis
déroule le formulaire ordinaire pré-rempli : une réponse vide garde la valeur
du préréglage.
Les préréglages sont lus dans ces répertoires, dans l'ordre :
| Chemin | Usage |
|--------|-------|
| `conf/vpn_presets/` | livré avec le dépôt — des gabarits seulement, passerelles inventées, **rien d'identifiant** |
| `private/vpn/presets/` | ignoré par git : le point de montage d'un dépôt privé de préréglages réels |
| tout répertoire de `vpn_preset_paths` | un dépôt privé cloné ailleurs |
Le **plus tardif gagne** sur un même identifiant. C'est ce qui permet à un
site de corriger un gabarit livré — 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 `.json` porte un préréglage (objet) ou plusieurs (liste). Outre les
champs de profil, trois clés décrivent le préréglage lui-même : `preset`
(identifiant, minuscules, chiffres, `-` ou `_`), `label` et un `hint`
facultatif. Un fichier illisible est signalé et sauté — un préréglage fautif
ne doit pas rendre les autres inatteignables.
## Un VPN SSL (AnyConnect), distribution par distribution
Le pilote `openconnect` parle AnyConnect (Cisco), Pulse/Juniper,
GlobalProtect, Fortinet, F5 et Array. Une commande installe son client :
```bash
sudo bash script/install/install_vpn.sh openconnect
./.venv.erplibre/bin/python script/vpn/vpn.py check --driver openconnect
```
| Distribution | Paquets | `vpnc-script` |
|--------------|---------|---------------|
| Debian, Ubuntu | `openconnect vpnc-scripts` | `/usr/share/vpnc-scripts/vpnc-script` |
| Arch, Manjaro | `openconnect` (tire `vpnc-scripts`) | `/usr/share/vpnc-scripts/vpnc-script` |
| Fedora, RHEL, Rocky, Alma | `openconnect` (tire `vpnc-script`) | `/etc/vpnc/vpnc-script` |
| openSUSE | `openconnect` (tire `vpnc-script`) | `/etc/vpnc/vpnc-script` |
`vpnc-script` est le seul prérequis qui **n'est pas** un binaire du `PATH` :
l'installateur cherche donc le fichier lui-même et nomme le paquet à installer
quand il manque. Sans lui, openconnect démarre, la session s'ouvre, et
l'interface tun n'apparaît jamais — une panne trois étages au-dessus du paquet
absent, sur un symptôme qui ne l'accuse pas.
Deux champs décident de presque tout le reste. `oc_authgroup` est le **groupe
d'authentification** que le site demande de choisir ; une faute de frappe
dedans revient en « identifiants refusés », sans que rien ne désigne le
groupe. `oc_password_len` déclare que le concentrateur **ne compare que les N
premiers caractères** du mot de passe — certains le font, reste d'une limite
d'annuaire. Zéro veut dire aucune limite. Le champ ne tronque jamais : il le
dit avant qu'on dépose le secret, et il compare les longueurs au montage. N'en
déposer que ces N caractères.
À la première connexion, openconnect refuse un certificat serveur non épinglé
et **imprime** la ligne `--servercert sha256:…` à recopier dans
`oc_servercert`. Ce refus est la première étape attendue, pas une panne.
## Les deux « groupes » d'une passerelle AnyConnect
Un même concentrateur héberge plusieurs services, et deux mécanismes tout à
fait différents servent à en désigner un. Les confondre ne donne pas une
erreur de syntaxe : cela donne **le formulaire d'un autre service**, donc un
refus sur des identifiants justes, sans que rien ne désigne le groupe.
| Champ du profil | openconnect | Ce que c'est |
|-----------------|-------------|--------------|
| `oc_usergroup` | `--usergroup=X` | le **chemin d'URL** : `--usergroup=X` et `https://hôte/X` sont la même chose |
| `oc_authgroup` | `--authgroup=X` | une valeur à choisir dans un **menu déroulant** que le serveur présente |
Un site qui remet un profil `.xml` désigne son service par le chemin ; un
site qui décrit une liste à choisir dans une capture d'écran désigne le sien
par le menu déroulant.
Dans un fichier `AnyConnectProfile` de Cisco, `<UserGroup>` est le chemin et
`<HostName>` n'est qu'un libellé d'affichage — malgré son nom, ce n'est pas
un nom d'hôte ; c'est `<HostAddress>` qui l'est. `VPN › Importer un profil
AnyConnect (.xml)` lit ces trois balises et écrit des préréglages dans
`private/vpn/presets/`, pour que le champ qui décide vraiment du service
joint ne soit jamais retapé.
## SSO / SAML : ce qu'openconnect sait faire, et ce qu'il ne sait pas
Quand une passerelle authentifie par un fournisseur d'identité (Okta, Azure
AD, Duo), il n'y a pas de mot de passe à envoyer — il faut compléter une page
web. Cisco l'annonce de **deux** façons différentes, et une seule des deux
fonctionne depuis un CLI nu :
| Le serveur annonce | openconnect exige | Marche avec un paquet de distribution |
|--------------------|-------------------|---------------------------------------|
| `single-sign-on-external-browser` | `--external-browser=<cmd>` | **oui** — remplir `oc_external_browser` |
| `sso-v2` (navigateur intégré) | une webview compilée (libwebkit2gtk) | **non** — Debian, Ubuntu, Fedora et Arch la compilent sans |
C'est la passerelle qui choisit, groupe de connexion par groupe de
connexion. Quand elle réclame le navigateur intégré et qu'openconnect n'a
pas de webview, il s'arrête sur :
```
Please complete the authentication process in the AnyConnect Login window.
No SSO handler
Failed to complete authentication
```
`--external-browser` n'y change **rien** : openconnect ne prend ce chemin
que si le serveur a annoncé la méthode « navigateur externe ». Laquelle
une passerelle veut se lit sans envoyer aucun secret :
```bash
openconnect --protocol=anyconnect --usergroup=<GROUPE> \
--authenticate --dump-http-traffic <passerelle> 2>&1 \
| grep -E 'sso-v2|external-browser|No SSO handler'
```
### Installer le greffon
`VPN › Installer les paquets client` le propose quand le pilote est
OpenConnect et qu'aucun greffon n'est trouvé — et se taît sinon. En ligne
de commande :
```bash
sudo bash script/install/install_vpn.sh openconnect --sso
```
Lire ce que cette étape porte avant de l'accepter. L'amont du greffon n'est
**plus entretenu depuis 2023**, si bien que l'installateur porte des
contournements qui ne se résoudront pas d'eux-mêmes : ses épingles de
version sont intenables sur un Python récent (`lxml` d'avant la 5 ne
compile pas), Qt et `lxml` viennent de la distribution et non de PyPI, et
un appel à `asyncio.get_event_loop()` lève depuis Python 3.12 — corrigé à
chaque installation, puisque toute réinstallation l'effacerait. pip signale
un conflit d'épingles sur `lxml` et `keyring` : il est attendu, ce sont les
deux qu'on relâche sciemment.
Les noms de paquets ne sont **vérifiés que sur Debian et Ubuntu** ; sur les
autres familles ils sont donnés au mieux, et une erreur s'y lit « paquet
introuvable » sans rien casser d'autre.
Le venv appartient à l'**utilisateur**, pas à root : le greffon a besoin
d'un affichage et d'un trousseau, que root n'a pas. L'installateur refuse
donc de tourner sans `sudo`, dont il lit pour qui installer.
### Déléguer le formulaire web, garder le tunnel
Pour une passerelle qui exige le navigateur intégré, renseigner
`oc_sso_helper` avec un exécutable `openconnect-sso`. Le pilote partage
alors le travail :
| Étape | Qui | Sous quel compte | Ce qui circule |
|-------|-----|------------------|----------------|
| SAML / MFA dans un vrai navigateur | le greffon, `--authenticate json` | **vous** (il lui faut votre affichage et votre trousseau) | rend `{host, cookie, fingerprint}` |
| montage du tunnel | ce pilote, `--cookie-on-stdin` | root, par `sudo` | le cookie, par l'entrée standard seulement |
Ce partage est tout le dispositif. Le greffon ne fait *que* la danse SAML ;
le **profil** reste la source de vérité pour le nom d'interface, les routes
ajoutées, les fichiers d'état et le diagnostic. Un tunnel ouvert par le
greffon lui-même s'appellerait `tun0`, ne laisserait rien dans `/run`, et
`status`, `diagnose` et `down` ne le verraient pas.
Deux détails font échouer le cookie si on les néglige, et le pilote s'en
charge : l'**identité annoncée** doit être la même aux deux étapes
(`oc_ac_version` va au greffon *et* à openconnect — un cookie délivré à une
version de client est refusé à une autre), et l'**empreinte** que rend le
greffon prime sur `oc_servercert`, parce que c'est celle contre laquelle il
a authentifié. Beaucoup de ces passerelles présentent une chaîne que le
magasin du système ne valide pas (`signer not found`), et `--non-inter` la
refuserait sans épinglage.
Le cookie ne touche aucun fichier : il vit dans une variable, part par
l'entrée standard, et est masqué de tout affichage dès qu'il existe. Sur une
machine sans accélération exploitable — une machine virtuelle, typiquement —
le Chromium embarqué se rabat sur Vulkan et la fenêtre meurt au milieu de
l'authentification ; le pilote force donc le rendu logiciel, sauf si ces
variables sont déjà posées.
Le chemin retenu se décide en UN endroit, et se lit dans cet ordre :
| `oc_sso` | `oc_sso_helper` resolves | Path |
|---|---|---|
| yes | yes | helper authenticates, this driver mounts |
| yes | declared but not executable | **refused, and says so** — never a silent fallback |
| yes | no | `--external-browser`, openconnect alone |
| no | — | password from the vault |
Un greffon déclaré qui ne peut pas s'exécuter est une erreur, pas une
invitation à prendre l'autre chemin : se replier sans bruit ferait échouer le
montage sur `No SSO handler`, trois étages au-dessus de la vraie cause — un
chemin fautif.
`vpn.py status` et `diagnose` portent une ligne `greffon SSO` : le chemin
résolu, `absent`, ou `sans objet` quand le profil authentifie par mot de
passe.
## Les trois règles de sécurité
1. **Aucun secret en argument.** `/proc/<pid>/cmdline` est lisible par tout

View file

@ -53,6 +53,196 @@ own, and `--dry-run` shows every one of them without running any.
| `/run/erplibre-vpn/<profile>.*` | non-secret state (chosen interface, pid, log), readable without sudo |
| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP only: a marked block, removed on `down` |
## Site presets
A preset is a **partial profile**: everything an institution publishes and
that is the same for everybody — gateway, protocol, authentication group,
port, concentrator limits. It carries **no username and no secret**, which is
exactly what lets it be handed around. Creating a profile from one leaves the
identity to type, and nothing else.
`VPN › Create a profile from a site preset` lists them, then runs the ordinary
form pre-filled: an empty answer keeps the preset value.
Presets are read from these directories, in order:
| Path | Use |
|------|-----|
| `conf/vpn_presets/` | shipped with the repository — templates only, invented gateways, **nothing identifying** |
| `private/vpn/presets/` | git-ignored: the mount point for a private repository of real site presets |
| any directory in `vpn_preset_paths` | a private repository cloned somewhere else |
The **latest wins** on the same identifier. That is what lets a site correct a
shipped template — a gateway that moved, a group that was renamed — without
editing a git-tracked file, so without a conflict on the next `git pull`.
One `.json` file holds one preset (an object) or several (a list). Beyond the
profile fields, three keys describe the preset itself: `preset` (identifier,
lowercase, digits, `-` or `_`), `label` and an optional `hint`. An unreadable
file is reported and skipped — a broken preset must not make the others
unreachable.
## An SSL VPN (AnyConnect), distribution by distribution
The `openconnect` driver speaks AnyConnect (Cisco), Pulse/Juniper,
GlobalProtect, Fortinet, F5 and Array. One command installs its client:
```bash
sudo bash script/install/install_vpn.sh openconnect
./.venv.erplibre/bin/python script/vpn/vpn.py check --driver openconnect
```
| Distribution | Packages | `vpnc-script` |
|--------------|----------|---------------|
| Debian, Ubuntu | `openconnect vpnc-scripts` | `/usr/share/vpnc-scripts/vpnc-script` |
| Arch, Manjaro | `openconnect` (pulls `vpnc-scripts`) | `/usr/share/vpnc-scripts/vpnc-script` |
| Fedora, RHEL, Rocky, Alma | `openconnect` (pulls `vpnc-script`) | `/etc/vpnc/vpnc-script` |
| openSUSE | `openconnect` (pulls `vpnc-script`) | `/etc/vpnc/vpnc-script` |
`vpnc-script` is the one prerequisite that is **not** a binary on the `PATH`,
so the installer looks for the file itself and names the package to install
when it is missing. Without it openconnect starts, the session opens, and the
tun interface never appears — a failure three stages above the missing
package, on a symptom that does not accuse it.
Two fields decide almost everything else. `oc_authgroup` is the
**authentication group** the site tells you to select; a typo in it comes back
as “login failed”, with nothing pointing at the group. `oc_password_len`
declares that the concentrator **compares only the first N characters** of the
password — some do, a legacy directory limit. Zero means no limit. The field
never truncates: it says so before you store the secret, and it compares
lengths when the tunnel comes up. Store just those N characters.
On the first connection openconnect refuses an unpinned server certificate and
**prints** the `--servercert sha256:…` line to copy into `oc_servercert`. That
refusal is the expected first step, not a failure.
## The two “groups” of an AnyConnect gateway
One concentrator hosts several services, and two entirely different
mechanisms select one. Confusing them does not raise a syntax error: it
hands you **another service's login form**, so correct credentials are
refused and nothing points at the group.
| Profile field | openconnect | What it is |
|---------------|-------------|------------|
| `oc_usergroup` | `--usergroup=X` | the **URL path**: `--usergroup=X` and `https://host/X` are the same thing |
| `oc_authgroup` | `--authgroup=X` | a value to pick in a **dropdown** the server presents |
A site that hands you an `.xml` profile designates its service by the path;
a site that shows you a list to choose from in a screenshot designates its
own by the dropdown.
In a Cisco `AnyConnectProfile` file, `<UserGroup>` is the path and
`<HostName>` is only a display label — despite the tag name, it is not a
hostname; `<HostAddress>` is. `VPN › Import an AnyConnect profile (.xml)`
reads those three tags and writes presets into `private/vpn/presets/`, so
the field that actually decides which service you reach is never retyped.
## SSO / SAML: what openconnect can and cannot do
When a gateway authenticates through an identity provider (Okta, Azure AD,
Duo), there is no password to send — a web page has to be completed. Cisco
signals this in **two** different ways, and only one of them works from a
plain CLI:
| Server announces | openconnect needs | Works with a distribution package |
|------------------|-------------------|-----------------------------------|
| `single-sign-on-external-browser` | `--external-browser=<cmd>` | **yes** — set `oc_external_browser` |
| `sso-v2` (embedded browser) | a built-in webview (libwebkit2gtk) | **no** — Debian, Ubuntu, Fedora and Arch all build without it |
The gateway decides which one, per tunnel group. When it asks for the
embedded browser and openconnect has no webview, it stops on:
```
Please complete the authentication process in the AnyConnect Login window.
No SSO handler
Failed to complete authentication
```
`--external-browser` does **not** help there: openconnect only takes that
path when the server announced the external-browser method. Which one a
gateway wants can be read without sending any secret:
```bash
openconnect --protocol=anyconnect --usergroup=<GROUPE> \
--authenticate --dump-http-traffic <passerelle> 2>&1 \
| grep -E 'sso-v2|external-browser|No SSO handler'
```
### Installing the helper
`VPN › Install the client packages` offers it when the driver is
OpenConnect and no helper is found — and stays quiet otherwise. From the
command line:
```bash
sudo bash script/install/install_vpn.sh openconnect --sso
```
Read what that step carries before accepting it. The helper's upstream has
been **unmaintained since 2023**, so the installer holds workarounds that
will not resolve on their own: its version pins are unsatisfiable on a
recent Python (pre-5 `lxml` does not build), Qt and `lxml` come from the
distribution rather than PyPI, and a call to `asyncio.get_event_loop()`
raises from Python 3.12 on — patched on every install, since any
reinstallation erases it. pip reports a pin conflict on `lxml` and
`keyring`; it is expected, those are the two pins deliberately relaxed.
Package names are **verified on Debian and Ubuntu only**; on the other
families they are best-effort, and a mistake there reads as "package not
found" without breaking anything else.
The venv belongs to the **user**, not root: the helper needs a display and
a keyring, which root does not have. The installer therefore refuses to run
without `sudo`, from which it reads who to install for.
### Delegating the web form, keeping the tunnel
For a gateway that insists on the embedded browser, set `oc_sso_helper` to
an `openconnect-sso` executable. The driver then splits the work:
| Step | Who | Runs as | Carries |
|------|-----|---------|---------|
| SAML / MFA in a real browser | the helper, `--authenticate json` | **you** (needs your display and keyring) | returns `{host, cookie, fingerprint}` |
| bringing the tunnel up | this driver, `--cookie-on-stdin` | root, via `sudo` | the cookie, on standard input only |
That split is the whole point. The helper does *only* the SAML dance; the
**profile** stays the source of truth for the interface name, the added
routes, the state files and the diagnosis. A tunnel opened by the helper
itself would be called `tun0`, would leave nothing in `/run`, and `status`,
`diagnose` and `down` would not see it.
Two details make the cookie fail if you neglect them, and the driver handles
both: the **announced identity** must match on both steps (`oc_ac_version`
goes to the helper *and* to openconnect — a cookie issued to one client
version is refused to another), and the **fingerprint** the helper reports
wins over `oc_servercert`, because it is the one it authenticated against.
Many of these gateways present a chain the system store does not validate
(`signer not found`), and `--non-inter` would refuse it without a pin.
The cookie never touches a file: it lives in a variable, leaves by standard
input, and is masked from every display the moment it exists. On a machine
with no usable GPU — a virtual machine, typically — the embedded Chromium
falls back to Vulkan and the window dies mid-authentication; the driver
therefore forces software rendering unless those variables are already set.
Which path is taken is decided in one place, and reads in this order:
| `oc_sso` | `oc_sso_helper` resolves | Path |
|---|---|---|
| yes | yes | helper authenticates, this driver mounts |
| yes | declared but not executable | **refused, and says so** — never a silent fallback |
| yes | no | `--external-browser`, openconnect alone |
| no | — | password from the vault |
A declared helper that cannot run is an error, not an invitation to take the
other path: falling back quietly would make the mount fail on `No SSO
handler`, three stages above the real cause — a wrong path.
`vpn.py status` and `diagnose` carry a `SSO helper` line: the resolved path,
`absent`, or `not applicable` when the profile authenticates by password.
## The three security rules
1. **No secret in an argument.** `/proc/<pid>/cmdline` is readable by every

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

View file

@ -409,6 +409,37 @@ class VpnDriver:
"""Les valeurs à masquer dans tout affichage."""
return [v for v in self.secrets.values() if v]
@classmethod
def wants_secrets(cls, profile: dict) -> bool:
"""Ce profil a-t-il un secret À LIRE dans le coffre ?
Distinct de `secret_fields`, qui dit ce que la TECHNOLOGIE peut
avoir : un même pilote peut authentifier par mot de passe sur un
profil et par formulaire web sur un autre, et le second n'a rien à
y chercher.
Ce que cela évite : ouvrir le coffre, donc réclamer le mot de passe
MAÎTRE, pour un secret que le montage n'utilisera pas — et échouer
là où il n'y a pas de terminal pour répondre.
"""
return bool(cls.secret_fields)
def secret_notes(self) -> list:
"""Ce qu'il faut savoir AVANT de taper un secret.
Rendu par le pilote, parce que la contrainte appartient à la
technologie ou au concentrateur, et affiché par le menu au moment de
la saisie. Une contrainte annoncée après coup coûte une deuxième
saisie : le mode de défaillance qu'elle évite est un secret déposé
sous une forme que le serveur n'acceptera pas, et qui ressort en
« identifiants refusés » sans que la longueur soit mise en cause.
Rend des chaînes DÉJÀ traduites, et non des clés : une note porte
souvent un nombre, et un gabarit à trous ne se traduit pas chez
l'appelant.
"""
return []
def ensure_ready(self, runner) -> bool:
"""Noyau, binaires et secrets présents ?
@ -752,13 +783,47 @@ class VpnDriver:
"présents" if not missing else f"absents : {', '.join(missing)}",
)
def is_up(self) -> bool:
"""Ce profil porte-t-il un tunnel VIVANT ?
Le fichier d'état ne suffit pas à répondre : il est écrit au
montage et RIEN ne l'efface quand le tunnel meurt sans passer par
« down » — machine redémarrée, processus tué, session expirée. Un
état laissé derrière déclarerait monté un profil dont l'interface a
disparu, et deux lignes plus bas le même écran dirait que le
processus est mort.
L'interface est donc l'arbitre. Son nom vient de l'état quand il y
en a un, sinon du pilote pour ceux qui la NOMMENT d'avance — ainsi
un tunnel monté hors de l'outil est vu lui aussi. Reste sshuttle,
qui détourne par le pare-feu sans créer d'interface : là, le
processus est le seul juge possible.
"""
iface = self.recorded_iface() or getattr(self, "iface", "")
if iface:
return interface_exists(iface)
if self.iface_kind:
return False
return self.pid_alive() is True
def check_mounted(self):
iface = self.recorded_iface()
return (
"profil monté (état /run)",
bool(iface),
f"interface {iface}" if iface else "aucun état : non connecté",
)
"""Verdict du montage, et la CAUSE quand il est faux.
« aucun état » et « état périmé » demandent deux gestes
différents : monter dans le premier cas, jouer « down » dans le
second pour effacer ce que le tunnel mort a laissé.
"""
iface = self.recorded_iface() or getattr(self, "iface", "")
if self.is_up():
return ("profil monté", True, f"interface {iface}")
if self.recorded_iface():
return (
"profil monté",
False,
f"état laissé pour {iface}, interface disparue :"
" jouer « down » pour le nettoyer",
)
return ("profil monté", False, "aucun état : non connecté")
def check_daemon(self, label="démon"):
alive = self.pid_alive()

View file

@ -23,36 +23,134 @@ Les routes appartiennent au serveur : c'est `vpnc-script` qui les pose, à
partir de ce que le concentrateur pousse. Le profil peut en AJOUTER, il ne
les remplace pas — d'où `needs_routes = False`.
Deux « groupes » que rien ne distingue au premier regard
--------------------------------------------------------
Un même concentrateur héberge plusieurs services, et deux mécanismes tout
à fait différents servent à en désigner un. Les confondre ne donne pas une
erreur de syntaxe : cela donne le formulaire d'authentification d'un AUTRE
service, donc un refus d'identifiants sur des identifiants justes.
`oc_usergroup` → `--usergroup` : le CHEMIN D'URL de la connexion initiale.
`--usergroup=X` et `https://hôte/X` sont la même chose. C'est ce que porte
la balise `<UserGroup>` d'un profil AnyConnect (`.xml`), à ne pas confondre
avec son `<HostName>`, qui n'est qu'un libellé d'affichage.
`oc_authgroup` → `--authgroup` : une valeur à choisir dans un MENU
DÉROULANT du formulaire, quand le serveur en présente un. Cisco l'appelle
authgroup, Juniper et Fortinet realm, F5 domain, GlobalProtect gateway.
Un site qui remet un profil `.xml` désigne son service par le chemin ; un
site qui décrit une liste à choisir dans une capture d'écran désigne le
sien par le menu déroulant. Les deux champs coexistent parce que les deux
cas existent.
SSO / SAML — le cas du « formulaire web »
-----------------------------------------
Quand le concentrateur authentifie par un fournisseur d'identité (Azure AD,
Okta, Duo…), il n'y a pas de mot de passe à envoyer : il faut une page web.
Le client de Cisco la rend dans un navigateur WebKit embarqué — donc un
écran, et sur bien des postes la variable `WEBKIT_DISABLE_DMABUF_RENDERER=1`
en prime pour qu'elle s'affiche. Son CLI, lui, ne sait pas le faire.
Okta, Duo…), il n'y a pas de mot de passe à envoyer : il faut compléter une
page web. Cisco annonce ce besoin de DEUX façons, et openconnect n'en sait
traiter qu'une seule depuis un CLI :
openconnect le fait sans écran sur la machine cliente. Mesuré dans sa
bibliothèque : il ÉCOUTE sur le port local 29786 et attend la redirection
(« Accepted incoming external-browser connection on port 29786 »), après
avoir lancé le programme donné à `--external-browser` avec l'URL de
connexion. Sur un serveur, ce « navigateur » est un simple `echo` : l'URL
s'affiche, on l'ouvre dans SON navigateur, et un
· `single-sign-on-external-browser` — openconnect lance le programme donné à
`--external-browser`, écoute sur son port local 29786 et attend la
redirection. Fonctionne avec un paquet de distribution ;
· `sso-v2` — le concentrateur exige un navigateur INTÉGRÉ au client. Il faut
alors une webview compilée dans openconnect (libwebkit2gtk), et Debian,
Ubuntu, Fedora et Arch la compilent tous sans. openconnect s'arrête sur
« No SSO handler », et `--external-browser` n'y change rien : il ne prend
ce chemin que si le SERVEUR a annoncé la méthode « navigateur externe ».
ssh -L 29786:localhost:29786 <la machine cliente>
C'est la passerelle qui choisit, groupe de connexion par groupe de connexion.
Pour le second cas, `oc_sso_helper` délègue la seule étape que ce pilote ne
sait pas faire.
fait revenir la redirection à openconnect. Aucun écran là-bas, et le mot de
passe ne quitte jamais le poste de l'utilisateur.
Déléguer l'authentification, garder le tunnel
---------------------------------------------
`openconnect-sso --authenticate json` pilote un vrai navigateur, laisse
l'humain s'authentifier, et rend `{host, cookie, fingerprint}` sans monter
aucun tunnel. Le pilote reprend alors la main et monte lui-même, avec
`--cookie-on-stdin`.
La frontière est là, et elle est le tout de ce dispositif : le greffon ne
fait que la danse SAML ; le PROFIL reste la source de vérité pour le nom
d'interface, les routes ajoutées, les fichiers d'état et le diagnostic. Un
tunnel monté par le greffon lui-même s'appellerait `tun0`, ne laisserait
rien dans /run, et `status`, `diagnose` et `down` ne le verraient pas.
Deux détails qui font échouer le cookie si on les néglige :
· l'IDENTITÉ annoncée doit être la même aux deux étapes. Le concentrateur
délivre le cookie à un client qui s'est présenté sous une version donnée ;
monter ensuite sous une autre le fait refuser. D'où `oc_ac_version`, passé
au greffon ET à openconnect ;
· l'EMPREINTE que rend le greffon est celle contre laquelle il a authentifié.
Elle est préférée à `oc_servercert` du profil, qui peut dater. Beaucoup de
ces passerelles présentent une chaîne que le magasin du système ne valide
pas (« signer not found »), et `--non-inter` la refuserait sans elle.
Le greffon lance un navigateur : il tourne donc SANS sudo, sous
l'utilisateur, avec son affichage. Le cookie qu'il rend ne touche aucun
fichier — il vit dans une variable, part par l'entrée standard, et est
masqué de tout affichage dès qu'il existe.
Les concentrateurs qui tronquent le mot de passe
------------------------------------------------
Certains ne comparent que les N premiers caractères — un reste d'annuaire
qui borne la longueur, dont le site documente la valeur. Un mot de passe
plus long est alors refusé, et le refus se lit « identifiants invalides » :
rien n'y met la longueur en cause, et on cherche du côté du groupe
d'authentification ou du certificat.
`oc_password_len` déclare cette borne. Le champ ne TRONQUE rien : le coffre
reste la source de vérité de ce qu'on envoie, et un outil qui couperait un
mot de passe en silence rendrait un jour un « ça marchait pourtant »
indébrouillable — le jour où le site lève la limite. Il fait deux choses,
toutes deux à un moment où l'humain peut agir : le menu l'annonce avant la
saisie du secret, et le montage compare les longueurs si ce qui est déposé
la dépasse. Zéro = aucune limite.
"""
from __future__ import annotations
import json
import os
import shlex
import shutil
from script.todo.todo_i18n import t
from script.vpn import valid
from script.vpn.drivers.base import (
VpnDriver,
interface_addresses,
interface_exists,
wait_for_interface_address,
)
from script.vpn.vault import PLACEHOLDER
# Emplacements conventionnels du greffon SSO, essayés dans l'ordre après
# le PATH. `pipx install openconnect-sso` pose le premier ; une install en
# environnement virtuel dédié n'est trouvable que par `oc_sso_helper`.
SSO_HELPER_NAME = "openconnect-sso"
SSO_HELPER_PATHS = (
"~/.local/bin/openconnect-sso",
"~/.local/share/openconnect-sso-venv/bin/openconnect-sso",
)
# Réglages de rendu que le greffon hérite, SAUF s'ils sont déjà dans
# l'environnement — qui les a posés sait mieux.
#
# Le navigateur du greffon est un Chromium embarqué. Sur une machine
# virtuelle, il n'y a pas d'accélération exploitable : il se rabat sur
# Vulkan, échoue à importer sa mémoire graphique et la fenêtre MEURT au
# milieu de l'authentification. Le rendu logiciel est plus lent et il
# aboutit, ce qui est le seul critère ici.
SSO_RENDER_ENV = {
"QTWEBENGINE_CHROMIUM_FLAGS": (
"--disable-gpu --disable-gpu-compositing"
" --disable-features=Vulkan --disable-dev-shm-usage"
),
"LIBGL_ALWAYS_SOFTWARE": "1",
}
SSO_RENDER_ENV_HINT = " ".join(f"{k}={v!r}" for k, v in SSO_RENDER_ENV.items())
# Ce que ce client sait parler. La liste vient de `openconnect --protocol`.
PROTOCOLS = ("anyconnect", "nc", "pulse", "gp", "f5", "fortinet", "array")
@ -86,6 +184,20 @@ class OpenconnectDriver(VpnDriver):
# Programme lancé avec l'URL de connexion. Vide = `echo`, qui
# l'affiche : c'est ce qu'on veut sur une machine sans écran.
"oc_external_browser": "",
# Nombre de caractères du mot de passe que le concentrateur compare.
# 0 = aucune limite. Voir l'en-tête du fichier.
"oc_password_len": 0,
# Chemin d'URL de la connexion initiale. Voir l'en-tête : ce n'est
# PAS `oc_authgroup`, et les confondre mène à un formulaire
# d'authentification qui n'est pas celui du service visé.
"oc_usergroup": "",
# Greffon qui pilote un navigateur pour l'étape SAML. Vide : cherché
# dans le PATH puis aux emplacements conventionnels. Voir l'en-tête.
"oc_sso_helper": "",
# Version de client AnyConnect annoncée. Elle doit être la MÊME à
# l'authentification et au montage : le concentrateur délivre le
# cookie à un client qui s'est présenté ainsi.
"oc_ac_version": "4.7.00136",
}
form_fields = (
("oc_user", "VPN user", "text", False),
@ -95,6 +207,13 @@ class OpenconnectDriver(VpnDriver):
"text",
False,
),
(
"oc_usergroup",
"Connection group in the URL — <UserGroup> of an AnyConnect"
" profile (optional)",
"text",
False,
),
(
"oc_authgroup",
"Authentication group / realm (optional)",
@ -119,6 +238,25 @@ class OpenconnectDriver(VpnDriver):
"path",
True,
),
(
"oc_sso_helper",
"SSO helper driving a real browser (empty: openconnect-sso from"
" the PATH)",
"path",
True,
),
(
"oc_ac_version",
"AnyConnect version announced to the gateway",
"text",
True,
),
(
"oc_password_len",
"Password characters the server compares (0: no limit)",
"int",
True,
),
("port", "HTTPS port", "int", True),
)
@ -148,6 +286,13 @@ class OpenconnectDriver(VpnDriver):
f"Protocole inconnu : « {protocol} »."
f" Connus : {', '.join(PROTOCOLS)}."
)
valid.text(
profile,
"oc_usergroup",
"Groupe de connexion (chemin d'URL)",
required=False,
pattern=valid.URL_PATH_RE,
)
valid.text(
profile,
"oc_authgroup",
@ -161,12 +306,29 @@ class OpenconnectDriver(VpnDriver):
required=False,
)
valid.port(profile, "port", "Port HTTPS")
# 128 comme plafond : au-delà, ce n'est plus une borne d'annuaire
# mais une valeur tapée de travers, et l'accepter ferait taire
# l'avertissement pour tous les mots de passe.
valid.integer(
profile,
"oc_password_len",
"Longueur de mot de passe comparée",
0,
128,
)
valid.path(
profile,
"oc_external_browser",
"Programme navigateur",
required=False,
)
valid.path(profile, "oc_sso_helper", "Greffon SSO", required=False)
valid.text(
profile,
"oc_ac_version",
"Version AnyConnect annoncée",
required=False,
)
@property
def browser(self):
@ -177,6 +339,279 @@ class OpenconnectDriver(VpnDriver):
sur son port 29786."""
return self.profile.get("oc_external_browser") or "echo"
@property
def password_len(self):
"""Longueur comparée par le concentrateur, 0 si aucune limite."""
try:
return int(self.profile.get("oc_password_len") or 0)
except (TypeError, ValueError):
return 0
@classmethod
def wants_secrets(cls, profile):
"""Rien à lire au coffre en SSO : c'est le fournisseur d'identité
qui authentifie, dans un navigateur, et aucun mot de passe stocké
n'entre dans l'échange."""
return not profile.get("oc_sso")
def secret_notes(self):
"""La borne de longueur, dite avant la saisie du mot de passe.
En SSO il n'y a pas de mot de passe à déposer : annoncer une borne
de longueur y serait une consigne sans objet.
"""
limit = self.password_len
if not limit or self.profile.get("oc_sso"):
return []
return [
t(
"This gateway compares only the first {limit} characters of"
" the password: store just those, a longer one is refused."
).format(limit=limit)
]
# ------------------------------------------------------------------
# SSO délégué : le greffon authentifie, ce pilote monte
# ------------------------------------------------------------------
@property
def sso_helper(self):
"""Chemin du greffon SSO, "" s'il est introuvable.
Le champ du profil d'abord — c'est le seul moyen de désigner une
installation en environnement virtuel dédié. Puis le PATH, puis les
emplacements conventionnels.
Un chemin déclaré mais inexécutable rend "" ; c'est `up` qui le DIT,
plutôt que de se replier sans bruit sur un autre chemin
d'authentification.
"""
declared = self.profile.get("oc_sso_helper") or ""
if declared:
path = os.path.expanduser(declared)
return path if os.access(path, os.X_OK) else ""
found = shutil.which(SSO_HELPER_NAME)
if found:
return found
for candidate in SSO_HELPER_PATHS:
path = os.path.expanduser(candidate)
if os.access(path, os.X_OK):
return path
return ""
@property
def ac_version(self):
return self.profile.get("oc_ac_version") or "4.7.00136"
def helper_command(self):
"""La ligne du greffon : elle ne porte AUCUN secret.
Les variables de rendu sont préfixées plutôt que posées dans
l'environnement de ce processus : la commande affichée est alors
exactement celle qui s'exécute, ce que `--dry-run` promet.
"""
p = self.profile
target = p["server"]
if p.get("oc_usergroup"):
target = f"{target}/{p['oc_usergroup']}"
parts = []
for key, value in SSO_RENDER_ENV.items():
if key not in os.environ:
parts.append(f"{key}={shlex.quote(value)}")
parts += [
shlex.quote(self.sso_helper),
"--authenticate",
"json",
f"--server={shlex.quote(target)}",
f"--ac-version={shlex.quote(self.ac_version)}",
]
if p.get("oc_authgroup"):
parts.append(f"--authgroup={shlex.quote(p['oc_authgroup'])}")
# `| tee /dev/stderr` : la sortie standard du greffon est à la fois
# LUE par nous et VUE par l'utilisateur.
#
# Le processus navigateur du greffon journalise sur sa SORTIE
# STANDARD — il n'a pas de configuration propre et hérite du
# journaliseur par défaut de structlog, qui imprime là. Le parent,
# lui, journalise sur l'erreur standard. Capturer la sortie sans la
# dupliquer laisse donc l'utilisateur devant un terminal muet
# pendant qu'une fenêtre attend son geste, et mélange ces lignes au
# JSON qu'on doit lire.
return " ".join(parts) + " | tee /dev/stderr"
@staticmethod
def extract_json(text):
"""Le dernier objet JSON de `text`, ou None.
La sortie du greffon MÊLE ses lignes de journal au JSON final :
`json.loads` sur le tout échoue même quand l'authentification a
réussi. On isole donc les accolades équilibrées, et on retient le
dernier objet qui parse et qui porte un cookie — le dernier, parce
qu'une ligne de journal peut elle aussi contenir des accolades.
"""
best = None
depth = 0
start = -1
for index, char in enumerate(text):
if char == "{":
if depth == 0:
start = index
depth += 1
elif char == "}" and depth:
depth -= 1
if depth == 0 and start >= 0:
try:
candidate = json.loads(text[start : index + 1])
except ValueError:
continue
if isinstance(candidate, dict) and candidate.get("cookie"):
best = candidate
return best
def _kill_helper_strays(self, runner):
"""Les navigateurs que le greffon laisse derrière lui.
Le greffon lance son navigateur dans des processus séparés. Quand
il est tué — délai dépassé — eux survivent, et le suivant repart
sur une machine déjà encombrée. Reconnaissables sans ambiguïté :
QtWebEngine porte `--application-name=openconnect-sso`.
"""
# `[Q]t…` et non `Qt…` : `pkill -f` compare le motif à TOUTES les
# lignes de commande, y compris celle du shell qui le porte. Le
# motif écrit en clair s'y trouve donc lui-même, et pkill tuerait
# son propre parent avant d'avoir servi. Entre crochets, le motif
# désigne toujours « Qt… » mais ne se reconnaît plus dans le texte
# qui le contient.
runner.cmd(
"fermer les navigateurs laissés par le greffon",
"pkill -f '[Q]tWebEngineProcess.*application-name=openconnect-sso'",
sudo=False,
check=False,
)
def authenticate_with_helper(self, runner):
"""(cookie, empreinte) rendus par le greffon, ou (None, None).
Sans sudo, et c'est essentiel : le greffon ouvre un navigateur, donc
il lui faut l'affichage et le trousseau de l'UTILISATEUR. Sous sudo
il perdrait les deux.
`capture="stdout"` : le JSON est LU, les messages de progression du
greffon restent VUS. L'authentification réclame un geste humain —
taper un mot de passe, approuver une notification — et une attente
muette de plusieurs minutes ressemble à un blocage.
"""
helper = self.sso_helper
if not helper:
report = runner.warn if runner.dry_run else runner.fail
report(
"Greffon SSO introuvable. Ce concentrateur exige un"
" navigateur intégré, que l'openconnect des distributions"
" n'a pas. Installer openconnect-sso, ou renseigner"
" oc_sso_helper avec son chemin."
)
return None, None
self._explain_the_web_form(runner)
code, out = runner.cmd(
"authentifier par formulaire web (navigateur du greffon)",
self.helper_command(),
sudo=False,
check=False,
capture="stdout",
timeout=600,
)
if runner.dry_run:
runner.info(
" (à blanc : le greffon rendrait un cookie de session"
" et l'empreinte du certificat qu'il a vu)"
)
# L'empreinte du profil, faute de mieux : à blanc on ne peut pas
# savoir celle que le greffon verrait, et une empreinte n'est
# pas un secret — lui donner le marqueur des secrets ferait
# lire au plan une nature qu'elle n'a pas.
return PLACEHOLDER, self.profile.get("oc_servercert") or ""
# Le VERDICT est le cookie, pas le code de retour : la commande est
# un tube, et son code est celui de `tee`. Un cookie obtenu vaut
# succès, quoi qu'ait rendu le tube.
answer = self.extract_json(out)
if answer is None:
if code == 124:
runner.fail(
"Le formulaire web n'a pas abouti dans le délai"
" imparti (10 min) : personne ne l'a complété, ou la"
" fenêtre ne s'est jamais affichée."
)
else:
runner.fail(
f"Le greffon SSO n'a rendu aucun cookie (code {code})."
)
self._explain_helper_failure(runner)
self._kill_helper_strays(runner)
return None, None
cookie = answer["cookie"]
fingerprint = answer.get("fingerprint") or ""
# Masqué DÈS qu'il existe : ce cookie ouvre le tunnel à lui seul, et
# il va traverser des affichages et un enregistrement d'opérations.
runner.add_secret(cookie)
runner.ok("Authentification web réussie, cookie de session obtenu.")
return cookie, fingerprint
def cookie_command(self, fingerprint):
"""Le montage à partir d'un cookie. Le cookie N'Y EST PAS : il
arrive par l'entrée standard, via `--cookie-on-stdin`.
L'identité annoncée est celle sous laquelle le cookie a été délivré,
sinon le concentrateur le refuse. L'empreinte du greffon prime sur
celle du profil : c'est celle contre laquelle il a authentifié.
"""
p = self.profile
pinned = fingerprint or p.get("oc_servercert") or ""
parts = [
"openconnect",
f"--protocol={shlex.quote(p['oc_protocol'])}",
f"--useragent={shlex.quote(f'AnyConnect Linux_64 {self.ac_version}')}",
f"--version-string={shlex.quote(self.ac_version)}",
"--cookie-on-stdin",
"--non-inter",
"--background",
f"--pid-file={shlex.quote(self.pid_file)}",
f"--interface={shlex.quote(self.iface)}",
]
if p.get("oc_usergroup"):
parts.append(f"--usergroup={shlex.quote(p['oc_usergroup'])}")
if pinned:
parts.append(f"--servercert={shlex.quote(pinned)}")
parts.append(shlex.quote(f"{p['server']}:{p['port']}"))
return " ".join(parts)
def _explain_the_web_form(self, runner):
"""Dit ce qui va s'ouvrir, AVANT que ça s'ouvre.
Le greffon lance un navigateur et attend, silencieusement du point
de vue du terminal. Sans cette annonce, la fenêtre surgit sans
raison apparente et l'attente ressemble à un blocage.
"""
runner.info(
" Une fenêtre de navigateur va s'ouvrir pour"
" l'authentification. La compléter à l'écran ; le tunnel monte"
" ensuite tout seul."
)
runner.info(
" Le mot de passe et le second facteur ne passent que par"
" cette fenêtre : ni ce terminal ni le coffre ne les voient."
)
def _explain_helper_failure(self, runner):
runner.info(
" → Fenêtre morte en cours de route ? Le navigateur"
" embarqué échoue au rendu sur une machine sans accélération"
f" exploitable. Relancer avec {SSO_RENDER_ENV_HINT}."
)
runner.info(
" → Identifiants refusés ? Ce service authentifie par le"
" fournisseur d'identité, qui prend le mot de passe ENTIER."
" Une limite oc_password_len ne s'applique PAS ici."
)
def command(self):
"""La ligne de commande, dans l'une de ses deux formes.
@ -203,6 +638,8 @@ class OpenconnectDriver(VpnDriver):
f"--pid-file={shlex.quote(self.pid_file)}",
f"--interface={shlex.quote(self.iface)}",
]
if p.get("oc_usergroup"):
parts.append(f"--usergroup={shlex.quote(p['oc_usergroup'])}")
if p.get("oc_authgroup"):
parts.append(f"--authgroup={shlex.quote(p['oc_authgroup'])}")
if p.get("oc_servercert"):
@ -212,6 +649,13 @@ class OpenconnectDriver(VpnDriver):
# ------------------------------------------------------------------
def up(self, runner):
"""Trois chemins, un seul aboutissement.
Le choix est explicite et se lit ici : greffon si le profil est en
SSO et qu'un greffon existe, `--external-browser` en SSO sans
greffon, mot de passe sinon. Un greffon sait faire les DEUX formes
de SSO ; `--external-browser` n'en fait qu'une. D'où l'ordre.
"""
p = self.profile
if not self.ensure_ready(runner):
return False
@ -220,59 +664,170 @@ class OpenconnectDriver(VpnDriver):
self.prepare_dirs(runner, secrets=False)
if p["oc_sso"]:
self._explain_the_sso_round_trip(runner)
mot_de_passe, delai = None, 300
else:
if not self.secrets.get("password"):
# Un greffon DÉCLARÉ mais inexécutable est une erreur, pas une
# invitation à prendre l'autre chemin : basculer en silence
# ferait échouer le montage sur « No SSO handler », trois
# étages au-dessus de la vraie cause — un chemin fautif.
declared = p.get("oc_sso_helper") or ""
if declared and not self.sso_helper:
report = runner.warn if runner.dry_run else runner.fail
report(
"Aucun mot de passe dans le coffre, et le profil n'est"
" pas en SSO : les déposer, ou cocher « formulaire web »."
f"Greffon SSO déclaré mais inexécutable : {declared}."
" Corriger oc_sso_helper, ou le vider pour chercher"
" openconnect-sso dans le PATH."
)
if not runner.dry_run:
return False
mot_de_passe = self.secrets.get("password", "") + "\n"
delai = 120
code, _ = runner.cmd(
f"ouvrir la session {p['oc_protocol']} sur {p['server']}",
self.command(),
stdin=mot_de_passe,
secret_stdin=bool(mot_de_passe),
check=False,
timeout=delai,
)
if code != 0 and not runner.dry_run:
runner.fail("openconnect a refusé.")
if p["oc_sso"]:
runner.info(
" → En SSO, les deux causes sont : la redirection"
" n'est jamais revenue sur le port 29786 (redirection"
" ssh en place ?), ou le délai de 5 minutes a expiré"
" avant la fin de l'authentification."
)
else:
runner.info(
" → Causes usuelles : identifiants, certificat"
" serveur non épinglé (recopier la ligne"
" « --servercert sha256:… » ci-dessus dans le champ"
" oc_servercert), groupe d'authentification absent."
)
monte = (
self._up_delegated(runner)
if self.sso_helper
else self._up_external_browser(runner)
)
else:
monte = self._up_password(runner)
if not monte:
return False
if runner.dry_run:
runner.info(f" (à blanc : l'interface serait {self.iface})")
return True
return self._settle(runner)
if not interface_exists(self.iface):
def _up_delegated(self, runner):
"""Le greffon authentifie, ce pilote monte. Voir l'en-tête."""
cookie, fingerprint = self.authenticate_with_helper(runner)
if cookie is None:
return False
code, _ = runner.cmd(
f"monter le tunnel sur {self.profile['server']} avec le cookie",
self.cookie_command(fingerprint),
stdin=f"{cookie}\n",
secret_stdin=True,
check=False,
timeout=120,
)
if code != 0 and not runner.dry_run:
runner.fail("openconnect a refusé le cookie.")
runner.info(
" → Un cookie de session est à usage unique et de"
" courte durée. S'il a été délivré à un client annonçant"
" une autre version que oc_ac_version, ou si le montage"
" arrive trop tard, il est refusé : relancer."
)
return False
return True
def _up_external_browser(self, runner):
"""SSO sans greffon : openconnect s'en charge, s'il peut."""
self._explain_the_sso_round_trip(runner)
# Entrée standard VIDE, et non héritée du terminal. En SSO
# openconnect ne lit jamais l'entrée standard : il attend la
# redirection sur son port. Mais un concentrateur qui ne fait PAS de
# SSO répond par un formulaire mot de passe, et openconnect se met
# alors à le demander — sur le terminal, puisqu'il en a un, et sans
# que `--non-inter` soit là pour l'en empêcher (en SSO la
# redirection EST l'interaction).
#
# L'attente ressemble alors à l'attente normale du SSO, et chaque
# essai revient en « Login failed » jusqu'au délai. Une entrée
# standard fermée transforme cette boucle en un échec immédiat, que
# le message d'aide ci-dessous explique.
code, _ = runner.cmd(
f"ouvrir la session {self.profile['oc_protocol']} sur"
f" {self.profile['server']}",
self.command(),
stdin="",
check=False,
timeout=300,
)
if code != 0 and not runner.dry_run:
runner.fail("openconnect a refusé.")
# Les causes sont classées par ce que le message d'openconnect
# permet de reconnaître, et non par fréquence : chacune se lit
# sur une ligne précise de la sortie ci-dessus.
runner.info(
" → « No SSO handler » : ce concentrateur exige le"
" navigateur INTÉGRÉ (sso-v2), et l'openconnect des"
" distributions est bâti sans. `--external-browser` ne"
" s'applique que si le serveur annonce le mode navigateur"
" externe. Installer openconnect-sso, ou renseigner"
" oc_sso_helper : ce pilote délèguera l'étape web."
)
runner.info(
" → « username and password » demandé : alors le"
" service ne fait PAS de SSO. Décocher « formulaire web »"
" (oc_sso) et déposer le mot de passe."
)
runner.info(
" → Sinon : la redirection n'est jamais revenue sur le"
" port 29786 (redirection ssh en place ?), ou le délai de"
" 5 minutes a expiré avant la fin de l'authentification."
)
return False
return True
def _up_password(self, runner):
"""Mot de passe du coffre, par l'entrée standard."""
if not self.secrets.get("password"):
report = runner.warn if runner.dry_run else runner.fail
report(
"Aucun mot de passe dans le coffre, et le profil n'est pas"
" en SSO : les déposer, ou cocher « formulaire web »."
)
if not runner.dry_run:
return False
secret = self.secrets.get("password", "")
# Comparé, jamais tronqué : le coffre décide de ce qui part.
# Comparer des LONGUEURS ne divulgue rien, et c'est le seul endroit
# où le mot de passe déposé et la borne déclarée sont tous deux
# connus.
limite = self.password_len
if limite and secret != PLACEHOLDER and len(secret) > limite:
runner.warn(
f"Le mot de passe du coffre fait {len(secret)} caractères"
f" et ce concentrateur n'en compare que {limite} : n'en"
f" déposer que {limite}."
)
code, _ = runner.cmd(
f"ouvrir la session {self.profile['oc_protocol']} sur"
f" {self.profile['server']}",
self.command(),
stdin=f"{secret}\n",
secret_stdin=True,
check=False,
timeout=120,
)
if code != 0 and not runner.dry_run:
runner.fail("openconnect a refusé.")
runner.info(
" → Causes usuelles : identifiants, certificat serveur"
" non épinglé (recopier la ligne « --servercert sha256:… »"
" ci-dessus dans le champ oc_servercert), groupe"
" d'authentification absent."
)
return False
return True
def _settle(self, runner):
"""Ce qui suit un montage réussi, quel que soit le chemin pris.
On ATTEND l'interface au lieu de la constater. `--background` fait
sortir openconnect dès que la session est ouverte, et c'est
`vpnc-script` qui crée l'interface et lui pose son adresse, un
instant plus tard. Regarder tout de suite déclare absent un tunnel
qui monte — et accuse `vpnc-script` d'être absent alors qu'il est
précisément en train de travailler.
"""
addresses = wait_for_interface_address(self.iface, timeout=25)
if not addresses and not interface_exists(self.iface):
runner.fail(
f"openconnect s'est lancé mais {self.iface} n'existe pas."
" vpnc-script est-il installé ? (paquet vpnc-scripts)"
)
return False
addresses = (
", ".join(interface_addresses(self.iface)) or "sans adresse"
runner.ok(
f"interface {self.iface} :"
f" {', '.join(addresses) or 'sans adresse'}"
)
runner.ok(f"interface {self.iface} : {addresses}")
self.write_state(runner, "iface", self.iface)
# Les routes du serveur sont déjà posées par vpnc-script. Celles du
# profil s'AJOUTENT : un réseau que le concentrateur ne pousse pas
@ -316,9 +871,44 @@ class OpenconnectDriver(VpnDriver):
)
return True
def check_sso_helper(self):
"""Verdict sur le greffon, quand le profil en a besoin.
`None` en verdict quand le profil n'est pas en SSO : ce n'est pas
« bon », ce n'est pas « en défaut », c'est hors sujet — et un ✓ sur
une ligne hors sujet fait croire qu'elle a été vérifiée.
"""
if not self.profile.get("oc_sso"):
return (
t("SSO helper"),
None,
"sans objet : ce profil authentifie par mot de passe",
)
helper = self.sso_helper
if helper:
return (
t("SSO helper"),
True,
helper,
)
declared = self.profile.get("oc_sso_helper") or ""
return (
t("SSO helper"),
False,
(
f"déclaré mais inexécutable : {declared}"
if declared
else "absent : openconnect-sso introuvable dans le PATH"
),
)
def status(self, runner):
return self.standard_status(
runner, extra=[self.check_daemon("processus openconnect")]
runner,
extra=[
self.check_daemon("processus openconnect"),
self.check_sso_helper(),
],
)
def log_commands(self):

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

@ -26,6 +26,10 @@ import shlex
import subprocess
import sys
# Le même masque que le coffre : deux masques différents dans la même
# sortie feraient croire à deux natures de secret.
from script.vpn.vault import MASK
# Marqueurs des blocs gérés dans les fichiers de configuration du système.
# Reconnaissables, uniques, et ils DISENT de ne pas éditer à la main.
BLOCK_BEGIN = "# >>> erplibre-vpn %s — généré, ne pas éditer"
@ -79,6 +83,25 @@ class Runner:
self.ops: list[dict] = []
self.failures: list[str] = []
def add_secret(self, value):
"""Masque `value` dans tout ce qui s'affichera DÉSORMAIS.
Le masquage est monté une fois pour toutes au démarrage, à partir de
ce que le coffre a rendu. Mais un secret peut NAÎTRE en cours de
route : un jeton de session obtenu par une authentification web
n'existe pas avant qu'elle aboutisse, et il ne doit pas moins être
masqué que le mot de passe qui l'a produit.
Sous huit caractères, on ne masque pas : une valeur courte se
retrouve par hasard dans un chemin ou un nom d'interface, et on
masquerait du texte utile en croyant protéger un secret.
"""
value = str(value or "")
if len(value) < 8:
return
previous = self.redactor
self.redactor = lambda text: previous(text).replace(value, MASK)
# ------------------------------------------------------------------
# Affichage
# ------------------------------------------------------------------
@ -119,6 +142,26 @@ class Runner:
`stdin` est le seul chemin par lequel un secret entre dans un
processus. `secret_stdin` ne change PAS l'exécution : il dit à
l'affichage et aux tests que ce contenu ne doit jamais être montré.
`capture` vaut True (sortie et erreurs lues, donc invisibles),
False (tout à l'écran, rien de lu), ou « stdout » — la sortie est
lue, les erreurs restent à l'écran. Ce troisième cas existe pour
une commande qui RETOURNE un secret sur sa sortie tout en parlant
sur ses erreurs : capturer les deux ferait attendre l'utilisateur
en silence devant une authentification qui réclame son geste.
Faute de `stdin`, l'entrée est /dev/null et JAMAIS le terminal
hérité. Une commande qui reçoit un terminal sur son entrée peut
appeler `tcsetattr` ; hors du groupe de processus d'avant-plan,
elle reçoit alors SIGTTOU et s'ARRÊTE — état T, que ni SIGINT ni
SIGTERM ne lèvent, et qui garde les verrous déjà pris. Le cas se
produit quand la sortie est capturée : sudo alloue un
pseudo-terminal pour l'entrée pendant que la sortie part dans un
tuyau, et le groupe d'avant-plan de ce terminal n'est pas celui de
la commande. Rien ici n'a besoin de lire l'humain : les questions
passent par `confirm`, dans CE processus, et un secret arrive par
`stdin`. Une invite de mot de passe sudo n'en souffre pas — sudo
ouvre /dev/tty, pas son entrée standard.
"""
full = command
if sudo is None:
@ -143,13 +186,24 @@ class Runner:
full,
shell=True,
input=stdin,
# Rien à fournir : /dev/null, et jamais le terminal hérité
# (voir la docstring). Quand `input` porte un contenu,
# subprocess branche lui-même le tuyau et `stdin` doit
# rester None — les deux ensemble sont refusés.
stdin=subprocess.DEVNULL if stdin is None else None,
text=True,
timeout=timeout,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.STDOUT if capture else None,
stderr=(subprocess.STDOUT if capture is True else None),
)
code, out = proc.returncode, proc.stdout or ""
except subprocess.TimeoutExpired:
# Le délai rend la main à l'appelant ; il ne garantit pas que
# la commande soit morte. `shell=True` met un shell entre nous
# et le vrai travail, et subprocess ne tue que ce shell — un
# `sudo apt-get` lancé par lui devient orphelin et continue,
# verrous compris. D'où le code 124 et un échec ANNONCÉ plutôt
# qu'un silence : la suite se juge sur un état inconnu.
code, out = 124, ""
self.fail(f"{label} : délai dépassé ({timeout} s)")
return code, out

View file

@ -32,6 +32,16 @@ SERVER_RE = re.compile(
HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$")
# Clé WireGuard : 32 octets en base64, donc 43 caractères + « = ».
WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$")
# Chemin d'URL, sans barre oblique de tête : ce qui se colle derrière
# « https://hôte/ » pour désigner un groupe de connexion. Ni « ? », ni
# « # », ni espace — la valeur finit dans une URL ET dans une ligne de
# commande lancée par sudo.
#
# La sentinelle refuse aussi un segment « . » ou « .. ». Aucun groupe de
# connexion ne s'appelle ainsi, et un chemin que le serveur réduirait
# désignerait un service autre que celui qu'on croit avoir écrit.
_URL_SEGMENT = r"(?!\.\.?(?:/|$))[A-Za-z0-9._~-]+"
URL_PATH_RE = re.compile(rf"^{_URL_SEGMENT}(/{_URL_SEGMENT})*$")
class ProfileError(ValueError):
@ -110,10 +120,10 @@ def ip_address(profile, key, label, required=False):
def ip_interface(profile, key, label, required=True):
"""Adresse AVEC préfixe (10.7.0.2/32) : c'est ce qu'une interface porte.
"""Adresse AVEC son préfixe : c'est ce qu'une interface porte.
Une adresse sans préfixe est acceptée et complétée en /32 — mais dire
« 10.7.0.2 » quand on veut dire « /24 » est une erreur silencieuse
Une adresse sans préfixe est acceptée et complétée en /32 — mais écrire
l'adresse nue quand on voulait dire /24 est une erreur silencieuse
coûteuse, alors le message le rappelle en cas de doute.
"""
value = str(profile.get(key) or "").strip()

View file

@ -46,6 +46,15 @@ FIELD_PSK = "psk"
MASK = "********"
# Ce qui tient la place d'un secret que le coffre n'a pas rendu, en mode à
# blanc : montrer un plan ne justifie pas d'exiger le mot de passe maître.
#
# Une CONSTANTE partagée, et non une chaîne écrite à deux endroits : un
# pilote qui juge un secret — sa longueur, sa forme — doit pouvoir
# reconnaître le marqueur et se taire, sinon le plan à blanc porte un
# verdict sur une valeur qui n'est pas celle de l'utilisateur.
PLACEHOLDER = "<secret-du-coffre>"
# Le menu a déjà le coffre ouvert quand il lance `vpn.py` : il lui passe les
# secrets par l'ENVIRONNEMENT plutôt que de le faire redemander le mot de
# passe maître — deux fois par connexion, puisqu'un essai à blanc précède le

View file

@ -38,15 +38,13 @@ from script.vpn.drivers import DRIVERS, driver_names, get_driver
from script.vpn.drivers.base import INSTALL_SCRIPT
from script.vpn.runner import Runner
from script.vpn.vault import (
PLACEHOLDER,
VaultError,
VpnVault,
redact,
secrets_from_env,
)
# Ce qu'on met à la place d'un secret qu'on n'a pas pu lire, en mode à blanc.
PLACEHOLDER = "<secret-du-coffre>"
def _vault():
cfg = config_file.ConfigFile()
@ -100,7 +98,10 @@ def _build(args, want_secrets=True, secrets_required=True):
)
return None, None, None
secrets = {}
if want_secrets:
# Le PROFIL décide, pas seulement la technologie : ouvrir le coffre
# pour un secret que ce montage n'utilisera pas réclamerait le mot de
# passe maître pour rien.
if want_secrets and driver_cls.wants_secrets(profile):
try:
secrets = _load_secrets(
profile, driver_cls, required=secrets_required
@ -280,13 +281,23 @@ def cmd_check(args):
def cmd_install(args):
"""Une SEULE invocation, même pour plusieurs pilotes : le script fait
un `apt-get update` par appel, et cinq appels le referaient cinq
fois."""
fois.
Le délai couvre le cas le plus lourd — tous les pilotes, index des
dépôts rafraîchi, paquets tirés d'un miroir lent — et existe pour
borner l'attente, pas pour la mesurer. Sans lui, un gestionnaire de
paquets qui ne rend jamais la main immobilise le menu sans fin.
"""
names = [args.driver] if args.driver else driver_names()
runner = Runner()
# `--sso` en QUEUE : le script retire ce drapeau avant de traiter le
# reste comme une liste de pilotes.
extra = " --sso" if getattr(args, "with_sso", False) else ""
code, _ = runner.cmd(
f"installer les paquets de : {', '.join(names)}",
f"bash {INSTALL_SCRIPT} {' '.join(names)}",
f"bash {INSTALL_SCRIPT} {' '.join(names)}{extra}",
check=True,
timeout=900,
)
return code
@ -342,6 +353,15 @@ def build_parser():
choices=sorted(DRIVERS),
help="Se limiter à ce pilote",
)
if name == "install":
sp.add_argument(
"--with-sso",
action="store_true",
help=(
"Installer aussi le greffon d'authentification par"
" formulaire web (openconnect-sso)"
),
)
return parser

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

View file

@ -416,26 +416,53 @@ class Openconnect(unittest.TestCase):
self.assertEqual(driver.profile["routes"], [])
class _OpenconnectNoHelper(DRIVERS["openconnect"]):
"""Le pilote openconnect, mais sans greffon SSO.
L'attribut de classe masque la propriété du parent, qui irait chercher
dans le PATH. Sans cela, « SSO sans greffon » serait vrai ou faux selon
la machine qui exécute la suite.
"""
sso_helper = ""
class OpenconnectSingleSignOn(unittest.TestCase):
"""Le cas du « formulaire web » : le concentrateur délègue à un
fournisseur d'identité, et il n'y a aucun mot de passe à envoyer.
Le client de Cisco exige alors un navigateur embarqué, donc un écran.
openconnect s'en passe : mesuré dans sa bibliothèque, il écoute sur le
port local 29786 et attend la redirection du navigateur, lequel peut
être celui de l'utilisateur, ailleurs, à travers un `ssh -L`.
Deux chemins existent, et cette classe couvre le premier. Sans greffon,
openconnect s'en charge seul : il écoute sur son port local 29786 et
attend la redirection d'un navigateur, lequel peut être celui de
l'utilisateur, ailleurs, à travers un `ssh -L`. Cela ne fonctionne que
si le serveur annonce la méthode « navigateur externe ».
Le second chemin — le concentrateur exige un navigateur intégré, et un
greffon fait l'étape web — vit dans `test_vpn_presets.py`, classe
`DelegatedSso`.
"""
def _sso(self, **overrides):
def _sso(self, helper="", **overrides):
"""Un pilote en SSO, sur le chemin CHOISI par le test.
`helper` décide : vide, c'est `--external-browser` et openconnect se
débrouille ; renseigné, le pilote délègue l'étape web au greffon.
Il est TOUJOURS explicite, jamais découvert — sinon le test dépend
de ce qui est installé sur la machine qui l'exécute, et le même code
passe ici et échoue ailleurs.
"""
profile = dict(
SAMPLES["openconnect"][0],
name="t-sso",
driver="openconnect",
oc_sso=True,
oc_user="",
oc_sso_helper=helper,
)
profile.update(overrides)
return DRIVERS["openconnect"](profiles.validate(profile), {})
clean = profiles.validate(profile)
which = DRIVERS["openconnect"] if helper else _OpenconnectNoHelper
return which(clean, {})
def test_a_profile_without_user_or_password_is_valid(self):
"""En SSO, c'est le fournisseur d'identité qui décide de qui on est :

View file

@ -19,6 +19,7 @@ import json
import os
import sys
import tempfile
import unicodedata
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
@ -37,6 +38,9 @@ from script.todo.vpn_menu import ( # noqa: E402
)
from script.vpn import profiles # noqa: E402
from script.vpn.drivers import DRIVERS # noqa: E402
from script.vpn.drivers.openconnect import ( # noqa: E402
OpenconnectDriver,
)
WG_PUBLIC = base64.b64encode(bytes(range(32, 64))).decode()
@ -597,5 +601,438 @@ class SecretsOnlyWhenThereAreSome(MenuBase):
self.assertEqual(self.todo._vpn_ask_secret("PSK"), "")
class SsoHelperOffer(MenuBase):
"""La proposition d'installer le greffon SSO.
Elle doit être ÉCLAIRÉE et ne pas se répéter : le greffon ne sert
qu'aux passerelles à navigateur intégré, et proposer d'installer ce qui
est déjà installé fait douter de ce qu'on lit.
"""
def installing(self, driver, absent, *answers):
"""Déroule `_vpn_install` et rend (sortie, commandes lancées)."""
launched = []
with patch(
"script.todo.vpn_menu._sso_helper_seen", return_value=not absent
):
with patch.object(
self.todo, "_vpn_pick_driver", return_value=DRIVERS[driver]
):
with patch.object(
self.todo,
"_vpn_cli",
lambda arguments, env=None: launched.append(arguments),
):
with self.answering(*answers):
out = io.StringIO()
with redirect_stdout(out):
self.todo._vpn_install()
return out.getvalue(), launched
def test_it_is_offered_when_the_helper_is_missing(self):
printed, launched = self.installing("openconnect", True, "o")
self.assertIn("No SSO handler", printed)
self.assertEqual(launched, ["install --driver openconnect --with-sso"])
def test_declining_installs_only_the_packages(self):
_, launched = self.installing("openconnect", True, "n")
self.assertEqual(launched, ["install --driver openconnect"])
def test_it_is_not_offered_when_the_helper_is_there(self):
"""Une liste de réponses VIDE : si la question était posée, le test
lèverait StopIteration."""
printed, launched = self.installing("openconnect", False)
self.assertNotIn("No SSO handler", printed)
self.assertEqual(launched, ["install --driver openconnect"])
def test_it_is_not_offered_for_a_driver_that_cannot_use_it(self):
"""WireGuard n'a pas de formulaire web : la question serait sans
objet, et la liste de réponses vide le prouve."""
printed, launched = self.installing("wireguard", True)
self.assertNotIn("No SSO handler", printed)
self.assertEqual(launched, ["install --driver wireguard"])
def test_the_offer_says_the_upstream_is_unmaintained(self):
"""La réponse doit être éclairée : le greffon porte une dette, et
la taire ferait accepter sans savoir."""
printed, _ = self.installing("openconnect", True, "n")
self.assertIn("entretenu", printed)
def largeur_affichee(texte):
"""Largeur de `texte` en colonnes de terminal.
Les caractères que la norme Unicode classe « W » (wide) ou « F »
(fullwidth) — dont les emoji — en occupent deux pour un seul
caractère. Une colonne alignée à l'écran ne l'est donc pas dans
l'index de la chaîne, et l'inverse.
"""
return sum(
2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in texte
)
class ShowingWhatIsConnected(MenuBase):
"""L'état de chaque profil, dans la liste qui sert à choisir.
La même liste sert à connecter et à déconnecter : sans l'état, on
descend un tunnel déjà mort ou on remonte celui qui tient, et la
sortie du CLI est la première chose qui le dit — trop tard.
"""
def setUp(self):
super().setUp()
profiles.save(
{
"name": "vivant",
"driver": "openconnect",
"server": "ssl.vpn.example-campus.net",
"oc_user": "someone",
}
)
profiles.save(
{
"name": "mort",
"driver": "openconnect",
"server": "ssl.vpn.example-campus.net",
"oc_user": "someone",
}
)
def listing(self, up):
"""La liste, avec `up` disant quels profils sont montés."""
with patch.object(
OpenconnectDriver,
"is_up",
lambda self: self.profile["name"] in up,
):
with self.answering("0"):
out = io.StringIO()
with redirect_stdout(out):
self.todo._vpn_select_profile()
return out.getvalue()
def test_the_connected_profile_is_marked(self):
printed = self.listing({"vivant"})
vivant = [l for l in printed.splitlines() if "vivant" in l][0]
mort = [l for l in printed.splitlines() if "mort" in l][0]
self.assertIn("🟢", vivant)
self.assertNotIn("🟢", mort)
def test_the_columns_stay_aligned(self):
"""Un emoji occupe deux COLONNES pour un seul caractère : la ligne
non marquée en réserve deux, sinon tout ce qui suit se décale.
La mesure porte donc sur les colonnes affichées et non sur
`str.index`, qui compte des caractères — l'écart d'un caractère
entre les deux lignes est précisément ce qui les aligne à l'écran.
"""
printed = self.listing({"vivant"})
lignes = [l for l in printed.splitlines() if "example-campus" in l]
self.assertEqual(len(lignes), 2, printed)
colonnes = {
largeur_affichee(l[: l.index("ssl.vpn.example-campus.net")])
for l in lignes
}
self.assertEqual(len(colonnes), 1, lignes)
def test_an_unknown_driver_does_not_break_the_listing(self):
"""Un pilote retiré de la configuration ne doit pas empêcher de
lister les profils, ni de supprimer celui qui le nomme."""
self.assertFalse(
self.todo._vpn_is_up({"name": "x", "driver": "disparu"})
)
def test_connecting_what_is_already_up_asks_first(self):
"""Remonter un tunnel qui tient rejoue toute l'authentification —
jusqu'à un formulaire web — pour aboutir à une interface qui
existait déjà."""
launched = []
with patch.object(OpenconnectDriver, "is_up", lambda self: True):
with patch.object(
self.todo, "_vpn_select_profile", return_value="vivant"
):
with patch.object(
self.todo,
"_vpn_cli",
lambda arguments, env=None: launched.append(arguments),
):
with self.answering("n"):
out = io.StringIO()
with redirect_stdout(out):
self.todo._vpn_connect()
self.assertIn("déjà connecté", out.getvalue())
self.assertEqual(launched, [], "rien ne devait être lancé")
def test_disconnecting_what_is_down_says_so_but_proceeds(self):
"""« down » reste utile : c'est lui qui efface l'état laissé dans
/run par un tunnel mort sans lui."""
launched = []
with patch.object(OpenconnectDriver, "is_up", lambda self: False):
with patch.object(
self.todo, "_vpn_select_profile", return_value="mort"
):
with patch.object(
self.todo,
"_vpn_cli",
lambda arguments, env=None: launched.append(arguments),
):
out = io.StringIO()
with redirect_stdout(out):
self.todo._vpn_disconnect()
self.assertIn("n'est pas connecté", out.getvalue())
self.assertEqual(launched, ["down --profile mort"])
class ChoosingTheXmlProfile(MenuBase):
"""Le choix du fichier `.xml` : parcours d'abord, saisie ensuite.
Ni l'un ni l'autre ne suffit. Le parcours part des répertoires du
client de Cisco et n'aide pas si le fichier vient d'ailleurs ; le
chemin tapé oblige à le connaître, or personne ne retient
« /opt/cisco/secureclient/vpn/profile ».
"""
def setUp(self):
super().setUp()
self.xml = os.path.join(self.tmp.name, "campus.xml")
with open(self.xml, "w") as fh:
fh.write("<AnyConnectProfile/>")
def browsing(self, chosen, *answers, dirs=None):
"""Déroule `_vpn_select_xml` avec un parcours qui rend `chosen`."""
picked = []
class FauxNavigateur:
def __init__(self, start, callback):
picked.append(start)
self._callback = callback
def run_main_frame(inner):
if chosen is not None:
inner._callback(chosen)
with patch(
"script.todo.vpn_menu.ANYCONNECT_DIRS",
dirs if dirs is not None else (self.tmp.name,),
):
with patch(
"script.todo.vpn_menu.todo_file_browser.FileBrowser",
FauxNavigateur,
):
with self.answering(*answers):
with redirect_stdout(io.StringIO()):
return self.todo._vpn_select_xml(), picked
def test_the_browser_starts_in_the_cisco_directory(self):
path, started = self.browsing(self.xml, "")
self.assertEqual(path, self.xml)
self.assertEqual(started, [self.tmp.name])
def test_declining_the_browser_falls_back_to_typing(self):
path, started = self.browsing(self.xml, "n", self.xml)
self.assertEqual(path, self.xml)
self.assertEqual(started, [], "le parcours ne devait pas s'ouvrir")
def test_leaving_the_browser_empty_falls_back_to_typing(self):
"""On peut sortir du parcours sans rien choisir : la saisie reste."""
path, _ = self.browsing(None, "", self.xml)
self.assertEqual(path, self.xml)
def test_a_path_that_is_not_a_file_does_not_pass_as_chosen(self):
"""Le parcours peut rendre un répertoire : il ne vaut pas fichier,
et la saisie reprend la main."""
path, _ = self.browsing(self.tmp.name, "", self.xml)
self.assertEqual(path, self.xml)
def test_no_cisco_directory_means_no_offer(self):
"""Ouvrir un parcours sur un chemin absent afficherait une liste
vide, ce qui ressemble à une panne. Liste de réponses courte : si
la question était posée, le test lèverait StopIteration."""
path, started = self.browsing(
self.xml, self.xml, dirs=("/nowhere/cisco",)
)
self.assertEqual(path, self.xml)
self.assertEqual(started, [])
def test_a_typed_path_is_expanded(self):
path, _ = self.browsing(None, "n", "~")
self.assertEqual(path, os.path.expanduser("~"))
def test_giving_up_returns_nothing(self):
path, _ = self.browsing(None, "n", "")
self.assertEqual(path, "")
class FromPreset(MenuBase):
"""Le chemin « créer un profil à partir d'un préréglage ».
Le préréglage porte ce que l'établissement publie ; il ne reste qu'un
identifiant à taper. Le formulaire est celui de `_vpn_edit_profile`,
amorcé — dupliquer les questions ferait vivre deux formulaires qui
divergeraient au prochain champ ajouté à un pilote.
"""
# Passerelle INVENTÉE : voir la règle du dépôt sur ce qu'un exemple
# a le droit de nommer.
PRESET = {
"preset": "campus",
"label": "Campus SSL VPN",
"driver": "openconnect",
"server": "ssl.vpn.example-campus.net",
"oc_protocol": "anyconnect",
"oc_usergroup": "SSLProfileCampus",
"oc_authgroup": "CampusSSL",
"oc_password_len": 8,
}
def choosing(self, *answers):
"""Le préréglage servi sans toucher au disque, et les réponses."""
return patch(
"script.vpn.presets.load_all",
return_value=([dict(self.PRESET)], []),
), self.answering(*answers)
def test_only_the_identity_is_left_to_type(self):
"""Aucune question sur la technologie : le préréglage y a répondu.
Si le formulaire la posait, la liste de réponses serait décalée et
le profil ne porterait pas les bonnes valeurs."""
loading, answering = self.choosing(
"1", # le préréglage
"campus_me", # nom du profil
"", # serveur : celui du préréglage
"someone", # oc_user
"", # protocole
"", # groupe de connexion (chemin d'URL)
"", # SSO ? défaut non
"", # réseaux
"", # tout le trafic ? défaut non
"", # témoin
"n", # réglages avancés ?
)
with loading:
with answering:
with redirect_stdout(io.StringIO()):
self.todo._vpn_from_preset()
saved = profiles.load("campus_me")
self.assertIsNotNone(saved, "profil non enregistré")
self.assertEqual(saved["driver"], "openconnect")
self.assertEqual(saved["server"], self.PRESET["server"])
# Le chemin d'URL : le champ qui décide QUEL service du
# concentrateur on joint, et celui qu'on ne devine pas.
self.assertEqual(saved["oc_usergroup"], "SSLProfileCampus")
self.assertEqual(saved["oc_authgroup"], "CampusSSL")
self.assertEqual(saved["oc_user"], "someone")
# La borne du concentrateur est un réglage AVANCÉ, jamais demandé
# ici : elle doit venir du préréglage quand même.
self.assertEqual(saved["oc_password_len"], 8)
def test_going_back_saves_nothing(self):
loading, answering = self.choosing("0")
with loading:
with answering:
with redirect_stdout(io.StringIO()):
self.todo._vpn_from_preset()
self.assertEqual(profiles.names(), [])
def test_an_unreadable_preset_is_reported(self):
with patch(
"script.vpn.presets.load_all",
return_value=([], ["campus.json : ligne 3"]),
):
with redirect_stdout(io.StringIO()) as out:
self.todo._vpn_from_preset()
self.assertIn("campus.json", out.getvalue())
def test_replaying_refreshes_the_gateway_and_keeps_the_identity(self):
"""Rejouer un préréglage sur un profil existant sert à le remettre à
jour — passerelle déménagée, groupe renommé. Ce qui est PERSONNEL et
qu'aucun préréglage ne porte se garde : identifiant, routes ajoutées
à la main, certificat épinglé."""
profiles.save(
{
"name": "campus_me",
"driver": "openconnect",
"server": "ssl.vpn.example-campus.net",
"oc_user": "someone",
"oc_authgroup": "OldGroup",
"oc_servercert": "sha256:abc",
"routes": ["10.60.0.0/16"],
"oc_password_len": 0,
}
)
moved = dict(
self.PRESET,
server="ssl2.vpn.example-campus.net",
oc_authgroup="NewGroup",
)
with patch("script.vpn.presets.load_all", return_value=([moved], [])):
with self.answering(
"1",
"campus_me",
"", # serveur : celui du préréglage, désormais à jour
"", # oc_user : gardé
"", # protocole
"", # groupe de connexion : celui du préréglage
"", # SSO ?
"", # réseaux : gardés
"", # tout le trafic ?
"", # témoin
"n", # réglages avancés ?
):
with redirect_stdout(io.StringIO()):
self.todo._vpn_from_preset()
saved = profiles.load("campus_me")
# Le préréglage rafraîchit ce qu'il déclare.
self.assertEqual(saved["server"], "ssl2.vpn.example-campus.net")
self.assertEqual(saved["oc_authgroup"], "NewGroup")
self.assertEqual(saved["oc_password_len"], 8)
# Le profil garde ce qui est personnel.
self.assertEqual(saved["oc_user"], "someone")
self.assertEqual(saved["oc_servercert"], "sha256:abc")
self.assertEqual(saved["routes"], ["10.60.0.0/16"])
def test_replaying_over_another_technology_drags_nothing_along(self):
"""Un profil qui change de technologie ne doit pas faire suivre une
clé WireGuard dans un profil OpenConnect, où rien ne la lirait."""
profiles.save(
{
"name": "campus_me",
"driver": "wireguard",
"server": "vpn.acme.example",
"wg_address": "10.7.0.2/32",
"wg_peer_key": WG_PUBLIC,
"routes": ["10.7.0.0/24"],
}
)
loading, answering = self.choosing(
"1",
"campus_me",
"", # serveur
"someone", # oc_user
"", # protocole
"", # groupe de connexion
"", # SSO ?
"", # réseaux
"", # tout le trafic ?
"", # témoin
"n", # réglages avancés ?
)
with loading:
with answering:
with redirect_stdout(io.StringIO()):
self.todo._vpn_from_preset()
saved = profiles.load("campus_me")
self.assertEqual(saved["driver"], "openconnect")
self.assertNotIn("wg_peer_key", saved)
self.assertNotIn("wg_address", saved)
def test_no_preset_says_where_to_put_one(self):
with patch("script.vpn.presets.load_all", return_value=([], [])):
with redirect_stdout(io.StringIO()) as out:
self.todo._vpn_from_preset()
self.assertIn("conf/vpn_presets", out.getvalue())
if __name__ == "__main__":
unittest.main()

781
test/test_vpn_presets.py Normal file
View file

@ -0,0 +1,781 @@
#!/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 VPN : chargement, priorité, application.
Ni root, ni réseau, ni serveur VPN. Les répertoires de préréglages sont
déplacés dans un temporaire, et les trois fichiers de configuration avec eux :
un test qui lirait `private/vpn/presets/` verrait les préréglages de la
personne qui le lance, et un test qui écrirait dans le fichier privé
détruirait ses profils.
Le fichier couvre aussi la borne de longueur de mot de passe — elle n'a de
sens qu'avec un préréglage qui la porte, et c'est le seul réglage du dépôt
qui décrit le CONCENTRATEUR plutôt que le client.
"""
import io
import json
import os
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.vpn import presets, profiles # noqa: E402
from script.vpn.drivers import get_driver # noqa: E402
from script.vpn.drivers.openconnect import OpenconnectDriver # noqa: E402
from script.vpn.profiles import ProfileError # noqa: E402
from script.vpn.vault import PLACEHOLDER # noqa: E402
# Passerelle INVENTÉE. La règle du dépôt interdit de nommer une organisation
# tierce, et un test fige pour toujours l'exemple qu'il choisit : prendre un
# vrai site « parce qu'il est parlant » est exactement le réflexe à éviter.
PRESET = {
"preset": "campus",
"label": "Campus SSL VPN",
"hint": "AnyConnect gateway",
"driver": "openconnect",
"server": "ssl.vpn.example-campus.net",
"oc_protocol": "anyconnect",
"oc_authgroup": "CampusSSL",
"oc_user": "",
"oc_password_len": 8,
}
def write_preset(directory, filename, payload):
os.makedirs(directory, exist_ok=True)
path = os.path.join(directory, filename)
with open(path, "w") as fh:
if isinstance(payload, str):
fh.write(payload)
else:
json.dump(payload, fh)
return path
class PresetLoading(unittest.TestCase):
"""Les répertoires de préréglages, dans un temporaire."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.shared = os.path.join(self.tmp.name, "shared")
self.private = os.path.join(self.tmp.name, "private")
base = os.path.join(self.tmp.name, "todo.json")
with open(base, "w") as fh:
json.dump({"vpn": []}, fh)
self.patches = [
patch(
"script.vpn.presets.PRESET_DIRS",
(self.shared, self.private),
),
patch("script.config.config_file.CONFIG_FILE", base),
patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmp.name, "override.json"),
),
patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmp.name, "private.json"),
),
]
for item in self.patches:
item.start()
def tearDown(self):
for item in self.patches:
item.stop()
self.tmp.cleanup()
def test_a_directory_is_read(self):
write_preset(self.shared, "campus.json", PRESET)
found, errors = presets.load_all()
self.assertEqual(errors, [])
self.assertEqual([p["preset"] for p in found], ["campus"])
self.assertEqual(found[0]["server"], PRESET["server"])
def test_a_file_can_hold_several_presets(self):
"""Un site qui en distribue trois n'a pas à ouvrir trois fichiers."""
write_preset(
self.shared,
"many.json",
[PRESET, dict(PRESET, preset="campus_lab", label="Lab")],
)
found, errors = presets.load_all()
self.assertEqual(errors, [])
self.assertEqual(
sorted(p["preset"] for p in found), ["campus", "campus_lab"]
)
def test_the_latest_directory_wins(self):
"""C'est ce qui permet de corriger un gabarit du dépôt — passerelle
déménagée, groupe renommé — sans toucher un fichier suivi par git,
donc sans conflit au prochain `git pull`."""
write_preset(self.shared, "campus.json", PRESET)
write_preset(
self.private,
"campus.json",
dict(PRESET, server="ssl2.vpn.example-campus.net"),
)
found, _ = presets.load_all()
self.assertEqual(len(found), 1)
self.assertEqual(found[0]["server"], "ssl2.vpn.example-campus.net")
def test_a_broken_file_does_not_hide_the_others(self):
"""Sinon la panne se lit « aucun préréglage » alors qu'il y en a
dix, et on cherche du côté du répertoire."""
write_preset(self.shared, "broken.json", "{ pas du JSON")
write_preset(self.shared, "campus.json", PRESET)
found, errors = presets.load_all()
self.assertEqual([p["preset"] for p in found], ["campus"])
self.assertEqual(len(errors), 1)
self.assertIn("broken.json", errors[0])
def test_a_refused_identifier_is_named(self):
write_preset(self.shared, "bad.json", dict(PRESET, preset="Campus!"))
found, errors = presets.load_all()
self.assertEqual(found, [])
self.assertEqual(len(errors), 1)
self.assertIn("Campus!", errors[0])
def test_extra_directories_come_from_the_configuration(self):
"""Un dépôt privé cloné ailleurs qu'au point de montage."""
elsewhere = os.path.join(self.tmp.name, "elsewhere")
write_preset(elsewhere, "campus.json", PRESET)
with open(os.path.join(self.tmp.name, "override.json"), "w") as fh:
json.dump({"vpn_preset_paths": [elsewhere]}, fh)
found, errors = presets.load_all()
self.assertEqual(errors, [])
self.assertEqual([p["preset"] for p in found], ["campus"])
def test_a_missing_directory_is_not_an_error(self):
"""Le cas normal : `private/vpn/presets/` n'existe pas encore."""
found, errors = presets.load_all()
self.assertEqual((found, errors), ([], []))
def test_load_finds_one_by_identifier(self):
write_preset(self.shared, "campus.json", PRESET)
self.assertEqual(presets.load("campus")["server"], PRESET["server"])
self.assertIsNone(presets.load("nowhere"))
class PresetApplication(unittest.TestCase):
"""`apply` : d'un préréglage à un profil que la validation accepte."""
def test_the_identity_is_what_is_still_missing(self):
"""Un préréglage sans identifiant est refusé, et c'est le contrat :
`apply` rend un profil INCOMPLET, le formulaire demande le reste.
Le valider tel quel passerait à côté de ce que le préréglage
promet — tout sauf ce qui est personnel."""
with self.assertRaises(ProfileError):
profiles.validate(presets.apply(PRESET, "campus_me"))
def test_it_validates_once_the_identity_is_given(self):
profile = presets.apply(PRESET, "campus_me")
profile["oc_user"] = "someone"
clean = profiles.validate(profile)
self.assertEqual(clean["name"], "campus_me")
self.assertEqual(clean["oc_authgroup"], "CampusSSL")
def test_the_description_keys_are_dropped(self):
"""`preset`, `label` et `hint` nomment le préréglage. Laissés dans
le profil, ils seraient écrits dans la configuration et traîneraient
là sans que rien ne les lise."""
profile = presets.apply(PRESET, "campus_me")
for key in presets.META_KEYS:
self.assertNotIn(key, profile)
def test_what_is_personal_stays_empty(self):
"""Un préréglage ne porte ni identifiant ni secret : c'est ce qui
lui permet de se distribuer. Ce qui manque est donc ce que le
formulaire demande ensuite."""
profile = presets.apply(PRESET, "campus_me")
self.assertEqual(profile["oc_user"], "")
def test_the_driver_defaults_are_filled_in(self):
profile = presets.apply(PRESET, "campus_me")
self.assertEqual(profile["port"], 443)
self.assertFalse(profile["oc_sso"])
def test_a_preset_without_a_label_is_still_choosable(self):
self.assertEqual(presets.label({"preset": "campus"}), "campus")
self.assertEqual(presets.label(PRESET), "Campus SSL VPN")
class ShippedPresets(unittest.TestCase):
"""Garde-fou sur ce que le dépôt livre dans `conf/vpn_presets/`.
Un gabarit cassé se verrait autrement chez l'utilisateur, au moment où
il essaie de s'en servir.
"""
def test_every_shipped_preset_validates(self):
"""Chaque gabarit livré donne un profil valide dès qu'on lui donne
l'identifiant que le formulaire demanderait. L'identité est remplie
par le champ que le PILOTE désigne : le garde-fou reste vrai pour un
gabarit d'une autre technologie."""
with patch("script.vpn.presets.PRESET_DIRS", ("./conf/vpn_presets",)):
found, errors = presets.load_all()
self.assertEqual(errors, [])
self.assertTrue(found, "aucun gabarit livré")
for preset in found:
with self.subTest(preset=preset["preset"]):
profile = presets.apply(preset, "shipped_check")
driver_cls = get_driver(profile["driver"])
self.assertIsNotNone(
driver_cls, f"pilote inconnu : {profile['driver']}"
)
if driver_cls.user_field:
profile[driver_cls.user_field] = "someone"
profiles.validate(profile)
class PasswordLengthLimit(unittest.TestCase):
"""La borne que certains concentrateurs imposent au mot de passe.
Elle est INFORMATIVE : le coffre reste la source de vérité de ce qui
part. Un outil qui tronquerait en silence rendrait indébrouillable le
jour où le site lève la limite.
"""
def mount(self, secret, dry_run=False):
"""Le pilote monté jusqu'à la commande, prérequis court-circuités.
`ensure_ready` juge les binaires de la machine : le laisser décider
ferait passer ce test là où openconnect est installé et échouer
ailleurs, alors que ce qui est en jeu est la décision du PILOTE.
"""
driver = OpenconnectDriver(self.profile(), {"password": secret})
runner = FakeRunner(dry_run=dry_run)
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with redirect_stdout(io.StringIO()):
driver.up(runner)
return runner
def profile(self, **overrides):
base = {
"name": "campus",
"driver": "openconnect",
"server": "ssl.vpn.example-campus.net",
"oc_user": "someone",
"oc_password_len": 8,
}
base.update(overrides)
return profiles.with_defaults(base)
def test_zero_means_no_limit(self):
profile = self.profile(oc_password_len=0)
self.assertEqual(profiles.validate(profile)["oc_password_len"], 0)
self.assertEqual(OpenconnectDriver(profile).secret_notes(), [])
def test_out_of_bounds_is_refused(self):
for value in (-1, 129):
with self.subTest(value=value):
with self.assertRaises(ProfileError):
profiles.validate(self.profile(oc_password_len=value))
def test_the_note_is_rendered_before_the_prompt(self):
notes = OpenconnectDriver(self.profile()).secret_notes()
self.assertEqual(len(notes), 1)
self.assertIn("8", notes[0])
def test_sso_gets_no_note(self):
"""En SSO il n'y a aucun mot de passe à déposer : une consigne sur
sa longueur y serait sans objet."""
profile = self.profile(oc_sso=True, oc_user="")
self.assertEqual(OpenconnectDriver(profile).secret_notes(), [])
def test_the_password_is_warned_about_but_sent_whole(self):
runner = self.mount("0123456789")
self.assertTrue(
any("10" in w and "8" in w for w in runner.warnings),
runner.warnings,
)
self.assertEqual(runner.stdin, "0123456789\n")
def test_a_short_enough_password_says_nothing(self):
self.assertEqual(self.mount("01234567").warnings, [])
def test_sso_closes_standard_input(self):
"""En SSO, openconnect n'attend rien sur l'entrée standard — il
attend la redirection sur son port. Mais un concentrateur qui ne
fait PAS de SSO répond par un formulaire mot de passe, et
openconnect se met à le demander sur le terminal, sans que
`--non-inter` soit là pour l'en empêcher. Une entrée standard
FERMÉE transforme cette boucle de cinq minutes en échec immédiat.
"""
profile = self.profile(oc_sso=True, oc_user="")
driver = _NoHelper(profile, {})
runner = FakeRunner()
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with redirect_stdout(io.StringIO()):
driver.up(runner)
self.assertEqual(runner.stdin, "")
def test_the_dry_run_placeholder_is_not_judged(self):
"""En mode à blanc sans coffre joignable, le secret est un marqueur.
Le mesurer rendrait un verdict sur une valeur qui n'est pas celle de
l'utilisateur."""
runner = self.mount(PLACEHOLDER, dry_run=True)
self.assertEqual(runner.warnings, [])
class _NoHelper(OpenconnectDriver):
"""Le pilote sans greffon SSO, quoi que porte la machine.
L'attribut de classe masque la propriété du parent, qui chercherait
openconnect-sso dans le PATH — et le test passerait ou non selon ce qui
est installé là où la suite tourne.
"""
sso_helper = ""
class ConnectionGroups(unittest.TestCase):
"""Les deux « groupes » d'openconnect, et leurs deux options.
`--usergroup` pose le chemin d'URL, `--authgroup` choisit dans un menu
déroulant. Les confondre ne donne pas une erreur de syntaxe : cela
donne le formulaire d'authentification d'un AUTRE service, donc un
refus d'identifiants sur des identifiants justes. C'est ce que ce test
empêche de réintroduire.
"""
def profile(self, **overrides):
base = {
"name": "campus",
"driver": "openconnect",
"server": "ssl.vpn.example-campus.net",
"oc_user": "someone",
}
base.update(overrides)
return profiles.validate(profiles.with_defaults(base))
def test_the_usergroup_becomes_the_url_path_option(self):
command = OpenconnectDriver(
self.profile(oc_usergroup="SSLProfileLab")
).command()
self.assertIn("--usergroup=SSLProfileLab", command)
self.assertNotIn("--authgroup", command)
def test_the_authgroup_stays_the_dropdown_option(self):
command = OpenconnectDriver(
self.profile(oc_authgroup="CampusSSL")
).command()
self.assertIn("--authgroup=CampusSSL", command)
self.assertNotIn("--usergroup", command)
def test_both_can_coexist(self):
"""Un site peut exiger le chemin ET un choix dans la liste."""
command = OpenconnectDriver(
self.profile(oc_usergroup="SSLProfileLab", oc_authgroup="Staff")
).command()
self.assertIn("--usergroup=SSLProfileLab", command)
self.assertIn("--authgroup=Staff", command)
def test_neither_appears_when_empty(self):
command = OpenconnectDriver(self.profile()).command()
self.assertNotIn("group", command)
def test_a_multi_segment_path_is_accepted(self):
clean = self.profile(oc_usergroup="tunnel/lab")
self.assertEqual(clean["oc_usergroup"], "tunnel/lab")
def test_what_would_break_a_url_or_a_shell_is_refused(self):
for bad in (
"/leading",
"trailing/",
"with space",
"a?b",
"a#b",
"../etc",
"a;rm -rf",
"a&b",
):
with self.subTest(value=bad):
with self.assertRaises(ProfileError):
self.profile(oc_usergroup=bad)
class DelegatedSso(unittest.TestCase):
"""SSO délégué : le greffon authentifie, le PILOTE monte.
C'est toute la valeur du dispositif. Un tunnel monté par le greffon
lui-même s'appellerait `tun0`, ne laisserait aucun état dans /run, et
`status`, `diagnose` et `down` ne le verraient pas. Ces tests vérifient
que la frontière tient : le greffon ne rend qu'un cookie, le reste
vient du profil.
"""
# Un exécutable qui existe partout : ce qui est testé est la DÉCISION
# du pilote, pas openconnect-sso.
HELPER = "/bin/echo"
def driver(self, **overrides):
base = {
"name": "campus",
"driver": "openconnect",
"server": "ssl.vpn.example-campus.net",
"oc_sso": True,
"oc_user": "",
"oc_usergroup": "SSLProfileLab",
"oc_sso_helper": self.HELPER,
}
base.update(overrides)
return OpenconnectDriver(profiles.validate(base), {})
# -- la ligne du greffon ------------------------------------------
def test_the_helper_gets_the_url_path_appended_to_the_host(self):
"""`--server hôte/chemin` : c'est la forme que le greffon accepte,
et le chemin décide QUEL service du concentrateur on joint."""
command = self.driver().helper_command()
self.assertIn(
"--server=ssl.vpn.example-campus.net/SSLProfileLab", command
)
def test_the_helper_line_carries_no_secret(self):
command = self.driver().helper_command()
self.assertNotIn("cookie", command.lower())
self.assertIn("--authenticate json", command)
def test_the_announced_identity_is_the_same_on_both_steps(self):
"""Le concentrateur délivre le cookie à un client qui s'est
présenté sous une version donnée ; monter sous une autre le fait
refuser. Les deux lignes doivent donc porter la MÊME."""
driver = self.driver(oc_ac_version="4.10.07061")
self.assertIn("--ac-version=4.10.07061", driver.helper_command())
mount = driver.cookie_command("")
self.assertIn("--version-string=4.10.07061", mount)
self.assertIn("AnyConnect Linux_64 4.10.07061", mount)
def test_the_render_flags_are_dropped_when_already_set(self):
"""Qui a posé ces variables sait mieux que ce pilote."""
with patch.dict(
os.environ, {"LIBGL_ALWAYS_SOFTWARE": "0"}, clear=False
):
command = self.driver().helper_command()
self.assertNotIn("LIBGL_ALWAYS_SOFTWARE=", command)
self.assertIn("QTWEBENGINE_CHROMIUM_FLAGS=", command)
# -- la ligne de montage ------------------------------------------
def test_the_cookie_never_reaches_the_command_line(self):
"""`/proc/<pid>/cmdline` est lisible par tout utilisateur de la
machine, et ce cookie ouvre le tunnel à lui seul."""
command = self.driver().cookie_command("sha256:aa")
self.assertIn("--cookie-on-stdin", command)
self.assertNotIn("--cookie=", command)
def test_the_profile_owns_the_interface_and_the_pid_file(self):
command = self.driver().cookie_command("")
self.assertIn("--interface=vpn-campus", command)
self.assertIn("--pid-file=/run/erplibre-vpn/campus.pid", command)
def test_the_helper_fingerprint_wins_over_the_profile(self):
"""Le greffon rend l'empreinte contre laquelle il a AUTHENTIFIÉ ;
celle du profil peut dater."""
driver = self.driver(oc_servercert="sha256:vieille")
command = driver.cookie_command("sha256:fraiche")
self.assertIn("--servercert=sha256:fraiche", command)
self.assertNotIn("vieille", command)
def test_the_profile_fingerprint_serves_when_the_helper_gives_none(self):
driver = self.driver(oc_servercert="sha256:duprofil")
self.assertIn(
"--servercert=sha256:duprofil", driver.cookie_command("")
)
# -- le déroulé ----------------------------------------------------
def test_the_cookie_is_masked_as_soon_as_it_exists(self):
"""Il naît en cours de route : le masquage monté au démarrage ne le
connaît pas, et il va traverser des affichages."""
driver = self.driver()
runner = FakeRunner()
runner.stdout = '{"host": "h", "cookie": "S3cr3t-C00k13-long", '
runner.stdout += '"fingerprint": "sha256:aa"}'
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with redirect_stdout(io.StringIO()):
driver.up(runner)
self.assertEqual(runner.stdin, "S3cr3t-C00k13-long\n")
self.assertNotIn(
"S3cr3t-C00k13-long", runner.redactor("S3cr3t-C00k13-long")
)
def test_the_helper_runs_without_sudo(self):
"""Il ouvre un navigateur : sous sudo il perdrait l'affichage et le
trousseau de l'utilisateur."""
driver = self.driver()
runner = FakeRunner()
runner.stdout = '{"host": "h", "cookie": "abcdefghij"}'
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with redirect_stdout(io.StringIO()):
driver.up(runner)
helper_calls = [
call
for call in runner.calls
if "openconnect-sso" in call["cmd"] or "/bin/echo" in call["cmd"]
]
self.assertTrue(helper_calls, runner.calls)
self.assertIs(helper_calls[0]["sudo"], False)
def test_the_json_is_extracted_from_output_mixed_with_logs(self):
"""Le processus navigateur du greffon journalise sur sa SORTIE
STANDARD, mêlée au JSON final. `json.loads` sur le tout échoue même
quand l'authentification a RÉUSSI — c'est ce qui rendait
l'intégration inopérante, et aucun test ne le voyait parce qu'ils
nourrissaient tous un JSON propre.
"""
mixed = (
"2026-01-01 [info ] Browser started"
" startup_info=StartupInfo(url='https://gw/x')\n"
"2026-01-01 [debug] Cookie set name=JSESSIONID\n"
'{\n "host": "https://gw/Grp",\n'
' "cookie": "le-cookie-de-session",\n'
' "fingerprint": "pin-sha256:abc"\n}\n'
)
answer = OpenconnectDriver.extract_json(mixed)
self.assertEqual(answer["cookie"], "le-cookie-de-session")
self.assertEqual(answer["fingerprint"], "pin-sha256:abc")
def test_the_last_object_wins_over_a_brace_in_a_log_line(self):
text = '{"cookie": "vieux"}\nbruit {pas du json}\n{"cookie": "neuf"}'
self.assertEqual(
OpenconnectDriver.extract_json(text)["cookie"], "neuf"
)
def test_an_object_without_a_cookie_is_not_an_answer(self):
self.assertIsNone(OpenconnectDriver.extract_json('{"host": "h"}'))
self.assertIsNone(OpenconnectDriver.extract_json("rien"))
def test_the_helper_output_stays_visible(self):
"""Capturer sans dupliquer laisse l'utilisateur devant un terminal
muet pendant qu'une fenêtre attend son geste."""
self.assertTrue(
self.driver().helper_command().endswith("| tee /dev/stderr")
)
def test_a_timeout_says_what_expired(self):
"""Un délai dépassé n'est pas un refus d'identifiants : le dire
ferait chercher un mot de passe là où il manquait un geste."""
driver = self.driver()
runner = FakeRunner()
runner.stdout = ""
runner.capture_code = 124
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with redirect_stdout(io.StringIO()):
self.assertFalse(driver.up(runner))
self.assertTrue(
any("délai" in f for f in runner.failures), runner.failures
)
def test_strays_are_closed_after_a_failure(self):
"""Le greffon laisse ses navigateurs derrière lui quand il est tué,
et le suivant repartirait sur une machine encombrée."""
driver = self.driver()
runner = FakeRunner()
runner.stdout = ""
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with redirect_stdout(io.StringIO()):
driver.up(runner)
self.assertTrue(
any("pkill" in call["cmd"] for call in runner.calls),
runner.calls,
)
def test_the_interface_is_awaited_not_merely_observed(self):
"""`--background` fait sortir openconnect dès la session ouverte ;
`vpnc-script` crée l'interface un instant PLUS TARD. Constater tout
de suite déclare absent un tunnel qui monte — et accuse
`vpnc-script` d'être absent alors qu'il travaille.
Le montage réel est tombé exactement là : le tunnel portait son
adresse, et l'outil annonçait « montage incomplet » sans écrire son
fichier d'état, si bien que `status` le croyait déconnecté.
"""
driver = self.driver()
runner = FakeRunner()
runner.stdout = '{"host": "h", "cookie": "un-cookie-valide"}'
runner.code = 0 # le montage réussit
# Adresse INVENTÉE, dans la plage documentaire que le dépôt
# reconnaît : un test fige pour toujours l'exemple qu'il
# choisit, et prendre celle qu'un concentrateur venait
# d'attribuer est exactement le réflexe que la règle combat.
appearances = [[], [], ["192.0.2.10"]]
def slowly(iface, timeout=25, interval=0.5):
# L'interface n'apparaît qu'au troisième regard.
for value in appearances:
if value:
return value
appearances.pop(0)
return []
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with patch(
"script.vpn.drivers.openconnect." "wait_for_interface_address",
side_effect=slowly,
) as waited:
with redirect_stdout(io.StringIO()):
self.assertTrue(driver.up(runner))
self.assertTrue(waited.called, "l'interface n'est pas ATTENDUE")
self.assertFalse(runner.failures, runner.failures)
self.assertIn(
"iface", [w["key"] for w in runner.states], runner.states
)
def test_a_truly_absent_interface_is_still_a_failure(self):
"""L'attente ne doit pas rendre le diagnostic muet : une interface
qui n'arrive JAMAIS reste une panne, et vpnc-script en est la
cause la plus fréquente."""
driver = self.driver()
runner = FakeRunner()
runner.stdout = '{"host": "h", "cookie": "un-cookie-valide"}'
runner.code = 0 # le montage réussit
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with patch(
"script.vpn.drivers.openconnect." "wait_for_interface_address",
return_value=[],
):
with patch(
"script.vpn.drivers.openconnect.interface_exists",
return_value=False,
):
with redirect_stdout(io.StringIO()):
self.assertFalse(driver.up(runner))
self.assertTrue(
any("vpnc-script" in f for f in runner.failures),
runner.failures,
)
def test_unparsable_helper_output_is_refused(self):
driver = self.driver()
runner = FakeRunner()
runner.stdout = "ce n'est pas du JSON"
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with redirect_stdout(io.StringIO()):
self.assertFalse(driver.up(runner))
self.assertTrue(runner.failures)
def test_a_declared_helper_that_cannot_run_is_named(self):
"""Basculer en silence sur l'autre chemin ferait échouer le montage
sur « No SSO handler », trois étages au-dessus de la vraie cause."""
driver = self.driver(oc_sso_helper="/nowhere/openconnect-sso")
runner = FakeRunner()
with patch.object(
OpenconnectDriver, "ensure_ready", return_value=True
):
with redirect_stdout(io.StringIO()):
self.assertFalse(driver.up(runner))
self.assertTrue(
any("/nowhere/openconnect-sso" in f for f in runner.failures),
runner.failures,
)
class FakeRunner:
"""Un exécuteur qui n'exécute rien et retient ce qu'on lui a passé.
Le vrai `Runner` appelle sudo et lance openconnect : ce test porte sur
ce que le pilote DÉCIDE, pas sur ce que la machine fait.
"""
def __init__(self, dry_run=False):
self.dry_run = dry_run
self.quiet = False
self.failures = []
self.warnings = []
self.stdin = None
# Ce que la prochaine commande capturée rendra sur sa sortie, et la
# trace de tous les appels : le SSO délégué se juge sur ce que le
# pilote DEMANDE, pas sur ce que la machine ferait.
self.stdout = ""
self.capture_code = 0
# Code des commandes NON capturées — le montage. Non nul par
# défaut : la plupart des tests n'ont rien à vérifier après lui, et
# le laisser réussir les ferait attendre une interface qui
# n'existera jamais sur la machine de test.
self.code = 1
self.calls = []
self.states = []
self.redactor = lambda text: text
def add_secret(self, value):
value = str(value or "")
if len(value) < 8:
return
previous = self.redactor
self.redactor = lambda text: previous(text).replace(value, "***")
def info(self, message):
pass
def ok(self, message):
pass
def warn(self, message):
self.warnings.append(message)
def fail(self, message):
self.failures.append(message)
def propose(self, constat, command, sudo=True, question=None):
return False
def mkdir(self, path, mode):
pass
def write(self, path, content, mode=None):
# Les fichiers d'état sont nommés « <profil>.<clé> » : c'est ce que
# `status` relit dans un autre processus, donc ce qu'un test doit
# pouvoir vérifier.
self.states.append(
{"key": path.rsplit(".", 1)[-1], "path": path, "value": content}
)
def remove(self, path):
pass
def cmd(self, label, command, **kwargs):
self.calls.append(
{
"label": label,
"cmd": command,
"sudo": kwargs.get("sudo"),
"capture": kwargs.get("capture"),
"secret_stdin": kwargs.get("secret_stdin", False),
}
)
if "stdin" in kwargs:
self.stdin = kwargs["stdin"]
if kwargs.get("capture"):
# Le greffon rend ce qu'on lui a fait dire, sous le code qu'on
# lui a fait rendre.
return self.capture_code, self.stdout
return self.code, ""
if __name__ == "__main__":
unittest.main()

View file

@ -666,6 +666,57 @@ class DownOrder(unittest.TestCase):
self.assertIn("rm -rf -- /dev/shm/erplibre-vpn/acme", joined)
class NoCommandInheritsTheTerminal(unittest.TestCase):
"""L'entrée standard d'une commande lancée par le `Runner` est /dev/null,
ou le contenu qu'on lui a donné — jamais le terminal de l'appelant.
Une commande qui tient un terminal sur son entrée peut appeler
`tcsetattr`. Hors du groupe de processus d'avant-plan, elle reçoit alors
SIGTTOU et passe à l'état T : ni SIGINT ni SIGTERM ne l'en sortent, elle
garde les verrous déjà pris, et seul root peut la relancer par SIGCONT.
Un gestionnaire de paquets figé de la sorte bloque tout apt de la
machine.
"""
def _runner(self):
return Runner(dry_run=False, quiet=True)
def test_stdin_is_dev_null(self):
code, out = self._runner().cmd(
"à quoi mène l'entrée standard",
"readlink /proc/self/fd/0",
sudo=False,
capture=True,
)
self.assertEqual(code, 0)
self.assertEqual(out.strip(), "/dev/null")
def test_stdin_is_not_a_terminal(self):
"""La question que se pose le programme lancé, et non le chemin du
descripteur : c'est `isatty` qui décide d'un `tcsetattr`."""
code, _ = self._runner().cmd(
"l'entrée est-elle un terminal",
"test -t 0",
sudo=False,
check=False,
capture=True,
)
self.assertEqual(code, 1)
def test_given_content_still_reaches_the_command(self):
"""Le canal des secrets reste ouvert : couper l'entrée par défaut ne
doit pas couper celle qu'on fournit."""
code, out = self._runner().cmd(
"relire ce qu'on donne",
"cat",
stdin="une ligne\n",
sudo=False,
capture=True,
)
self.assertEqual(code, 0)
self.assertEqual(out, "une ligne\n")
class MarkedBlocks(unittest.TestCase):
"""`replace_block` décide de ce qu'on écrit dans /etc/ipsec.conf.
Elle est pure : elle se juge sans /etc."""