[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.
|
# /chemin » — la question avant ce dont elle parle.
|
||||||
print(f"{t('kdbx_vault_is')} {kdbx_file_path}", flush=True)
|
print(f"{t('kdbx_vault_is')} {kdbx_file_path}", flush=True)
|
||||||
for _ in range(attempts):
|
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:
|
if not password:
|
||||||
print(t("kdbx_give_up"))
|
print(t("kdbx_give_up"))
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -11688,6 +11688,159 @@ TRANSLATIONS = {
|
||||||
"fr": "Quelle technologie ?",
|
"fr": "Quelle technologie ?",
|
||||||
"en": "Which technology?",
|
"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.": {
|
"No VPN profile yet: create one first.": {
|
||||||
"fr": "Aucun profil VPN : en créer un d'abord.",
|
"fr": "Aucun profil VPN : en créer un d'abord.",
|
||||||
"en": "No VPN profile yet: create one first.",
|
"en": "No VPN profile yet: create one first.",
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,12 @@ Deux chemins d'exécution, pour une raison :
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import getpass
|
import getpass
|
||||||
|
import os
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from script.todo.todo_i18n import t
|
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.drivers import DRIVERS, get_driver
|
||||||
from script.vpn.vault import VaultError, VpnVault, secrets_to_env
|
from script.vpn.vault import VaultError, VpnVault, secrets_to_env
|
||||||
|
|
||||||
|
|
@ -48,6 +49,39 @@ NO_ROUTE_NOTE = (
|
||||||
# douteux : les tests unitaires, eux, sont là.
|
# douteux : les tests unitaires, eux, sont là.
|
||||||
UNPROVEN_NOTE = "never mounted against a real server: only unit tests cover it"
|
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 = (
|
MASTER_PASSWORD_WARNING = (
|
||||||
"The vault MASTER password is stored in the configuration in clear"
|
"The vault MASTER password is stored in the configuration in clear"
|
||||||
" text. Remove it and type it on demand."
|
" 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 - Disconnect a profile")},
|
||||||
{"prompt_description": t("VPN - Status and diagnosis")},
|
{"prompt_description": t("VPN - Status and diagnosis")},
|
||||||
{"section": t("Profiles & secrets")},
|
{"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 - Add or edit a profile")},
|
||||||
{"prompt_description": t("VPN - Store secrets in the vault")},
|
{"prompt_description": t("VPN - Store secrets in the vault")},
|
||||||
{
|
{
|
||||||
|
|
@ -132,16 +176,20 @@ class VpnMenuMixin:
|
||||||
elif status == "3":
|
elif status == "3":
|
||||||
self._vpn_diagnose()
|
self._vpn_diagnose()
|
||||||
elif status == "4":
|
elif status == "4":
|
||||||
self._vpn_edit_profile()
|
self._vpn_from_preset()
|
||||||
elif status == "5":
|
elif status == "5":
|
||||||
self._vpn_store_secrets()
|
self._vpn_import_anyconnect()
|
||||||
elif status == "6":
|
elif status == "6":
|
||||||
self._vpn_show_config()
|
self._vpn_edit_profile()
|
||||||
elif status == "7":
|
elif status == "7":
|
||||||
self._vpn_delete_profile()
|
self._vpn_store_secrets()
|
||||||
elif status == "8":
|
elif status == "8":
|
||||||
self._vpn_install()
|
self._vpn_show_config()
|
||||||
elif status == "9":
|
elif status == "9":
|
||||||
|
self._vpn_delete_profile()
|
||||||
|
elif status == "10":
|
||||||
|
self._vpn_install()
|
||||||
|
elif status == "11":
|
||||||
self._vpn_check()
|
self._vpn_check()
|
||||||
else:
|
else:
|
||||||
print(t("Command not found !"))
|
print(t("Command not found !"))
|
||||||
|
|
@ -247,7 +295,103 @@ class VpnMenuMixin:
|
||||||
return ""
|
return ""
|
||||||
return all_profiles[int(answer) - 1]["name"]
|
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.
|
"""Crée ou modifie un profil, quelle que soit la technologie.
|
||||||
|
|
||||||
Les questions viennent du PILOTE (`form_fields`) : ce menu ne sait
|
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
|
Une réponse vide garde la valeur actuelle : modifier une seule route
|
||||||
ne doit pas obliger à ressaisir tout le reste.
|
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 _)')} : ")
|
if seed is not None:
|
||||||
name = name.strip()
|
current = dict(seed)
|
||||||
if not name:
|
name = current["name"]
|
||||||
return
|
driver_cls = get_driver(current.get("driver"))
|
||||||
current = profiles.load(name) or {"name": name}
|
if driver_cls is None:
|
||||||
driver_cls = self._vpn_pick_driver(current.get("driver"))
|
print(f"✗ {t('Unknown driver: ')}{current.get('driver')}")
|
||||||
if driver_cls is None:
|
return
|
||||||
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
|
# Les défauts DU PILOTE CHOISI, pour que chaque question ait un
|
||||||
# défaut sensé même sur un profil qui change de technologie.
|
# défaut sensé même sur un profil qui change de technologie.
|
||||||
|
|
@ -440,7 +599,12 @@ class VpnMenuMixin:
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"\n{t('Vault entry')} : {title}")
|
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 = {}
|
values = {}
|
||||||
if driver_cls.user_field:
|
if driver_cls.user_field:
|
||||||
# Recopié pour que le coffre reste LISIBLE dans KeePassXC ; le
|
# 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 |
|
| `/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` |
|
| `/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] -->
|
<!-- [en] -->
|
||||||
## The three security rules
|
## 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 |
|
| `/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` |
|
| `/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é
|
## Les trois règles de sécurité
|
||||||
|
|
||||||
1. **Aucun secret en argument.** `/proc/<pid>/cmdline` est lisible par tout
|
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 |
|
| `/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` |
|
| `/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
|
## The three security rules
|
||||||
|
|
||||||
1. **No secret in an argument.** `/proc/<pid>/cmdline` is readable by every
|
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."""
|
"""Les valeurs à masquer dans tout affichage."""
|
||||||
return [v for v in self.secrets.values() if v]
|
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:
|
def ensure_ready(self, runner) -> bool:
|
||||||
"""Noyau, binaires et secrets présents ?
|
"""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
|
partir de ce que le concentrateur pousse. Le profil peut en AJOUTER, il ne
|
||||||
les remplace pas — d'où `needs_routes = False`.
|
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 »
|
SSO / SAML — le cas du « formulaire web »
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
Quand le concentrateur authentifie par un fournisseur d'identité (Azure AD,
|
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.
|
Okta, Duo…), il n'y a pas de mot de passe à envoyer : il faut compléter une
|
||||||
Le client de Cisco la rend dans un navigateur WebKit embarqué — donc un
|
page web. Cisco annonce ce besoin de DEUX façons, et openconnect n'en sait
|
||||||
écran, et sur bien des postes la variable `WEBKIT_DISABLE_DMABUF_RENDERER=1`
|
traiter qu'une seule depuis un CLI :
|
||||||
en prime pour qu'elle s'affiche. Son CLI, lui, ne sait pas le faire.
|
|
||||||
|
|
||||||
openconnect le fait sans écran sur la machine cliente. Mesuré dans sa
|
· `single-sign-on-external-browser` — openconnect lance le programme donné à
|
||||||
bibliothèque : il ÉCOUTE sur le port local 29786 et attend la redirection
|
`--external-browser`, écoute sur son port local 29786 et attend la
|
||||||
(« Accepted incoming external-browser connection on port 29786 »), après
|
redirection. Fonctionne avec un paquet de distribution ;
|
||||||
avoir lancé le programme donné à `--external-browser` avec l'URL de
|
· `sso-v2` — le concentrateur exige un navigateur INTÉGRÉ au client. Il faut
|
||||||
connexion. Sur un serveur, ce « navigateur » est un simple `echo` : l'URL
|
alors une webview compilée dans openconnect (libwebkit2gtk), et Debian,
|
||||||
s'affiche, on l'ouvre dans SON navigateur, et un
|
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
|
Déléguer l'authentification, garder le tunnel
|
||||||
passe ne quitte jamais le poste de l'utilisateur.
|
---------------------------------------------
|
||||||
|
`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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from script.todo.todo_i18n import t
|
||||||
from script.vpn import valid
|
from script.vpn import valid
|
||||||
from script.vpn.drivers.base import (
|
from script.vpn.drivers.base import (
|
||||||
VpnDriver,
|
VpnDriver,
|
||||||
interface_addresses,
|
|
||||||
interface_exists,
|
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`.
|
# Ce que ce client sait parler. La liste vient de `openconnect --protocol`.
|
||||||
PROTOCOLS = ("anyconnect", "nc", "pulse", "gp", "f5", "fortinet", "array")
|
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
|
# Programme lancé avec l'URL de connexion. Vide = `echo`, qui
|
||||||
# l'affiche : c'est ce qu'on veut sur une machine sans écran.
|
# l'affiche : c'est ce qu'on veut sur une machine sans écran.
|
||||||
"oc_external_browser": "",
|
"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 = (
|
form_fields = (
|
||||||
("oc_user", "VPN user", "text", False),
|
("oc_user", "VPN user", "text", False),
|
||||||
|
|
@ -95,6 +207,13 @@ class OpenconnectDriver(VpnDriver):
|
||||||
"text",
|
"text",
|
||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"oc_usergroup",
|
||||||
|
"Connection group in the URL — <UserGroup> of an AnyConnect"
|
||||||
|
" profile (optional)",
|
||||||
|
"text",
|
||||||
|
False,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"oc_authgroup",
|
"oc_authgroup",
|
||||||
"Authentication group / realm (optional)",
|
"Authentication group / realm (optional)",
|
||||||
|
|
@ -119,6 +238,25 @@ class OpenconnectDriver(VpnDriver):
|
||||||
"path",
|
"path",
|
||||||
True,
|
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),
|
("port", "HTTPS port", "int", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -148,6 +286,13 @@ class OpenconnectDriver(VpnDriver):
|
||||||
f"Protocole inconnu : « {protocol} »."
|
f"Protocole inconnu : « {protocol} »."
|
||||||
f" Connus : {', '.join(PROTOCOLS)}."
|
f" Connus : {', '.join(PROTOCOLS)}."
|
||||||
)
|
)
|
||||||
|
valid.text(
|
||||||
|
profile,
|
||||||
|
"oc_usergroup",
|
||||||
|
"Groupe de connexion (chemin d'URL)",
|
||||||
|
required=False,
|
||||||
|
pattern=valid.URL_PATH_RE,
|
||||||
|
)
|
||||||
valid.text(
|
valid.text(
|
||||||
profile,
|
profile,
|
||||||
"oc_authgroup",
|
"oc_authgroup",
|
||||||
|
|
@ -161,12 +306,29 @@ class OpenconnectDriver(VpnDriver):
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
valid.port(profile, "port", "Port HTTPS")
|
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(
|
valid.path(
|
||||||
profile,
|
profile,
|
||||||
"oc_external_browser",
|
"oc_external_browser",
|
||||||
"Programme navigateur",
|
"Programme navigateur",
|
||||||
required=False,
|
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
|
@property
|
||||||
def browser(self):
|
def browser(self):
|
||||||
|
|
@ -177,6 +339,279 @@ class OpenconnectDriver(VpnDriver):
|
||||||
sur son port 29786."""
|
sur son port 29786."""
|
||||||
return self.profile.get("oc_external_browser") or "echo"
|
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):
|
def command(self):
|
||||||
"""La ligne de commande, dans l'une de ses deux formes.
|
"""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"--pid-file={shlex.quote(self.pid_file)}",
|
||||||
f"--interface={shlex.quote(self.iface)}",
|
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"):
|
if p.get("oc_authgroup"):
|
||||||
parts.append(f"--authgroup={shlex.quote(p['oc_authgroup'])}")
|
parts.append(f"--authgroup={shlex.quote(p['oc_authgroup'])}")
|
||||||
if p.get("oc_servercert"):
|
if p.get("oc_servercert"):
|
||||||
|
|
@ -212,6 +649,13 @@ class OpenconnectDriver(VpnDriver):
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def up(self, runner):
|
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
|
p = self.profile
|
||||||
if not self.ensure_ready(runner):
|
if not self.ensure_ready(runner):
|
||||||
return False
|
return False
|
||||||
|
|
@ -220,59 +664,170 @@ class OpenconnectDriver(VpnDriver):
|
||||||
self.prepare_dirs(runner, secrets=False)
|
self.prepare_dirs(runner, secrets=False)
|
||||||
|
|
||||||
if p["oc_sso"]:
|
if p["oc_sso"]:
|
||||||
self._explain_the_sso_round_trip(runner)
|
# Un greffon DÉCLARÉ mais inexécutable est une erreur, pas une
|
||||||
mot_de_passe, delai = None, 300
|
# invitation à prendre l'autre chemin : basculer en silence
|
||||||
else:
|
# ferait échouer le montage sur « No SSO handler », trois
|
||||||
if not self.secrets.get("password"):
|
# é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 = runner.warn if runner.dry_run else runner.fail
|
||||||
report(
|
report(
|
||||||
"Aucun mot de passe dans le coffre, et le profil n'est"
|
f"Greffon SSO déclaré mais inexécutable : {declared}."
|
||||||
" pas en SSO : les déposer, ou cocher « formulaire web »."
|
" Corriger oc_sso_helper, ou le vider pour chercher"
|
||||||
|
" openconnect-sso dans le PATH."
|
||||||
)
|
)
|
||||||
if not runner.dry_run:
|
if not runner.dry_run:
|
||||||
return False
|
return False
|
||||||
mot_de_passe = self.secrets.get("password", "") + "\n"
|
monte = (
|
||||||
delai = 120
|
self._up_delegated(runner)
|
||||||
|
if self.sso_helper
|
||||||
code, _ = runner.cmd(
|
else self._up_external_browser(runner)
|
||||||
f"ouvrir la session {p['oc_protocol']} sur {p['server']}",
|
)
|
||||||
self.command(),
|
else:
|
||||||
stdin=mot_de_passe,
|
monte = self._up_password(runner)
|
||||||
secret_stdin=bool(mot_de_passe),
|
if not monte:
|
||||||
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."
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
if runner.dry_run:
|
if runner.dry_run:
|
||||||
runner.info(f" (à blanc : l'interface serait {self.iface})")
|
runner.info(f" (à blanc : l'interface serait {self.iface})")
|
||||||
return True
|
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(
|
runner.fail(
|
||||||
f"openconnect s'est lancé mais {self.iface} n'existe pas."
|
f"openconnect s'est lancé mais {self.iface} n'existe pas."
|
||||||
" vpnc-script est-il installé ? (paquet vpnc-scripts)"
|
" vpnc-script est-il installé ? (paquet vpnc-scripts)"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
addresses = (
|
runner.ok(
|
||||||
", ".join(interface_addresses(self.iface)) or "sans adresse"
|
f"interface {self.iface} :"
|
||||||
|
f" {', '.join(addresses) or 'sans adresse'}"
|
||||||
)
|
)
|
||||||
runner.ok(f"interface {self.iface} : {addresses}")
|
|
||||||
self.write_state(runner, "iface", self.iface)
|
self.write_state(runner, "iface", self.iface)
|
||||||
# Les routes du serveur sont déjà posées par vpnc-script. Celles du
|
# 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
|
# profil s'AJOUTENT : un réseau que le concentrateur ne pousse pas
|
||||||
|
|
@ -316,9 +871,44 @@ class OpenconnectDriver(VpnDriver):
|
||||||
)
|
)
|
||||||
return True
|
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):
|
def status(self, runner):
|
||||||
return self.standard_status(
|
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):
|
def log_commands(self):
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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.
|
# 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.
|
# Reconnaissables, uniques, et ils DISENT de ne pas éditer à la main.
|
||||||
BLOCK_BEGIN = "# >>> erplibre-vpn %s — généré, ne pas éditer"
|
BLOCK_BEGIN = "# >>> erplibre-vpn %s — généré, ne pas éditer"
|
||||||
|
|
@ -79,6 +83,25 @@ class Runner:
|
||||||
self.ops: list[dict] = []
|
self.ops: list[dict] = []
|
||||||
self.failures: list[str] = []
|
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
|
# Affichage
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -119,6 +142,13 @@ class Runner:
|
||||||
`stdin` est le seul chemin par lequel un secret entre dans un
|
`stdin` est le seul chemin par lequel un secret entre dans un
|
||||||
processus. `secret_stdin` ne change PAS l'exécution : il dit à
|
processus. `secret_stdin` ne change PAS l'exécution : il dit à
|
||||||
l'affichage et aux tests que ce contenu ne doit jamais être montré.
|
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
|
full = command
|
||||||
if sudo is None:
|
if sudo is None:
|
||||||
|
|
@ -146,7 +176,7 @@ class Runner:
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
stdout=subprocess.PIPE if capture else None,
|
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 ""
|
code, out = proc.returncode, proc.stdout or ""
|
||||||
except subprocess.TimeoutExpired:
|
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}$")
|
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 + « = ».
|
# Clé WireGuard : 32 octets en base64, donc 43 caractères + « = ».
|
||||||
WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$")
|
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):
|
class ProfileError(ValueError):
|
||||||
|
|
@ -110,10 +120,10 @@ def ip_address(profile, key, label, required=False):
|
||||||
|
|
||||||
|
|
||||||
def ip_interface(profile, key, label, required=True):
|
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
|
Une adresse sans préfixe est acceptée et complétée en /32 — mais écrire
|
||||||
« 10.7.0.2 » quand on veut dire « /24 » est une erreur silencieuse
|
l'adresse nue quand on voulait dire /24 est une erreur silencieuse
|
||||||
coûteuse, alors le message le rappelle en cas de doute.
|
coûteuse, alors le message le rappelle en cas de doute.
|
||||||
"""
|
"""
|
||||||
value = str(profile.get(key) or "").strip()
|
value = str(profile.get(key) or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,15 @@ FIELD_PSK = "psk"
|
||||||
|
|
||||||
MASK = "********"
|
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
|
# 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
|
# 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
|
# 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.drivers.base import INSTALL_SCRIPT
|
||||||
from script.vpn.runner import Runner
|
from script.vpn.runner import Runner
|
||||||
from script.vpn.vault import (
|
from script.vpn.vault import (
|
||||||
|
PLACEHOLDER,
|
||||||
VaultError,
|
VaultError,
|
||||||
VpnVault,
|
VpnVault,
|
||||||
redact,
|
redact,
|
||||||
secrets_from_env,
|
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():
|
def _vault():
|
||||||
cfg = config_file.ConfigFile()
|
cfg = config_file.ConfigFile()
|
||||||
|
|
@ -100,7 +98,10 @@ def _build(args, want_secrets=True, secrets_required=True):
|
||||||
)
|
)
|
||||||
return None, None, None
|
return None, None, None
|
||||||
secrets = {}
|
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:
|
try:
|
||||||
secrets = _load_secrets(
|
secrets = _load_secrets(
|
||||||
profile, driver_cls, required=secrets_required
|
profile, driver_cls, required=secrets_required
|
||||||
|
|
|
||||||
|
|
@ -416,26 +416,53 @@ class Openconnect(unittest.TestCase):
|
||||||
self.assertEqual(driver.profile["routes"], [])
|
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):
|
class OpenconnectSingleSignOn(unittest.TestCase):
|
||||||
"""Le cas du « formulaire web » : le concentrateur délègue à un
|
"""Le cas du « formulaire web » : le concentrateur délègue à un
|
||||||
fournisseur d'identité, et il n'y a aucun mot de passe à envoyer.
|
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.
|
Deux chemins existent, et cette classe couvre le premier. Sans greffon,
|
||||||
openconnect s'en passe : mesuré dans sa bibliothèque, il écoute sur le
|
openconnect s'en charge seul : il écoute sur son port local 29786 et
|
||||||
port local 29786 et attend la redirection du navigateur, lequel peut
|
attend la redirection d'un navigateur, lequel peut être celui de
|
||||||
être celui de l'utilisateur, ailleurs, à travers un `ssh -L`.
|
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(
|
profile = dict(
|
||||||
SAMPLES["openconnect"][0],
|
SAMPLES["openconnect"][0],
|
||||||
name="t-sso",
|
name="t-sso",
|
||||||
driver="openconnect",
|
driver="openconnect",
|
||||||
oc_sso=True,
|
oc_sso=True,
|
||||||
oc_user="",
|
oc_user="",
|
||||||
|
oc_sso_helper=helper,
|
||||||
)
|
)
|
||||||
profile.update(overrides)
|
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):
|
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 :
|
"""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"), "")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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