[ADD] vpn openconnect : groupe d'URL, SSO délégué, mot de passe borné

Deux mécanismes désignent un service sur un concentrateur : un chemin
d'URL, une valeur de menu déroulant. Les confondre rend le formulaire d'un
AUTRE service — identifiants justes refusés, rien ne désignant le groupe.
Et certains ne comparent que les N premiers caractères du mot de passe,
que le profil déclare désormais sans jamais rien tronquer.

Une passerelle qui exige un navigateur intégré arrête openconnect sur
« No SSO handler », les distributions le bâtissant sans webview. Un greffon
fait l'étape web et rend un cookie ; le pilote monte lui-même, et le profil
garde son interface, son état et son diagnostic.

Vérifié : 249 tests unitaires, un tunnel monté contre une passerelle SAML.

--- EN ---

Two mechanisms designate a service on one concentrator: a URL path, and a
dropdown value. Confusing them hands over ANOTHER service's login form —
correct credentials refused, with nothing pointing at the group. And some
compare only the first N characters of the password, which the profile now
declares without ever truncating anything.

A gateway demanding an embedded browser stops openconnect on "No SSO
handler", distributions building it without a webview. A helper does the
web step and returns a cookie; the driver mounts the tunnel itself, so the
profile keeps its interface, its state and its diagnosis.

Checked: 249 unit tests, and one tunnel mounted against a SAML gateway.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-04 09:52:09 +00:00
parent 7b749e43bb
commit 9250d4d401
15 changed files with 2729 additions and 83 deletions

View file

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

View file

@ -11688,6 +11688,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 <UserGroup> of an AnyConnect"
" profile (optional)": {
"fr": (
"Groupe de connexion dans l'URL \u2014 <UserGroup> d'un profil"
" AnyConnect (facultatif)"
),
"en": (
"Connection group in the URL \u2014 <UserGroup> of an"
" AnyConnect profile (optional)"
),
},
"No site preset available.": {
"fr": "Aucun préréglage de site disponible.",
"en": "No site preset available.",
},
"Drop a .json file in conf/vpn_presets/ (shared, nothing identifying)"
" or in private/vpn/presets/ (git-ignored, where a site preset goes).": {
"fr": (
"Poser un fichier .json dans conf/vpn_presets/ (partagé, rien"
" d'identifiant) ou dans private/vpn/presets/ (ignoré par git,"
" c'est là qu'un préréglage de site va)."
),
"en": (
"Drop a .json file in conf/vpn_presets/ (shared, nothing"
" identifying) or in private/vpn/presets/ (git-ignored, where a"
" site preset goes)."
),
},
"Unreadable preset: ": {
"fr": "Préréglage illisible : ",
"en": "Unreadable preset: ",
},
"Preset number (0 to go back)": {
"fr": "Numéro du préréglage (0 pour revenir)",
"en": "Preset number (0 to go back)",
},
"An empty answer keeps the preset value.": {
"fr": "Une réponse vide garde la valeur du préréglage.",
"en": "An empty answer keeps the preset value.",
},
"This profile already exists: the preset refreshes what it declares,"
" everything personal is kept.": {
"fr": (
"Ce profil existe déjà : le préréglage rafraîchit ce qu'il"
" déclare, tout ce qui est personnel est gardé."
),
"en": (
"This profile already exists: the preset refreshes what it"
" declares, everything personal is kept."
),
},
"Cisco AnyConnect gateway with an authentication group": {
"fr": "Passerelle Cisco AnyConnect avec groupe d'authentification",
"en": "Cisco AnyConnect gateway with an authentication group",
},
"Password characters the server compares (0: no limit)": {
"fr": (
"Caractères du mot de passe que le serveur compare (0 : aucune"
" limite)"
),
"en": "Password characters the server compares (0: no limit)",
},
"This gateway compares only the first {limit} characters of"
" the password: store just those, a longer one is refused.": {
"fr": (
"Cette passerelle ne compare que les {limit} premiers caractères"
" du mot de passe : n'en déposer que ceux-là, un plus long est"
" refusé."
),
"en": (
"This gateway compares only the first {limit} characters of the"
" password: store just those, a longer one is refused."
),
},
"No VPN profile yet: create one first.": {
"fr": "Aucun profil VPN : en créer un d'abord.",
"en": "No VPN profile yet: create one first.",

View file

@ -19,11 +19,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

View file

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

View file

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

View file

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

View file

@ -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 ?

View file

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

View file

@ -26,6 +26,10 @@ import shlex
import subprocess
import sys
# Le même masque que le coffre : deux masques différents dans la même
# sortie feraient croire à deux natures de secret.
from script.vpn.vault import MASK
# Marqueurs des blocs gérés dans les fichiers de configuration du système.
# Reconnaissables, uniques, et ils DISENT de ne pas éditer à la main.
BLOCK_BEGIN = "# >>> erplibre-vpn %s — généré, ne pas éditer"
@ -79,6 +83,25 @@ class Runner:
self.ops: list[dict] = []
self.failures: list[str] = []
def add_secret(self, value):
"""Masque `value` dans tout ce qui s'affichera DÉSORMAIS.
Le masquage est monté une fois pour toutes au démarrage, à partir de
ce que le coffre a rendu. Mais un secret peut NAÎTRE en cours de
route : un jeton de session obtenu par une authentification web
n'existe pas avant qu'elle aboutisse, et il ne doit pas moins être
masqué que le mot de passe qui l'a produit.
Sous huit caractères, on ne masque pas : une valeur courte se
retrouve par hasard dans un chemin ou un nom d'interface, et on
masquerait du texte utile en croyant protéger un secret.
"""
value = str(value or "")
if len(value) < 8:
return
previous = self.redactor
self.redactor = lambda text: previous(text).replace(value, MASK)
# ------------------------------------------------------------------
# Affichage
# ------------------------------------------------------------------
@ -119,6 +142,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:

View file

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

View file

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

View file

@ -38,15 +38,13 @@ from script.vpn.drivers import DRIVERS, driver_names, get_driver
from script.vpn.drivers.base import INSTALL_SCRIPT
from script.vpn.runner import Runner
from script.vpn.vault import (
PLACEHOLDER,
VaultError,
VpnVault,
redact,
secrets_from_env,
)
# Ce qu'on met à la place d'un secret qu'on n'a pas pu lire, en mode à blanc.
PLACEHOLDER = "<secret-du-coffre>"
def _vault():
cfg = config_file.ConfigFile()
@ -100,7 +98,10 @@ def _build(args, want_secrets=True, secrets_required=True):
)
return None, None, None
secrets = {}
if want_secrets:
# Le PROFIL décide, pas seulement la technologie : ouvrir le coffre
# pour un secret que ce montage n'utilisera pas réclamerait le mot de
# passe maître pour rien.
if want_secrets and driver_cls.wants_secrets(profile):
try:
secrets = _load_secrets(
profile, driver_cls, required=secrets_required

View file

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

View file

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

781
test/test_vpn_presets.py Normal file
View file

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