[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
"""Monter, démonter et diagnostiquer un tunnel VPN.
|
|
|
|
|
|
|
|
|
|
|
|
./script/vpn/vpn.py list
|
|
|
|
|
|
./script/vpn/vpn.py up --profile client-acme [--dry-run]
|
|
|
|
|
|
./script/vpn/vpn.py down --profile client-acme
|
|
|
|
|
|
./script/vpn/vpn.py status --profile client-acme
|
|
|
|
|
|
./script/vpn/vpn.py diagnose --profile client-acme
|
|
|
|
|
|
./script/vpn/vpn.py check [--driver l2tp_ipsec]
|
|
|
|
|
|
|
|
|
|
|
|
Les profils (hôte, utilisateur, routes) viennent de la configuration ; les
|
|
|
|
|
|
secrets (PSK, mot de passe) du coffre KeePassXC. Voir `profiles.py` et
|
|
|
|
|
|
`vault.py`.
|
|
|
|
|
|
|
|
|
|
|
|
À lancer en tant qu'UTILISATEUR, pas sous sudo : le coffre est dans le home
|
|
|
|
|
|
de l'utilisateur et son mot de passe maître est saisi par lui. Chaque étape
|
|
|
|
|
|
privilégiée appelle `sudo` séparément, et `--dry-run` les montre toutes sans
|
|
|
|
|
|
en exécuter aucune.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
|
|
new_path = os.path.normpath(
|
|
|
|
|
|
os.path.join(os.path.dirname(__file__), "..", "..")
|
|
|
|
|
|
)
|
|
|
|
|
|
if new_path not in sys.path:
|
|
|
|
|
|
sys.path.append(new_path)
|
|
|
|
|
|
|
|
|
|
|
|
from script.config import config_file
|
|
|
|
|
|
from script.todo.kdbx_manager import KdbxManager
|
|
|
|
|
|
from script.vpn import profiles
|
|
|
|
|
|
from script.vpn.drivers import DRIVERS, driver_names, get_driver
|
|
|
|
|
|
from script.vpn.drivers.base import INSTALL_SCRIPT
|
|
|
|
|
|
from script.vpn.runner import Runner
|
|
|
|
|
|
from script.vpn.vault import (
|
[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
2026-09-04 05:52:09 -04:00
|
|
|
|
PLACEHOLDER,
|
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
|
|
|
|
VaultError,
|
|
|
|
|
|
VpnVault,
|
|
|
|
|
|
redact,
|
|
|
|
|
|
secrets_from_env,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _vault():
|
|
|
|
|
|
cfg = config_file.ConfigFile()
|
|
|
|
|
|
return VpnVault(cfg, KdbxManager(cfg)), cfg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_secrets(profile, driver_cls, required=True):
|
|
|
|
|
|
"""Secrets du profil, ou {} si on renonce.
|
|
|
|
|
|
|
|
|
|
|
|
`required=False` sert le mode à blanc : montrer le plan ne justifie pas
|
|
|
|
|
|
d'exiger le mot de passe maître, et un plan avec des marqueurs à la
|
|
|
|
|
|
place des secrets reste un plan juste.
|
|
|
|
|
|
"""
|
|
|
|
|
|
fields = tuple(key for key, _, _ in driver_cls.secret_fields)
|
|
|
|
|
|
# Déjà fournis par le menu, qui tient le coffre ouvert ? Alors ne pas
|
|
|
|
|
|
# redemander le mot de passe maître.
|
|
|
|
|
|
deja = secrets_from_env(fields)
|
|
|
|
|
|
if deja is not None:
|
|
|
|
|
|
return deja
|
|
|
|
|
|
vault, _ = _vault()
|
|
|
|
|
|
title = profiles.secret_title(profile["name"])
|
|
|
|
|
|
try:
|
|
|
|
|
|
values = vault.read(title, fields=fields)
|
|
|
|
|
|
except VaultError as err:
|
|
|
|
|
|
if required:
|
|
|
|
|
|
raise
|
|
|
|
|
|
print(f" ! {err}")
|
|
|
|
|
|
print(f" ! plan rendu avec « {PLACEHOLDER} » à la place des secrets")
|
|
|
|
|
|
return {field: PLACEHOLDER for field in fields}
|
|
|
|
|
|
if vault.master_password_is_stored():
|
|
|
|
|
|
print(
|
|
|
|
|
|
" ! le mot de passe MAÎTRE du coffre est écrit dans la"
|
|
|
|
|
|
" configuration : le retirer et le saisir à la demande"
|
|
|
|
|
|
)
|
|
|
|
|
|
return values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build(args, want_secrets=True, secrets_required=True):
|
|
|
|
|
|
"""(profil, pilote, exécuteur) ou (None, None, None) après un message."""
|
|
|
|
|
|
profile = profiles.load(args.profile)
|
|
|
|
|
|
if profile is None:
|
|
|
|
|
|
known = ", ".join(profiles.names()) or "aucun"
|
|
|
|
|
|
print(f"✗ Profil « {args.profile} » inconnu. Connus : {known}.")
|
|
|
|
|
|
return None, None, None
|
|
|
|
|
|
driver_cls = get_driver(profile["driver"])
|
|
|
|
|
|
if driver_cls is None:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"✗ Le profil « {args.profile} » demande le pilote"
|
|
|
|
|
|
f" « {profile['driver']} », qui n'existe pas."
|
|
|
|
|
|
f" Connus : {', '.join(driver_names())}."
|
|
|
|
|
|
)
|
|
|
|
|
|
return None, None, None
|
|
|
|
|
|
secrets = {}
|
[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
2026-09-04 05:52:09 -04:00
|
|
|
|
# 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):
|
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
|
|
|
|
try:
|
|
|
|
|
|
secrets = _load_secrets(
|
|
|
|
|
|
profile, driver_cls, required=secrets_required
|
|
|
|
|
|
)
|
|
|
|
|
|
except VaultError as err:
|
|
|
|
|
|
print(f"✗ {err}")
|
|
|
|
|
|
return None, None, None
|
|
|
|
|
|
driver = driver_cls(profile, secrets)
|
|
|
|
|
|
values = driver.secret_values()
|
|
|
|
|
|
runner = Runner(
|
|
|
|
|
|
dry_run=getattr(args, "dry_run", False),
|
|
|
|
|
|
redactor=lambda text: redact(text, values),
|
|
|
|
|
|
)
|
|
|
|
|
|
return profile, driver, runner
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _prime_sudo(runner):
|
|
|
|
|
|
"""Demande le mot de passe sudo UNE fois, au début.
|
|
|
|
|
|
|
|
|
|
|
|
Sans cela, l'invite surgit au milieu de la séquence — entre le « ipsec
|
|
|
|
|
|
up » et le « c <lac> » — là où une saisie lente fait expirer le tunnel.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if runner.dry_run:
|
|
|
|
|
|
return
|
|
|
|
|
|
runner.cmd("autoriser sudo", "-v", sudo=True, check=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
# Commandes
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def cmd_list(args):
|
|
|
|
|
|
all_profiles = profiles.load_all()
|
|
|
|
|
|
if not all_profiles:
|
|
|
|
|
|
print(
|
|
|
|
|
|
"Aucun profil VPN. En créer un depuis TODO › Execute ›"
|
|
|
|
|
|
" Déploiement › VPN › « Ajouter / modifier un profil »."
|
|
|
|
|
|
)
|
|
|
|
|
|
return 0
|
|
|
|
|
|
for raw in all_profiles:
|
|
|
|
|
|
profile = profiles.with_defaults(raw)
|
|
|
|
|
|
mode = (
|
|
|
|
|
|
"tout le trafic"
|
|
|
|
|
|
if profile["default_route"]
|
|
|
|
|
|
else ", ".join(profile["routes"]) or "aucune route"
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {profile['name']:<20} {profile['driver']:<12}"
|
|
|
|
|
|
f" {profile['server']:<28} {mode}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_up(args):
|
|
|
|
|
|
profile, driver, runner = _build(
|
|
|
|
|
|
args, secrets_required=not getattr(args, "dry_run", False)
|
|
|
|
|
|
)
|
|
|
|
|
|
if driver is None:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
title = "Plan de montage (à blanc)" if runner.dry_run else "Montage"
|
|
|
|
|
|
print(f"\n{title} — {profile['name']} ({driver.label})\n")
|
|
|
|
|
|
_prime_sudo(runner)
|
|
|
|
|
|
ok = driver.up(runner)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if ok and not runner.failures:
|
|
|
|
|
|
print(
|
|
|
|
|
|
"✓ Tunnel monté."
|
|
|
|
|
|
if not runner.dry_run
|
|
|
|
|
|
else "✓ Plan complet, rien n'a été exécuté."
|
|
|
|
|
|
)
|
|
|
|
|
|
return 0
|
|
|
|
|
|
print("✗ Montage incomplet :")
|
|
|
|
|
|
for failure in runner.failures:
|
|
|
|
|
|
print(f" · {failure}")
|
|
|
|
|
|
print(
|
|
|
|
|
|
" Démonter proprement avant de réessayer :"
|
|
|
|
|
|
f" ./script/vpn/vpn.py down --profile {profile['name']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_down(args):
|
|
|
|
|
|
profile, driver, runner = _build(args, want_secrets=False)
|
|
|
|
|
|
if driver is None:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
print(f"\nDémontage — {profile['name']}\n")
|
|
|
|
|
|
_prime_sudo(runner)
|
|
|
|
|
|
driver.down(runner)
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_status(args):
|
|
|
|
|
|
if not args.profile:
|
|
|
|
|
|
return cmd_list(args)
|
|
|
|
|
|
profile, driver, runner = _build(args, want_secrets=False)
|
|
|
|
|
|
if driver is None:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
runner.quiet = True
|
|
|
|
|
|
print(f"\nÉtat — {profile['name']} ({driver.label})\n")
|
|
|
|
|
|
verdicts = driver.status(runner)
|
|
|
|
|
|
_print_verdicts(verdicts)
|
|
|
|
|
|
return 0 if all(ok for _, ok, _ in verdicts if ok is not None) else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_diagnose(args):
|
|
|
|
|
|
profile, driver, runner = _build(args, want_secrets=False)
|
|
|
|
|
|
if driver is None:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
runner.quiet = True
|
|
|
|
|
|
print(f"\nDiagnostic — {profile['name']} ({driver.label})\n")
|
|
|
|
|
|
verdicts = driver.status(runner)
|
|
|
|
|
|
_print_verdicts(verdicts)
|
|
|
|
|
|
print()
|
|
|
|
|
|
# Avant les journaux : quand le noyau est la cause, ils sont vides —
|
|
|
|
|
|
# le démon n'a pas vécu assez longtemps pour écrire — et faire lire
|
|
|
|
|
|
# soixante lignes de rien avant d'annoncer le remède n'aide personne.
|
|
|
|
|
|
if driver.needs_reboot():
|
|
|
|
|
|
runner.quiet = False
|
|
|
|
|
|
driver.propose_reboot(runner)
|
|
|
|
|
|
runner.quiet = True
|
|
|
|
|
|
print()
|
|
|
|
|
|
for label, command in driver.log_commands():
|
|
|
|
|
|
print(f"── {label} ──")
|
|
|
|
|
|
runner.quiet = False
|
|
|
|
|
|
runner.cmd(label, command, check=False)
|
|
|
|
|
|
runner.quiet = True
|
|
|
|
|
|
print()
|
|
|
|
|
|
failed = [label for label, ok, _ in verdicts if ok is False]
|
|
|
|
|
|
if failed:
|
|
|
|
|
|
print(f"✗ En défaut : {', '.join(failed)}")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
print("✓ Tous les étages répondent.")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_check(args):
|
|
|
|
|
|
"""Ce que la machine sait faire, avant tout profil."""
|
|
|
|
|
|
names = [args.driver] if args.driver else driver_names()
|
|
|
|
|
|
code = 0
|
|
|
|
|
|
for name in names:
|
|
|
|
|
|
driver_cls = get_driver(name)
|
|
|
|
|
|
if driver_cls is None:
|
|
|
|
|
|
print(f"✗ Pilote inconnu : {name}")
|
|
|
|
|
|
code = 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
driver = driver_cls({"name": "check"})
|
|
|
|
|
|
missing = driver.missing_binaries()
|
|
|
|
|
|
broken = [d for _, ok, d in driver.check_kernel() if ok is False]
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
code = 1
|
|
|
|
|
|
_line("✗", driver_cls.label, f"absents : {', '.join(missing)}")
|
|
|
|
|
|
print(f" {INSTALL_SCRIPT} {name}")
|
|
|
|
|
|
elif broken:
|
|
|
|
|
|
# Les paquets sont là et le noyau ne suit pas : « prêt » serait
|
|
|
|
|
|
# faux, et l'installateur n'y changerait rien.
|
|
|
|
|
|
code = 1
|
|
|
|
|
|
_line("✗", driver_cls.label, "; ".join(broken))
|
|
|
|
|
|
else:
|
|
|
|
|
|
_line("✓", driver_cls.label, "prêt")
|
|
|
|
|
|
vault, _ = _vault()
|
|
|
|
|
|
path = vault.vault_path()
|
|
|
|
|
|
if not path:
|
|
|
|
|
|
_line("!", "coffre KeePassXC", "non configuré")
|
|
|
|
|
|
else:
|
|
|
|
|
|
exists = os.path.exists(os.path.expanduser(path))
|
|
|
|
|
|
_line("✓" if exists else "✗", "coffre KeePassXC", path)
|
|
|
|
|
|
if not exists:
|
|
|
|
|
|
code = 1
|
|
|
|
|
|
if vault.master_password_is_stored():
|
|
|
|
|
|
_line(
|
|
|
|
|
|
"!",
|
|
|
|
|
|
"mot de passe maître",
|
|
|
|
|
|
"stocké en clair dans la configuration : le retirer",
|
|
|
|
|
|
)
|
|
|
|
|
|
return code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_install(args):
|
|
|
|
|
|
"""Une SEULE invocation, même pour plusieurs pilotes : le script fait
|
|
|
|
|
|
un `apt-get update` par appel, et cinq appels le referaient cinq
|
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
|
|
|
|
fois.
|
|
|
|
|
|
|
|
|
|
|
|
Le délai couvre le cas le plus lourd — tous les pilotes, index des
|
|
|
|
|
|
dépôts rafraîchi, paquets tirés d'un miroir lent — et existe pour
|
|
|
|
|
|
borner l'attente, pas pour la mesurer. Sans lui, un gestionnaire de
|
|
|
|
|
|
paquets qui ne rend jamais la main immobilise le menu sans fin.
|
|
|
|
|
|
"""
|
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
|
|
|
|
names = [args.driver] if args.driver else driver_names()
|
|
|
|
|
|
runner = Runner()
|
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
|
|
|
|
# `--sso` en QUEUE : le script retire ce drapeau avant de traiter le
|
|
|
|
|
|
# reste comme une liste de pilotes.
|
|
|
|
|
|
extra = " --sso" if getattr(args, "with_sso", False) else ""
|
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
|
|
|
|
code, _ = runner.cmd(
|
|
|
|
|
|
f"installer les paquets de : {', '.join(names)}",
|
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
|
|
|
|
f"bash {INSTALL_SCRIPT} {' '.join(names)}{extra}",
|
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
|
|
|
|
check=True,
|
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
|
|
|
|
timeout=900,
|
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
|
|
|
|
)
|
|
|
|
|
|
return code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _line(mark, label, detail):
|
|
|
|
|
|
"""Une ligne de verdict, en colonnes. Un seul endroit décide de la
|
|
|
|
|
|
largeur : sinon les listes de `check` et de `status` cessent de
|
|
|
|
|
|
s'aligner entre elles."""
|
|
|
|
|
|
print(f" {mark} {label:<34} {detail}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _print_verdicts(verdicts):
|
|
|
|
|
|
for label, ok, detail in verdicts:
|
|
|
|
|
|
_line("✓" if ok else ("?" if ok is None else "✗"), label, detail)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_parser():
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
prog="vpn.py",
|
|
|
|
|
|
description=__doc__,
|
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
|
)
|
|
|
|
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
|
|
|
|
|
|
|
|
sub.add_parser("list", help="Lister les profils")
|
|
|
|
|
|
|
|
|
|
|
|
for name, help_text, with_dry in (
|
|
|
|
|
|
("up", "Monter le tunnel", True),
|
|
|
|
|
|
("down", "Démonter le tunnel", True),
|
|
|
|
|
|
("status", "État du tunnel", False),
|
|
|
|
|
|
("diagnose", "État détaillé + journaux", False),
|
|
|
|
|
|
):
|
|
|
|
|
|
sp = sub.add_parser(name, help=help_text)
|
|
|
|
|
|
sp.add_argument(
|
|
|
|
|
|
"--profile",
|
|
|
|
|
|
required=name not in ("status",),
|
|
|
|
|
|
help="Nom du profil VPN",
|
|
|
|
|
|
)
|
|
|
|
|
|
if with_dry:
|
|
|
|
|
|
sp.add_argument(
|
|
|
|
|
|
"--dry-run",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Montrer le plan, secrets masqués, sans rien exécuter",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for name, help_text in (
|
|
|
|
|
|
("check", "Vérifier ce que la machine sait faire"),
|
|
|
|
|
|
("install", "Installer les paquets client"),
|
|
|
|
|
|
):
|
|
|
|
|
|
sp = sub.add_parser(name, help=help_text)
|
|
|
|
|
|
sp.add_argument(
|
|
|
|
|
|
"--driver",
|
|
|
|
|
|
choices=sorted(DRIVERS),
|
|
|
|
|
|
help="Se limiter à ce pilote",
|
|
|
|
|
|
)
|
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
|
|
|
|
if name == "install":
|
|
|
|
|
|
sp.add_argument(
|
|
|
|
|
|
"--with-sso",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help=(
|
|
|
|
|
|
"Installer aussi le greffon d'authentification par"
|
|
|
|
|
|
" formulaire web (openconnect-sso)"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
|
|
|
|
return parser
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv=None):
|
|
|
|
|
|
args = build_parser().parse_args(argv)
|
|
|
|
|
|
handlers = {
|
|
|
|
|
|
"list": cmd_list,
|
|
|
|
|
|
"up": cmd_up,
|
|
|
|
|
|
"down": cmd_down,
|
|
|
|
|
|
"status": cmd_status,
|
|
|
|
|
|
"diagnose": cmd_diagnose,
|
|
|
|
|
|
"check": cmd_check,
|
|
|
|
|
|
"install": cmd_install,
|
|
|
|
|
|
}
|
|
|
|
|
|
return handlers[args.command](args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|