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:
commit
8231275e89
25 changed files with 4253 additions and 99 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
conf/vpn_presets/example-ssl-vpn.json
Normal file
16
conf/vpn_presets/example-ssl-vpn.json
Normal 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
5
private/.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,7 +298,13 @@ class VpnMenuMixin:
|
|||
|
||||
def _vpn_disconnect(self):
|
||||
name = self._vpn_select_profile()
|
||||
if 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):
|
||||
|
|
@ -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,9 +525,24 @@ 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 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
151
script/vpn/anyconnect_xml.py
Normal file
151
script/vpn/anyconnect_xml.py
Normal 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}")
|
||||
|
|
@ -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()
|
||||
"""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é (état /run)",
|
||||
bool(iface),
|
||||
f"interface {iface}" if iface else "aucun état : non connecté",
|
||||
"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()
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
monte = (
|
||||
self._up_delegated(runner)
|
||||
if self.sso_helper
|
||||
else self._up_external_browser(runner)
|
||||
)
|
||||
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_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
190
script/vpn/presets.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
204
test/test_vpn_anyconnect_xml.py
Normal file
204
test/test_vpn_anyconnect_xml.py
Normal 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()
|
||||
|
|
@ -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 :
|
||||
|
|
|
|||
|
|
@ -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
781
test/test_vpn_presets.py
Normal 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()
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue