364 lines
12 KiB
Python
364 lines
12 KiB
Python
|
|
#!/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 (
|
|||
|
|
VaultError,
|
|||
|
|
VpnVault,
|
|||
|
|
redact,
|
|||
|
|
secrets_from_env,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Ce qu'on met à la place d'un secret qu'on n'a pas pu lire, en mode à blanc.
|
|||
|
|
PLACEHOLDER = "<secret-du-coffre>"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _vault():
|
|||
|
|
cfg = config_file.ConfigFile()
|
|||
|
|
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 = {}
|
|||
|
|
if want_secrets:
|
|||
|
|
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."""
|
|||
|
|
names = [args.driver] if args.driver else driver_names()
|
|||
|
|
runner = Runner()
|
|||
|
|
code, _ = runner.cmd(
|
|||
|
|
f"installer les paquets de : {', '.join(names)}",
|
|||
|
|
f"bash {INSTALL_SCRIPT} {' '.join(names)}",
|
|||
|
|
check=True,
|
|||
|
|
)
|
|||
|
|
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",
|
|||
|
|
)
|
|||
|
|
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())
|