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