diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index 6e9c782..ba55b91 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -88,7 +88,17 @@ class KdbxManager: # /chemin » — la question avant ce dont elle parle. print(f"{t('kdbx_vault_is')} {kdbx_file_path}", flush=True) for _ in range(attempts): - password = getpass.getpass(prompt=t("kdbx_ask_password")) + # Sans terminal, `getpass` lève — `termios.error` quand il ne + # peut pas couper l'écho, `EOFError` quand l'entrée standard + # est déjà fermée. Renoncer proprement plutôt que de laisser la + # trace tuer le CLI : l'appelant sait dire « coffre non + # joignable », et un script lancé sans terminal ne peut de + # toute façon pas répondre. + try: + password = getpass.getpass(prompt=t("kdbx_ask_password")) + except (EOFError, OSError): + print(t("kdbx_no_terminal")) + return None if not password: print(t("kdbx_give_up")) return None diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f7e4911..10e37b5 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -11688,6 +11688,159 @@ 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." + ), + }, + "SSO helper": { + "fr": "greffon SSO", + "en": "SSO helper", + }, + "Connection group in the URL \u2014 of an AnyConnect" + " profile (optional)": { + "fr": ( + "Groupe de connexion dans l'URL \u2014 d'un profil" + " AnyConnect (facultatif)" + ), + "en": ( + "Connection group in the URL \u2014 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.", diff --git a/script/todo/vpn_menu.py b/script/todo/vpn_menu.py index 2457e39..48305e4 100644 --- a/script/todo/vpn_menu.py +++ b/script/todo/vpn_menu.py @@ -19,11 +19,12 @@ Deux chemins d'exécution, pour une raison : """ import getpass +import os import click 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 +49,39 @@ 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." +) + +# 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." +) + MASTER_PASSWORD_WARNING = ( "The vault MASTER password is stored in the configuration in clear" " text. Remove it and type it on demand." @@ -106,6 +140,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 +176,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 !")) @@ -247,7 +295,103 @@ 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_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 = input(f"{t('Path to the .xml profile')} : ").strip() + if not path: + return + try: + found = anyconnect_xml.parse_file(os.path.expanduser(path)) + except anyconnect_xml.ProfileXmlError as error: + print(f"\n✗ {error}") + return + + print() + for preset in found: + print(f" {presets.label(preset)}") + print(f" {t('Gateway')} : {preset['server']}") + print( + f" {t('Connection group')} :" + f" {preset['oc_usergroup'] or t('none')}" + ) + stem = os.path.splitext(os.path.basename(path))[0] + stem = presets.slug_stem(stem) + written = presets.save(found, stem) + print(f"\n✓ {t('Presets written: ')}{written}") + print(f" {t(ANYCONNECT_NEXT_STEP)}") + + def _vpn_edit_profile(self, seed=None): """Crée ou modifie un profil, quelle que soit la technologie. Les questions viennent du PILOTE (`form_fields`) : ce menu ne sait @@ -257,15 +401,30 @@ class VpnMenuMixin: Une réponse vide garde la valeur actuelle : modifier une seule route ne doit pas obliger à ressaisir tout le reste. + + `seed` amorce le formulaire avec un profil déjà rempli — un + préréglage de site. Il porte alors le nom ET la technologie, donc les + deux questions correspondantes ne sont pas posées : le préréglage y a + déjà répondu, et redemander « quelle technologie ? » invite à + contredire le seul champ qu'on ne doit pas changer. """ - name = input(f"{t('Profile name (lowercase, digits, - or _)')} : ") - name = name.strip() - if not name: - return - current = profiles.load(name) or {"name": name} - driver_cls = self._vpn_pick_driver(current.get("driver")) - if driver_cls is None: - return + if seed is not None: + current = dict(seed) + name = current["name"] + driver_cls = get_driver(current.get("driver")) + if driver_cls is None: + print(f"✗ {t('Unknown driver: ')}{current.get('driver')}") + return + else: + name = input( + f"{t('Profile name (lowercase, digits, - or _)')} : " + ).strip() + if not name: + return + current = profiles.load(name) or {"name": name} + driver_cls = self._vpn_pick_driver(current.get("driver")) + if driver_cls is None: + return # Les défauts DU PILOTE CHOISI, pour que chaque question ait un # défaut sensé même sur un profil qui change de technologie. @@ -440,7 +599,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 diff --git a/script/vpn/README.base.md b/script/vpn/README.base.md index ce8fcee..904fb39 100644 --- a/script/vpn/README.base.md +++ b/script/vpn/README.base.md @@ -112,6 +112,339 @@ home et son mot de passe maître est le vôtre. Chaque étape privilégiée appe | `/run/erplibre-vpn/.*` | 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` | + +## 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. + + +## 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. + + +## 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: + + +## 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 | 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. + + +| 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. + + +## 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, `` is the path and +`` is only a display label — despite the tag name, it is not a +hostname; `` 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. + + +## 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, `` est le chemin et +`` n'est qu'un libellé d'affichage — malgré son nom, ce n'est pas +un nom d'hôte ; c'est `` 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: 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=` | **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: + + +## 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=` | **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` 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: + + +`--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= \ + --authenticate --dump-http-traffic 2>&1 \ + | grep -E 'sso-v2|external-browser|No SSO handler' +``` + + +### 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: + + +### 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 | + + +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. + + +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. + ## The three security rules diff --git a/script/vpn/README.fr.md b/script/vpn/README.fr.md index cb8e5b2..e2dabe0 100644 --- a/script/vpn/README.fr.md +++ b/script/vpn/README.fr.md @@ -54,6 +54,180 @@ home et son mot de passe maître est le vôtre. Chaque étape privilégiée appe | `/run/erplibre-vpn/.*` | 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, `` est le chemin et +`` n'est qu'un libellé d'affichage — malgré son nom, ce n'est pas +un nom d'hôte ; c'est `` 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=` | **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= \ + --authenticate --dump-http-traffic 2>&1 \ + | grep -E 'sso-v2|external-browser|No SSO handler' +``` + +### 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//cmdline` est lisible par tout diff --git a/script/vpn/README.md b/script/vpn/README.md index 8c8f04a..d0a4760 100644 --- a/script/vpn/README.md +++ b/script/vpn/README.md @@ -53,6 +53,169 @@ own, and `--dry-run` shows every one of them without running any. | `/run/erplibre-vpn/.*` | 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, `` is the path and +`` is only a display label — despite the tag name, it is not a +hostname; `` 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=` | **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= \ + --authenticate --dump-http-traffic 2>&1 \ + | grep -E 'sso-v2|external-browser|No SSO handler' +``` + +### 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//cmdline` is readable by every diff --git a/script/vpn/drivers/base.py b/script/vpn/drivers/base.py index 46a9e78..2fb5840 100644 --- a/script/vpn/drivers/base.py +++ b/script/vpn/drivers/base.py @@ -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 ? diff --git a/script/vpn/drivers/openconnect.py b/script/vpn/drivers/openconnect.py index bda50ad..b1f7f1d 100644 --- a/script/vpn/drivers/openconnect.py +++ b/script/vpn/drivers/openconnect.py @@ -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 `` d'un profil AnyConnect (`.xml`), à ne pas confondre +avec son ``, 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 +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 — of an AnyConnect" + " profile (optional)", + "text", + False, + ), ( "oc_authgroup", "Authentication group / realm (optional)", @@ -119,6 +238,25 @@ class OpenconnectDriver(VpnDriver): "path", True, ), + ( + "oc_sso_helper", + "SSO helper driving a real browser (empty: openconnect-sso from" + " the PATH)", + "path", + True, + ), + ( + "oc_ac_version", + "AnyConnect version announced to the gateway", + "text", + True, + ), + ( + "oc_password_len", + "Password characters the server compares (0: no limit)", + "int", + True, + ), ("port", "HTTPS port", "int", True), ) @@ -148,6 +286,13 @@ class OpenconnectDriver(VpnDriver): f"Protocole inconnu : « {protocol} »." f" Connus : {', '.join(PROTOCOLS)}." ) + valid.text( + profile, + "oc_usergroup", + "Groupe de connexion (chemin d'URL)", + required=False, + pattern=valid.URL_PATH_RE, + ) valid.text( profile, "oc_authgroup", @@ -161,12 +306,29 @@ class OpenconnectDriver(VpnDriver): required=False, ) valid.port(profile, "port", "Port HTTPS") + # 128 comme plafond : au-delà, ce n'est plus une borne d'annuaire + # mais une valeur tapée de travers, et l'accepter ferait taire + # l'avertissement pour tous les mots de passe. + valid.integer( + profile, + "oc_password_len", + "Longueur de mot de passe comparée", + 0, + 128, + ) valid.path( profile, "oc_external_browser", "Programme navigateur", required=False, ) + valid.path(profile, "oc_sso_helper", "Greffon SSO", required=False) + valid.text( + profile, + "oc_ac_version", + "Version AnyConnect annoncée", + required=False, + ) @property def browser(self): @@ -177,6 +339,279 @@ class OpenconnectDriver(VpnDriver): sur son port 29786.""" return self.profile.get("oc_external_browser") or "echo" + @property + def password_len(self): + """Longueur comparée par le concentrateur, 0 si aucune limite.""" + try: + return int(self.profile.get("oc_password_len") or 0) + except (TypeError, ValueError): + return 0 + + @classmethod + def wants_secrets(cls, profile): + """Rien à lire au coffre en SSO : c'est le fournisseur d'identité + qui authentifie, dans un navigateur, et aucun mot de passe stocké + n'entre dans l'échange.""" + return not profile.get("oc_sso") + + def secret_notes(self): + """La borne de longueur, dite avant la saisie du mot de passe. + + En SSO il n'y a pas de mot de passe à déposer : annoncer une borne + de longueur y serait une consigne sans objet. + """ + limit = self.password_len + if not limit or self.profile.get("oc_sso"): + return [] + return [ + t( + "This gateway compares only the first {limit} characters of" + " the password: store just those, a longer one is refused." + ).format(limit=limit) + ] + + # ------------------------------------------------------------------ + # SSO délégué : le greffon authentifie, ce pilote monte + # ------------------------------------------------------------------ + @property + def sso_helper(self): + """Chemin du greffon SSO, "" s'il est introuvable. + + Le champ du profil d'abord — c'est le seul moyen de désigner une + installation en environnement virtuel dédié. Puis le PATH, puis les + emplacements conventionnels. + + Un chemin déclaré mais inexécutable rend "" ; c'est `up` qui le DIT, + plutôt que de se replier sans bruit sur un autre chemin + d'authentification. + """ + declared = self.profile.get("oc_sso_helper") or "" + if declared: + path = os.path.expanduser(declared) + return path if os.access(path, os.X_OK) else "" + found = shutil.which(SSO_HELPER_NAME) + if found: + return found + for candidate in SSO_HELPER_PATHS: + path = os.path.expanduser(candidate) + if os.access(path, os.X_OK): + return path + return "" + + @property + def ac_version(self): + return self.profile.get("oc_ac_version") or "4.7.00136" + + def helper_command(self): + """La ligne du greffon : elle ne porte AUCUN secret. + + Les variables de rendu sont préfixées plutôt que posées dans + l'environnement de ce processus : la commande affichée est alors + exactement celle qui s'exécute, ce que `--dry-run` promet. + """ + p = self.profile + target = p["server"] + if p.get("oc_usergroup"): + target = f"{target}/{p['oc_usergroup']}" + parts = [] + for key, value in SSO_RENDER_ENV.items(): + if key not in os.environ: + parts.append(f"{key}={shlex.quote(value)}") + parts += [ + shlex.quote(self.sso_helper), + "--authenticate", + "json", + f"--server={shlex.quote(target)}", + f"--ac-version={shlex.quote(self.ac_version)}", + ] + if p.get("oc_authgroup"): + parts.append(f"--authgroup={shlex.quote(p['oc_authgroup'])}") + # `| tee /dev/stderr` : la sortie standard du greffon est à la fois + # LUE par nous et VUE par l'utilisateur. + # + # Le processus navigateur du greffon journalise sur sa SORTIE + # STANDARD — il n'a pas de configuration propre et hérite du + # journaliseur par défaut de structlog, qui imprime là. Le parent, + # lui, journalise sur l'erreur standard. Capturer la sortie sans la + # dupliquer laisse donc l'utilisateur devant un terminal muet + # pendant qu'une fenêtre attend son geste, et mélange ces lignes au + # JSON qu'on doit lire. + return " ".join(parts) + " | tee /dev/stderr" + + @staticmethod + def extract_json(text): + """Le dernier objet JSON de `text`, ou None. + + La sortie du greffon MÊLE ses lignes de journal au JSON final : + `json.loads` sur le tout échoue même quand l'authentification a + réussi. On isole donc les accolades équilibrées, et on retient le + dernier objet qui parse et qui porte un cookie — le dernier, parce + qu'une ligne de journal peut elle aussi contenir des accolades. + """ + best = None + depth = 0 + start = -1 + for index, char in enumerate(text): + if char == "{": + if depth == 0: + start = index + depth += 1 + elif char == "}" and depth: + depth -= 1 + if depth == 0 and start >= 0: + try: + candidate = json.loads(text[start : index + 1]) + except ValueError: + continue + if isinstance(candidate, dict) and candidate.get("cookie"): + best = candidate + return best + + def _kill_helper_strays(self, runner): + """Les navigateurs que le greffon laisse derrière lui. + + Le greffon lance son navigateur dans des processus séparés. Quand + il est tué — délai dépassé — eux survivent, et le suivant repart + sur une machine déjà encombrée. Reconnaissables sans ambiguïté : + QtWebEngine porte `--application-name=openconnect-sso`. + """ + # `[Q]t…` et non `Qt…` : `pkill -f` compare le motif à TOUTES les + # lignes de commande, y compris celle du shell qui le porte. Le + # motif écrit en clair s'y trouve donc lui-même, et pkill tuerait + # son propre parent avant d'avoir servi. Entre crochets, le motif + # désigne toujours « Qt… » mais ne se reconnaît plus dans le texte + # qui le contient. + runner.cmd( + "fermer les navigateurs laissés par le greffon", + "pkill -f '[Q]tWebEngineProcess.*application-name=openconnect-sso'", + sudo=False, + check=False, + ) + + def authenticate_with_helper(self, runner): + """(cookie, empreinte) rendus par le greffon, ou (None, None). + + Sans sudo, et c'est essentiel : le greffon ouvre un navigateur, donc + il lui faut l'affichage et le trousseau de l'UTILISATEUR. Sous sudo + il perdrait les deux. + + `capture="stdout"` : le JSON est LU, les messages de progression du + greffon restent VUS. L'authentification réclame un geste humain — + taper un mot de passe, approuver une notification — et une attente + muette de plusieurs minutes ressemble à un blocage. + """ + helper = self.sso_helper + if not helper: + report = runner.warn if runner.dry_run else runner.fail + report( + "Greffon SSO introuvable. Ce concentrateur exige un" + " navigateur intégré, que l'openconnect des distributions" + " n'a pas. Installer openconnect-sso, ou renseigner" + " oc_sso_helper avec son chemin." + ) + return None, None + self._explain_the_web_form(runner) + code, out = runner.cmd( + "authentifier par formulaire web (navigateur du greffon)", + self.helper_command(), + sudo=False, + check=False, + capture="stdout", + timeout=600, + ) + if runner.dry_run: + runner.info( + " (à blanc : le greffon rendrait un cookie de session" + " et l'empreinte du certificat qu'il a vu)" + ) + # L'empreinte du profil, faute de mieux : à blanc on ne peut pas + # savoir celle que le greffon verrait, et une empreinte n'est + # pas un secret — lui donner le marqueur des secrets ferait + # lire au plan une nature qu'elle n'a pas. + return PLACEHOLDER, self.profile.get("oc_servercert") or "" + # Le VERDICT est le cookie, pas le code de retour : la commande est + # un tube, et son code est celui de `tee`. Un cookie obtenu vaut + # succès, quoi qu'ait rendu le tube. + answer = self.extract_json(out) + if answer is None: + if code == 124: + runner.fail( + "Le formulaire web n'a pas abouti dans le délai" + " imparti (10 min) : personne ne l'a complété, ou la" + " fenêtre ne s'est jamais affichée." + ) + else: + runner.fail( + f"Le greffon SSO n'a rendu aucun cookie (code {code})." + ) + self._explain_helper_failure(runner) + self._kill_helper_strays(runner) + return None, None + cookie = answer["cookie"] + fingerprint = answer.get("fingerprint") or "" + # Masqué DÈS qu'il existe : ce cookie ouvre le tunnel à lui seul, et + # il va traverser des affichages et un enregistrement d'opérations. + runner.add_secret(cookie) + runner.ok("Authentification web réussie, cookie de session obtenu.") + return cookie, fingerprint + + def cookie_command(self, fingerprint): + """Le montage à partir d'un cookie. Le cookie N'Y EST PAS : il + arrive par l'entrée standard, via `--cookie-on-stdin`. + + L'identité annoncée est celle sous laquelle le cookie a été délivré, + sinon le concentrateur le refuse. L'empreinte du greffon prime sur + celle du profil : c'est celle contre laquelle il a authentifié. + """ + p = self.profile + pinned = fingerprint or p.get("oc_servercert") or "" + parts = [ + "openconnect", + f"--protocol={shlex.quote(p['oc_protocol'])}", + f"--useragent={shlex.quote(f'AnyConnect Linux_64 {self.ac_version}')}", + f"--version-string={shlex.quote(self.ac_version)}", + "--cookie-on-stdin", + "--non-inter", + "--background", + f"--pid-file={shlex.quote(self.pid_file)}", + f"--interface={shlex.quote(self.iface)}", + ] + if p.get("oc_usergroup"): + parts.append(f"--usergroup={shlex.quote(p['oc_usergroup'])}") + if pinned: + parts.append(f"--servercert={shlex.quote(pinned)}") + parts.append(shlex.quote(f"{p['server']}:{p['port']}")) + return " ".join(parts) + + def _explain_the_web_form(self, runner): + """Dit ce qui va s'ouvrir, AVANT que ça s'ouvre. + + Le greffon lance un navigateur et attend, silencieusement du point + de vue du terminal. Sans cette annonce, la fenêtre surgit sans + raison apparente et l'attente ressemble à un blocage. + """ + runner.info( + " Une fenêtre de navigateur va s'ouvrir pour" + " l'authentification. La compléter à l'écran ; le tunnel monte" + " ensuite tout seul." + ) + runner.info( + " Le mot de passe et le second facteur ne passent que par" + " cette fenêtre : ni ce terminal ni le coffre ne les voient." + ) + + def _explain_helper_failure(self, runner): + runner.info( + " → Fenêtre morte en cours de route ? Le navigateur" + " embarqué échoue au rendu sur une machine sans accélération" + f" exploitable. Relancer avec {SSO_RENDER_ENV_HINT}." + ) + runner.info( + " → Identifiants refusés ? Ce service authentifie par le" + " fournisseur d'identité, qui prend le mot de passe ENTIER." + " Une limite oc_password_len ne s'applique PAS ici." + ) + def command(self): """La ligne de commande, dans l'une de ses deux formes. @@ -203,6 +638,8 @@ class OpenconnectDriver(VpnDriver): f"--pid-file={shlex.quote(self.pid_file)}", f"--interface={shlex.quote(self.iface)}", ] + if p.get("oc_usergroup"): + parts.append(f"--usergroup={shlex.quote(p['oc_usergroup'])}") if p.get("oc_authgroup"): parts.append(f"--authgroup={shlex.quote(p['oc_authgroup'])}") if p.get("oc_servercert"): @@ -212,6 +649,13 @@ class OpenconnectDriver(VpnDriver): # ------------------------------------------------------------------ def up(self, runner): + """Trois chemins, un seul aboutissement. + + Le choix est explicite et se lit ici : greffon si le profil est en + SSO et qu'un greffon existe, `--external-browser` en SSO sans + greffon, mot de passe sinon. Un greffon sait faire les DEUX formes + de SSO ; `--external-browser` n'en fait qu'une. D'où l'ordre. + """ p = self.profile if not self.ensure_ready(runner): return False @@ -220,59 +664,170 @@ class OpenconnectDriver(VpnDriver): self.prepare_dirs(runner, secrets=False) if p["oc_sso"]: - self._explain_the_sso_round_trip(runner) - mot_de_passe, delai = None, 300 - else: - if not self.secrets.get("password"): + # Un greffon DÉCLARÉ mais inexécutable est une erreur, pas une + # invitation à prendre l'autre chemin : basculer en silence + # ferait échouer le montage sur « No SSO handler », trois + # étages au-dessus de la vraie cause — un chemin fautif. + declared = p.get("oc_sso_helper") or "" + if declared and not self.sso_helper: report = runner.warn if runner.dry_run else runner.fail report( - "Aucun mot de passe dans le coffre, et le profil n'est" - " pas en SSO : les déposer, ou cocher « formulaire web »." + f"Greffon SSO déclaré mais inexécutable : {declared}." + " Corriger oc_sso_helper, ou le vider pour chercher" + " openconnect-sso dans le PATH." ) if not runner.dry_run: return False - mot_de_passe = self.secrets.get("password", "") + "\n" - delai = 120 - - code, _ = runner.cmd( - f"ouvrir la session {p['oc_protocol']} sur {p['server']}", - self.command(), - stdin=mot_de_passe, - secret_stdin=bool(mot_de_passe), - check=False, - timeout=delai, - ) - if code != 0 and not runner.dry_run: - runner.fail("openconnect a refusé.") - if p["oc_sso"]: - runner.info( - " → En SSO, les deux causes sont : la redirection" - " n'est jamais revenue sur le port 29786 (redirection" - " ssh en place ?), ou le délai de 5 minutes a expiré" - " avant la fin de l'authentification." - ) - else: - runner.info( - " → Causes usuelles : identifiants, certificat" - " serveur non épinglé (recopier la ligne" - " « --servercert sha256:… » ci-dessus dans le champ" - " oc_servercert), groupe d'authentification absent." - ) + monte = ( + self._up_delegated(runner) + if self.sso_helper + else self._up_external_browser(runner) + ) + else: + monte = self._up_password(runner) + if not monte: return False if runner.dry_run: runner.info(f" (à blanc : l'interface serait {self.iface})") return True + return self._settle(runner) - if not interface_exists(self.iface): + def _up_delegated(self, runner): + """Le greffon authentifie, ce pilote monte. Voir l'en-tête.""" + cookie, fingerprint = self.authenticate_with_helper(runner) + if cookie is None: + return False + code, _ = runner.cmd( + f"monter le tunnel sur {self.profile['server']} avec le cookie", + self.cookie_command(fingerprint), + stdin=f"{cookie}\n", + secret_stdin=True, + check=False, + timeout=120, + ) + if code != 0 and not runner.dry_run: + runner.fail("openconnect a refusé le cookie.") + runner.info( + " → Un cookie de session est à usage unique et de" + " courte durée. S'il a été délivré à un client annonçant" + " une autre version que oc_ac_version, ou si le montage" + " arrive trop tard, il est refusé : relancer." + ) + return False + return True + + def _up_external_browser(self, runner): + """SSO sans greffon : openconnect s'en charge, s'il peut.""" + self._explain_the_sso_round_trip(runner) + # Entrée standard VIDE, et non héritée du terminal. En SSO + # openconnect ne lit jamais l'entrée standard : il attend la + # redirection sur son port. Mais un concentrateur qui ne fait PAS de + # SSO répond par un formulaire mot de passe, et openconnect se met + # alors à le demander — sur le terminal, puisqu'il en a un, et sans + # que `--non-inter` soit là pour l'en empêcher (en SSO la + # redirection EST l'interaction). + # + # L'attente ressemble alors à l'attente normale du SSO, et chaque + # essai revient en « Login failed » jusqu'au délai. Une entrée + # standard fermée transforme cette boucle en un échec immédiat, que + # le message d'aide ci-dessous explique. + code, _ = runner.cmd( + f"ouvrir la session {self.profile['oc_protocol']} sur" + f" {self.profile['server']}", + self.command(), + stdin="", + check=False, + timeout=300, + ) + if code != 0 and not runner.dry_run: + runner.fail("openconnect a refusé.") + # Les causes sont classées par ce que le message d'openconnect + # permet de reconnaître, et non par fréquence : chacune se lit + # sur une ligne précise de la sortie ci-dessus. + runner.info( + " → « No SSO handler » : ce concentrateur exige le" + " navigateur INTÉGRÉ (sso-v2), et l'openconnect des" + " distributions est bâti sans. `--external-browser` ne" + " s'applique que si le serveur annonce le mode navigateur" + " externe. Installer openconnect-sso, ou renseigner" + " oc_sso_helper : ce pilote délèguera l'étape web." + ) + runner.info( + " → « username and password » demandé : alors le" + " service ne fait PAS de SSO. Décocher « formulaire web »" + " (oc_sso) et déposer le mot de passe." + ) + runner.info( + " → Sinon : la redirection n'est jamais revenue sur le" + " port 29786 (redirection ssh en place ?), ou le délai de" + " 5 minutes a expiré avant la fin de l'authentification." + ) + return False + return True + + def _up_password(self, runner): + """Mot de passe du coffre, par l'entrée standard.""" + if not self.secrets.get("password"): + report = runner.warn if runner.dry_run else runner.fail + report( + "Aucun mot de passe dans le coffre, et le profil n'est pas" + " en SSO : les déposer, ou cocher « formulaire web »." + ) + if not runner.dry_run: + return False + secret = self.secrets.get("password", "") + # Comparé, jamais tronqué : le coffre décide de ce qui part. + # Comparer des LONGUEURS ne divulgue rien, et c'est le seul endroit + # où le mot de passe déposé et la borne déclarée sont tous deux + # connus. + limite = self.password_len + if limite and secret != PLACEHOLDER and len(secret) > limite: + runner.warn( + f"Le mot de passe du coffre fait {len(secret)} caractères" + f" et ce concentrateur n'en compare que {limite} : n'en" + f" déposer que {limite}." + ) + code, _ = runner.cmd( + f"ouvrir la session {self.profile['oc_protocol']} sur" + f" {self.profile['server']}", + self.command(), + stdin=f"{secret}\n", + secret_stdin=True, + check=False, + timeout=120, + ) + if code != 0 and not runner.dry_run: + runner.fail("openconnect a refusé.") + runner.info( + " → Causes usuelles : identifiants, certificat serveur" + " non épinglé (recopier la ligne « --servercert sha256:… »" + " ci-dessus dans le champ oc_servercert), groupe" + " d'authentification absent." + ) + return False + return True + + def _settle(self, runner): + """Ce qui suit un montage réussi, quel que soit le chemin pris. + + On ATTEND l'interface au lieu de la constater. `--background` fait + sortir openconnect dès que la session est ouverte, et c'est + `vpnc-script` qui crée l'interface et lui pose son adresse, un + instant plus tard. Regarder tout de suite déclare absent un tunnel + qui monte — et accuse `vpnc-script` d'être absent alors qu'il est + précisément en train de travailler. + """ + addresses = wait_for_interface_address(self.iface, timeout=25) + if not addresses and not interface_exists(self.iface): runner.fail( f"openconnect s'est lancé mais {self.iface} n'existe pas." " vpnc-script est-il installé ? (paquet vpnc-scripts)" ) return False - addresses = ( - ", ".join(interface_addresses(self.iface)) or "sans adresse" + runner.ok( + f"interface {self.iface} :" + f" {', '.join(addresses) or 'sans adresse'}" ) - runner.ok(f"interface {self.iface} : {addresses}") self.write_state(runner, "iface", self.iface) # Les routes du serveur sont déjà posées par vpnc-script. Celles du # profil s'AJOUTENT : un réseau que le concentrateur ne pousse pas @@ -316,9 +871,44 @@ class OpenconnectDriver(VpnDriver): ) return True + def check_sso_helper(self): + """Verdict sur le greffon, quand le profil en a besoin. + + `None` en verdict quand le profil n'est pas en SSO : ce n'est pas + « bon », ce n'est pas « en défaut », c'est hors sujet — et un ✓ sur + une ligne hors sujet fait croire qu'elle a été vérifiée. + """ + if not self.profile.get("oc_sso"): + return ( + t("SSO helper"), + None, + "sans objet : ce profil authentifie par mot de passe", + ) + helper = self.sso_helper + if helper: + return ( + t("SSO helper"), + True, + helper, + ) + declared = self.profile.get("oc_sso_helper") or "" + return ( + t("SSO helper"), + False, + ( + f"déclaré mais inexécutable : {declared}" + if declared + else "absent : openconnect-sso introuvable dans le PATH" + ), + ) + def status(self, runner): return self.standard_status( - runner, extra=[self.check_daemon("processus openconnect")] + runner, + extra=[ + self.check_daemon("processus openconnect"), + self.check_sso_helper(), + ], ) def log_commands(self): diff --git a/script/vpn/runner.py b/script/vpn/runner.py index a63e046..d9ba520 100644 --- a/script/vpn/runner.py +++ b/script/vpn/runner.py @@ -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,13 @@ 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. """ full = command if sudo is None: @@ -146,7 +176,7 @@ class Runner: 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: diff --git a/script/vpn/valid.py b/script/vpn/valid.py index d08907d..dc05b8b 100644 --- a/script/vpn/valid.py +++ b/script/vpn/valid.py @@ -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() diff --git a/script/vpn/vault.py b/script/vpn/vault.py index 4853e07..0ff3326 100644 --- a/script/vpn/vault.py +++ b/script/vpn/vault.py @@ -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 = "" + # 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 diff --git a/script/vpn/vpn.py b/script/vpn/vpn.py index c3753cc..ff9a8cf 100755 --- a/script/vpn/vpn.py +++ b/script/vpn/vpn.py @@ -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 = "" - 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 diff --git a/test/test_vpn_drivers.py b/test/test_vpn_drivers.py index b8cb0b4..6936d76 100644 --- a/test/test_vpn_drivers.py +++ b/test/test_vpn_drivers.py @@ -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 : diff --git a/test/test_vpn_menu.py b/test/test_vpn_menu.py index 665653c..f0928f3 100644 --- a/test/test_vpn_menu.py +++ b/test/test_vpn_menu.py @@ -597,5 +597,175 @@ class SecretsOnlyWhenThereAreSome(MenuBase): self.assertEqual(self.todo._vpn_ask_secret("PSK"), "") +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() diff --git a/test/test_vpn_presets.py b/test/test_vpn_presets.py new file mode 100644 index 0000000..2ba6d16 --- /dev/null +++ b/test/test_vpn_presets.py @@ -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//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 « . » : 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()