diff --git a/script/install/install_vpn.sh b/script/install/install_vpn.sh index 9837c83..a1f851b 100755 --- a/script/install/install_vpn.sh +++ b/script/install/install_vpn.sh @@ -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 " } diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 10e37b5..f3b858e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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", diff --git a/script/todo/vpn_menu.py b/script/todo/vpn_menu.py index 48305e4..76246a5 100644 --- a/script/todo/vpn_menu.py +++ b/script/todo/vpn_menu.py @@ -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 diff --git a/script/vpn/README.base.md b/script/vpn/README.base.md index 904fb39..ae13a87 100644 --- a/script/vpn/README.base.md +++ b/script/vpn/README.base.md @@ -350,6 +350,62 @@ openconnect --protocol=anyconnect --usergroup= \ | 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: + + +### 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 +``` + + +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. + + +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. + ### Delegating the web form, keeping the tunnel diff --git a/script/vpn/README.fr.md b/script/vpn/README.fr.md index e2dabe0..f062551 100644 --- a/script/vpn/README.fr.md +++ b/script/vpn/README.fr.md @@ -177,6 +177,34 @@ openconnect --protocol=anyconnect --usergroup= \ | 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 diff --git a/script/vpn/README.md b/script/vpn/README.md index d0a4760..fa966ef 100644 --- a/script/vpn/README.md +++ b/script/vpn/README.md @@ -170,6 +170,33 @@ openconnect --protocol=anyconnect --usergroup= \ | 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 diff --git a/script/vpn/runner.py b/script/vpn/runner.py index d9ba520..07b1388 100644 --- a/script/vpn/runner.py +++ b/script/vpn/runner.py @@ -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 diff --git a/script/vpn/vpn.py b/script/vpn/vpn.py index ff9a8cf..c570def 100755 --- a/script/vpn/vpn.py +++ b/script/vpn/vpn.py @@ -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 diff --git a/test/test_vpn_menu.py b/test/test_vpn_menu.py index f0928f3..0cf21e2 100644 --- a/test/test_vpn_menu.py +++ b/test/test_vpn_menu.py @@ -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 ». diff --git a/test/test_vpn_render.py b/test/test_vpn_render.py index 3807b36..f49d3d4 100644 --- a/test/test_vpn_render.py +++ b/test/test_vpn_render.py @@ -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."""