[ADD] vpn install : proposer et poser le greffon SSO

openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.

La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.

--- EN ---

openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.

The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-04 07:37:49 -04:00
parent 9250d4d401
commit 5aabbe4b87
10 changed files with 501 additions and 4 deletions

View file

@ -32,6 +32,9 @@ Pilotes connus :
sshuttle sshuttle (sur RHEL/Rocky/Alma : dépôt EPEL requis)
Sans argument : tous les pilotes.
--sso installe EN PLUS le greffon d'authentification par formulaire web
(openconnect-sso). Voir la section « greffon SSO » plus bas.
USAGE
}
@ -197,6 +200,165 @@ verify() {
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() {
@ -205,8 +367,17 @@ 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}}"
@ -216,6 +387,19 @@ main() {
verify "$driver" "$fam"
done
disable_autostart
# La question se posait dans le menu seulement, et l'invocation directe
# — celle que le README documente — n'offrait rien : on repartait sans
# le greffon sans avoir su qu'il existait.
#
# `[ -t 0 ]` : sans terminal il n'y a personne pour répondre, et un
# déploiement automatisé ne doit pas se bloquer sur une invite. Le
# drapeau reste alors le seul moyen de le demander.
if [ "$with_sso" -eq 0 ] && [ -t 0 ] && sso_is_wanted; then
with_sso=1
fi
if [ "$with_sso" -eq 1 ]; then
install_sso "$fam"
fi
log "Terminé. Monter un tunnel : ./script/vpn/vpn.py up --profile <nom>"
}

View file

@ -11763,6 +11763,26 @@ TRANSLATIONS = {
"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."
),
},
"Install it as well? (y/N)": {
"fr": "L'installer aussi ? (o/N)",
"en": "Install it as well? (y/N)",
},
"SSO helper": {
"fr": "greffon SSO",
"en": "SSO helper",

View file

@ -82,6 +82,15 @@ ANYCONNECT_NEXT_STEP = (
" 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."
)
MASTER_PASSWORD_WARNING = (
"The vault MASTER password is stored in the configuration in clear"
" text. Remove it and type it on demand."
@ -95,6 +104,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`.
@ -266,8 +288,16 @@ 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

View file

@ -350,6 +350,62 @@ openconnect --protocol=anyconnect --usergroup=<GROUPE> \
| 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

View file

@ -177,6 +177,34 @@ openconnect --protocol=anyconnect --usergroup=<GROUPE> \
| 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

View file

@ -170,6 +170,33 @@ openconnect --protocol=anyconnect --usergroup=<GROUPE> \
| 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

View file

@ -149,6 +149,19 @@ class Runner:
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:
@ -173,6 +186,11 @@ 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,
@ -180,6 +198,12 @@ class Runner:
)
code, out = proc.returncode, proc.stdout or ""
except subprocess.TimeoutExpired:
# Le délai rend la main à l'appelant ; il ne garantit pas que
# la commande soit morte. `shell=True` met un shell entre nous
# et le vrai travail, et subprocess ne tue que ce shell — un
# `sudo apt-get` lancé par lui devient orphelin et continue,
# verrous compris. D'où le code 124 et un échec ANNONCÉ plutôt
# qu'un silence : la suite se juge sur un état inconnu.
code, out = 124, ""
self.fail(f"{label} : délai dépassé ({timeout} s)")
return code, out

View file

@ -281,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
@ -343,6 +353,15 @@ def build_parser():
choices=sorted(DRIVERS),
help="Se limiter à ce pilote",
)
if name == "install":
sp.add_argument(
"--with-sso",
action="store_true",
help=(
"Installer aussi le greffon d'authentification par"
" formulaire web (openconnect-sso)"
),
)
return parser

View file

@ -597,6 +597,64 @@ 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)
class FromPreset(MenuBase):
"""Le chemin « créer un profil à partir d'un préréglage ».

View file

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