erplibre/script/vpn/vpn.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

384 lines
12 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 (
PLACEHOLDER,
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 = {}
# Le PROFIL décide, pas seulement la technologie : ouvrir le coffre
# pour un secret que ce montage n'utilisera pas réclamerait le mot de
# passe maître pour rien.
if want_secrets and driver_cls.wants_secrets(profile):
try:
secrets = _load_secrets(
profile, driver_cls, required=secrets_required
)
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
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.
"""
names = [args.driver] if args.driver else driver_names()
runner = Runner()
# `--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 ""
code, _ = runner.cmd(
f"installer les paquets de : {', '.join(names)}",
f"bash {INSTALL_SCRIPT} {' '.join(names)}{extra}",
check=True,
timeout=900,
)
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",
)
if name == "install":
sp.add_argument(
"--with-sso",
action="store_true",
help=(
"Installer aussi le greffon d'authentification par"
" formulaire web (openconnect-sso)"
),
)
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())