From cedf7022268af8677a2f06dba8b50ed5e0377c98 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 03:42:00 +0000 Subject: [PATCH 1/7] [FIX] kdbx : ouvrir le coffre sans tkinter, donc sur un serveur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur une machine sans tkinter — tout serveur — `get_kdbx()` rendait None en journalisant « pykeepass is not installed », alors que pykeepass était là. Les deux imports partageaient un seul `try` : l'absence de tkinter mettait aussi `PyKeePass` à None, et le coffre restait inouvrable même avec chemin et mot de passe configurés. tkinter ne sert qu'au sélecteur de fichier, quand aucun chemin n'est configuré ; deux blocs séparés le rendent à ce seul rôle. --- EN --- On a machine without tkinter — every server — `get_kdbx()` returned None while logging "pykeepass is not installed", though pykeepass was present. Both imports shared one `try`: a missing tkinter set `PyKeePass` to None as well, and the vault stayed unopenable even with path and password configured. tkinter serves only the file picker, when no path is configured; two separate blocks give it back that single role. Assisted-by: Claude Opus 5 --- script/todo/kdbx_manager.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index 3b7ba59..8032165 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -9,22 +9,30 @@ from script.todo.todo_i18n import t _logger = logging.getLogger(__name__) +# DEUX blocs, et c'est le point : tkinter ne sert QU'au sélecteur de fichier +# quand aucun chemin n'est configuré. Réunis dans un seul `try`, l'absence de +# tkinter mettait aussi `PyKeePass` à None — et le coffre devenait impossible +# à ouvrir sur toute machine sans interface graphique, chemin et mot de passe +# configurés ou non. C'est-à-dire sur tous les serveurs. try: - import tkinter as tk - from tkinter import filedialog - from pykeepass import PyKeePass from pykeepass.exceptions import CredentialsError except ModuleNotFoundError: PyKeePass = None - tk = None - filedialog = None class CredentialsError(Exception): """Jamais levée ici : sans pykeepass, `get_kdbx` sort avant d'ouvrir quoi que ce soit. Définie pour que le `except` reste écrivable.""" +try: + import tkinter as tk + from tkinter import filedialog +except ModuleNotFoundError: + tk = None + filedialog = None + + class KdbxManager: def __init__(self, config_file) -> None: self._config_file = config_file From ae9463ebcb9533c1e0e8094913734acd83755897 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 03:42:18 +0000 Subject: [PATCH 2/7] [FIX] kdbx : nommer le coffre avant que getpass ne pose sa question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'invite de `getpass` part vers le terminal, la ligne qui nomme le coffre vers la sortie standard — un TUBE dès qu'un menu nous lance. Sans vidage, les deux ressortaient dans le désordre : « Mot de passe du coffre : Coffre KeePass : », la question avant ce dont elle parle. Le bouchon de `print` des tests accepte désormais la signature complète de `print` : sans `**k`, ajouter un `flush` au code testé faisait échouer six tests sur une différence étrangère à ce qu'ils vérifient. --- EN --- The `getpass` prompt goes to the terminal, the line naming the vault to standard output — a PIPE as soon as a menu launches us. Without a flush the two came out in the wrong order: "Vault password: KeePass vault: ", the question before what it is about. The tests' `print` stub now accepts the full signature of `print`: without `**k`, adding a `flush` to the code under test failed six tests over a difference foreign to what they check. Assisted-by: Claude Opus 5 --- script/todo/kdbx_manager.py | 6 +++++- test/test_kdbx_manager.py | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index 8032165..edcb6e4 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -82,7 +82,11 @@ class KdbxManager: # pas une panne : la bibliothèque lève `CredentialsError` et, sans # ce rattrapage, la trace remontait jusqu'à tuer le CLI. On nomme # aussi le coffre — l'invite ne disait pas DE QUOI elle parlait. - print(f"{t('kdbx_vault_is')} {kdbx_file_path}") + # `flush` : l'invite de getpass part vers le terminal, ce `print` + # vers la sortie standard — qui est un TUBE quand le menu nous lance. + # Sans vidage, on lisait « Mot de passe du coffre : Coffre KeePass : + # /chemin » — la question avant ce dont elle parle. + print(f"{t('kdbx_vault_is')} {kdbx_file_path}", flush=True) for _ in range(attempts): password = getpass.getpass(prompt=t("kdbx_ask_password")) if not password: diff --git a/test/test_kdbx_manager.py b/test/test_kdbx_manager.py index 716a04f..24f9ba5 100644 --- a/test/test_kdbx_manager.py +++ b/test/test_kdbx_manager.py @@ -42,7 +42,11 @@ class KdbxCase(unittest.TestCase): vues = [] with patch("getpass.getpass", side_effect=list(reponses)), patch( "builtins.print", - side_effect=lambda *a: vues.append(" ".join(map(str, a))), + # `**k` : un bouchon de `print` doit accepter la signature de + # `print`. Sans lui, ajouter un `flush=True` dans le code + # testé faisait échouer six tests sur une différence qui n'a + # rien à voir avec ce qu'ils vérifient. + side_effect=lambda *a, **k: vues.append(" ".join(map(str, a))), ): resultat = self._manager().get_kdbx() return resultat, "\n".join(vues) @@ -106,7 +110,11 @@ class TestRecoveryAndSuccess(KdbxCase): vues = [] with patch( "builtins.print", - side_effect=lambda *a: vues.append(" ".join(map(str, a))), + # `**k` : un bouchon de `print` doit accepter la signature de + # `print`. Sans lui, ajouter un `flush=True` dans le code + # testé faisait échouer six tests sur une différence qui n'a + # rien à voir avec ce qu'ils vérifient. + side_effect=lambda *a, **k: vues.append(" ".join(map(str, a))), ): resultat = self._manager("mauvais").get_kdbx() self.assertIsNone(resultat) From bdc0e19302bb609a1b0e21db4676b83a00553e17 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 03:42:27 +0000 Subject: [PATCH 3/7] =?UTF-8?q?[UPD]=20tests=20:=20nommer=20le=20r=C3=A9pe?= =?UTF-8?q?rtoire=20comme=20fronti=C3=A8re=20de=20la=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le lanceur prend TOUT `test/test_*.py` par glob, et l'en-tête disait pourquoi : une liste de préfixes oublie ce qu'on ajoute. Il ne disait pas où mettre ce qui doit rester dehors, si bien qu'une famille de tests nouvelle semblait avoir quelque chose à déclarer ici. La frontière est un RÉPERTOIRE : ce qui ne doit pas être lancé vit ailleurs que dans `test/`. Rien à déclarer, donc rien à oublier de déclarer. --- EN --- The launcher takes ALL of `test/test_*.py` by glob, and the header said why: a list of prefixes forgets whatever gets added. It did not say where to put what must stay out, so a new family of tests looked as though it had something to declare here. The boundary is a DIRECTORY: what must not run lives somewhere other than `test/`. Nothing to declare, hence nothing to forget to declare. Assisted-by: Claude Opus 5 --- script/test/run_unit_test.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index 57772ab..ead05e2 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -22,6 +22,10 @@ # injoignable, et la liste laissait 2400 d'entre eux hors de la suite — # écrits, verts, jamais lancés. Un glob n'oublie personne ; une liste, si. # +# La frontière est donc un RÉPERTOIRE, pas un nom : ce qui doit rester hors +# de la suite vit ailleurs que dans test/. Une famille de tests nouvelle n'a +# rien à déclarer ici. +# # Un fichier n'est vu que s'il finit par le bloc habituel : # # if __name__ == "__main__": From 8181e693a29d4f0ca5213211a0a2edee6a9ae8bf Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 03:42:34 +0000 Subject: [PATCH 4/7] =?UTF-8?q?[REF]=20tests=20:=20passer=20isort=20et=20b?= =?UTF-8?q?lack=20sur=20trois=20fichiers=20oubli=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois fichiers de la suite n'étaient pas au format du dépôt : imports non triés, expressions coupées là où la ligne tient. `make format` les rattrape. Aucun test n'est ajouté, retiré ni modifié dans ce qu'il vérifie. --- EN --- Three files of the suite were not in the repository's format: unsorted imports, expressions broken where the line fits. `make format` catches them up. No test is added, removed, or changed in what it checks. Assisted-by: Claude Opus 5 --- test/test_code_generator_tools.py | 8 ++++---- test/test_docker_update_version.py | 19 +++++-------------- test/test_version.py | 20 +++++++------------- 3 files changed, 16 insertions(+), 31 deletions(-) diff --git a/test/test_code_generator_tools.py b/test/test_code_generator_tools.py index 7adff06..3cdda3d 100644 --- a/test/test_code_generator_tools.py +++ b/test/test_code_generator_tools.py @@ -6,11 +6,13 @@ import ast import unittest from script.code_generator.search_class_model import ( + ARGS_TYPE_PARAM, extract_lambda, fill_search_field, search_and_replace, - ARGS_TYPE_PARAM, ) + + def count_space_tab(word, group_space=4): """Copied from transform_python_to_code_writer (cannot import due to code_writer dependency not available in erplibre venv).""" @@ -152,9 +154,7 @@ class TestFillSearchField(unittest.TestCase): class TestSearchAndReplace(unittest.TestCase): def test_replace_quoted_value(self): content = 'template_model_name = "old_model"' - result = search_and_replace( - content, "hooks.py", "new_model" - ) + result = search_and_replace(content, "hooks.py", "new_model") self.assertIn('"new_model"', result) self.assertNotIn("old_model", result) diff --git a/test/test_docker_update_version.py b/test/test_docker_update_version.py index fe47931..5c597e2 100644 --- a/test/test_docker_update_version.py +++ b/test/test_docker_update_version.py @@ -7,26 +7,21 @@ import tempfile import unittest from types import SimpleNamespace -from script.docker.docker_update_version import edit_text, edit_docker_prod +from script.docker.docker_update_version import edit_docker_prod, edit_text class TestEditText(unittest.TestCase): """Test docker-compose.yml image version update.""" def _write_compose(self, content): - f = tempfile.NamedTemporaryFile( - mode="w", suffix=".yml", delete=False - ) + f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) f.write(content) f.close() return f.name def test_updates_image_after_erplibre(self): path = self._write_compose( - "services:\n" - " ERPLibre:\n" - " image: old:1.0\n" - " ports:\n" + "services:\n" " ERPLibre:\n" " image: old:1.0\n" " ports:\n" ) config = SimpleNamespace( docker_compose_file=path, @@ -64,17 +59,13 @@ class TestEditDockerProd(unittest.TestCase): """Test Dockerfile.prod FROM line update.""" def _write_dockerfile(self, content): - f = tempfile.NamedTemporaryFile( - mode="w", suffix=".pkg", delete=False - ) + f = tempfile.NamedTemporaryFile(mode="w", suffix=".pkg", delete=False) f.write(content) f.close() return f.name def test_updates_from_line(self): - path = self._write_dockerfile( - "FROM base:old\nRUN apt-get update\n" - ) + path = self._write_dockerfile("FROM base:old\nRUN apt-get update\n") config = SimpleNamespace( docker_compose_file="unused", docker_prod=path, diff --git a/test/test_version.py b/test/test_version.py index dffbb19..567d86f 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -34,9 +34,7 @@ class TestRemoveDotPath(unittest.TestCase): self.assertEqual(remove_dot_path("./"), "") def test_nested_dot_slash(self): - self.assertEqual( - remove_dot_path("./a/./b"), "a/./b" - ) + self.assertEqual(remove_dot_path("./a/./b"), "a/./b") def test_empty_string(self): self.assertEqual(remove_dot_path(""), "") @@ -75,15 +73,11 @@ class TestConstants(unittest.TestCase): def test_pyproject_template(self): result = PYPROJECT_TEMPLATE_FILE % "odoo18.0_python3.12.10" - self.assertEqual( - result, "pyproject.odoo18.0_python3.12.10.toml" - ) + self.assertEqual(result, "pyproject.odoo18.0_python3.12.10.toml") def test_poetry_lock_template(self): result = POETRY_LOCK_TEMPLATE_FILE % "odoo18.0_python3.12.10" - self.assertEqual( - result, "poetry.odoo18.0_python3.12.10.lock" - ) + self.assertEqual(result, "poetry.odoo18.0_python3.12.10.lock") def test_addons_template(self): result = ADDONS_TEMPLATE_FILE % "18.0" @@ -123,9 +117,7 @@ class TestUpdateValidateVersion(unittest.TestCase): self.assertEqual(update.new_version_odoo, "18.0") self.assertEqual(update.new_version_python, "3.12.10") self.assertEqual(update.new_version_poetry, "2.1.3") - self.assertEqual( - update.new_version_erplibre, "odoo18.0_python3.12.10" - ) + self.assertEqual(update.new_version_erplibre, "odoo18.0_python3.12.10") def test_explicit_odoo_version(self): data = {} @@ -177,7 +169,9 @@ class TestUpdateValidateVersion(unittest.TestCase): {"erplibre_version": "odoo18.0_python3.12.10"}, ) update.validate_version() - self.assertIn("default.dev.odoo18.0.xml", update.expected_manifest_name) + self.assertIn( + "default.dev.odoo18.0.xml", update.expected_manifest_name + ) self.assertIn("requirement", update.expected_pyproject_path) self.assertIn("requirement", update.expected_poetry_lock_path) self.assertEqual(update.expected_odoo_name, "odoo18.0") From 197d19d61ef7945cf202ab0bf2fea8a563da79b2 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 03:42:49 +0000 Subject: [PATCH 5/7] =?UTF-8?q?[ADD]=20vpn=20:=20cinq=20pilotes,=20secrets?= =?UTF-8?q?=20en=20coffre,=20diagnostic=20=C3=A9tag=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/erplibre-deployment/SKILL.md | 3 + script/install/install_vpn.sh | 189 ++++ script/todo/kdbx_manager.py | 10 + script/todo/todo_i18n.py | 345 ++++++++ script/vpn/README.base.md | 407 +++++++++ script/vpn/README.fr.md | 202 +++++ script/vpn/README.md | 193 +++++ script/vpn/__init__.py | 0 script/vpn/drivers/__init__.py | 43 + script/vpn/drivers/base.py | 845 ++++++++++++++++++ script/vpn/drivers/l2tp_ipsec.py | 898 ++++++++++++++++++++ script/vpn/drivers/openconnect.py | 330 +++++++ script/vpn/drivers/openvpn.py | 278 ++++++ script/vpn/drivers/sshuttle.py | 178 ++++ script/vpn/drivers/wireguard.py | 269 ++++++ script/vpn/profiles.py | 200 +++++ script/vpn/runner.py | 344 ++++++++ script/vpn/valid.py | 175 ++++ script/vpn/vault.py | 312 +++++++ script/vpn/vpn.py | 363 ++++++++ test/test_vpn_drivers.py | 553 ++++++++++++ test/test_vpn_profiles.py | 212 +++++ test/test_vpn_render.py | 870 +++++++++++++++++++ test/test_vpn_vault.py | 379 +++++++++ 24 files changed, 7598 insertions(+) create mode 100755 script/install/install_vpn.sh create mode 100644 script/vpn/README.base.md create mode 100644 script/vpn/README.fr.md create mode 100644 script/vpn/README.md create mode 100644 script/vpn/__init__.py create mode 100644 script/vpn/drivers/__init__.py create mode 100644 script/vpn/drivers/base.py create mode 100644 script/vpn/drivers/l2tp_ipsec.py create mode 100644 script/vpn/drivers/openconnect.py create mode 100644 script/vpn/drivers/openvpn.py create mode 100644 script/vpn/drivers/sshuttle.py create mode 100644 script/vpn/drivers/wireguard.py create mode 100644 script/vpn/profiles.py create mode 100644 script/vpn/runner.py create mode 100644 script/vpn/valid.py create mode 100644 script/vpn/vault.py create mode 100755 script/vpn/vpn.py create mode 100644 test/test_vpn_drivers.py create mode 100644 test/test_vpn_profiles.py create mode 100644 test/test_vpn_render.py create mode 100644 test/test_vpn_vault.py diff --git a/.claude/skills/erplibre-deployment/SKILL.md b/.claude/skills/erplibre-deployment/SKILL.md index 22f6f01..552e18d 100644 --- a/.claude/skills/erplibre-deployment/SKILL.md +++ b/.claude/skills/erplibre-deployment/SKILL.md @@ -12,6 +12,9 @@ description: >- - **Nginx** : `script/nginx/` pour le reverse proxy - **SSL** : Certbot pour les certificats - **DNS** : `script/deployment/update_dns_cloudflare.py` +- **VPN** : `script/vpn/` — cinq pilotes (L2TP/IPsec PSK, WireGuard, + OpenVPN, OpenConnect, sshuttle), profils en JSON et secrets dans un + coffre KeePassXC. Mode d'emploi : `script/vpn/README.md`. Plateformes supportées : Ubuntu 24.04 / 25.10 / 26.04, Linux Mint 22.3, Debian 12, AlmaLinux 9+, Rocky Linux 9+, openSUSE Leap 16 et Tumbleweed, diff --git a/script/install/install_vpn.sh b/script/install/install_vpn.sh new file mode 100755 index 0000000..60e95aa --- /dev/null +++ b/script/install/install_vpn.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Paquets client VPN, par pilote de script/vpn/drivers/. +# +# sudo bash script/install/install_vpn.sh l2tp_ipsec +# +# Un groupe de paquets par pilote : le nom passé en argument est le `name` du +# pilote, et non un nom de paquet. C'est le CLI (script/vpn/vpn.py install) +# qui appelle ce script, et il ne connaît que les noms de pilotes. +# +# Ce script INSTALLE et ne configure rien : la configuration est rendue au +# montage du tunnel, dans un tmpfs, par le pilote. Il désactive tout de même +# le démarrage automatique des services — un strongSwan ou un xl2tpd lancé au +# boot tiendrait UDP 500/1701 et empêcherait l'instance dédiée au profil de +# s'attacher. +set -euo pipefail + +log() { echo "[VPN] $*"; } +die() { echo "[VPN] ERREUR: $*" >&2; exit 1; } + +usage() { + cat <<'USAGE' +Usage : sudo bash script/install/install_vpn.sh + +Pilotes connus : + l2tp_ipsec strongSwan + xl2tpd + pppd (L2TP/IPsec à clé pré-partagée) + wireguard wireguard-tools + openvpn openvpn + openconnect openconnect + vpnc-scripts + sshuttle sshuttle (sur RHEL/Rocky/Alma : dépôt EPEL requis) + +Sans argument : tous les pilotes. +USAGE +} + +check_root() { + [ "$(id -u)" -eq 0 ] || die "à lancer en root : sudo bash $0 $*" +} + +detect_os() { + [ -f /etc/os-release ] || die "OS indéterminable (pas de /etc/os-release)" + # shellcheck disable=SC1091 + . /etc/os-release + OS="${ID}" + OS_LIKE="${ID_LIKE:-}" + log "OS détecté : ${OS}" +} + +family() { + case "$OS" in + ubuntu|debian|linuxmint|pop|elementary|raspbian) echo debian; return ;; + arch|manjaro|endeavouros|artix|garuda) echo arch; return ;; + fedora|rhel|centos|almalinux|rocky) echo rhel; return ;; + opensuse*|sles|sled) echo suse; return ;; + esac + case "$OS_LIKE" in + *debian*|*ubuntu*) echo debian; return ;; + *arch*) echo arch; return ;; + *rhel*|*fedora*) echo rhel; return ;; + *suse*) echo suse; return ;; + esac + die "famille de distribution inconnue : ${OS} (ID_LIKE=${OS_LIKE})" +} + +# Les paquets, par pilote puis par famille. `strongswan-starter` fournit la +# commande `ipsec` et le démon starter, que le pilote L2TP utilise ; les +# paquets `charon-systemd`/`swanctl` seuls ne la fournissent PAS. +packages_for() { + local driver="$1" fam="$2" + case "${driver}:${fam}" in + # libstrongswan-standard-plugins apporte le greffon openssl, et + # avec lui 3DES. Sans ce paquet, charon ANNONCE 3DES, le + # concentrateur le choisit — c'est souvent le seul qu'il connaisse — + # et la négociation meurt sur « ENCRYPTION_ALGORITHM 3DES_CBC not + # supported! ». Mesuré sur Ubuntu 24.04 : greffons chargés sans lui, + # « aes md5 rc2 sha1 », donc pas de 3DES. + l2tp_ipsec:debian) + echo "strongswan strongswan-starter libstrongswan-standard-plugins libcharon-extra-plugins xl2tpd ppp" ;; + l2tp_ipsec:arch) echo "strongswan xl2tpd ppp" ;; + l2tp_ipsec:rhel) echo "strongswan xl2tpd ppp" ;; + l2tp_ipsec:suse) echo "strongswan xl2tpd ppp" ;; + + # wireguard-tools fournit wg ET wg-quick. Le module noyau est dans + # Linux depuis 5.6 : rien à compiler sur les distributions visées. + wireguard:*) echo "wireguard-tools" ;; + + openvpn:*) echo "openvpn" ;; + + # vpnc-scripts porte le script que openconnect appelle pour poser + # les routes et le DNS. Sans lui, la session s'ouvre et la machine + # ne voit rien passer. + openconnect:debian) echo "openconnect vpnc-scripts" ;; + openconnect:*) echo "openconnect" ;; + + sshuttle:*) echo "sshuttle" ;; + + *) die "pilote inconnu : ${driver}. Voir --help." ;; + esac +} + +install_packages() { + local fam="$1"; shift + log "Installation : $*" + case "$fam" in + debian) + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + # shellcheck disable=SC2086 + apt-get install -y --no-install-recommends $* + ;; + arch) + # shellcheck disable=SC2086 + pacman -Sy --needed --noconfirm $* + ;; + rhel) + # shellcheck disable=SC2086 + { command -v dnf >/dev/null && dnf install -y $*; } \ + || yum install -y $* + ;; + suse) + # shellcheck disable=SC2086 + zypper --non-interactive install $* + ;; + esac +} + +disable_autostart() { + # Un service lancé au boot tient le port et fait échouer l'instance + # dédiée au profil. On les arrête et on les désactive : le pilote + # démarre ce dont il a besoin, quand il en a besoin. + command -v systemctl >/dev/null || return 0 + for unit in xl2tpd strongswan-starter strongswan ipsec; do + if systemctl list-unit-files "${unit}.service" >/dev/null 2>&1 \ + && systemctl is-enabled "${unit}.service" >/dev/null 2>&1; then + log "désactivation de ${unit}.service (le pilote le pilote)" + systemctl disable --now "${unit}.service" >/dev/null 2>&1 || true + fi + done +} + +# Les binaires que chaque pilote exige, en miroir de `binaries` dans +# script/vpn/drivers/. Un paquet installé sans son binaire (nom changé, +# dépôt incomplet) doit être vu ICI, pas au premier montage. +binaries_for() { + case "$1" in + l2tp_ipsec) echo "ipsec xl2tpd pppd ip" ;; + wireguard) echo "wg wg-quick ip" ;; + openvpn) echo "openvpn ip" ;; + openconnect) echo "openconnect ip" ;; + sshuttle) echo "sshuttle ssh" ;; + esac +} + +verify() { + local driver="$1" missing="" + for b in $(binaries_for "$driver"); do + command -v "$b" >/dev/null || missing="${missing} ${b}" + done + if [ -n "$missing" ]; then + die "toujours absents après installation :${missing}" + fi + log "vérifié : tout est en place pour ${driver}" +} + +ALL_DRIVERS="l2tp_ipsec wireguard openvpn openconnect sshuttle" + +main() { + case "${1:-}" in + -h|--help) usage; exit 0 ;; + esac + check_root "$@" + detect_os + local fam drivers + fam="$(family)" + # Sans argument : tout. C'est ce que « [8] Installer les paquets + # client » demande quand on ne choisit pas de technologie. + drivers="${*:-${ALL_DRIVERS}}" + for driver in ${drivers}; do + log "── ${driver} ──" + install_packages "$fam" "$(packages_for "$driver" "$fam")" + verify "$driver" + done + disable_autostart + log "Terminé. Monter un tunnel : ./script/vpn/vpn.py up --profile " +} + +main "$@" diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index edcb6e4..6e9c782 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -101,6 +101,16 @@ class KdbxManager: print(t("kdbx_give_up")) return None + def adopt(self, kdbx) -> None: + """Prend pour la session une base DÉJÀ ouverte. + + Sert au moment où le coffre vient d'être CRÉÉ : `create_database` + rend la base ouverte, et sans cela le mot de passe maître serait + redemandé dans la seconde qui suit — à quelqu'un qui vient de le + taper deux fois. + """ + self._kdbx = kdbx + def get_extra_command_user( self, kdbx_key: str | list | None ) -> tuple[str | list, dict]: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4339082..b138590 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -11356,6 +11356,351 @@ TRANSLATIONS = { "fr": "intactes :", "en": "alone:", }, + # VPN (script/todo/vpn_menu.py, script/vpn/) + "VPN & tunnels": { + "fr": "🔐 VPN et tunnels", + "en": "🔐 VPN & tunnels", + }, + "VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)": { + "fr": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)", + "en": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)", + }, + "VPN tunnels: connect, profiles, vault secrets": { + "fr": "Tunnels VPN : connexion, profils, secrets du coffre", + "en": "VPN tunnels: connect, profiles, vault secrets", + }, + "Connection": { + "fr": "🔗 Connexion", + "en": "🔗 Connection", + }, + "VPN - Connect a profile": { + "fr": "🟢 VPN - Connecter un profil", + "en": "🟢 VPN - Connect a profile", + }, + "VPN - Disconnect a profile": { + "fr": "🔴 VPN - Déconnecter un profil", + "en": "🔴 VPN - Disconnect a profile", + }, + "VPN - Status and diagnosis": { + "fr": "🩺 VPN - État et diagnostic", + "en": "🩺 VPN - Status and diagnosis", + }, + "Profiles & secrets": { + "fr": "🗂 Profils et secrets", + "en": "🗂 Profiles & secrets", + }, + "VPN - Add or edit a profile": { + "fr": "📝 VPN - Ajouter ou modifier un profil", + "en": "📝 VPN - Add or edit a profile", + }, + "VPN - Store secrets in the vault": { + "fr": "🔑 VPN - Déposer les secrets dans le coffre", + "en": "🔑 VPN - Store secrets in the vault", + }, + "VPN - Show the rendered configuration (dry-run)": { + "fr": "🔍 VPN - Afficher la configuration rendue (à blanc)", + "en": "🔍 VPN - Show the rendered configuration (dry-run)", + }, + "VPN - Delete a profile": { + "fr": "🗑 VPN - Supprimer un profil", + "en": "🗑 VPN - Delete a profile", + }, + "VPN - Install the client packages": { + "fr": "📦 VPN - Installer les paquets client", + "en": "📦 VPN - Install the client packages", + }, + "VPN - What can this machine do?": { + "fr": "🧰 VPN - Ce que cette machine sait faire", + "en": "🧰 VPN - What can this machine do?", + }, + "Which technology?": { + "fr": "Quelle technologie ?", + "en": "Which technology?", + }, + "No VPN profile yet: create one first.": { + "fr": "Aucun profil VPN : en créer un d'abord.", + "en": "No VPN profile yet: create one first.", + }, + "all traffic": { + "fr": "tout le trafic", + "en": "all traffic", + }, + "Driver": { + "fr": "Pilote", + "en": "Driver", + }, + "PPP user (the one the server authenticates)": { + "fr": "Utilisateur PPP (celui que le serveur authentifie)", + "en": "PPP user (the one the server authenticates)", + }, + "MTU": { + "fr": "MTU", + "en": "MTU", + }, + "Local L2TP port": { + "fr": "Port L2TP local", + "en": "Local L2TP port", + }, + "Use the DNS pushed by the peer?": { + "fr": "Utiliser les DNS poussés par le pair ?", + "en": "Use the DNS pushed by the peer?", + }, + "DNS search domain (optional)": { + "fr": "Domaine de recherche DNS (facultatif)", + "en": "DNS search domain (optional)", + }, + "Server address (hostname or IP)": { + "fr": "Adresse du serveur (nom d'hôte ou IP)", + "en": "Server address (hostname or IP)", + }, + "Networks to reach, comma-separated": { + "fr": "Réseaux à joindre, séparés par des virgules", + "en": "Networks to reach, comma-separated", + }, + "Send ALL traffic through the tunnel?": { + "fr": "Envoyer TOUT le trafic dans le tunnel ?", + "en": "Send ALL traffic through the tunnel?", + }, + "Witness address reachable only through the tunnel (optional)": { + "fr": "Adresse témoin joignable seulement par le tunnel (facultatif)", + "en": "Witness address reachable only through the tunnel (optional)", + }, + "Advanced settings (MTU, L2TP port, DNS)? (y/N)": { + "fr": "Réglages avancés (MTU, port L2TP, DNS) ? (o/N)", + "en": "Advanced settings (MTU, L2TP port, DNS)? (y/N)", + }, + "Profile name (lowercase, digits, - or _)": { + "fr": "Nom du profil (minuscules, chiffres, « - » ou « _ »)", + "en": "Profile name (lowercase, digits, - or _)", + }, + "Profile number (0 to go back)": { + "fr": "Numéro du profil (0 pour revenir)", + "en": "Profile number (0 to go back)", + }, + "Profile saved: ": { + "fr": "Profil enregistré : ", + "en": "Profile saved: ", + }, + "Profile refused: ": { + "fr": "Profil refusé : ", + "en": "Profile refused: ", + }, + "Profile deleted.": { + "fr": "Profil supprimé.", + "en": "Profile deleted.", + }, + "Delete profile": { + "fr": "Supprimer le profil", + "en": "Delete profile", + }, + "Next step: store its secrets in the vault.": { + "fr": "Étape suivante : déposer ses secrets dans le coffre.", + "en": "Next step: store its secrets in the vault.", + }, + "Its vault entry is kept: delete it in KeePassXC.": { + "fr": "Son entrée du coffre est conservée : la supprimer dans KeePassXC.", + "en": "Its vault entry is kept: delete it in KeePassXC.", + }, + "Not deletable here: this profile comes from a shared configuration file.": { + "fr": "Pas supprimable d'ici : ce profil vient d'un fichier de configuration partagé.", + "en": "Not deletable here: this profile comes from a shared configuration file.", + }, + "Unknown driver: ": { + "fr": "Pilote inconnu : ", + "en": "Unknown driver: ", + }, + "Unknown choice.": { + "fr": "Choix inconnu.", + "en": "Unknown choice.", + }, + "The installation requires sudo.": { + "fr": "L'installation demande sudo.", + "en": "The installation requires sudo.", + }, + "Run this plan? (y/N): ": { + "fr": "Exécuter ce plan ? (o/N) : ", + "en": "Run this plan? (y/N): ", + }, + "No vault: nothing stored.": { + "fr": "Pas de coffre : rien n'a été déposé.", + "en": "No vault: nothing stored.", + }, + "Vault entry": { + "fr": "Entrée du coffre", + "en": "Vault entry", + }, + "An empty answer keeps the stored value.": { + "fr": "Une réponse vide garde la valeur déjà en place.", + "en": "An empty answer keeps the stored value.", + }, + "Secrets stored in the vault.": { + "fr": "Secrets déposés dans le coffre.", + "en": "Secrets stored in the vault.", + }, + "Confirm": { + "fr": "Confirmer", + "en": "Confirm", + }, + "The two entries differ, nothing stored.": { + "fr": "Les deux saisies diffèrent, rien n'a été déposé.", + "en": "The two entries differ, nothing stored.", + }, + "The vault MASTER password is stored in the configuration in clear text. Remove it and type it on demand.": { + "fr": "Le mot de passe MAÎTRE du coffre est écrit en clair dans la configuration. Le retirer et le saisir à la demande.", + "en": "The vault MASTER password is stored in the configuration in clear text. Remove it and type it on demand.", + }, + # VPN — pilotes de la phase 2 et 3 (WireGuard, OpenVPN, OpenConnect, sshuttle) + "never mounted against a real server: only unit tests cover it": { + "fr": ( + "jamais monté contre un vrai serveur : seuls les tests" + " unitaires le couvrent" + ), + "en": "never mounted against a real server: only unit tests cover it", + }, + "When the far side imposes it: a router, a firewall, Windows RRAS": { + "fr": "Quand le site l'impose : un routeur, un pare-feu, Windows RRAS", + "en": "When the far side imposes it: a router, a firewall, Windows RRAS", + }, + "When you control both ends: the fastest and the simplest": { + "fr": "Quand on tient les deux bouts : le plus rapide et le plus simple", + "en": "When you control both ends: the fastest and the simplest", + }, + "When the site handed you a .ovpn file": { + "fr": "Quand le site a fourni un fichier .ovpn", + "en": "When the site handed you a .ovpn file", + }, + "Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances": { + "fr": "Boîtiers Cisco AnyConnect, Pulse, GlobalProtect, Fortinet", + "en": "Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances", + }, + "When all you have is SSH access: nothing to install on the far side": { + "fr": "Quand on n'a qu'un accès SSH : rien à installer en face", + "en": "When all you have is SSH access: nothing to install on the far side", + }, + "IPsec pre-shared key (PSK)": { + "fr": "Clé pré-partagée IPsec (PSK)", + "en": "IPsec pre-shared key (PSK)", + }, + "PPP password": { + "fr": "Mot de passe PPP", + "en": "PPP password", + }, + "WireGuard private key of this machine": { + "fr": "Clé privée WireGuard de cette machine", + "en": "WireGuard private key of this machine", + }, + "WireGuard pre-shared key (optional)": { + "fr": "Clé pré-partagée WireGuard (facultative)", + "en": "WireGuard pre-shared key (optional)", + }, + "OpenVPN password": { + "fr": "Mot de passe OpenVPN", + "en": "OpenVPN password", + }, + "VPN password": { + "fr": "Mot de passe du VPN", + "en": "VPN password", + }, + "Address of this machine inside the tunnel (10.7.0.2/32)": { + "fr": "Adresse de cette machine dans le tunnel (10.7.0.2/32)", + "en": "Address of this machine inside the tunnel (10.7.0.2/32)", + }, + "Public key of the peer": { + "fr": "Clé publique du pair", + "en": "Public key of the peer", + }, + "WireGuard endpoint port": { + "fr": "Port de l'endpoint WireGuard", + "en": "WireGuard endpoint port", + }, + "DNS server inside the tunnel (optional)": { + "fr": "Serveur DNS dans le tunnel (facultatif)", + "en": "DNS server inside the tunnel (optional)", + }, + "PersistentKeepalive, in seconds": { + "fr": "PersistentKeepalive, en secondes", + "en": "PersistentKeepalive, in seconds", + }, + "Path to the .ovpn file provided by the site": { + "fr": "Chemin du fichier .ovpn fourni par le site", + "en": "Path to the .ovpn file provided by the site", + }, + "OpenVPN user (empty if the file authenticates by certificate)": { + "fr": "Utilisateur OpenVPN (vide si le fichier authentifie par certificat)", + "en": "OpenVPN user (empty if the file authenticates by certificate)", + }, + "VPN user": { + "fr": "Utilisateur du VPN", + "en": "VPN user", + }, + "Protocol (anyconnect, nc, pulse, gp, f5, fortinet, array)": { + "fr": "Protocole (anyconnect, nc, pulse, gp, f5, fortinet, array)", + "en": "Protocol (anyconnect, nc, pulse, gp, f5, fortinet, array)", + }, + "Authentication group / realm (optional)": { + "fr": "Groupe d'authentification / royaume (facultatif)", + "en": "Authentication group / realm (optional)", + }, + "Pinned server certificate (sha256:... , printed on first refusal)": { + "fr": "Certificat serveur épinglé (sha256:… , imprimé au premier refus)", + "en": "Pinned server certificate (sha256:... , printed on first refusal)", + }, + "HTTPS port": { + "fr": "Port HTTPS", + "en": "HTTPS port", + }, + "SSH port": { + "fr": "Port SSH", + "en": "SSH port", + }, + "SSH target (user@host, or a ~/.ssh/config alias)": { + "fr": "Cible SSH (utilisateur@hôte, ou un alias de ~/.ssh/config)", + "en": "SSH target (user@host, or a ~/.ssh/config alias)", + }, + "Also send DNS queries through the tunnel?": { + "fr": "Envoyer aussi les requêtes DNS dans le tunnel ?", + "en": "Also send DNS queries through the tunnel?", + }, + "Advanced settings? (y/N)": { + "fr": "Réglages avancés ? (o/N)", + "en": "Advanced settings? (y/N)", + }, + "No secret to store: this one authenticates over SSH.": { + "fr": "Aucun secret à déposer : celui-là s'authentifie par SSH.", + "en": "No secret to store: this one authenticates over SSH.", + }, + "Several technologies match: ": { + "fr": "Plusieurs technologies correspondent : ", + "en": "Several technologies match: ", + }, + "Authentication through a web form (SAML / SSO)?": { + "fr": "Authentification par formulaire web (SAML / SSO) ?", + "en": "Authentication through a web form (SAML / SSO)?", + }, + "Browser command for SSO (empty: show the URL to open yourself)": { + "fr": "Programme navigateur pour le SSO (vide : afficher l'URL à ouvrir soi-même)", + "en": "Browser command for SSO (empty: show the URL to open yourself)", + }, + "Vault permissions tightened to 0600, it was readable by others: ": { + "fr": "Permissions du coffre resserrées à 0600, il était lisible par d'autres : ", + "en": "Vault permissions tightened to 0600, it was readable by others: ", + }, + "already set": { + "fr": "déjà en place", + "en": "already set", + }, + "empty": { + "fr": "vide", + "en": "empty", + }, + "Still missing, the tunnel will not come up: ": { + "fr": "Toujours manquant, le tunnel ne montera pas : ", + "en": "Still missing, the tunnel will not come up: ", + }, + "No network routed yet: this tunnel will only reach the remote host. Connect once — the address you get tells you which network to add.": { + "fr": "Aucun réseau routé pour l'instant : ce tunnel ne joindra que l'hôte distant. Monter une fois — l'adresse obtenue dira quel réseau ajouter.", + "en": "No network routed yet: this tunnel will only reach the remote host. Connect once — the address you get tells you which network to add.", + }, } diff --git a/script/vpn/README.base.md b/script/vpn/README.base.md new file mode 100644 index 0000000..ce8fcee --- /dev/null +++ b/script/vpn/README.base.md @@ -0,0 +1,407 @@ + + + + + + +# VPN — five open tunnels, secrets in a KeePassXC vault + +`vpn.py` brings up, tears down and diagnoses a VPN tunnel. One driver per +technology, five of them, all free software. + +The split is the whole design: what is *not* secret (host, user, routes, MTU) +lives in readable JSON configuration; the pre-shared keys and the passwords +live in a KeePassXC `.kdbx` vault. A profile can therefore be shown, compared +and shared without handing over the means to bring the tunnel up. + + +# VPN — cinq tunnels ouverts, secrets dans un coffre KeePassXC + +`vpn.py` monte, démonte et diagnostique un tunnel VPN. Un pilote par +technologie, cinq en tout, tous libres. + +Le partage est tout le dispositif : ce qui n'est PAS secret (hôte, +utilisateur, routes, MTU) vit dans une configuration JSON lisible ; les clés +pré-partagées et les mots de passe vivent dans un coffre KeePassXC `.kdbx`. Un +profil peut donc être montré, comparé, partagé — sans donner de quoi monter le +tunnel. + + +## Which one to pick + +| Driver | Pick it when | Secrets in the vault | +|--------|--------------|----------------------| +| `l2tp_ipsec` | the far side imposes it: a router, a firewall, Windows RRAS | PSK + PPP password | +| `wireguard` | you control both ends — fastest, simplest | private key (+ optional PSK) | +| `openvpn` | the site handed you a `.ovpn` file | password, if the file needs one | +| `openconnect` | Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances | password | +| `sshuttle` | all you have is SSH access — nothing to install on the far side | none: SSH keys do the work | + + +## Lequel choisir + +| Pilote | À prendre quand | Secrets dans le coffre | +|--------|-----------------|------------------------| +| `l2tp_ipsec` | le site l'impose : un routeur, un pare-feu, Windows RRAS | PSK + mot de passe PPP | +| `wireguard` | on tient les deux bouts — le plus rapide, le plus simple | clé privée (+ PSK facultative) | +| `openvpn` | le site a fourni un fichier `.ovpn` | mot de passe, si le fichier en veut un | +| `openconnect` | boîtiers Cisco AnyConnect, Pulse, GlobalProtect, Fortinet | mot de passe | +| `sshuttle` | on n'a qu'un accès SSH — rien à installer en face | aucun : les clés SSH suffisent | + + +## Commands + + +## Commandes + + +```bash +./script/vpn/vpn.py check # ce que la machine sait faire +sudo bash script/install/install_vpn.sh wireguard # ou : tous, sans argument +./script/vpn/vpn.py list +./script/vpn/vpn.py up --profile acme --dry-run +./script/vpn/vpn.py up --profile acme +./script/vpn/vpn.py status --profile acme +./script/vpn/vpn.py diagnose --profile acme +./script/vpn/vpn.py down --profile acme +``` + + +Everything is also reachable from the CLI: **TODO › Execute › Deployment › +VPN**, and from **TODO › Execute › Network › VPN** — a tunnel gets looked for +in both places. The menu is where profiles are created and secrets are typed +in; `vpn.py` is what the menu runs. Connecting from the menu shows the plan +first and asks before running it. + +Run it as **yourself, not under sudo**: the vault lives in your home and its +master password is yours to type. Each privileged step calls `sudo` on its +own, and `--dry-run` shows every one of them without running any. + + +Tout est aussi accessible depuis le CLI : **TODO › Execute › Déploiement › +VPN**, et depuis **TODO › Execute › Réseau › VPN** — un tunnel se cherche aux +deux endroits. Le menu est là pour créer les profils et saisir les secrets ; +`vpn.py` est ce que le menu lance. Se connecter depuis le menu montre d'abord +le plan, et demande avant de l'exécuter. + +À lancer en tant qu'**utilisateur, pas sous sudo** : le coffre est dans votre +home et son mot de passe maître est le vôtre. Chaque étape privilégiée appelle +`sudo` séparément, et `--dry-run` les montre toutes sans en exécuter aucune. + + +## Where things live + +| Path | Content | +|------|---------| +| `private/todo/todo_override_private.json` | your profiles — gitignored, 0600 | +| `script/todo/todo.json` | the `vpn` section, empty: profiles shared by a team can go here | +| your `.kdbx` vault | one entry per profile, `ERPLibre VPN / ` | +| `/dev/shm/erplibre-vpn//` | 0700 root — **the secrets**, in tmpfs, erased on `down` | +| `/run/erplibre-vpn/.*` | non-secret state (chosen interface, pid, log), readable without sudo | +| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP only: a marked block, removed on `down` | + + +## Où vivent les choses + +| Chemin | Contenu | +|--------|---------| +| `private/todo/todo_override_private.json` | vos profils — gitignored, 0600 | +| `script/todo/todo.json` | la section `vpn`, vide : les profils partagés par une équipe peuvent y aller | +| votre coffre `.kdbx` | une entrée par profil, `ERPLibre VPN / ` | +| `/dev/shm/erplibre-vpn//` | 0700 root — **les secrets**, en tmpfs, effacés au `down` | +| `/run/erplibre-vpn/.*` | 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` | + + +## The three security rules + +1. **No secret in an argument.** `/proc//cmdline` is readable by every + user of the machine. Secrets travel on standard input only; a single place + (`runner.py`) holds that rule, and a unit test replays the plan of **every** + driver and fails if a secret ever reaches a command line. +2. **No secret on persistent storage.** The files a technology insists on are + written 0600 into tmpfs and erased on `down`. Two drivers need none at all: + OpenConnect passes the password on standard input (`--passwd-on-stdin`), and + sshuttle has no secret to begin with. One residual, stated rather than + hidden: while an L2TP tunnel is up, root can read the pppd options file. + pppd takes a password from a file or nothing. +3. **The master password is written nowhere.** Leave `kdbx.password` empty; it + is asked once per session. Only the vault *path* is stored, in the single + gitignored file. The CLI says so when it finds a master password in the + configuration. + +The L2TP PSK reaches strongSwan **hex-encoded** (`PSK 0x…`): same bytes, and +no question of escaping a `"` or a `\` inside a pre-shared key. + + +## Les trois règles de sécurité + +1. **Aucun secret en argument.** `/proc//cmdline` est lisible par tout + utilisateur de la machine. Les secrets ne passent que par l'entrée + standard ; un seul endroit (`runner.py`) porte cette règle, et un test + unitaire rejoue le plan de **chaque** pilote et échoue si un secret atteint + une ligne de commande. +2. **Aucun secret sur un disque persistant.** Les fichiers qu'une technologie + exige sont écrits en 0600 dans un tmpfs et effacés au `down`. Deux pilotes + n'en ont aucun : OpenConnect passe le mot de passe par l'entrée standard + (`--passwd-on-stdin`), et sshuttle n'a pas de secret du tout. Un résiduel, + dit plutôt que caché : tant qu'un tunnel L2TP est monté, root peut lire le + fichier d'options pppd. pppd prend un mot de passe dans un fichier, ou pas + du tout. +3. **Le mot de passe maître ne s'écrit nulle part.** Laisser `kdbx.password` + vide ; il est demandé une fois par session. Seul le *chemin* du coffre est + retenu, dans le seul fichier gitignored. Le CLI le signale s'il trouve un + mot de passe maître dans la configuration. + +Le PSK L2TP arrive à strongSwan **en hexadécimal** (`PSK 0x…`) : mêmes octets, +et plus aucune question d'échappement d'un `"` ou d'un `\` dans une clé +pré-partagée. + + +## What each driver settles for you + +**L2TP/IPsec** — three stages, and all three are needed for an interface: +IPsec in **transport** mode protects UDP 1701, L2TP opens a session inside it, +PPP authenticates. Six pitfalls are handled here, all six found by connecting +to a real concentrator: + +- `charon { install_routes = no }`, otherwise charon installs a route that + captures the L2TP traffic — the classic *"the SA is established, ppp0 never + appears"*. +- An **AppArmor** rule. AppArmor confines charon by path and `/dev/shm` is not + in its profile, so charon is denied the secrets file by the kernel and fails + three stages later on *"no shared key found"* — with the PSK sitting there, + correct. Only `journalctl -k | grep DENIED` says so. The rule goes in the + `local/` file Debian and Ubuntu provide for exactly this. +- **`rightid=%any`**. A gateway announces itself by its IP even when `right` + is a name; without this, strongSwan refuses: *"IDir '203.0.113.5' does not + match to 'vpn.example.com'"*. +- **A wait for the connection to load.** `ipsec start` returns before the + starter has pushed the connections; an immediate `ipsec up` fails on *"no + match"* — on a perfectly valid configuration, the most misleading error of + the sequence. +- **The direction of authentication.** `require chap` / `require + authentication` (xl2tpd) and `require-mschap-v2` (pppd) all mean *require + the PEER to authenticate to us*. A client must not: the server refuses, and + pppd tears the link down with *"LCP terminated by peer (peer refused to + authenticate)"*. What a client wants is `refuse-pap` and `refuse-eap` — + which speak about **us**. +- A `/32` survival route to the server (in all-traffic mode the ESP packets + would enter the tunnel they carry), and `resolvectl`, because + systemd-resolved ignores `/etc/ppp/resolv.conf`. + +One packaging note that costs an hour if missed: without the **openssl** +plugin (`libstrongswan-standard-plugins`), charon advertises 3DES, the +concentrator picks it — often the only cipher it knows — and the negotiation +dies on *"ENCRYPTION_ALGORITHM 3DES_CBC not supported!"*. The installer ships +it. + +**WireGuard** — it has no session, so `wg-quick up` succeeds even with a wrong +peer key or an unreachable endpoint. Nothing says no, because nobody is there +to say it. This driver therefore **waits for a handshake** before calling the +tunnel up. Routes come from `AllowedIPs` and belong to `wg-quick`; the driver +does not double its work. No `DNS =` line either: wg-quick hands that to +`resolvconf`, missing from many systemd-resolved installs, and the whole +configuration fails when it is. + +**OpenVPN** — it starts from the `.ovpn` the site gave you; this driver does +not invent one. Two things that are not obvious: `--cd`, because a `.ovpn` +references its neighbours relatively; and option order, because what follows +`--config` overrides the file — a bare `auth-user-pass` inside would otherwise +wait for a keystroke that never comes, the daemon being detached. Split tunnel +is asked for with `--route-nopull`, which also drops the pushed DNS; the driver +says so when it takes it. + +**OpenConnect** — `--non-inter` is deliberate in password mode. Without it an +unknown server certificate raises a question, and openconnect would read the +answer from the standard input the password arrives on. With it, openconnect +refuses at once **and** prints the `--servercert sha256:…` line to paste into +the profile's `oc_servercert`. Routes belong to the server, through +`vpnc-script`; the profile can add to them, not replace them. + +Set **`oc_sso`** when the concentrator authenticates through a **web form** +(SAML / SSO — Azure AD, Okta, Duo). There is then no password to send, and +Cisco's own client needs a screen for its embedded WebKit browser — often +with `WEBKIT_DISABLE_DMABUF_RENDERER=1` for it to render at all; its CLI +cannot do this flow. openconnect can, with no screen on the client machine: +measured in its library, it listens on **local port 29786** and waits for the +browser's redirect after launching `--external-browser` with the login URL. +On a server that "browser" is a plain `echo`, so the URL is printed for you to +open in **your own** browser — bring the redirect back with + +```bash +ssh -L 29786:localhost:29786 +``` + +before opening it. The password never leaves your own workstation. Both +timeouts differ on purpose: two minutes for a password, five for a human +walking through an identity provider. + +**sshuttle** — no interface at all: it redirects through the firewall. Every +interface and routing check is therefore silent for it, and the **witness +address** is the only judge — this driver is the reason the `probe` field +exists. It also insists on being run by *you*: it calls sudo itself, for the +firewall only. Running it under sudo would open the SSH session as root, with +root's keys. + + +## Ce que chaque pilote règle pour vous + +**L2TP/IPsec** — trois étages, et il faut les trois pour avoir une interface : +IPsec en mode **transport** protège l'UDP 1701, L2TP ouvre une session dedans, +PPP authentifie. Six pièges réglés ici, les six trouvés en montant un tunnel +vers un vrai concentrateur : + +- `charon { install_routes = no }`, sinon charon pose une route qui capte le + trafic L2TP — le classique *« la SA est établie, ppp0 n'apparaît pas »*. +- Une règle **AppArmor**. AppArmor confine charon par chemin et `/dev/shm` + n'est pas dans son profil : le noyau lui refuse le fichier de secrets, et + l'échec ressort trois étages plus loin en *« no shared key found »* — avec + le PSK bien là, bien formé. Seul `journalctl -k | grep DENIED` le dit. La + règle va dans le fichier `local/` que Debian et Ubuntu prévoient pour ça. +- **`rightid=%any`**. Une passerelle s'annonce par son IP même quand `right` + est un nom ; sans cela, strongSwan refuse : *« IDir '203.0.113.5' does not + match to 'vpn.exemple.com' »*. +- **Une attente du chargement de la connexion.** `ipsec start` rend la main + avant que le starter ait poussé les connexions ; un `ipsec up` immédiat + échoue sur *« no match »* — sur une configuration parfaitement valide, + l'erreur la plus trompeuse de la séquence. +- **Le sens de l'authentification.** `require chap` / `require + authentication` (xl2tpd) et `require-mschap-v2` (pppd) veulent tous dire + *exiger que le PAIR s'authentifie auprès de nous*. Un client ne doit pas : + le serveur refuse, et pppd coupe la liaison — *« LCP terminated by peer + (peer refused to authenticate) »*. Ce qu'un client veut, c'est `refuse-pap` + et `refuse-eap`, qui parlent de **nous**. +- Une route de survie `/32` vers le serveur (en mode « tout le trafic », les + paquets ESP entreraient dans le tunnel qu'ils portent), et `resolvectl`, + parce que systemd-resolved ignore `/etc/ppp/resolv.conf`. + +Une note d'empaquetage qui coûte une heure si on la manque : sans le greffon +**openssl** (`libstrongswan-standard-plugins`), charon annonce 3DES, le +concentrateur le choisit — c'est souvent le seul qu'il connaisse — et la +négociation meurt sur *« ENCRYPTION_ALGORITHM 3DES_CBC not supported! »*. +L'installateur le livre. + +**WireGuard** — il n'a pas de session, donc `wg-quick up` réussit même avec +une clé de pair fausse ou un endpoint injoignable. Rien ne dit non, parce +qu'il n'y a personne pour le dire. Ce pilote **attend donc une poignée de +main** avant de déclarer le tunnel monté. Les routes viennent d'`AllowedIPs` +et appartiennent à `wg-quick` ; le pilote ne double pas son travail. Pas de +ligne `DNS =` non plus : wg-quick la confie à `resolvconf`, absent de beaucoup +d'installations systemd-resolved, et c'est la configuration entière qui échoue +alors. + +**OpenVPN** — il part du `.ovpn` que le site a fourni ; ce pilote n'en +fabrique pas. Deux choses qu'on aurait tort de croire évidentes : `--cd`, +parce qu'un `.ovpn` référence ses voisins en relatif ; et l'ordre des options, +parce que ce qui suit `--config` l'emporte sur le fichier — un +`auth-user-pass` nu dedans ferait sinon attendre une saisie qui ne viendra +jamais, le démon étant détaché. Le tunnel scindé se demande par +`--route-nopull`, qui écarte aussi le DNS poussé ; le pilote le dit quand il +le prend. + +**OpenConnect** — `--non-inter` est voulu en mode mot de passe. Sans lui, un +certificat serveur inconnu déclenche une question, et openconnect la lirait +sur l'entrée standard par laquelle arrive le mot de passe. Avec lui, +openconnect refuse tout de suite **et** imprime la ligne +`--servercert sha256:…` à recopier dans le champ `oc_servercert` du profil. +Les routes appartiennent au serveur, via `vpnc-script` ; le profil peut en +ajouter, pas les remplacer. + +Cocher **`oc_sso`** quand le concentrateur authentifie par un **formulaire +web** (SAML / SSO — Azure AD, Okta, Duo). Il n'y a alors aucun mot de passe à +envoyer, et le client de Cisco réclame un écran pour son navigateur WebKit +embarqué — souvent avec `WEBKIT_DISABLE_DMABUF_RENDERER=1` pour qu'il +s'affiche ; son CLI, lui, ne sait pas faire cet échange. openconnect le fait +sans écran sur la machine cliente : mesuré dans sa bibliothèque, il écoute sur +le **port local 29786** et attend la redirection du navigateur, après avoir +lancé `--external-browser` avec l'URL de connexion. Sur un serveur, ce +« navigateur » est un simple `echo` : l'URL s'affiche, et on l'ouvre dans +**son propre** navigateur — en faisant revenir la redirection par + +```bash +ssh -L 29786:localhost:29786 +``` + +avant de l'ouvrir. Le mot de passe ne quitte jamais votre poste. Les deux +délais diffèrent exprès : deux minutes pour un mot de passe, cinq pour un +humain qui traverse un fournisseur d'identité. + +**sshuttle** — aucune interface : il détourne par le pare-feu. Toutes les +vérifications d'interface et de routage sont donc muettes pour lui, et +l'**adresse témoin** est le seul juge — ce pilote est la raison d'être du +champ `probe`. Il exige aussi d'être lancé par *vous* : il appelle sudo +lui-même, pour le pare-feu seulement. Le lancer sous sudo ferait ouvrir la +session SSH par root, avec les clés de root. + + +## Diagnosing + +`diagnose` chains the checks and names the failing stage, lowest first, so +that the first false line is the cause and not a consequence: what the +**kernel** exposes · packages present · the technology's own check (IPsec SA, +WireGuard handshake, daemon alive, OpenVPN initialisation) · interface and +addresses · each declared route · the witness address that only answers +through the tunnel · the last lines of the relevant journal. Set `probe` in +the profile to an address reachable only through the tunnel — without it, +*"it works"* stays an impression, and for sshuttle there is nothing else to +go on. + +The kernel stage catches a failure no configuration can fix. Upgrading the +kernel package replaces `/lib/modules/` with the new version's: +the running kernel keeps the modules already loaded and can load no other. +IPsec then becomes unavailable on a kernel that supports it, charon aborts +at initialisation on a missing `kernel-ipsec`, and the symptom surfaces three +stages higher as a connection never loaded. `diagnose` and `up` name the +version whose modules are gone and offer the only remedy — a reboot. It is +offered, never done: nothing is applied on a dry run, nor without a terminal +to answer. + + +## Diagnostiquer + +`diagnose` enchaîne les vérifications et nomme l'étage fautif, du plus bas au +plus haut pour que la première ligne fausse soit la cause et non une +conséquence : ce que le **noyau** expose · paquets présents · la vérification +propre à la technologie (SA IPsec, poignée de main WireGuard, démon vivant, +initialisation OpenVPN) · interface et adresses · chaque route déclarée · +l'adresse témoin qui ne répond qu'à travers le tunnel · les dernières lignes +du journal concerné. Mettre `probe` dans le profil à une adresse joignable +seulement par le tunnel — sans elle, *« ça marche »* reste une impression, et +pour sshuttle il n'y a rien d'autre. + +L'étage du noyau attrape une panne qu'aucune configuration ne rattrape. +Mettre à jour le paquet du noyau remplace `/lib/modules/` par celle +de la version neuve : le noyau qui tourne garde les modules déjà chargés et +ne peut plus en charger aucun autre. L'IPsec devient alors indisponible sur +un noyau qui le prend en charge, charon abandonne à l'initialisation sur un +`kernel-ipsec` manquant, et le symptôme ressort trois étages plus haut en +connexion jamais chargée. `diagnose` et `up` nomment la version dont les +modules ont disparu et proposent le seul remède : redémarrer. C'est proposé, +jamais fait : rien n'est appliqué à blanc, ni sans terminal pour répondre. + + +## Adding a driver + +`drivers/base.py` states the contract *and* carries everything true of all +technologies: directory layout, state kept between processes, routes, +systemd-resolved, the standard status checks. A new driver declares what is +its own — packages, secrets, profile fields, the form the menu unrolls, the +sequence up and down — and executes nothing: it asks a `Runner`, which either +runs or merely shows. Registering it is one line in `drivers/__init__.py`, and +`test_vpn_drivers.py` picks it up from the registry: the no-secret-on-a-command +-line rule applies to it whether or not anyone thought about it. + + +## Ajouter un pilote + +`drivers/base.py` énonce le contrat *et* porte tout ce qui est vrai de toutes +les technologies : disposition des répertoires, état gardé entre deux +processus, routes, systemd-resolved, vérifications d'état habituelles. Un +pilote nouveau déclare ce qui lui est propre — paquets, secrets, champs de +profil, formulaire que le menu déroule, séquence de montée et de descente — et +n'exécute rien : il demande à un `Runner`, qui exécute ou se contente de +montrer. L'enregistrer tient en une ligne dans `drivers/__init__.py`, et +`test_vpn_drivers.py` le prend depuis le registre : la règle « aucun secret +dans une ligne de commande » s'applique à lui, que quelqu'un y ait pensé ou +non. diff --git a/script/vpn/README.fr.md b/script/vpn/README.fr.md new file mode 100644 index 0000000..cb8e5b2 --- /dev/null +++ b/script/vpn/README.fr.md @@ -0,0 +1,202 @@ + +# VPN — cinq tunnels ouverts, secrets dans un coffre KeePassXC + +`vpn.py` monte, démonte et diagnostique un tunnel VPN. Un pilote par +technologie, cinq en tout, tous libres. + +Le partage est tout le dispositif : ce qui n'est PAS secret (hôte, +utilisateur, routes, MTU) vit dans une configuration JSON lisible ; les clés +pré-partagées et les mots de passe vivent dans un coffre KeePassXC `.kdbx`. Un +profil peut donc être montré, comparé, partagé — sans donner de quoi monter le +tunnel. + +## Lequel choisir + +| Pilote | À prendre quand | Secrets dans le coffre | +|--------|-----------------|------------------------| +| `l2tp_ipsec` | le site l'impose : un routeur, un pare-feu, Windows RRAS | PSK + mot de passe PPP | +| `wireguard` | on tient les deux bouts — le plus rapide, le plus simple | clé privée (+ PSK facultative) | +| `openvpn` | le site a fourni un fichier `.ovpn` | mot de passe, si le fichier en veut un | +| `openconnect` | boîtiers Cisco AnyConnect, Pulse, GlobalProtect, Fortinet | mot de passe | +| `sshuttle` | on n'a qu'un accès SSH — rien à installer en face | aucun : les clés SSH suffisent | + +## Commandes + +```bash +./script/vpn/vpn.py check # ce que la machine sait faire +sudo bash script/install/install_vpn.sh wireguard # ou : tous, sans argument +./script/vpn/vpn.py list +./script/vpn/vpn.py up --profile acme --dry-run +./script/vpn/vpn.py up --profile acme +./script/vpn/vpn.py status --profile acme +./script/vpn/vpn.py diagnose --profile acme +./script/vpn/vpn.py down --profile acme +``` + +Tout est aussi accessible depuis le CLI : **TODO › Execute › Déploiement › +VPN**, et depuis **TODO › Execute › Réseau › VPN** — un tunnel se cherche aux +deux endroits. Le menu est là pour créer les profils et saisir les secrets ; +`vpn.py` est ce que le menu lance. Se connecter depuis le menu montre d'abord +le plan, et demande avant de l'exécuter. + +À lancer en tant qu'**utilisateur, pas sous sudo** : le coffre est dans votre +home et son mot de passe maître est le vôtre. Chaque étape privilégiée appelle +`sudo` séparément, et `--dry-run` les montre toutes sans en exécuter aucune. + +## Où vivent les choses + +| Chemin | Contenu | +|--------|---------| +| `private/todo/todo_override_private.json` | vos profils — gitignored, 0600 | +| `script/todo/todo.json` | la section `vpn`, vide : les profils partagés par une équipe peuvent y aller | +| votre coffre `.kdbx` | une entrée par profil, `ERPLibre VPN / ` | +| `/dev/shm/erplibre-vpn//` | 0700 root — **les secrets**, en tmpfs, effacés au `down` | +| `/run/erplibre-vpn/.*` | 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` | + +## Les trois règles de sécurité + +1. **Aucun secret en argument.** `/proc//cmdline` est lisible par tout + utilisateur de la machine. Les secrets ne passent que par l'entrée + standard ; un seul endroit (`runner.py`) porte cette règle, et un test + unitaire rejoue le plan de **chaque** pilote et échoue si un secret atteint + une ligne de commande. +2. **Aucun secret sur un disque persistant.** Les fichiers qu'une technologie + exige sont écrits en 0600 dans un tmpfs et effacés au `down`. Deux pilotes + n'en ont aucun : OpenConnect passe le mot de passe par l'entrée standard + (`--passwd-on-stdin`), et sshuttle n'a pas de secret du tout. Un résiduel, + dit plutôt que caché : tant qu'un tunnel L2TP est monté, root peut lire le + fichier d'options pppd. pppd prend un mot de passe dans un fichier, ou pas + du tout. +3. **Le mot de passe maître ne s'écrit nulle part.** Laisser `kdbx.password` + vide ; il est demandé une fois par session. Seul le *chemin* du coffre est + retenu, dans le seul fichier gitignored. Le CLI le signale s'il trouve un + mot de passe maître dans la configuration. + +Le PSK L2TP arrive à strongSwan **en hexadécimal** (`PSK 0x…`) : mêmes octets, +et plus aucune question d'échappement d'un `"` ou d'un `\` dans une clé +pré-partagée. + +## Ce que chaque pilote règle pour vous + +**L2TP/IPsec** — trois étages, et il faut les trois pour avoir une interface : +IPsec en mode **transport** protège l'UDP 1701, L2TP ouvre une session dedans, +PPP authentifie. Six pièges réglés ici, les six trouvés en montant un tunnel +vers un vrai concentrateur : + +- `charon { install_routes = no }`, sinon charon pose une route qui capte le + trafic L2TP — le classique *« la SA est établie, ppp0 n'apparaît pas »*. +- Une règle **AppArmor**. AppArmor confine charon par chemin et `/dev/shm` + n'est pas dans son profil : le noyau lui refuse le fichier de secrets, et + l'échec ressort trois étages plus loin en *« no shared key found »* — avec + le PSK bien là, bien formé. Seul `journalctl -k | grep DENIED` le dit. La + règle va dans le fichier `local/` que Debian et Ubuntu prévoient pour ça. +- **`rightid=%any`**. Une passerelle s'annonce par son IP même quand `right` + est un nom ; sans cela, strongSwan refuse : *« IDir '203.0.113.5' does not + match to 'vpn.exemple.com' »*. +- **Une attente du chargement de la connexion.** `ipsec start` rend la main + avant que le starter ait poussé les connexions ; un `ipsec up` immédiat + échoue sur *« no match »* — sur une configuration parfaitement valide, + l'erreur la plus trompeuse de la séquence. +- **Le sens de l'authentification.** `require chap` / `require + authentication` (xl2tpd) et `require-mschap-v2` (pppd) veulent tous dire + *exiger que le PAIR s'authentifie auprès de nous*. Un client ne doit pas : + le serveur refuse, et pppd coupe la liaison — *« LCP terminated by peer + (peer refused to authenticate) »*. Ce qu'un client veut, c'est `refuse-pap` + et `refuse-eap`, qui parlent de **nous**. +- Une route de survie `/32` vers le serveur (en mode « tout le trafic », les + paquets ESP entreraient dans le tunnel qu'ils portent), et `resolvectl`, + parce que systemd-resolved ignore `/etc/ppp/resolv.conf`. + +Une note d'empaquetage qui coûte une heure si on la manque : sans le greffon +**openssl** (`libstrongswan-standard-plugins`), charon annonce 3DES, le +concentrateur le choisit — c'est souvent le seul qu'il connaisse — et la +négociation meurt sur *« ENCRYPTION_ALGORITHM 3DES_CBC not supported! »*. +L'installateur le livre. + +**WireGuard** — il n'a pas de session, donc `wg-quick up` réussit même avec +une clé de pair fausse ou un endpoint injoignable. Rien ne dit non, parce +qu'il n'y a personne pour le dire. Ce pilote **attend donc une poignée de +main** avant de déclarer le tunnel monté. Les routes viennent d'`AllowedIPs` +et appartiennent à `wg-quick` ; le pilote ne double pas son travail. Pas de +ligne `DNS =` non plus : wg-quick la confie à `resolvconf`, absent de beaucoup +d'installations systemd-resolved, et c'est la configuration entière qui échoue +alors. + +**OpenVPN** — il part du `.ovpn` que le site a fourni ; ce pilote n'en +fabrique pas. Deux choses qu'on aurait tort de croire évidentes : `--cd`, +parce qu'un `.ovpn` référence ses voisins en relatif ; et l'ordre des options, +parce que ce qui suit `--config` l'emporte sur le fichier — un +`auth-user-pass` nu dedans ferait sinon attendre une saisie qui ne viendra +jamais, le démon étant détaché. Le tunnel scindé se demande par +`--route-nopull`, qui écarte aussi le DNS poussé ; le pilote le dit quand il +le prend. + +**OpenConnect** — `--non-inter` est voulu en mode mot de passe. Sans lui, un +certificat serveur inconnu déclenche une question, et openconnect la lirait +sur l'entrée standard par laquelle arrive le mot de passe. Avec lui, +openconnect refuse tout de suite **et** imprime la ligne +`--servercert sha256:…` à recopier dans le champ `oc_servercert` du profil. +Les routes appartiennent au serveur, via `vpnc-script` ; le profil peut en +ajouter, pas les remplacer. + +Cocher **`oc_sso`** quand le concentrateur authentifie par un **formulaire +web** (SAML / SSO — Azure AD, Okta, Duo). Il n'y a alors aucun mot de passe à +envoyer, et le client de Cisco réclame un écran pour son navigateur WebKit +embarqué — souvent avec `WEBKIT_DISABLE_DMABUF_RENDERER=1` pour qu'il +s'affiche ; son CLI, lui, ne sait pas faire cet échange. openconnect le fait +sans écran sur la machine cliente : mesuré dans sa bibliothèque, il écoute sur +le **port local 29786** et attend la redirection du navigateur, après avoir +lancé `--external-browser` avec l'URL de connexion. Sur un serveur, ce +« navigateur » est un simple `echo` : l'URL s'affiche, et on l'ouvre dans +**son propre** navigateur — en faisant revenir la redirection par + +```bash +ssh -L 29786:localhost:29786 +``` + +avant de l'ouvrir. Le mot de passe ne quitte jamais votre poste. Les deux +délais diffèrent exprès : deux minutes pour un mot de passe, cinq pour un +humain qui traverse un fournisseur d'identité. + +**sshuttle** — aucune interface : il détourne par le pare-feu. Toutes les +vérifications d'interface et de routage sont donc muettes pour lui, et +l'**adresse témoin** est le seul juge — ce pilote est la raison d'être du +champ `probe`. Il exige aussi d'être lancé par *vous* : il appelle sudo +lui-même, pour le pare-feu seulement. Le lancer sous sudo ferait ouvrir la +session SSH par root, avec les clés de root. + +## Diagnostiquer + +`diagnose` enchaîne les vérifications et nomme l'étage fautif, du plus bas au +plus haut pour que la première ligne fausse soit la cause et non une +conséquence : ce que le **noyau** expose · paquets présents · la vérification +propre à la technologie (SA IPsec, poignée de main WireGuard, démon vivant, +initialisation OpenVPN) · interface et adresses · chaque route déclarée · +l'adresse témoin qui ne répond qu'à travers le tunnel · les dernières lignes +du journal concerné. Mettre `probe` dans le profil à une adresse joignable +seulement par le tunnel — sans elle, *« ça marche »* reste une impression, et +pour sshuttle il n'y a rien d'autre. + +L'étage du noyau attrape une panne qu'aucune configuration ne rattrape. +Mettre à jour le paquet du noyau remplace `/lib/modules/` par celle +de la version neuve : le noyau qui tourne garde les modules déjà chargés et +ne peut plus en charger aucun autre. L'IPsec devient alors indisponible sur +un noyau qui le prend en charge, charon abandonne à l'initialisation sur un +`kernel-ipsec` manquant, et le symptôme ressort trois étages plus haut en +connexion jamais chargée. `diagnose` et `up` nomment la version dont les +modules ont disparu et proposent le seul remède : redémarrer. C'est proposé, +jamais fait : rien n'est appliqué à blanc, ni sans terminal pour répondre. + +## Ajouter un pilote + +`drivers/base.py` énonce le contrat *et* porte tout ce qui est vrai de toutes +les technologies : disposition des répertoires, état gardé entre deux +processus, routes, systemd-resolved, vérifications d'état habituelles. Un +pilote nouveau déclare ce qui lui est propre — paquets, secrets, champs de +profil, formulaire que le menu déroule, séquence de montée et de descente — et +n'exécute rien : il demande à un `Runner`, qui exécute ou se contente de +montrer. L'enregistrer tient en une ligne dans `drivers/__init__.py`, et +`test_vpn_drivers.py` le prend depuis le registre : la règle « aucun secret +dans une ligne de commande » s'applique à lui, que quelqu'un y ait pensé ou +non. \ No newline at end of file diff --git a/script/vpn/README.md b/script/vpn/README.md new file mode 100644 index 0000000..8c8f04a --- /dev/null +++ b/script/vpn/README.md @@ -0,0 +1,193 @@ + +# VPN — five open tunnels, secrets in a KeePassXC vault + +`vpn.py` brings up, tears down and diagnoses a VPN tunnel. One driver per +technology, five of them, all free software. + +The split is the whole design: what is *not* secret (host, user, routes, MTU) +lives in readable JSON configuration; the pre-shared keys and the passwords +live in a KeePassXC `.kdbx` vault. A profile can therefore be shown, compared +and shared without handing over the means to bring the tunnel up. + +## Which one to pick + +| Driver | Pick it when | Secrets in the vault | +|--------|--------------|----------------------| +| `l2tp_ipsec` | the far side imposes it: a router, a firewall, Windows RRAS | PSK + PPP password | +| `wireguard` | you control both ends — fastest, simplest | private key (+ optional PSK) | +| `openvpn` | the site handed you a `.ovpn` file | password, if the file needs one | +| `openconnect` | Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances | password | +| `sshuttle` | all you have is SSH access — nothing to install on the far side | none: SSH keys do the work | + +## Commands + +```bash +./script/vpn/vpn.py check # ce que la machine sait faire +sudo bash script/install/install_vpn.sh wireguard # ou : tous, sans argument +./script/vpn/vpn.py list +./script/vpn/vpn.py up --profile acme --dry-run +./script/vpn/vpn.py up --profile acme +./script/vpn/vpn.py status --profile acme +./script/vpn/vpn.py diagnose --profile acme +./script/vpn/vpn.py down --profile acme +``` + +Everything is also reachable from the CLI: **TODO › Execute › Deployment › +VPN**, and from **TODO › Execute › Network › VPN** — a tunnel gets looked for +in both places. The menu is where profiles are created and secrets are typed +in; `vpn.py` is what the menu runs. Connecting from the menu shows the plan +first and asks before running it. + +Run it as **yourself, not under sudo**: the vault lives in your home and its +master password is yours to type. Each privileged step calls `sudo` on its +own, and `--dry-run` shows every one of them without running any. + +## Where things live + +| Path | Content | +|------|---------| +| `private/todo/todo_override_private.json` | your profiles — gitignored, 0600 | +| `script/todo/todo.json` | the `vpn` section, empty: profiles shared by a team can go here | +| your `.kdbx` vault | one entry per profile, `ERPLibre VPN / ` | +| `/dev/shm/erplibre-vpn//` | 0700 root — **the secrets**, in tmpfs, erased on `down` | +| `/run/erplibre-vpn/.*` | non-secret state (chosen interface, pid, log), readable without sudo | +| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP only: a marked block, removed on `down` | + +## The three security rules + +1. **No secret in an argument.** `/proc//cmdline` is readable by every + user of the machine. Secrets travel on standard input only; a single place + (`runner.py`) holds that rule, and a unit test replays the plan of **every** + driver and fails if a secret ever reaches a command line. +2. **No secret on persistent storage.** The files a technology insists on are + written 0600 into tmpfs and erased on `down`. Two drivers need none at all: + OpenConnect passes the password on standard input (`--passwd-on-stdin`), and + sshuttle has no secret to begin with. One residual, stated rather than + hidden: while an L2TP tunnel is up, root can read the pppd options file. + pppd takes a password from a file or nothing. +3. **The master password is written nowhere.** Leave `kdbx.password` empty; it + is asked once per session. Only the vault *path* is stored, in the single + gitignored file. The CLI says so when it finds a master password in the + configuration. + +The L2TP PSK reaches strongSwan **hex-encoded** (`PSK 0x…`): same bytes, and +no question of escaping a `"` or a `\` inside a pre-shared key. + +## What each driver settles for you + +**L2TP/IPsec** — three stages, and all three are needed for an interface: +IPsec in **transport** mode protects UDP 1701, L2TP opens a session inside it, +PPP authenticates. Six pitfalls are handled here, all six found by connecting +to a real concentrator: + +- `charon { install_routes = no }`, otherwise charon installs a route that + captures the L2TP traffic — the classic *"the SA is established, ppp0 never + appears"*. +- An **AppArmor** rule. AppArmor confines charon by path and `/dev/shm` is not + in its profile, so charon is denied the secrets file by the kernel and fails + three stages later on *"no shared key found"* — with the PSK sitting there, + correct. Only `journalctl -k | grep DENIED` says so. The rule goes in the + `local/` file Debian and Ubuntu provide for exactly this. +- **`rightid=%any`**. A gateway announces itself by its IP even when `right` + is a name; without this, strongSwan refuses: *"IDir '203.0.113.5' does not + match to 'vpn.example.com'"*. +- **A wait for the connection to load.** `ipsec start` returns before the + starter has pushed the connections; an immediate `ipsec up` fails on *"no + match"* — on a perfectly valid configuration, the most misleading error of + the sequence. +- **The direction of authentication.** `require chap` / `require + authentication` (xl2tpd) and `require-mschap-v2` (pppd) all mean *require + the PEER to authenticate to us*. A client must not: the server refuses, and + pppd tears the link down with *"LCP terminated by peer (peer refused to + authenticate)"*. What a client wants is `refuse-pap` and `refuse-eap` — + which speak about **us**. +- A `/32` survival route to the server (in all-traffic mode the ESP packets + would enter the tunnel they carry), and `resolvectl`, because + systemd-resolved ignores `/etc/ppp/resolv.conf`. + +One packaging note that costs an hour if missed: without the **openssl** +plugin (`libstrongswan-standard-plugins`), charon advertises 3DES, the +concentrator picks it — often the only cipher it knows — and the negotiation +dies on *"ENCRYPTION_ALGORITHM 3DES_CBC not supported!"*. The installer ships +it. + +**WireGuard** — it has no session, so `wg-quick up` succeeds even with a wrong +peer key or an unreachable endpoint. Nothing says no, because nobody is there +to say it. This driver therefore **waits for a handshake** before calling the +tunnel up. Routes come from `AllowedIPs` and belong to `wg-quick`; the driver +does not double its work. No `DNS =` line either: wg-quick hands that to +`resolvconf`, missing from many systemd-resolved installs, and the whole +configuration fails when it is. + +**OpenVPN** — it starts from the `.ovpn` the site gave you; this driver does +not invent one. Two things that are not obvious: `--cd`, because a `.ovpn` +references its neighbours relatively; and option order, because what follows +`--config` overrides the file — a bare `auth-user-pass` inside would otherwise +wait for a keystroke that never comes, the daemon being detached. Split tunnel +is asked for with `--route-nopull`, which also drops the pushed DNS; the driver +says so when it takes it. + +**OpenConnect** — `--non-inter` is deliberate in password mode. Without it an +unknown server certificate raises a question, and openconnect would read the +answer from the standard input the password arrives on. With it, openconnect +refuses at once **and** prints the `--servercert sha256:…` line to paste into +the profile's `oc_servercert`. Routes belong to the server, through +`vpnc-script`; the profile can add to them, not replace them. + +Set **`oc_sso`** when the concentrator authenticates through a **web form** +(SAML / SSO — Azure AD, Okta, Duo). There is then no password to send, and +Cisco's own client needs a screen for its embedded WebKit browser — often +with `WEBKIT_DISABLE_DMABUF_RENDERER=1` for it to render at all; its CLI +cannot do this flow. openconnect can, with no screen on the client machine: +measured in its library, it listens on **local port 29786** and waits for the +browser's redirect after launching `--external-browser` with the login URL. +On a server that "browser" is a plain `echo`, so the URL is printed for you to +open in **your own** browser — bring the redirect back with + +```bash +ssh -L 29786:localhost:29786 +``` + +before opening it. The password never leaves your own workstation. Both +timeouts differ on purpose: two minutes for a password, five for a human +walking through an identity provider. + +**sshuttle** — no interface at all: it redirects through the firewall. Every +interface and routing check is therefore silent for it, and the **witness +address** is the only judge — this driver is the reason the `probe` field +exists. It also insists on being run by *you*: it calls sudo itself, for the +firewall only. Running it under sudo would open the SSH session as root, with +root's keys. + +## Diagnosing + +`diagnose` chains the checks and names the failing stage, lowest first, so +that the first false line is the cause and not a consequence: what the +**kernel** exposes · packages present · the technology's own check (IPsec SA, +WireGuard handshake, daemon alive, OpenVPN initialisation) · interface and +addresses · each declared route · the witness address that only answers +through the tunnel · the last lines of the relevant journal. Set `probe` in +the profile to an address reachable only through the tunnel — without it, +*"it works"* stays an impression, and for sshuttle there is nothing else to +go on. + +The kernel stage catches a failure no configuration can fix. Upgrading the +kernel package replaces `/lib/modules/` with the new version's: +the running kernel keeps the modules already loaded and can load no other. +IPsec then becomes unavailable on a kernel that supports it, charon aborts +at initialisation on a missing `kernel-ipsec`, and the symptom surfaces three +stages higher as a connection never loaded. `diagnose` and `up` name the +version whose modules are gone and offer the only remedy — a reboot. It is +offered, never done: nothing is applied on a dry run, nor without a terminal +to answer. + +## Adding a driver + +`drivers/base.py` states the contract *and* carries everything true of all +technologies: directory layout, state kept between processes, routes, +systemd-resolved, the standard status checks. A new driver declares what is +its own — packages, secrets, profile fields, the form the menu unrolls, the +sequence up and down — and executes nothing: it asks a `Runner`, which either +runs or merely shows. Registering it is one line in `drivers/__init__.py`, and +`test_vpn_drivers.py` picks it up from the registry: the no-secret-on-a-command +-line rule applies to it whether or not anyone thought about it. diff --git a/script/vpn/__init__.py b/script/vpn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/script/vpn/drivers/__init__.py b/script/vpn/drivers/__init__.py new file mode 100644 index 0000000..f4025b7 --- /dev/null +++ b/script/vpn/drivers/__init__.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Registre des pilotes VPN. + +Un pilote par technologie, et RIEN d'autre ici : le registre est la seule +chose que le menu, le CLI et les tests ont besoin de connaître pour lister +les technologies disponibles. Ajouter un pilote, c'est une ligne. +""" + +from script.vpn.drivers.base import VpnDriver # noqa: F401 +from script.vpn.drivers.l2tp_ipsec import L2tpIpsecDriver +from script.vpn.drivers.openconnect import OpenconnectDriver +from script.vpn.drivers.openvpn import OpenvpnDriver +from script.vpn.drivers.sshuttle import SshuttleDriver +from script.vpn.drivers.wireguard import WireguardDriver + +# Nom technique -> classe. Le nom se retrouve dans `driver` du profil et +# dans le paquet à installer : il ne change pas. +# +# L'ordre est celui du plus contraint au plus libre — c'est aussi celui dans +# lequel le menu les propose, et il aide à choisir : L2TP/IPsec quand le +# site l'impose, WireGuard quand on tient les deux bouts, sshuttle quand il +# n'y a qu'un accès SSH. +DRIVERS = { + L2tpIpsecDriver.name: L2tpIpsecDriver, + WireguardDriver.name: WireguardDriver, + OpenvpnDriver.name: OpenvpnDriver, + OpenconnectDriver.name: OpenconnectDriver, + SshuttleDriver.name: SshuttleDriver, +} + + +def get_driver(name): + """Classe du pilote `name`, ou None. Ne lève pas : un profil peut + nommer un pilote retiré, et le CLI doit pouvoir le DIRE.""" + return DRIVERS.get(name) + + +def driver_names(): + """Les noms dans l'ordre du registre, pas dans l'ordre alphabétique : + cet ordre est un conseil de choix, voir le commentaire de DRIVERS.""" + return list(DRIVERS) diff --git a/script/vpn/drivers/base.py b/script/vpn/drivers/base.py new file mode 100644 index 0000000..46a9e78 --- /dev/null +++ b/script/vpn/drivers/base.py @@ -0,0 +1,845 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'un pilote VPN doit savoir faire, et tout ce qu'ils partagent. + +Un pilote décrit UNE technologie : quels paquets, quels secrets, quels +fichiers, quelle séquence pour monter, laquelle pour descendre, et comment +savoir si c'est monté. Il n'exécute rien : il demande à un `Runner` (voir +`runner.py`), qui exécute ou se contente de montrer. + +Ce fichier porte aussi tout ce qui est VRAI pour toutes les technologies : la +disposition des répertoires, l'état retenu entre deux processus, les routes, +le DNS de systemd-resolved, et les vérifications d'état. Un pilote nouveau +n'a donc à écrire que ce qui lui est propre — et quand une de ces mécaniques +se révèle fausse, elle se corrige à un seul endroit. + +Où vivent les fichiers, pour tous les pilotes : + + /dev/shm/erplibre-vpn// 0700 root — LES SECRETS. tmpfs : rien + n'est écrit sur un disque persistant, et + un redémarrage efface tout. + /run/erplibre-vpn/.* 0755 — l'état NON secret (interface + retenue, pid, route ajoutée). Lisible + sans sudo : `status` en a besoin, et il + tourne dans un autre processus que `up`. +""" +from __future__ import annotations + +import ipaddress +import os +import platform +import re +import shlex +import shutil +import socket +import subprocess +import time + +# Les `secret_fields` d'un pilote portent des clés i18n : affichées brutes, +# elles mettaient de l'anglais au milieu d'une phrase française. +from script.todo.todo_i18n import t + +# Un seul endroit nomme l'installateur : `vpn.py` l'importe d'ici. +INSTALL_SCRIPT = "./script/install/install_vpn.sh" + +SECRET_DIR = "/dev/shm/erplibre-vpn" +STATE_DIR = "/run/erplibre-vpn" + + +# ---------------------------------------------------------------------- +# Lecture de l'état de la machine — sans sudo, sans rien modifier +# ---------------------------------------------------------------------- +def which(binary: str) -> str: + """Chemin du binaire si NOUS pouvons l'exécuter, "" sinon. + + À réserver aux commandes lancées sous notre propre identité + (systemctl is-active, resolvectl, sshuttle). Pour celles que root + lance, voir `locate`. + """ + return shutil.which(binary) or "" + + +def locate(binary: str) -> str: + """Chemin du binaire s'il EXISTE dans le PATH, même si nous n'avons pas + le droit de l'exécuter. + + C'est la bonne question pour un binaire que ROOT lance. `pppd` est en + 4750 root:dip sur Debian et Ubuntu : `which` le déclare absent à tout + utilisateur hors du groupe dip, alors que xl2tpd — qui tourne en root — + l'exécute très bien. Confondre les deux fait annoncer « pppd absent » + sur une machine où le paquet ppp est installé, et envoie chercher au + mauvais endroit. + """ + return shutil.which(binary, mode=os.F_OK) or "" + + +# Famille netlink de l'IPsec du noyau. charon et `ip xfrm` n'ont pas +# d'autre porte : quand elle est fermée, aucune configuration ne rattrape. +NETLINK_XFRM = 6 + + +def netlink_family_available(protocol: int) -> bool: + """Le noyau expose-t-il cette famille netlink ? + + L'OUVERTURE suffit à répondre : le noyau charge à la demande le module + qui sert la famille, et rend `EPROTONOSUPPORT` quand il ne le trouve + pas. Aucune donnée n'est lue, aucun droit root n'est requis. C'est le + premier geste de charon, et ce qui lui fait dire « unable to create + netlink socket » avant d'abandonner sur `kernel-ipsec` manquant. + """ + try: + sock = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, protocol) + except OSError: + return False + sock.close() + return True + + +def stale_kernel() -> str: + """Version du noyau en cours d'exécution quand ses modules ont disparu, + "" quand ils sont là. + + Mettre à jour le paquet du noyau remplace `/lib/modules/` par + celle de la version neuve. Le noyau DÉJÀ démarré perd alors l'accès à + tous ses modules : ceux qui étaient chargés continuent, aucun autre ne + peut l'être. Une capacité que le noyau prend pourtant en charge devient + donc indisponible jusqu'au redémarrage, et rien d'autre ne la rétablit. + + L'absence est jugée RELATIVEMENT aux autres arborescences : un noyau + compilé sans modules n'en a aucune, et le déclarer périmé enverrait + redémarrer pour rien. + """ + release = platform.release() + if os.path.isdir(f"/lib/modules/{release}"): + return "" + try: + return release if os.listdir("/lib/modules") else "" + except OSError: + return "" + + +def resolve(host: str) -> str: + """Première adresse IPv4 de `host`, ou "" — et `host` lui-même s'il EST + déjà une adresse. Sans elle, impossible de préserver la route vers le + serveur quand on remplace la route par défaut.""" + try: + infos = socket.getaddrinfo(host, None, socket.AF_INET) + except (socket.gaierror, UnicodeError): + return "" + return infos[0][4][0] if infos else "" + + +def _ip(args: list[str]) -> str: + """Sortie de `ip …`, "" en cas d'échec. Lecture seule, sans sudo.""" + try: + proc = subprocess.run( + ["ip"] + args, capture_output=True, text=True, timeout=10 + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return proc.stdout if proc.returncode == 0 else "" + + +def interfaces(kind: str | None = None) -> set[str]: + """Interfaces existantes, éventuellement d'un seul type (ppp, tun, + wireguard). + + L'ensemble AVANT/APRÈS est ce qui permet de nommer l'interface qu'un + tunnel vient de créer : pppd n'annonce pas « ppp3 » à qui l'a lancé, et + supposer « ppp0 » est faux dès qu'un autre tunnel est déjà là. + """ + args = ["-o", "link", "show"] + if kind: + args += ["type", kind] + out = _ip(args) + return set(re.findall(r"^\d+:\s+([^:@]+)", out, re.MULTILINE)) + + +def ppp_interfaces() -> set[str]: + return interfaces("ppp") + + +def wait_for_new_interface(before: set, kind: str, timeout=25, interval=0.5): + """Nom de la première interface `kind` apparue depuis `before`, ou "". + + L'attente est nécessaire : entre la demande de session et l'interface + configurée, il y a la négociation — quelques secondes, parfois vingt sur + une liaison lente. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + new = interfaces(kind) - before + if new: + return sorted(new)[0] + time.sleep(interval) + return "" + + +def wait_for_interface_address(iface: str, timeout=25, interval=0.5): + """Attend que `iface` porte une adresse IPv4. Rend la liste, ou []. + + Une interface PPP existe dès que pppd la crée, bien avant qu'IPCP ait + négocié l'adresse. Lire trop tôt donne « sans adresse » sur un tunnel + parfaitement sain, et fait chercher les DNS du pair avant que pppd les + ait écrits. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + addresses = interface_addresses(iface) + if addresses: + return addresses + time.sleep(interval) + return [] + + +def interface_addresses(iface: str) -> list[str]: + out = _ip(["-brief", "addr", "show", "dev", iface]) + return re.findall(r"(\d+\.\d+\.\d+\.\d+)(?:/\d+)?", out) + + +def interface_exists(iface: str) -> bool: + return bool(iface) and bool(_ip(["-o", "link", "show", "dev", iface])) + + +def route_to(target: str) -> dict: + """{"via", "dev", "src"} de la route actuelle vers `target`, {} si + indéterminée. Sert à garder joignable le serveur VPN lui-même.""" + out = _ip(["route", "get", target]) + if not out: + return {} + result = {} + for key in ("via", "dev", "src"): + found = re.search(rf"\b{key}\s+(\S+)", out) + if found: + result[key] = found.group(1) + return result + + +def ssh_client_address() -> str: + """Adresse du client SSH de la session courante, "" si on n'est pas + dans une session SSH. + + `SSH_CONNECTION` vaut « ». Elle sert à + ne PAS scier la branche sur laquelle on est assis : piloter un client + VPN par SSH et lui faire capter tout le trafic coupe la session qui + donne l'ordre — et le menu, et le tunnel avec. + """ + connexion = os.environ.get("SSH_CONNECTION", "").split() + if not connexion: + return "" + adresse = connexion[0] + try: + ipaddress.ip_address(adresse) + except ValueError: + return "" + return adresse + + +def pppd_dns() -> list[str]: + """Serveurs DNS poussés par le pair, lus dans /etc/ppp/resolv.conf. + + pppd écrit LÀ et nulle part ailleurs quand on lui demande `usepeerdns` ; + c'est systemd-resolved qui ignore ce fichier, d'où l'étape `resolvectl` + du pilote.""" + try: + with open("/etc/ppp/resolv.conf") as fh: + content = fh.read() + except OSError: + return [] + return re.findall(r"nameserver\s+(\S+)", content) + + +class VpnDriver: + """Contrat d'un pilote, et les mécaniques communes.""" + + # Nom technique : valeur du champ `driver` d'un profil, et argument de + # `script/install/install_vpn.sh`. + name = "" + # Libellé montré à l'humain. + label = "" + # Binaires sans lesquels rien ne marche. + binaries: tuple[str, ...] = () + # Ce que le NOYAU doit exposer : (libellé, sonde). La sonde lit l'état + # de la machine, sans root et sans rien modifier. Vide quand tout se + # joue en espace utilisateur — un tunnel TLS n'exige rien du noyau. + # N'y mettre qu'une sonde dont le faux NÉGATIF est impossible : celle + # qui répond « absent » sur une machine saine fait proposer un + # redémarrage inutile, ce qui est pire que le diagnostic qu'elle rend. + kernel_features: tuple = () + # Secrets attendus dans le coffre : (clé, libellé, obligatoire). + # « password » et « username » désignent les champs NATIFS de + # KeePassXC ; tout autre nom devient une propriété protégée. + secret_fields: tuple[tuple[str, str, bool], ...] = () + # Champs de profil PROPRES à cette technologie : défauts, et le + # formulaire que le menu déroule. + defaults: dict = {} + # (clé, libellé i18n, type, avancé) ; type ∈ text|int|flag|path. + form_fields: tuple[tuple[str, str, str, bool], ...] = () + # Faux quand c'est le SERVEUR qui décide des routes : exiger une route + # déclarée serait alors une fausse exigence. + needs_routes = True + # Type d'interface que la technologie crée, "" quand elle n'en crée pas + # (sshuttle détourne par le pare-feu, sans interface). + iface_kind = "" + # Libellé i18n du champ « serveur » : une cible SSH ne se demande pas + # comme l'adresse d'un concentrateur. + server_label = "Server address (hostname or IP)" + # Libellé i18n d'une ligne : QUAND choisir cette technologie. C'est la + # seule décision où l'utilisateur a vraiment besoin d'un conseil. + hint = "" + # Vrai quand la technologie a été montée contre un vrai concentrateur. + # Faux quand seuls les tests unitaires la couvrent : le menu marque + # alors la ligne d'une étoile. Ce que l'utilisateur risque autrement, + # c'est de lire cinq choix d'apparence égale et de partir en production + # sur celui que personne n'a jamais vu aboutir. + proven = False + # Champ de profil qui porte l'identifiant, recopié dans le champ + # `username` de l'entrée du coffre — pour que le coffre reste lisible + # dans KeePassXC. Le profil reste la source de vérité. + user_field = "" + # Faux quand la technologie ne prend pas le MTU du profil — le demander + # serait une question sans effet. + uses_mtu = True + + def __init__(self, profile: dict, secrets: dict | None = None): + self.profile = profile + # `secrets` absent = mode « description » : on peut rendre les + # fichiers non secrets, lister les étapes, vérifier l'état. Monter + # le tunnel, non. + self.secrets = secrets or {} + self.name_tag = profile.get("name", "") + + # ------------------------------------------------------------------ + # À redéfinir + # ------------------------------------------------------------------ + @classmethod + def validate_profile(cls, profile: dict) -> None: + """Valide et NORMALISE en place les champs propres au pilote. + + Lève `valid.ProfileError`. Les contrôles communs (nom, serveur, + routes, MTU, témoin) sont déjà faits par `profiles.validate`. + """ + + def up(self, runner) -> bool: + raise NotImplementedError + + def down(self, runner) -> bool: + raise NotImplementedError + + def status(self, runner) -> list: + """Liste de (libellé, verdict, détail). `None` en verdict veut dire + « indéterminable » — pas « faux ».""" + raise NotImplementedError + + def log_commands(self) -> list: + """(libellé, commande) à montrer dans le diagnostic.""" + return [] + + # ------------------------------------------------------------------ + # Chemins et état + # ------------------------------------------------------------------ + @property + def secret_dir(self): + return f"{SECRET_DIR}/{self.name_tag}" + + @property + def pid_file(self): + return self.state_file("pid") + + def state_file(self, key): + return f"{STATE_DIR}/{self.name_tag}.{key}" + + def prepare_dirs(self, runner, secrets=True): + """Le répertoire des secrets en 0700, celui de l'état en 0755. + + Deux modes différents parce que deux usages différents : un secret + ne se lit que par root, l'état doit se lire par `status` lancé sans + sudo. + + Pas de répertoire de secrets pour un pilote qui n'écrit pas de + secret : sshuttle s'authentifie par clé SSH, et openconnect passe le + mot de passe par l'entrée standard — aucun des deux n'a de fichier à + y mettre. D'où `secrets=False`. + """ + if secrets and self.secret_fields: + runner.mkdir(self.secret_dir, "0700") + runner.mkdir(STATE_DIR, "0755") + + def write_state(self, runner, key, value): + runner.write(self.state_file(key), f"{value}\n", mode="0644") + + def read_state(self, key): + """Valeur retenue au montage, "" sinon. + + Retenue dans un fichier et non devinée : `status` tourne dans un + autre processus que `up`.""" + try: + with open(self.state_file(key)) as fh: + return fh.read().strip() + except OSError: + return "" + + def clear_state(self, runner, *keys): + for key in keys: + runner.remove(self.state_file(key)) + + def recorded_iface(self): + return self.read_state("iface") + + # ------------------------------------------------------------------ + # Prérequis + # ------------------------------------------------------------------ + def missing_binaries(self) -> list: + """Les binaires qui manquent VRAIMENT. + + `locate` et non `which` : ces binaires sont lancés par root, et + « puis-je l'exécuter ? » est la mauvaise question — voir `locate`. + """ + return [b for b in self.binaries if not locate(b)] + + def missing_secrets(self) -> list: + return [ + label + for key, label, required in self.secret_fields + if required and not self.secrets.get(key) + ] + + def secret_values(self) -> list: + """Les valeurs à masquer dans tout affichage.""" + return [v for v in self.secrets.values() if v] + + def ensure_ready(self, runner) -> bool: + """Noyau, binaires et secrets présents ? + + À blanc, un prérequis absent est un AVERTISSEMENT : montrer le plan + sur une machine où le client n'est pas encore installé est justement + à quoi sert le mode à blanc — c'est là qu'on relit une configuration + avant de la poser. + """ + report = runner.warn if runner.dry_run else runner.fail + ready = True + # Le noyau d'abord : quand c'est LUI qui manque, installer un paquet + # n'y changerait rien, et l'annoncer en premier évite de chercher la + # cause dans l'étage du dessus. + for _, ok, detail in self.check_kernel(): + if ok is False: + # Proposé avant d'être constaté, comme pour les paquets — + # mais l'échec est constaté MÊME si le redémarrage est + # accepté : la machine met quelques secondes à s'arrêter, et + # monter un tunnel dans cet intervalle serait le monter sur + # le noyau qu'on quitte. + self.propose_reboot(runner) + report(detail) + ready = False + missing = self.missing_binaries() + if missing: + # Proposé AVANT de constater l'échec : si le correctif passe, il + # n'y a plus d'échec à annoncer. Constater puis réparer laisserait + # un « montage incomplet » sur un montage qui a réussi. + runner.info(f" Binaires absents : {', '.join(missing)}") + if runner.propose( + f"paquets client de {self.name}", + f"bash {INSTALL_SCRIPT} {self.name}", + question="Installer les paquets client maintenant ?", + ): + missing = self.missing_binaries() + if not missing: + runner.ok("Paquets installés.") + if missing: + report( + f"Binaires absents : {', '.join(missing)}. Installer :" + f" sudo bash {INSTALL_SCRIPT} {self.name}" + ) + ready = False + missing_secret = self.missing_secrets() + if missing_secret: + labels = ", ".join(t(label) for label in missing_secret) + report( + f"Secrets manquants dans le coffre : {labels}. Les déposer :" + " TODO › Execute › Déploiement › VPN › « Déposer les" + " secrets dans le coffre »." + ) + ready = False + return ready or runner.dry_run + + def needs_reboot(self) -> bool: + """Un redémarrage est-il le SEUL remède à ce qui manque ? + + La conjonction qui le dit : une capacité du noyau manque ET les + modules du noyau qui tourne ont disparu. Le module ne peut plus être + chargé et aucune configuration n'y changera rien ; la version + installée, elle, porte la capacité. Un noyau qui ne l'expose pas du + tout ne gagnerait rien à redémarrer, et des modules périmés dont + rien ne manque encore ne pressent pas : ni l'un ni l'autre ne rend + vrai. + """ + return bool(self.missing_kernel_features() and stale_kernel()) + + def propose_reboot(self, runner) -> bool: + """Propose le redémarrage quand `needs_reboot` le dit. + + Les garde-fous de `Runner.propose` valent ici : on demande, rien + n'est appliqué à blanc ni sans terminal pour répondre. + """ + if not self.needs_reboot(): + return False + runner.info( + " Le noyau qui tourne n'a plus ses modules : le paquet du" + " noyau a été mis à jour depuis le démarrage. Redémarrer les" + " rétablit, et c'est le seul remède — toutes les sessions" + " ouvertes seront coupées." + ) + return runner.propose( + "modules du noyau inaccessibles", + "systemctl reboot", + question="Redémarrer la machine maintenant ?", + ) + + # ------------------------------------------------------------------ + # Étapes communes + # ------------------------------------------------------------------ + def add_routes(self, runner, iface): + """Les routes déclarées, par l'interface du tunnel. + + `replace` et non `add` : une route déjà là ne doit pas faire + échouer un remontage. Elles disparaissent avec l'interface, donc + rien à défaire au « down ».""" + for route in self.profile.get("routes", []): + runner.cmd( + f"router {route} par {iface}", + f"ip route replace {shlex.quote(route)}" + f" dev {shlex.quote(iface)}", + check=False, + ) + + def suggest_routes(self, runner, iface): + """Quand rien n'est routé, proposer le réseau de l'adresse obtenue. + + Le site ne remet souvent qu'une passerelle et des identifiants, et + personne ne sait quel réseau est derrière. Le premier montage, lui, + le dit : le concentrateur nous place dans le réseau qu'on cherchait + à joindre. + + Le /24 est une HYPOTHÈSE, annoncée comme telle — le préfixe réel ne + se déduit pas d'une adresse. C'est le point de départ d'une + question au site, pas une réponse. + """ + if self.profile.get("routes") or self.profile.get("default_route"): + return + addresses = interface_addresses(iface) + if not addresses: + return + try: + network = ipaddress.ip_network(f"{addresses[0]}/24", strict=False) + except ValueError: + return + runner.warn( + "Aucun réseau routé : ce tunnel ne joint que l'hôte distant." + ) + runner.info( + f" Adresse obtenue {addresses[0]}. Si le réseau du site" + f" est un /24 — hypothèse, pas déduction — ajouter" + f" « {network} » aux réseaux du profil." + ) + + def set_resolved_dns(self, runner, iface, servers, search=""): + """Donne les serveurs DNS du tunnel à systemd-resolved. + + Sans cet appel, le tunnel est monté et aucun nom interne ne résout : + resolved ne lit pas les fichiers que pppd ou vpnc-script écrivent. + """ + if not servers: + return + if not which("resolvectl"): + runner.warn( + "resolvectl absent : les DNS du tunnel ne sont pas" + " appliqués. Vérifier /etc/resolv.conf à la main." + ) + return + runner.cmd( + f"DNS de {iface} : {' '.join(servers)}", + f"resolvectl dns {shlex.quote(iface)} " + + " ".join(shlex.quote(s) for s in servers), + check=False, + ) + if search: + runner.cmd( + f"domaine de recherche {search} sur {iface}", + f"resolvectl domain {shlex.quote(iface)}" + f" {shlex.quote('~' + search)}", + check=False, + ) + + def kill_pidfile(self, runner, label="arrêter le démon", sudo=True): + """Tue le processus dont le pid est dans le fichier de pid. + + `|| true` : au « down », le démon est souvent DÉJÀ tombé — c'est + même la raison la plus fréquente d'un « down ». Ce n'est pas un + échec. + + `sudo=False` pour un démon lancé SOUS l'utilisateur : sshuttle + n'élève que la partie pare-feu, son processus principal est le + nôtre, et root n'a pas à s'en mêler. + """ + pid = shlex.quote(self.pid_file) + runner.cmd( + label, + "sh -c {}".format( + shlex.quote(f"[ -f {pid} ] && kill $(cat {pid}) || true") + ), + check=False, + sudo=sudo, + ) + + def pid_alive(self): + """Vrai/faux si le pid du fichier tourne, None si indéterminable. + + Pas de fichier de pid = FAUX, pas « on ne sait pas » : le démon + écrit ce fichier au démarrage, son absence est une réponse. `None` + est réservé au vrai doute — fichier illisible, pid corrompu. + + `os.kill(pid, 0)` ne tue rien : il demande au noyau si le processus + existe. `PermissionError` veut dire qu'il existe mais ne nous + appartient pas — donc vivant. + """ + try: + with open(self.pid_file) as fh: + pid = int(fh.read().strip()) + except FileNotFoundError: + return False + except (OSError, ValueError): + return None + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return None + return True + + def add_host_route(self, runner, server_ip, raison="le serveur"): + """Route /32 vers `server_ip` par la passerelle ACTUELLE. + + Posée avant de monter, retirée au « down ». Sans elle, dès que le + tunnel capte la route par défaut, les paquets à destination de cette + adresse entrent dans le tunnel — les paquets chiffrés vers le + concentrateur, qui transportent le tunnel, et ceux de la session SSH + qui donne l'ordre. + + Plusieurs adresses peuvent avoir besoin de cette protection : l'état + garde donc une LISTE, une par ligne.""" + info = ( + runner.call( + f"lire la route actuelle vers {server_ip}", + lambda: route_to(server_ip), + dry_safe=True, + ) + or {} + ) + via, dev = info.get("via"), info.get("dev") + if not dev: + runner.warn( + f"Route actuelle vers {server_ip} indéterminée : la route de" + f" survie de {raison} n'est pas posée. En mode « tout le" + " trafic », le tunnel peut se couper lui-même." + ) + return + spec = f"{server_ip}/32" + command = f"ip route replace {spec} dev {shlex.quote(dev)}" + if via: + command = ( + f"ip route replace {spec} via {shlex.quote(via)}" + f" dev {shlex.quote(dev)}" + ) + runner.cmd( + f"poser la route de survie {spec} ({raison})", + command, + check=False, + ) + gardees = [ + ligne + for ligne in self.read_state("hostroute").splitlines() + if ligne.strip() and ligne.strip() != spec + ] + gardees.append(spec) + self.write_state(runner, "hostroute", "\n".join(gardees)) + + def protect_the_ssh_session(self, runner): + """Garde joignable le client SSH qui donne l'ordre. + + Piloter un client VPN par SSH et lui faire capter TOUT le trafic + coupe la session qui vient de lancer la commande : le retour part + dans le tunnel. On perd la machine, le menu, et le moyen de démonter + ce qu'on vient de monter.""" + adresse = ssh_client_address() + if not adresse: + return + runner.info( + f" Session SSH depuis {adresse} : on lui garde une route" + " directe, sinon « tout le trafic » la couperait." + ) + self.add_host_route(runner, adresse, raison="la session SSH") + + def del_host_route(self, runner): + for spec in self.read_state("hostroute").splitlines(): + spec = spec.strip() + if not spec: + continue + runner.cmd( + f"retirer la route de survie {spec}", + f"ip route del {shlex.quote(spec)}", + check=False, + ) + + # ------------------------------------------------------------------ + # Vérifications d'état, communes + # ------------------------------------------------------------------ + def missing_kernel_features(self) -> list: + """Libellés des capacités du noyau que la machine n'expose pas.""" + return [label for label, probe in self.kernel_features if not probe()] + + def check_kernel(self) -> list: + """L'étage le plus bas : ce que le noyau donne, et ce qui l'en + empêche. + + Sans cette vérification, un module inaccessible se manifeste trois + étages plus haut et sous un autre nom — charon démarre, abandonne à + l'initialisation, et l'attente de la connexion accuse le bloc de + `/etc/ipsec.conf`, qui est pourtant bien formé. + + Le verdict distingue deux causes que le même symptôme recouvre. Les + modules du noyau qui tourne ont disparu : la capacité EXISTE dans ce + noyau et un redémarrage la rend. Ce noyau ne l'expose pas : rien à + redémarrer, c'est le noyau qu'il faut changer. Rend une liste vide + quand le pilote n'exige rien du noyau et qu'il n'y a rien à signaler. + """ + missing = self.missing_kernel_features() + stale = stale_kernel() + names = ", ".join(label for label, _ in self.kernel_features) + if missing: + absent = ", ".join(missing) + if stale: + return [ + ( + "noyau", + False, + f"{absent} : indisponible — les modules du noyau" + f" {stale} ont disparu, redémarrer", + ) + ] + return [("noyau", False, f"{absent} : absent de ce noyau")] + if stale: + # Les capacités sondées répondent, mais elles sont les SEULES : + # les modules qu'une négociation charge ensuite (ESP, AH, ppp) + # ne peuvent plus l'être. Signalé, jamais compté en échec — un + # tunnel déjà monté, lui, continue de fonctionner. + detail = f"modules du noyau {stale} disparus — redémarrer" + if not names: + return [("noyau", None, detail)] + return [("noyau", True, f"{names} présent, mais {detail}")] + if not names: + return [] + return [("noyau", True, f"{names} : présent")] + + def check_binaries(self): + missing = self.missing_binaries() + return ( + "paquets client", + not missing, + "présents" if not missing else f"absents : {', '.join(missing)}", + ) + + def check_mounted(self): + iface = self.recorded_iface() + return ( + "profil monté (état /run)", + bool(iface), + f"interface {iface}" if iface else "aucun état : non connecté", + ) + + def check_daemon(self, label="démon"): + alive = self.pid_alive() + if alive is None: + detail = f"fichier de pid illisible : {self.pid_file}" + elif alive: + detail = f"vivant (pid dans {self.pid_file})" + elif os.path.exists(self.pid_file): + detail = "pid connu mais processus absent — démontage inachevé" + else: + detail = "aucun fichier de pid : non connecté" + return (label, alive, detail) + + def check_iface(self, iface): + exists = interface_exists(iface) + addresses = interface_addresses(iface) if exists else [] + return ( + f"interface {iface}", + exists and bool(addresses), + ", ".join(addresses) if addresses else "absente ou sans adresse", + ) + + def check_routes(self, iface): + checks = [] + for route in self.profile.get("routes", []): + info = route_to(route.split("/")[0]) + ok = info.get("dev") == iface + checks.append( + ( + f"route {route}", + ok, + f"via {info.get('dev', '?')}" + + (f" (attendu {iface})" if not ok else ""), + ) + ) + if self.profile.get("default_route"): + info = route_to("1.1.1.1") + checks.append( + ( + "route par défaut", + info.get("dev") == iface, + f"via {info.get('dev', '?')}", + ) + ) + return checks + + def check_probe(self, runner): + """Le témoin : la seule vérification qui PROUVE que ça marche. + + Tout le reste dit que les tuyaux sont en place ; celle-ci dit qu'un + paquet est allé au bout et revenu.""" + probe = self.profile.get("probe") + if not probe: + return [] + code, _ = runner.cmd( + f"joindre {probe} à travers le tunnel", + f"ping -c 2 -W 3 {shlex.quote(probe)}", + sudo=False, + check=False, + capture=True, + ) + return [ + ( + f"témoin {probe}", + code == 0, + "répond" if code == 0 else "ne répond pas", + ) + ] + + def standard_status(self, runner, extra=()): + """L'enchaînement habituel : noyau, paquets, état, interface, + routes, témoin — du plus bas au plus haut, pour que la première + ligne fausse soit la CAUSE et non une conséquence. Un pilote insère + ses propres vérifications par `extra`, une liste de (libellé, + verdict, détail).""" + checks = self.check_kernel() + checks.extend([self.check_binaries(), self.check_mounted()]) + checks.extend(extra) + iface = self.recorded_iface() + if iface: + checks.append(self.check_iface(iface)) + checks.extend(self.check_routes(iface)) + checks.extend(self.check_probe(runner)) + return checks diff --git a/script/vpn/drivers/l2tp_ipsec.py b/script/vpn/drivers/l2tp_ipsec.py new file mode 100644 index 0000000..6990337 --- /dev/null +++ b/script/vpn/drivers/l2tp_ipsec.py @@ -0,0 +1,898 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L2TP sur IPsec, clé pré-partagée : strongSwan + xl2tpd + pppd. + +Trois étages, et il faut les trois pour avoir une interface : + + 1. IPsec en mode TRANSPORT protège l'UDP 1701 entre nous et le serveur. + Mode transport, pas tunnel : c'est L2TP qui encapsule, IPsec ne fait + que chiffrer. Un `type=tunnel` ici ne monte jamais. + 2. L2TP (xl2tpd) ouvre une session dans ce canal protégé. + 3. PPP s'authentifie (MS-CHAPv2) et crée l'interface ppp*. + +Trois pièges connus, réglés ici et pas ailleurs : + + · `charon { install_routes = no }` — sinon charon pose lui-même une + route pour la SA, qui détourne le trafic L2TP et le tunnel n'aboutit + jamais. C'est LE symptôme classique « la SA est établie, ppp0 + n'apparaît pas ». + · Route de survie vers le serveur. En mode « tout le trafic », la route + par défaut part dans ppp0 — y compris les paquets ESP à destination du + serveur, qui se retrouvent à passer par le tunnel qu'ils portent. Une + route /32 vers le serveur via la passerelle d'origine évite ce + serpent qui se mord la queue. + · systemd-resolved ignore /etc/ppp/resolv.conf. `usepeerdns` remplit ce + fichier, personne ne le lit, et « le VPN marche mais aucun nom ne + résout ». D'où l'appel `resolvectl` explicite. + +Où vivent les fichiers, et pourquoi : + + /dev/shm/erplibre-vpn// 0700 root — LES SECRETS. tmpfs : rien + n'est écrit sur un disque persistant, et + un redémarrage efface tout. + /run/erplibre-vpn/.* 0755 — l'état non secret (interface + retenue, pid, route ajoutée). Lisible + sans sudo : `status` en a besoin. + /etc/ipsec.conf, /etc/ipsec.secrets un bloc marqué, retiré au « down ». + /etc/strongswan.d/erplibre-vpn.conf le réglage install_routes. +""" +from __future__ import annotations + +import os +import re +import shlex +import sys + +from script.vpn import valid +from script.vpn.drivers.base import ( + NETLINK_XFRM, + SECRET_DIR, + STATE_DIR, + VpnDriver, + interface_addresses, + locate, + netlink_family_available, + ppp_interfaces, + pppd_dns, + resolve, + wait_for_interface_address, + wait_for_new_interface, + which, +) + +IPSEC_CONF = "/etc/ipsec.conf" +IPSEC_SECRETS = "/etc/ipsec.secrets" +STRONGSWAN_DROPIN = "/etc/strongswan.d/erplibre-vpn.conf" +# AppArmor confine charon par CHEMIN sur Debian et Ubuntu. Le profil se +# termine par `#include ` : c'est le point d'extension prévu, et le +# fichier local existe déjà, vide. +APPARMOR_PROFILE = "/etc/apparmor.d/usr.lib.ipsec.charon" +APPARMOR_LOCAL = "/etc/apparmor.d/local/usr.lib.ipsec.charon" + +# Le drop-in est PARTAGÉ par tous les profils : c'est un réglage de charon, +# pas d'une connexion. Il vaut pour toute SA de la machine, et c'est assumé — +# sur un poste client, aucune SA ne veut que charon pose ses routes. +DROPIN_BODY = """# Généré par ERPLibre (script/vpn) — ne pas éditer. +# +# charon poserait sinon une route pour chaque SA. En L2TP/IPsec, cette route +# détourne le trafic UDP 1701 et le tunnel ne monte jamais : la SA s'établit, +# ppp0 n'apparaît pas. xl2tpd et pppd posent les routes dont on a besoin. +charon { + install_routes = no +} +""" + + +# Ce que charon dit, et ce que ça veut dire. Ces cinq messages sont ceux +# qu'on rencontre en montant un tunnel L2TP/IPsec, et aucun ne se comprend +# seul : le premier a coûté une heure de recherche du côté du PSK, alors que +# le PSK était juste et le refus venait d'AppArmor. +IPSEC_HINTS = ( + ( + "no shared key found", + "charon n'a pas trouvé le PSK. Le fichier est là, mais un refus" + " AppArmor sur /dev/shm l'empêche de le LIRE :" + " journalctl -k | grep DENIED.", + ), + ( + "not supported!", + "charon a retenu un algorithme qu'il ne sait pas exécuter — le" + " greffon manque. Sur Debian et Ubuntu, 3DES vient du greffon" + " openssl : paquet libstrongswan-standard-plugins.", + ), + ( + "does not match to", + "l'identité annoncée par la passerelle diffère de celle attendue." + " `rightid=%any` l'accepte : le bloc de /etc/ipsec.conf est-il à" + " jour ? Un « down » puis un « up » le réécrit.", + ), + ( + "AUTHENTICATION_FAILED", + "la passerelle a refusé la clé pré-partagée.", + ), + ( + "NO_PROPOSAL_CHOSEN", + "la passerelle refuse toutes nos propositions de chiffrement.", + ), +) + + +class L2tpIpsecDriver(VpnDriver): + name = "l2tp_ipsec" + label = "L2TP/IPsec PSK" + binaries = ("ipsec", "xl2tpd", "pppd", "ip") + # L'IPsec du noyau est la première condition de tout : sans la famille + # netlink XFRM, charon s'arrête à l'initialisation sur « kernel-ipsec » + # manquant, et l'échec se lit ensuite comme une connexion jamais + # chargée. La sonde ne rend « absent » que si le noyau refuse la + # famille, ce qu'aucun droit ni aucun réglage ne provoque. + kernel_features = ( + ( + "XFRM (IPsec du noyau)", + lambda: netlink_family_available(NETLINK_XFRM), + ), + ) + secret_fields = ( + ("psk", "IPsec pre-shared key (PSK)", True), + ("password", "PPP password", True), + ) + iface_kind = "ppp" + # Faux : un profil sans route reste utilisable — il joint l'hôte + # distant, et l'adresse qu'on y obtient dit quel réseau ajouter. Le + # site ne donne souvent qu'une passerelle et des identifiants. + needs_routes = False + hint = "When the far side imposes it: a router, a firewall, Windows RRAS" + proven = True + user_field = "ppp_user" + defaults = { + "ppp_user": "", + "use_peer_dns": True, + "dns_search": "", + # Le port L2TP LOCAL. 1701 est la valeur attendue ; le déplacer est + # le remède quand un xl2tpd du système tient déjà le port. + "l2tp_local_port": 1701, + } + form_fields = ( + ( + "ppp_user", + "PPP user (the one the server authenticates)", + "text", + False, + ), + ("l2tp_local_port", "Local L2TP port", "int", True), + ("use_peer_dns", "Use the DNS pushed by the peer?", "flag", True), + ("dns_search", "DNS search domain (optional)", "text", True), + ) + + # ------------------------------------------------------------------ + # Chemins et noms dérivés du profil + # ------------------------------------------------------------------ + @property + def conn(self): + """Nom de la connexion IPsec ET du LAC xl2tpd. Préfixé pour ne + jamais entrer en collision avec une connexion de l'utilisateur.""" + return f"erplibre-{self.name_tag}" + + @property + def secrets_file(self): + return f"{self.secret_dir}/ipsec.secrets" + + @property + def xl2tpd_conf(self): + return f"{self.secret_dir}/xl2tpd.conf" + + @property + def ppp_options(self): + return f"{self.secret_dir}/ppp.options" + + @property + def control_file(self): + return f"{STATE_DIR}/{self.name_tag}.control" + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + @classmethod + def validate_profile(cls, profile): + valid.text( + profile, + "ppp_user", + "Utilisateur PPP (c'est lui que le serveur authentifie)", + ) + valid.port(profile, "l2tp_local_port", "Port L2TP local") + valid.flag(profile, "use_peer_dns") + valid.text( + profile, + "dns_search", + "Domaine de recherche DNS", + required=False, + pattern=valid.HOST_RE, + ) + + # ------------------------------------------------------------------ + # Rendu des fichiers + # ------------------------------------------------------------------ + def ipsec_conn_body(self): + """Le bloc `conn` pour /etc/ipsec.conf. + + `leftprotoport=17/%any` et non `17/1701` : derrière du NAT, le port + source local est réécrit, et une politique clouée sur 1701 ne + s'applique alors plus aux paquets qui sortent. `%any` couvre les + deux cas — dont 1701. + + Les propositions incluent 3DES et modp1024 : c'est vieux, et c'est + exactement ce que servent les concentrateurs L2TP qu'on rencontre. + Les listes sont ordonnées, le meilleur d'abord. + """ + p = self.profile + return "\n".join( + [ + f"conn {self.conn}", + " keyexchange=ikev1", + " authby=secret", + " type=transport", + " left=%defaultroute", + " leftprotoport=17/%any", + f" right={p['server']}", + # La passerelle s'annonce comme elle veut : par son IP, par + # un FQDN, parfois par autre chose. Sans `rightid=%any`, + # strongSwan déduit l'identité attendue de `right` et refuse + # tout ce qui en diffère — « IDir '203.0.113.5' does not + # match to 'vpn.exemple.com' », sur une configuration par + # ailleurs juste. En PSK, c'est la CLÉ qui protège, pas + # l'identité annoncée par le pair. + " rightid=%any", + " rightprotoport=17/1701", + " ike=aes256-sha1-modp1024,aes128-sha1-modp1024," + "3des-sha1-modp1024!", + " esp=aes256-sha1,aes128-sha1,3des-sha1!", + " dpdaction=clear", + " dpddelay=30s", + " auto=add", + ] + ) + + def ipsec_secrets_body(self, server_ip): + """Le PSK, en HEXADÉCIMAL. + + strongSwan accepte `PSK "texte"` ou `PSK 0x`, et les deux + donnent les mêmes octets. L'hexadécimal évite toute question + d'échappement : un PSK contenant `"` ou `\\` casse la forme citée, + et un PSK est justement ce qu'on ne veut pas voir se faire tronquer + en silence. + """ + psk = self.secrets.get("psk", "") + as_hex = psk.encode("utf-8").hex() + return "\n".join( + [ + "# Généré par ERPLibre (script/vpn). tmpfs : jamais sur", + "# disque, effacé au « down » et à l'extinction.", + f"%any {server_ip} : PSK 0x{as_hex}", + ] + ) + + def xl2tpd_conf_body(self): + p = self.profile + # « ; » et non « # » : l'analyseur de xl2tpd ne connaît que le + # point-virgule, et refuse le fichier ENTIER sur un « # » en tête — + # « data '#…' occurs with no context », suivi de « Unable to load + # config file ». Un commentaire mal marqué cassait tout. + return "\n".join( + [ + "; Généré par ERPLibre (script/vpn).", + "[global]", + f"port = {p['l2tp_local_port']}", + "access control = no", + "", + f"[lac {self.conn}]", + f"lns = {p['server']}", + # Rien à EXIGER du pair : « require chap » et « require + # authentication » font passer « require-chap » et « auth » + # à pppd, c'est-à-dire « que le serveur s'authentifie + # auprès de moi ». Le serveur refuse, à juste titre, et pppd + # coupe : « LCP terminated by peer (peer refused to + # authenticate) ». La politique d'authentification est celle + # de ppp.options, et elle ne parle que de NOUS. + "require chap = no", + "refuse pap = no", + "require authentication = no", + "ppp debug = no", + f"pppoptfile = {self.ppp_options}", + "length bit = yes", + "redial = no", + ] + ) + + def ppp_options_body(self): + """Les options pppd, mot de passe compris. + + C'est le seul secret que la technologie oblige à poser dans un + fichier : pppd ne lit un mot de passe ni d'une variable + d'environnement, ni d'un argument. Le fichier est donc en 0600 + dans un tmpfs 0700 appartenant à root, et il est effacé au « down ». + Résiduel assumé : tant que le tunnel est monté, root peut le lire. + """ + p = self.profile + lines = [ + "# Généré par ERPLibre (script/vpn). tmpfs, 0600, effacé au down.", + "ipcp-accept-local", + "ipcp-accept-remote", + # AUCUN `refuse-*`. La méthode est celle que le concentrateur + # demande, et mesuré sur un vrai : « rcvd [LCP ConfReq … + # …] », auquel un `refuse-pap` répond + # « ConfNak » — le serveur coupe alors la + # liaison sur « peer refused to authenticate », et le « peer » + # de ce message, c'est NOUS. + # + # PAP envoie le mot de passe en clair SUR LA LIAISON PPP, qui + # voyage dans la session L2TP, elle-même dans l'ESP. C'est le + # dispositif même de L2TP/IPsec : c'est IPsec qui protège + # l'authentification PPP. Ce pilote ne lance jamais L2TP sans SA + # IPsec établie — il abandonne avant —, donc le mot de passe ne + # sort jamais en clair du poste. + "noccp", + # Ne rien exiger du pair. Un client n'authentifie pas son + # concentrateur en PPP : c'est IPsec qui l'a fait, avant. + "noauth", + "noipdefault", + f"mtu {p['mtu']}", + f"mru {p['mtu']}", + "connect-delay 5000", + "lcp-echo-interval 30", + "lcp-echo-failure 4", + f"name {_pppd_quote(p['ppp_user'])}", + f"password {_pppd_quote(self.secrets.get('password', ''))}", + ] + if p.get("use_peer_dns"): + lines.append("usepeerdns") + if p.get("default_route"): + # `replacedefaultroute` remet l'ancienne route en descendant : + # sans lui, une déconnexion brutale laisse la machine sans + # route par défaut du tout. + lines.append("defaultroute") + lines.append("replacedefaultroute") + return "\n".join(lines) + + # ------------------------------------------------------------------ + # Montée + # ------------------------------------------------------------------ + def up(self, runner): + p = self.profile + if not self.ensure_ready(runner): + return False + + server_ip = runner.call( + f"résoudre {p['server']}", + lambda: resolve(p["server"]), + dry_safe=True, + ) + if not server_ip: + runner.fail( + f"« {p['server']} » ne résout pas : sans son adresse, ni le" + " PSK ni la route de survie ne peuvent être posés." + ) + return False + runner.ok(f"{p['server']} → {server_ip}") + + before = ( + runner.call( + "relever les interfaces PPP existantes", + ppp_interfaces, + dry_safe=True, + ) + or set() + ) + + if not self._l2tp_port_is_free(runner): + return False + + self.prepare_dirs(runner) + runner.write( + self.secrets_file, + self.ipsec_secrets_body(server_ip) + "\n", + mode="0600", + secret=True, + ) + runner.write( + self.xl2tpd_conf, self.xl2tpd_conf_body() + "\n", mode="0600" + ) + runner.write( + self.ppp_options, + self.ppp_options_body() + "\n", + mode="0600", + secret=True, + ) + runner.write(STRONGSWAN_DROPIN, DROPIN_BODY, mode="0644") + self._allow_apparmor_to_read_secrets(runner) + runner.block(IPSEC_CONF, self.name_tag, self.ipsec_conn_body(), "0644") + runner.block( + IPSEC_SECRETS, + self.name_tag, + f"include {self.secrets_file}", + "0600", + ) + + self._reload_charon(runner) + if not self._wait_for_conn(runner): + return False + + if p.get("default_route"): + self.add_host_route(runner, server_ip) + self.protect_the_ssh_session(runner) + + # Capturé, et non affiché en direct : cette sortie EST le + # diagnostic. Une négociation IKE tient en une vingtaine de lignes, + # et c'est la dernière qui dit pourquoi ça a échoué. + code, out = runner.cmd( + f"monter la SA IPsec ({self.conn})", + f"ipsec up {shlex.quote(self.conn)}", + timeout=120, + check=False, + capture=True, + ) + if runner.dry_run: + pass + elif code != 0 or "established successfully" not in out: + for line in out.strip().splitlines()[-6:]: + runner.info(f" │ {line}") + runner.fail("La SA IPsec n'est pas montée.") + for motif, explication in IPSEC_HINTS: + if motif in out: + runner.info(f" → {explication}") + if not any(motif in out for motif, _ in IPSEC_HINTS): + runner.info( + " → Causes usuelles restantes : UDP 500/4500" + " filtré, ou passerelle injoignable. Voir" + " « diagnostic »." + ) + return False + else: + runner.ok("SA IPsec établie.") + + runner.cmd( + "lancer xl2tpd (instance dédiée à ce profil)", + "xl2tpd -c {} -C {} -p {}".format( + shlex.quote(self.xl2tpd_conf), + shlex.quote(self.control_file), + shlex.quote(self.pid_file), + ), + ) + if not self._wait_for_control(runner): + return False + runner.cmd( + "demander la session L2TP", + "sh -c {}".format( + shlex.quote( + f'echo "c {self.conn}" > {shlex.quote(self.control_file)}' + ) + ), + timeout=30, + ) + + iface = runner.call( + "attendre l'interface PPP", + lambda: wait_for_new_interface(before, "ppp"), + ) + if runner.dry_run: + runner.info(" (à blanc : l'interface serait nommée ici)") + return True + if not iface: + runner.fail( + "Aucune interface PPP n'est apparue. La SA IPsec est" + " montée : le refus vient de L2TP ou de PPP" + " (identifiants, MS-CHAPv2). Voir « diagnostic »." + ) + return False + # L'interface EXISTE dès que pppd la crée, bien avant qu'IPCP ait + # négocié l'adresse. La lire tout de suite annonçait « ppp0 : sans + # adresse » sur un tunnel qui allait très bien — et faisait chercher + # les DNS du pair avant que pppd les ait écrits. + addresses = runner.call( + f"attendre l'adresse de {iface}", + lambda: wait_for_interface_address(iface), + ) + if not addresses: + runner.fail( + f"{iface} est apparue sans obtenir d'adresse : IPCP n'a pas" + " abouti. L'authentification PPP a-t-elle réussi ? Le" + " journal de pppd le dit — voir « diagnostic »." + ) + return False + runner.ok(f"interface {iface} : {', '.join(addresses)}") + self.write_state(runner, "iface", iface) + self.add_routes(runner, iface) + self.suggest_routes(runner, iface) + if p.get("use_peer_dns"): + servers = runner.call( + "lire les DNS poussés par le pair", pppd_dns, dry_safe=True + ) + if servers: + self.set_resolved_dns( + runner, iface, servers, p.get("dns_search", "") + ) + else: + runner.warn("Le pair n'a poussé aucun DNS.") + return True + + # ------------------------------------------------------------------ + # Descente + # ------------------------------------------------------------------ + def down(self, runner): + """Défait tout, dans l'ordre inverse, sans s'arrêter au premier + échec : une descente doit nettoyer ce qu'elle PEUT nettoyer, même + si un étage est déjà tombé de lui-même.""" + runner.cmd( + "fermer la session L2TP", + "sh -c {}".format( + shlex.quote( + f"[ -p {shlex.quote(self.control_file)} ] &&" + f' echo "d {self.conn}" >' + f" {shlex.quote(self.control_file)} || true" + ) + ), + check=False, + timeout=20, + ) + self.kill_pidfile(runner, "arrêter xl2tpd") + runner.cmd( + f"descendre la SA IPsec ({self.conn})", + f"ipsec down {shlex.quote(self.conn)}", + check=False, + ) + self.del_host_route(runner) + # Les blocs AVANT les fichiers : un `include` qui pointe vers un + # fichier disparu fait échouer tout rechargement de charon, y + # compris ceux d'une autre connexion. + runner.block(IPSEC_SECRETS, self.name_tag, "", "0600") + runner.block(IPSEC_CONF, self.name_tag, "", "0644") + runner.remove(self.secret_dir) + self.clear_state(runner, "iface", "hostroute", "pid", "control") + self._reload_charon(runner, check=False) + runner.ok("Tunnel démonté, secrets effacés.") + return True + + # ------------------------------------------------------------------ + # État + # ------------------------------------------------------------------ + def status(self, runner): + # `statusall` et non `status` : `status` ne montre que les SA, et son + # « no match » est la réponse NORMALE d'un tunnel démonté. Il ne dit + # rien de la connexion elle-même — confondre les deux envoyait + # chercher une configuration absente alors qu'elle était chargée. + code, out = runner.cmd( + "état de charon", + f"ipsec statusall {shlex.quote(self.conn)}", + check=False, + capture=True, + ) + if code != 0: + unreachable = "charon injoignable (arrêté ? sudo refusé ?)" + return self.standard_status( + runner, + extra=[ + ("connexion chargée", None, unreachable), + ("SA IPsec", None, unreachable), + ], + ) + loaded = f"{self.conn}:" in out + established = "ESTABLISHED" in out + extra = [ + ( + "connexion chargée", + loaded, + self.conn if loaded else "absente de charon", + ), + ( + "SA IPsec", + established, + "établie" if established else "aucune SA (tunnel démonté)", + ), + ] + return self.standard_status(runner, extra=extra) + + def log_commands(self): + """L'unité strongSwan n'a pas le même nom partout : on essaie les + deux plutôt que de deviner la distribution.""" + return [ + ( + "journal strongSwan / xl2tpd", + "journalctl -n 40 --no-pager -u strongswan-starter" + " -u strongswan -u xl2tpd", + ), + ("journal pppd", "journalctl -n 20 --no-pager -t pppd"), + # Le seul endroit où un refus AppArmor apparaît. Sans cette + # ligne, un « no shared key found » reste inexplicable. + ( + "refus AppArmor (noyau)", + 'sh -c "journalctl -k --no-pager -n 200' + " | grep -i 'apparmor=\\\"DENIED\\\"' | tail -5" + " || echo 'aucun refus AppArmor récent'\"", + ), + ] + + # ------------------------------------------------------------------ + # Détails propres à L2TP/IPsec + # ------------------------------------------------------------------ + def _wait_for_control(self, runner): + """Attend que xl2tpd crée son tube de contrôle. + + xl2tpd se détache immédiatement et crée le FIFO ensuite. Écrire + dedans sans attendre échoue sur « No such file or directory », une + erreur qui ne dit rien du vrai problème — lequel est presque + toujours un xl2tpd qui n'a pas pu s'attacher au port. + """ + control = shlex.quote(self.control_file) + script = ( + f"for i in $(seq 1 40); do [ -p {control} ] && exit 0;" + " sleep 0.25; done; exit 1" + ) + code, _ = runner.cmd( + "attendre le tube de contrôle de xl2tpd", + f"sh -c {shlex.quote(script)}", + check=False, + timeout=25, + ) + if code == 0 or runner.dry_run: + return True + runner.fail( + "xl2tpd n'a pas créé son tube de contrôle : il n'a pas" + " démarré. Cause la plus fréquente, UDP" + f" {self.profile['l2tp_local_port']} déjà pris — voir" + " « diagnostic »." + ) + return False + + def _l2tp_port_is_free(self, runner): + """Faux si le port L2TP local est encore tenu après remèdes. + + La question n'est PAS « le service xl2tpd est-il actif ? » mais « le + port est-il libre ? » : un xl2tpd orphelin tient UDP 1701 alors que + `systemctl is-active xl2tpd` répond « inactive », et le service en + relance un second par-dessus. Le nom du service ne dit rien ; le + port, tout. + + On classe donc pid par pid, parce que les détenteurs peuvent être de + natures différentes en même temps : + + · un reste d'un montage précédent de CE profil → on propose notre + propre « down » ; + · un xl2tpd qui n'est pas à nous (service, orphelin) → on propose de + l'arrêter et de le terminer ; + · le tunnel d'un AUTRE de nos profils → on le nomme et on s'arrête : + couper le sien est une décision qui revient à son propriétaire ; + · autre chose → on le nomme, on n'y touche pas. + + Sans `ss`, on ne bloque pas : l'absence du tube de contrôle de + xl2tpd le dira deux étapes plus loin, et un faux blocage serait pire + qu'un échec tardif. + """ + port = self.profile["l2tp_local_port"] + if not locate("ss"): + return True + report = runner.warn if runner.dry_run else runner.fail + + holders, tenu = self._port_holders(runner, port) + if not tenu: + return True + + voisin = self._sibling_profile(holders) + if voisin: + report(f"UDP {port} est tenu par notre tunnel « {voisin} ».") + runner.info( + " → Le démonter d'abord :" + f" ./script/vpn/vpn.py down --profile {voisin}" + " (deux profils L2TP ne partagent pas le même port, et" + " couper le sien est votre décision, pas la nôtre)." + ) + return runner.dry_run + + # Remède 1 : un reste de CE profil. + if self._mine(holders): + runner.info( + f" Un montage précédent de « {self.name_tag} » n'a pas" + " été démonté." + ) + if runner.propose( + f"reste du montage précédent de {self.name_tag}", + f"{sys.executable} -u ./script/vpn/vpn.py down --profile" + f" {shlex.quote(self.name_tag)}", + sudo=False, + question="Démonter ce reste et réessayer ?", + ): + holders, tenu = self._port_holders(runner, port) + if not tenu: + runner.ok(f"Reste démonté, UDP {port} libre.") + return True + + # Remède 2 : des xl2tpd qui ne sont pas à nous. + etrangers = self._foreign_xl2tpd(holders) + if etrangers: + if runner.propose( + f"UDP {port} tenu par xl2tpd", + "sh -c {}".format( + shlex.quote( + "systemctl stop xl2tpd 2>/dev/null;" + f" kill {' '.join(etrangers)} 2>/dev/null; true" + ) + ), + question=( + "Arrêter le service xl2tpd et terminer les processus" + f" restants ({', '.join(etrangers)}), puis réessayer ?" + ), + ): + holders, tenu = self._port_holders(runner, port) + if not tenu: + runner.ok(f"UDP {port} libéré.") + runner.info( + " (le service reviendra au prochain démarrage :" + " sudo systemctl disable xl2tpd)" + ) + return True + + report(f"UDP {port} est encore tenu : {tenu.splitlines()[0][:110]}") + inconnus = [ + args for _pid, args in holders if "xl2tpd" not in args and args + ] + if inconnus: + runner.info( + f" → « {inconnus[0][:60]} » n'est pas à nous :" + " l'arrêter demande votre décision, ou changer" + " « l2tp_local_port » dans le profil." + ) + return runner.dry_run + + def _who_holds_the_port(self, runner, port): + """Ligne(s) de `ss` décrivant qui tient UDP `port`, "" si personne.""" + script = f"ss -lunp 2>/dev/null | grep ':{port} ' || true" + _, out = runner.cmd( + f"UDP {port} est-il déjà tenu ?", + f"sh -c {shlex.quote(script)}", + check=False, + capture=True, + ) + return out.strip() + + def _port_holders(self, runner, port): + """([(pid, ligne de commande)], sortie brute de `ss`). + + La ligne de commande est ce qui distingue nos instances des autres : + les nôtres portent leur configuration dans SECRET_DIR//. + """ + tenu = self._who_holds_the_port(runner, port) + pids = re.findall(r"pid=(\d+)", tenu) + if not pids: + return [], tenu + _, out = runner.cmd( + "à qui appartiennent ces processus ?", + f"ps -o pid=,args= -p {' '.join(pids)}", + sudo=False, + check=False, + capture=True, + ) + vu = {} + for line in (out or "").splitlines(): + morceaux = line.strip().split(None, 1) + if len(morceaux) == 2: + vu[morceaux[0]] = morceaux[1] + # `ss` nomme déjà le processus : c'est le repli quand `ps` ne dit + # rien (hidepid, processus disparu entre les deux appels). Sans ce + # repli on perdrait la classification, donc le remède, sur une + # information qu'on avait pourtant déjà. + noms = dict( + (pid, nom) + for nom, pid in re.findall(r'\("([^"]+)",pid=(\d+)', tenu) + ) + return [(pid, vu.get(pid) or noms.get(pid, "")) for pid in pids], tenu + + def _mine(self, holders): + """Pids qui sont des instances de CE profil.""" + marque = f"{SECRET_DIR}/{self.name_tag}/" + return [pid for pid, args in holders if marque in args] + + def _sibling_profile(self, holders): + """Nom d'un AUTRE de nos profils tenant le port, "" sinon.""" + for _pid, args in holders: + trouve = re.search( + rf"{re.escape(SECRET_DIR)}/([^/\s]+)/", args or "" + ) + if trouve and trouve.group(1) != self.name_tag: + return trouve.group(1) + return "" + + def _foreign_xl2tpd(self, holders): + """Pids xl2tpd qui ne sont pas des instances à nous.""" + return [ + pid + for pid, args in holders + if "xl2tpd" in args and SECRET_DIR not in args + ] + + def _allow_apparmor_to_read_secrets(self, runner): + """Autorise charon à lire nos secrets en tmpfs. + + AppArmor confine charon par CHEMIN, et `/dev/shm` ne figure pas dans + son profil. Sans cette règle, charon lit /etc/ipsec.secrets, suit + notre `include`, et se fait refuser le fichier par le noyau — pour + échouer trois étages plus loin sur « no shared key found », alors + que le PSK est là et bien formé. Seul `journalctl -k` le dit, en + clair : `apparmor="DENIED" … denied_mask="r"`. + + Le fichier `local/` est le point d'extension prévu par Debian et + Ubuntu ; il ne contient aucun secret, seulement un chemin. Il n'est + PAS retiré au démontage : c'est une permission de chemin, valable + pour tous les profils, et inoffensive quand le répertoire est vide. + """ + if not os.path.exists(APPARMOR_PROFILE): + # Ni Debian ni Ubuntu : pas de profil charon à étendre. + return + changed = runner.block( + APPARMOR_LOCAL, + "secrets", + f"{SECRET_DIR}/** r,", + "0644", + ) + if not changed: + return + # Le rechargement s'applique aussi au charon DÉJÀ lancé : sans lui, + # la règle n'entrerait en vigueur qu'au prochain démarrage. + runner.cmd( + "recharger le profil AppArmor de charon", + f"apparmor_parser -r {shlex.quote(APPARMOR_PROFILE)}", + check=False, + ) + + def _wait_for_conn(self, runner): + """Attend que la connexion soit chargée dans charon. + + `ipsec start` rend la main tout de suite : charon met une fraction + de seconde à démarrer, et le starter lui pousse les connexions + ENSUITE. Un `ipsec up` lancé dans l'instant échoue sur « no match » + — la connexion étant parfaitement valide, c'est l'erreur la plus + trompeuse de toute la séquence. + """ + conn = shlex.quote(self.conn) + script = ( + f"for i in $(seq 1 40); do ipsec statusall {conn} 2>/dev/null" + f" | grep -q {shlex.quote(self.conn + ':')} && exit 0;" + " sleep 0.25; done; exit 1" + ) + code, _ = runner.cmd( + "attendre que charon ait chargé la connexion", + f"sh -c {shlex.quote(script)}", + check=False, + timeout=20, + ) + if code == 0 or runner.dry_run: + return True + runner.fail( + f"charon n'a pas chargé « {self.conn} » en 10 s. Le bloc de" + " /etc/ipsec.conf est-il bien formé ? Voir le journal de" + " strongSwan." + ) + return False + + def _reload_charon(self, runner, check=True): + code, _ = runner.cmd( + "charon tourne-t-il ?", + "ipsec status", + check=False, + capture=True, + ) + if code != 0: + runner.cmd("démarrer charon", "ipsec start", check=check) + return + runner.cmd("recharger ipsec.conf", "ipsec reload", check=check) + runner.cmd("relire les secrets", "ipsec rereadsecrets", check=check) + + +def _pppd_quote(value: str) -> str: + """`value` cité pour un fichier d'options pppd. + + pppd lit des chaînes entre guillemets doubles et y traite `\\` comme + échappement. Un utilisateur de la forme `DOMAINE\\prenom` est courant sur + les concentrateurs L2TP : sans cet échappement, pppd envoie + `DOMAINEprenom` et le serveur refuse sans dire pourquoi. + """ + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' diff --git a/script/vpn/drivers/openconnect.py b/script/vpn/drivers/openconnect.py new file mode 100644 index 0000000..bda50ad --- /dev/null +++ b/script/vpn/drivers/openconnect.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""OpenConnect : le seul pilote où aucun secret ne touche un fichier. + +`--passwd-on-stdin` fait lire le mot de passe sur l'entrée standard. Il ne +passe donc ni par un argument (`/proc//cmdline`, lisible par tous), ni +par un fichier, même en tmpfs. C'est le cas idéal, et il vaut la peine d'être +nommé : les autres pilotes composent avec des technologies qui EXIGENT un +fichier, celui-ci n'en a pas besoin. + +Un client, plusieurs protocoles : AnyConnect (Cisco), Pulse/Juniper, +GlobalProtect (Palo Alto), Fortinet, F5, Array. `--protocol` décide. + +`--non-inter` est passé volontairement. Sans lui, un certificat serveur +inconnu déclenche une question — et openconnect la lirait sur l'entrée +standard, celle par laquelle arrive justement le mot de passe. Le tunnel +échouerait sur un « certificate verify failed » incompréhensible. Avec +`--non-inter`, openconnect refuse tout de suite ET imprime la ligne +`--servercert sha256:…` à recopier dans le champ `oc_servercert` du profil. + +Les routes appartiennent au serveur : c'est `vpnc-script` qui les pose, à +partir de ce que le concentrateur pousse. Le profil peut en AJOUTER, il ne +les remplace pas — d'où `needs_routes = False`. + +SSO / SAML — le cas du « formulaire web » +----------------------------------------- +Quand le concentrateur authentifie par un fournisseur d'identité (Azure AD, +Okta, Duo…), il n'y a pas de mot de passe à envoyer : il faut une page web. +Le client de Cisco la rend dans un navigateur WebKit embarqué — donc un +écran, et sur bien des postes la variable `WEBKIT_DISABLE_DMABUF_RENDERER=1` +en prime pour qu'elle s'affiche. Son CLI, lui, ne sait pas le faire. + +openconnect le fait sans écran sur la machine cliente. Mesuré dans sa +bibliothèque : il ÉCOUTE sur le port local 29786 et attend la redirection +(« Accepted incoming external-browser connection on port 29786 »), après +avoir lancé le programme donné à `--external-browser` avec l'URL de +connexion. Sur un serveur, ce « navigateur » est un simple `echo` : l'URL +s'affiche, on l'ouvre dans SON navigateur, et un + + ssh -L 29786:localhost:29786 + +fait revenir la redirection à openconnect. Aucun écran là-bas, et le mot de +passe ne quitte jamais le poste de l'utilisateur. +""" +from __future__ import annotations + +import shlex + +from script.vpn import valid +from script.vpn.drivers.base import ( + VpnDriver, + interface_addresses, + interface_exists, +) + +# Ce que ce client sait parler. La liste vient de `openconnect --protocol`. +PROTOCOLS = ("anyconnect", "nc", "pulse", "gp", "f5", "fortinet", "array") + + +class OpenconnectDriver(VpnDriver): + name = "openconnect" + label = "OpenConnect" + binaries = ("openconnect", "ip") + # Non obligatoire : en SSO il n'y a AUCUN mot de passe à déposer, et le + # menu ne doit pas réclamer un secret que la méthode n'utilise pas. La + # vraie exigence dépend du mode, elle est donc dans `up`. + secret_fields = (("password", "VPN password", False),) + iface_kind = "tun" + hint = "Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances" + user_field = "oc_user" + # Le MTU vient du serveur (ou du .ovpn), pas du profil. + uses_mtu = False + # Le serveur pousse les routes : exiger une route déclarée serait une + # fausse exigence. + needs_routes = False + defaults = { + "port": 443, + "oc_user": "", + "oc_protocol": "anyconnect", + "oc_authgroup": "", + "oc_servercert": "", + # SSO : le concentrateur authentifie par un fournisseur d'identité, + # dans un navigateur. Voir l'en-tête du fichier. + "oc_sso": False, + # Programme lancé avec l'URL de connexion. Vide = `echo`, qui + # l'affiche : c'est ce qu'on veut sur une machine sans écran. + "oc_external_browser": "", + } + form_fields = ( + ("oc_user", "VPN user", "text", False), + ( + "oc_protocol", + "Protocol (anyconnect, nc, pulse, gp, f5, fortinet, array)", + "text", + False, + ), + ( + "oc_authgroup", + "Authentication group / realm (optional)", + "text", + True, + ), + ( + "oc_servercert", + "Pinned server certificate (sha256:... , printed on first refusal)", + "text", + True, + ), + ( + "oc_sso", + "Authentication through a web form (SAML / SSO)?", + "flag", + False, + ), + ( + "oc_external_browser", + "Browser command for SSO (empty: show the URL to open yourself)", + "path", + True, + ), + ("port", "HTTPS port", "int", True), + ) + + # ------------------------------------------------------------------ + @property + def iface(self): + """Interface NOMMÉE, et non découverte : openconnect sait le faire + (`--interface`), et un nom connu d'avance rend `status` fiable même + après un redémarrage du CLI. Tronqué à 15 caractères.""" + return f"vpn-{self.name_tag}"[:15] + + @classmethod + def validate_profile(cls, profile): + # Le mode d'abord : en SSO, c'est le fournisseur d'identité qui + # décide de qui on est, et exiger un utilisateur ici refuserait un + # profil parfaitement valide. + valid.flag(profile, "oc_sso") + valid.text( + profile, + "oc_user", + "Utilisateur VPN", + required=not profile["oc_sso"], + ) + protocol = valid.text(profile, "oc_protocol", "Protocole") + if protocol not in PROTOCOLS: + raise valid.ProfileError( + f"Protocole inconnu : « {protocol} »." + f" Connus : {', '.join(PROTOCOLS)}." + ) + valid.text( + profile, + "oc_authgroup", + "Groupe d'authentification", + required=False, + ) + valid.text( + profile, + "oc_servercert", + "Empreinte du certificat serveur", + required=False, + ) + valid.port(profile, "port", "Port HTTPS") + valid.path( + profile, + "oc_external_browser", + "Programme navigateur", + required=False, + ) + + @property + def browser(self): + """Programme lancé avec l'URL de connexion SSO. + + `echo` par défaut : sur une machine sans écran, afficher l'URL est + exactement ce qu'on veut — openconnect attend ensuite la redirection + sur son port 29786.""" + return self.profile.get("oc_external_browser") or "echo" + + def command(self): + """La ligne de commande, dans l'une de ses deux formes. + + Classique : le mot de passe arrive par l'entrée standard, grâce à + `--passwd-on-stdin` — il n'est jamais dans la ligne de commande. + + SSO : il n'y a pas de mot de passe. Pas de `--non-inter` non plus, + car l'échange avec le navigateur EST l'interaction ; l'interdire + ferait échouer la seule étape qui compte. + """ + p = self.profile + parts = [ + "openconnect", + f"--protocol={shlex.quote(p['oc_protocol'])}", + ] + if p["oc_user"]: + parts.append(f"--user={shlex.quote(p['oc_user'])}") + if p["oc_sso"]: + parts.append(f"--external-browser={shlex.quote(self.browser)}") + else: + parts += ["--passwd-on-stdin", "--non-inter"] + parts += [ + "--background", + f"--pid-file={shlex.quote(self.pid_file)}", + f"--interface={shlex.quote(self.iface)}", + ] + if p.get("oc_authgroup"): + parts.append(f"--authgroup={shlex.quote(p['oc_authgroup'])}") + if p.get("oc_servercert"): + parts.append(f"--servercert={shlex.quote(p['oc_servercert'])}") + parts.append(shlex.quote(f"{p['server']}:{p['port']}")) + return " ".join(parts) + + # ------------------------------------------------------------------ + def up(self, runner): + p = self.profile + if not self.ensure_ready(runner): + return False + # `secrets=False` : ce pilote n'écrit AUCUN fichier de secret, et + # créer un répertoire pour rien serait laisser croire qu'il en a un. + self.prepare_dirs(runner, secrets=False) + + if p["oc_sso"]: + self._explain_the_sso_round_trip(runner) + mot_de_passe, delai = None, 300 + else: + if not self.secrets.get("password"): + 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 + mot_de_passe = self.secrets.get("password", "") + "\n" + delai = 120 + + code, _ = runner.cmd( + f"ouvrir la session {p['oc_protocol']} sur {p['server']}", + self.command(), + stdin=mot_de_passe, + secret_stdin=bool(mot_de_passe), + check=False, + timeout=delai, + ) + if code != 0 and not runner.dry_run: + runner.fail("openconnect a refusé.") + if p["oc_sso"]: + runner.info( + " → En SSO, les deux causes sont : la redirection" + " n'est jamais revenue sur le port 29786 (redirection" + " ssh en place ?), ou le délai de 5 minutes a expiré" + " avant la fin de l'authentification." + ) + else: + runner.info( + " → Causes usuelles : identifiants, certificat" + " serveur non épinglé (recopier la ligne" + " « --servercert sha256:… » ci-dessus dans le champ" + " oc_servercert), groupe d'authentification absent." + ) + return False + if runner.dry_run: + runner.info(f" (à blanc : l'interface serait {self.iface})") + return True + + if not interface_exists(self.iface): + runner.fail( + f"openconnect s'est lancé mais {self.iface} n'existe pas." + " vpnc-script est-il installé ? (paquet vpnc-scripts)" + ) + return False + addresses = ( + ", ".join(interface_addresses(self.iface)) or "sans adresse" + ) + runner.ok(f"interface {self.iface} : {addresses}") + self.write_state(runner, "iface", self.iface) + # Les routes du serveur sont déjà posées par vpnc-script. Celles du + # profil s'AJOUTENT : un réseau que le concentrateur ne pousse pas + # mais qu'on sait joignable. + self.add_routes(runner, self.iface) + return True + + def _explain_the_sso_round_trip(self, runner): + """Dit à l'humain ce qu'il va devoir faire, AVANT de le bloquer. + + openconnect va afficher une URL puis attendre, silencieusement, sur + son port 29786. Sans cette explication, l'attente ressemble à un + blocage — et la redirection ne revient jamais si personne n'a monté + le tunnel ssh.""" + runner.info( + " Authentification par formulaire web. openconnect va" + " afficher une URL, puis attendre la redirection sur son port" + " local 29786." + ) + runner.info( + " Depuis VOTRE poste, avant d'ouvrir l'URL :" + " ssh -L 29786:localhost:29786 " + ) + if self.browser == "echo": + runner.info( + " L'URL s'affichera ici : l'ouvrir dans votre propre" + " navigateur. Le mot de passe ne quitte pas votre poste." + ) + else: + runner.info(f" Navigateur lancé sur place : {self.browser}") + + def down(self, runner): + # SIGTERM : openconnect rappelle vpnc-script, qui défait les routes + # et le DNS qu'il avait posés. Un « kill -9 » les laisserait en + # place, et la machine resterait à moitié dans le tunnel. + self.kill_pidfile(runner, "arrêter openconnect (SIGTERM)") + self.clear_state(runner, "iface", "pid") + runner.ok( + "Session fermée. Aucun secret à effacer : il n'a jamais" + " touché le disque." + ) + return True + + def status(self, runner): + return self.standard_status( + runner, extra=[self.check_daemon("processus openconnect")] + ) + + def log_commands(self): + return [ + ( + "journal openconnect", + "journalctl -n 40 --no-pager -t openconnect", + ) + ] diff --git a/script/vpn/drivers/openvpn.py b/script/vpn/drivers/openvpn.py new file mode 100644 index 0000000..4407fcb --- /dev/null +++ b/script/vpn/drivers/openvpn.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""OpenVPN : on part du fichier `.ovpn` que le client a fourni. + +Ce pilote ne fabrique PAS de configuration OpenVPN. Un `.ovpn` porte une +autorité de certification, un certificat, une clé privée, des directives de +compression et de chiffrement : le modéliser dans un profil JSON serait +recopier un format qui existe déjà, et le recopier moins bien. Le profil +pointe donc vers le fichier, et ce pilote y ajoute ce que le fichier ne peut +pas contenir sans devenir un secret de plus : les identifiants, lus dans le +coffre et posés dans un tmpfs. + +Deux choses qu'on aurait tort de croire évidentes : + +· **`--cd`.** Un `.ovpn` référence ses fichiers voisins en relatif (`ca.crt`, + `client.key`). Lancé depuis la racine du dépôt, openvpn ne les trouve pas + et se plaint d'un certificat manquant, pas d'un répertoire. On se place + donc dans le répertoire du fichier. +· **L'ordre des options.** Ce qui suit `--config` sur la ligne de commande + l'emporte sur le contenu du fichier. Notre `--auth-user-pass ` + doit donc venir APRÈS, sinon un `auth-user-pass` nu dans le `.ovpn` fait + attendre une saisie qui ne viendra jamais — le démon est détaché. + +Le tunnel scindé se demande à OpenVPN par `--route-nopull` : ignorer les +routes poussées, puis poser les nôtres. C'est un gros marteau — il ignore +aussi le DNS poussé — et le pilote le dit quand il le prend. +""" +from __future__ import annotations + +import os +import shlex + +from script.vpn import valid +from script.vpn.drivers.base import ( + STATE_DIR, + VpnDriver, + interface_addresses, + interfaces, + wait_for_new_interface, +) + + +class OpenvpnDriver(VpnDriver): + name = "openvpn" + label = "OpenVPN" + binaries = ("openvpn", "ip") + # Pas obligatoire : beaucoup de `.ovpn` s'authentifient par certificat + # seul. `up` exige le mot de passe seulement si un utilisateur est + # déclaré. + secret_fields = (("password", "OpenVPN password", False),) + iface_kind = "tun" + # Faux : un profil sans route reste utilisable — il joint l'hôte + # distant, et l'adresse qu'on y obtient dit quel réseau ajouter. Le + # site ne donne souvent qu'une passerelle et des identifiants. + needs_routes = False + hint = "When the site handed you a .ovpn file" + user_field = "ovpn_user" + # Le MTU vient du serveur (ou du .ovpn), pas du profil. + uses_mtu = False + defaults = {"ovpn_config": "", "ovpn_user": ""} + form_fields = ( + ( + "ovpn_config", + "Path to the .ovpn file provided by the site", + "path", + False, + ), + ( + "ovpn_user", + "OpenVPN user (empty if the file authenticates by certificate)", + "text", + False, + ), + ) + + # ------------------------------------------------------------------ + @property + def auth_file(self): + return f"{self.secret_dir}/auth.txt" + + @property + def log_file(self): + """Journal en tmpfs, lisible sans sudo : c'est lui qui dit pourquoi + une connexion a échoué, et `diagnose` doit pouvoir le montrer.""" + return f"{STATE_DIR}/{self.name_tag}.log" + + @classmethod + def validate_profile(cls, profile): + valid.path(profile, "ovpn_config", "Fichier .ovpn") + valid.text(profile, "ovpn_user", "Utilisateur OpenVPN", required=False) + + def auth_body(self): + """Le format attendu par `--auth-user-pass` : deux lignes.""" + return "{}\n{}\n".format( + self.profile.get("ovpn_user", ""), + self.secrets.get("password", ""), + ) + + def command(self): + """La ligne de commande, sans aucun secret : le mot de passe est + dans le fichier d'authentification, pas ici.""" + p = self.profile + config = p["ovpn_config"] + parts = [ + "openvpn", + f"--config {shlex.quote(config)}", + f"--cd {shlex.quote(os.path.dirname(config) or '.')}", + f"--daemon erplibre-{self.name_tag}", + f"--writepid {shlex.quote(self.pid_file)}", + f"--log {shlex.quote(self.log_file)}", + ] + if p.get("ovpn_user"): + parts.append(f"--auth-user-pass {shlex.quote(self.auth_file)}") + # Le fichier est relu à chaque renégociation : rien à garder en + # mémoire, et un secret de moins qui traîne dans le processus. + parts.append("--auth-nocache") + if not p.get("default_route") and p.get("routes"): + parts.append("--route-nopull") + return " ".join(parts) + + # ------------------------------------------------------------------ + def up(self, runner): + p = self.profile + if not self.ensure_ready(runner): + return False + if p.get("ovpn_user") and not self.secrets.get("password"): + report = runner.warn if runner.dry_run else runner.fail + report( + f"Le profil déclare l'utilisateur « {p['ovpn_user']} » mais" + " aucun mot de passe n'est dans le coffre." + ) + if not runner.dry_run: + return False + self._warn_about_config_file(runner) + + before = ( + runner.call( + "relever les interfaces tun/tap existantes", + lambda: interfaces("tun"), + dry_safe=True, + ) + or set() + ) + + self.prepare_dirs(runner) + if p.get("ovpn_user"): + runner.write( + self.auth_file, self.auth_body(), mode="0600", secret=True + ) + if not p.get("default_route") and p.get("routes"): + runner.warn( + "Tunnel scindé par --route-nopull : les routes ET le DNS" + " poussés par le serveur sont ignorés. Seules les routes du" + " profil sont posées." + ) + + code, _ = runner.cmd("lancer openvpn", self.command(), timeout=60) + if code != 0 and not runner.dry_run: + runner.fail( + "openvpn n'a pas démarré. Le journal dit pourquoi :" + f" {self.log_file}" + ) + return False + + if not self._wait_for_init(runner): + return False + if runner.dry_run: + runner.info(" (à blanc : l'interface serait nommée ici)") + return True + + iface = wait_for_new_interface(before, "tun", timeout=10) + if not iface: + runner.fail( + "OpenVPN dit s'être initialisé, mais aucune interface" + " tun/tap n'est apparue. Cas rare : configuration en mode" + " pont (tap) sans interface propre." + ) + return False + addresses = ", ".join(interface_addresses(iface)) or "sans adresse" + runner.ok(f"interface {iface} : {addresses}") + self.write_state(runner, "iface", iface) + if not p.get("default_route"): + self.add_routes(runner, iface) + self.suggest_routes(runner, iface) + return True + + def _wait_for_init(self, runner): + """Attend « Initialization Sequence Completed » dans le journal. + + C'est LE signal de succès d'OpenVPN. Le processus détaché existe + bien avant : se contenter de son pid ferait dire « monté » à un + client encore en train de se faire refuser ses certificats. + """ + log = shlex.quote(self.log_file) + script = ( + "for i in $(seq 1 120); do" + f" grep -q 'Initialization Sequence Completed' {log}" + " 2>/dev/null && exit 0; sleep 0.5; done; exit 1" + ) + code, _ = runner.cmd( + "attendre l'initialisation d'OpenVPN", + f"sh -c {shlex.quote(script)}", + check=False, + timeout=75, + ) + if code == 0 or runner.dry_run: + return True + runner.fail( + "OpenVPN ne s'est pas initialisé en 60 s. Les dernières lignes" + f" de {self.log_file} disent laquelle des trois étapes a" + " échoué : TLS, authentification, ou pose des routes." + ) + return False + + def _warn_about_config_file(self, runner): + """Un `.ovpn` embarque souvent la clé privée du client. + + Le fichier appartient à l'utilisateur, pas à nous : on ne le + déplace pas, on ne le réécrit pas. Mais lisible par tout le monde, + il vaut la peine d'être signalé — c'est une clé privée. + """ + config = self.profile.get("ovpn_config", "") + try: + mode = os.stat(config).st_mode + except OSError: + runner.warn( + f"Fichier de configuration introuvable : {config}." + " Le montage échouera." + ) + return + if mode & 0o077: + runner.warn( + f"{config} est lisible au-delà de son propriétaire" + f" (mode {oct(mode & 0o777)}). Un .ovpn embarque souvent la" + " clé privée du client : chmod 600 est de rigueur." + ) + + # ------------------------------------------------------------------ + def down(self, runner): + self.kill_pidfile(runner, "arrêter openvpn") + runner.remove(self.secret_dir) + # Le journal SURVIT au démontage, volontairement : c'est juste + # après un « down » qu'on cherche pourquoi ça n'allait pas. Il est + # en tmpfs, donc il part au redémarrage de la machine. + self.clear_state(runner, "iface", "pid") + runner.ok( + f"Tunnel démonté, secrets effacés. Journal gardé : {self.log_file}" + ) + return True + + # ------------------------------------------------------------------ + def status(self, runner): + extra = [self.check_daemon("processus openvpn")] + try: + with open(self.log_file) as fh: + lines = [line.strip() for line in fh if line.strip()] + except OSError: + lines = [] + initialised = any( + "Initialization Sequence Completed" in line for line in lines + ) + extra.append( + ( + "initialisation OpenVPN", + initialised if lines else None, + lines[-1][:120] if lines else "aucun journal", + ) + ) + return self.standard_status(runner, extra=extra) + + def log_commands(self): + return [ + ( + f"journal OpenVPN ({self.log_file})", + f"tail -n 40 {shlex.quote(self.log_file)}", + ) + ] diff --git a/script/vpn/drivers/sshuttle.py b/script/vpn/drivers/sshuttle.py new file mode 100644 index 0000000..8182d30 --- /dev/null +++ b/script/vpn/drivers/sshuttle.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""sshuttle : un VPN sur une simple session SSH, sans rien à installer en face. + +Le pilote le plus utile quand il n'y a PAS de concentrateur : si on a un accès +SSH sur une machine du réseau visé, on a déjà tout. Rien à installer côté +serveur, aucune clé à échanger, aucun secret à ranger — l'authentification est +celle de SSH, donc `secret_fields` est vide et le coffre n'est même pas +ouvert. C'est le seul pilote qui n'en a pas besoin. + +Deux différences qui changent le code, et pas seulement les commandes : + +· **Pas d'interface.** sshuttle détourne le trafic par le pare-feu + (iptables/nftables), il ne crée pas de `tun`. Toutes les vérifications + d'interface et de table de routage sont donc muettes ici : c'est l'adresse + TÉMOIN qui dit si ça marche, et rien d'autre. Ce pilote est la raison d'être + du champ `probe`. +· **Il s'élève tout seul.** sshuttle veut être lancé par l'UTILISATEUR : il + n'appelle sudo que pour la partie pare-feu. Le lancer sous sudo ferait + ouvrir la session SSH par root, avec les clés de root — c'est-à-dire aucune. + D'où `sudo=False`, et un fichier de pid dans le home plutôt que dans /run. +""" +from __future__ import annotations + +import os +import shlex + +from script.vpn import valid +from script.vpn.drivers.base import VpnDriver + + +class SshuttleDriver(VpnDriver): + name = "sshuttle" + label = "sshuttle" + binaries = ("sshuttle", "ssh") + # Aucun. L'authentification est celle de SSH. + secret_fields = () + iface_kind = "" + hint = ( + "When all you have is SSH access: nothing to install on the far side" + ) + server_label = "SSH target (user@host, or a ~/.ssh/config alias)" + uses_mtu = False + defaults = {"port": 22, "ssh_dns": True} + form_fields = ( + ("port", "SSH port", "int", True), + ("ssh_dns", "Also send DNS queries through the tunnel?", "flag", True), + ) + + # ------------------------------------------------------------------ + @property + def pid_file(self): + """Dans le home, pas dans /run. + + sshuttle tourne sous l'utilisateur et écrit ce fichier lui-même : + /run/erplibre-vpn appartient à root, il ne pourrait pas.""" + return os.path.expanduser(f"~/.erplibre/vpn-{self.name_tag}.pid") + + @property + def subnets(self): + """Ce qui entre dans le tunnel. `0.0.0.0/0` pour « tout ».""" + if self.profile.get("default_route"): + return ["0.0.0.0/0"] + return list(self.profile.get("routes", [])) + + @classmethod + def validate_profile(cls, profile): + valid.port(profile, "port", "Port SSH") + valid.flag(profile, "ssh_dns") + + def command(self): + p = self.profile + remote = p["server"] + if int(p.get("port", 22)) != 22: + remote = f"{remote}:{p['port']}" + parts = [ + "sshuttle", + f"--remote {shlex.quote(remote)}", + "--daemon", + f"--pidfile {shlex.quote(self.pid_file)}", + ] + if p.get("ssh_dns"): + parts.append("--dns") + parts.extend(shlex.quote(subnet) for subnet in self.subnets) + return " ".join(parts) + + # ------------------------------------------------------------------ + def up(self, runner): + if not self.ensure_ready(runner): + return False + if not self.subnets: + runner.fail("Aucun réseau à détourner : rien à faire.") + return False + + runner.call( + f"préparer {os.path.dirname(self.pid_file)}", + lambda: os.makedirs(os.path.dirname(self.pid_file), exist_ok=True), + ) + # SANS sudo : sudo est demandé par sshuttle lui-même, et seulement + # pour le pare-feu. Voir l'en-tête du fichier. + code, _ = runner.cmd( + f"détourner {', '.join(self.subnets)} par {self.profile['server']}", + self.command(), + sudo=False, + check=False, + timeout=90, + ) + if code != 0 and not runner.dry_run: + runner.fail( + "sshuttle n'a pas démarré. Les causes usuelles : SSH qui ne" + f" passe pas vers {self.profile['server']} (l'essayer à la" + " main), python absent sur la machine distante, ou sudo" + " local refusé." + ) + return False + if runner.dry_run: + return True + if self.pid_alive() is not True: + runner.fail( + "sshuttle s'est lancé puis a rendu la main sans laisser de" + " processus vivant. Le relancer sans --daemon montre ce" + " qu'il refuse." + ) + return False + runner.ok( + "Détournement actif. Pas d'interface à montrer : sshuttle passe" + " par le pare-feu." + ) + return True + + def down(self, runner): + # Sans sudo, comme au montage : c'est notre processus. + self.kill_pidfile(runner, "arrêter sshuttle", sudo=False) + runner.ok("Détournement arrêté.") + return True + + def status(self, runner): + """Ni interface, ni route à vérifier : le témoin est le seul juge. + + On ne réutilise donc PAS `standard_status` — ses vérifications + d'interface et de routes rendraient des « ✗ » qui n'ont aucun sens + pour un détournement par le pare-feu.""" + checks = self.check_kernel() + checks += [ + self.check_binaries(), + self.check_daemon("processus sshuttle"), + ( + "réseaux détournés", + bool(self.subnets), + ", ".join(self.subnets) or "aucun", + ), + ] + probe = self.check_probe(runner) + if not probe: + checks.append( + ( + "témoin", + None, + "aucune adresse témoin : renseigner « probe » dans le" + " profil, c'est la seule preuve possible ici", + ) + ) + checks.extend(probe) + return checks + + def log_commands(self): + return [ + ( + "règles de détournement", + # Le nom de la chaîne porte le port choisi par sshuttle + # (12300 par défaut, mais pas toujours) : on cherche le + # motif plutôt que de parier sur le nom. + 'sh -c "iptables -t nat -S 2>/dev/null | grep -i sshuttle' + " || nft list ruleset 2>/dev/null | grep -i sshuttle" + " || echo 'aucune règle sshuttle visible'\"", + ) + ] diff --git a/script/vpn/drivers/wireguard.py b/script/vpn/drivers/wireguard.py new file mode 100644 index 0000000..00d1770 --- /dev/null +++ b/script/vpn/drivers/wireguard.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""WireGuard : une configuration, une commande, et c'est monté. + +Le plus simple des pilotes — et c'est justement là qu'il faut se méfier. +WireGuard n'a pas de session : `wg-quick up` réussit et l'interface apparaît +même si la clé du pair est fausse, même si l'endpoint est injoignable. Rien +ne dit non, parce qu'il n'y a personne à qui dire non. + +Ce pilote attend donc une POIGNÉE DE MAIN avant de déclarer le tunnel monté. +Sans cette attente, « ✓ Tunnel monté » voudrait dire « l'interface existe », +ce qui n'est pas la même chose et ne se découvre qu'au premier paquet perdu. + +Les routes viennent d'`AllowedIPs` et c'est `wg-quick` qui les pose — y +compris, en « tout le trafic », l'astuce de marquage (fwmark) qui garde +l'endpoint joignable. On ne double donc PAS son travail : un `ip route` de +plus ici entrerait en conflit avec le sien. +""" +from __future__ import annotations + +import shlex + +from script.vpn import valid +from script.vpn.drivers.base import VpnDriver, interface_addresses + + +class WireguardDriver(VpnDriver): + name = "wireguard" + label = "WireGuard" + binaries = ("wg", "wg-quick", "ip") + secret_fields = ( + ("wg_private_key", "WireGuard private key of this machine", True), + ("wg_preshared_key", "WireGuard pre-shared key (optional)", False), + ) + iface_kind = "wireguard" + hint = "When you control both ends: the fastest and the simplest" + defaults = { + "port": 51820, + "wg_address": "", + "wg_peer_key": "", + "wg_dns": "", + "wg_keepalive": 25, + } + form_fields = ( + ( + "wg_address", + "Address of this machine inside the tunnel (10.7.0.2/32)", + "text", + False, + ), + ("wg_peer_key", "Public key of the peer", "text", False), + ("port", "WireGuard endpoint port", "int", True), + ("wg_dns", "DNS server inside the tunnel (optional)", "text", True), + ("wg_keepalive", "PersistentKeepalive, in seconds", "int", True), + ) + + # ------------------------------------------------------------------ + @property + def iface(self): + """Nom de l'interface, et donc du fichier de configuration. + + `wg-quick` DÉDUIT le nom de l'interface du nom du fichier : le + fichier doit s'appeler `.conf`. Tronqué à 15 caractères, + limite du noyau pour un nom d'interface. + """ + return f"wg-{self.name_tag}"[:15] + + @property + def config_file(self): + return f"{self.secret_dir}/{self.iface}.conf" + + @property + def allowed_ips(self): + """`AllowedIPs` : ce qui entre dans le tunnel. + + C'est le champ le plus mal compris de WireGuard — il sert à LA FOIS + de filtre de trafic et de table de routage. `0.0.0.0/0` veut donc + dire « tout le trafic », et rien d'autre n'est nécessaire pour cela. + """ + if self.profile.get("default_route"): + return "0.0.0.0/0" + return ", ".join(self.profile.get("routes", [])) + + # ------------------------------------------------------------------ + @classmethod + def validate_profile(cls, profile): + valid.ip_interface( + profile, "wg_address", "Adresse de cette machine dans le tunnel" + ) + valid.wg_key(profile, "wg_peer_key", "Clé publique du pair") + valid.port(profile, "port", "Port de l'endpoint WireGuard") + valid.ip_address( + profile, "wg_dns", "Serveur DNS dans le tunnel", required=False + ) + valid.integer(profile, "wg_keepalive", "PersistentKeepalive", 0, 65535) + + def config_body(self): + p = self.profile + lines = [ + "# Généré par ERPLibre (script/vpn). tmpfs, 0600, effacé au down.", + "[Interface]", + f"PrivateKey = {self.secrets.get('wg_private_key', '')}", + f"Address = {p['wg_address']}", + f"MTU = {p['mtu']}", + "", + "[Peer]", + f"PublicKey = {p['wg_peer_key']}", + ] + preshared = self.secrets.get("wg_preshared_key") + if preshared: + lines.append(f"PresharedKey = {preshared}") + lines += [ + f"Endpoint = {p['server']}:{p['port']}", + f"AllowedIPs = {self.allowed_ips}", + ] + if p.get("wg_keepalive"): + # Indispensable derrière du NAT : sans trafic, la traduction + # expire et le pair ne sait plus où nous joindre. + lines.append(f"PersistentKeepalive = {p['wg_keepalive']}") + # Pas de « DNS = » : wg-quick le confie à `resolvconf`, absent de + # beaucoup d'installations systemd-resolved, et la configuration + # ENTIÈRE échoue alors. On appelle resolvectl nous-mêmes. + return "\n".join(lines) + + # ------------------------------------------------------------------ + def up(self, runner): + p = self.profile + if not self.ensure_ready(runner): + return False + if not self.allowed_ips: + runner.fail( + "Aucun réseau à router : AllowedIPs serait vide et le" + " tunnel ne porterait rien." + ) + return False + + self.prepare_dirs(runner) + runner.write( + self.config_file, + self.config_body() + "\n", + mode="0600", + secret=True, + ) + code, _ = runner.cmd( + f"monter {self.iface}", + f"wg-quick up {shlex.quote(self.config_file)}", + timeout=60, + ) + if code != 0 and not runner.dry_run: + runner.fail( + "wg-quick a refusé. Causes usuelles : clé mal formée," + " adresse déjà prise, module wireguard absent du noyau." + ) + return False + self.write_state(runner, "iface", self.iface) + + if not self._wait_for_handshake(runner): + return False + if runner.dry_run: + return True + + addresses = ( + ", ".join(interface_addresses(self.iface)) or "sans adresse" + ) + runner.ok(f"interface {self.iface} : {addresses}") + # Les routes appartiennent à wg-quick, via AllowedIPs. Rien à + # ajouter ici — voir l'en-tête du fichier. + if p.get("wg_dns"): + self.set_resolved_dns(runner, self.iface, [p["wg_dns"]]) + return True + + def _wait_for_handshake(self, runner): + """Attend une poignée de main avec le pair. + + `wg show … latest-handshakes` rend un horodatage par pair, à zéro + tant que rien n'a abouti. C'est le SEUL signe que la clé et + l'endpoint sont bons : l'interface, elle, monte de toute façon. + """ + script = ( + "for i in $(seq 1 20); do" + f" wg show {shlex.quote(self.iface)} latest-handshakes" + " | awk '$2 > 0 { found = 1 } END { exit !found }'" + " && exit 0; sleep 0.5; done; exit 1" + ) + code, _ = runner.cmd( + "attendre la poignée de main du pair", + f"sh -c {shlex.quote(script)}", + check=False, + timeout=30, + ) + if code == 0 or runner.dry_run: + return True + runner.fail( + "Aucune poignée de main en 10 s. L'interface est montée — c'est" + " toujours le cas avec WireGuard — mais le pair n'a pas" + " répondu : clé publique du pair, PSK, endpoint ou UDP" + f" {self.profile['port']} filtré." + ) + return False + + # ------------------------------------------------------------------ + def down(self, runner): + runner.cmd( + f"démonter {self.iface}", + f"wg-quick down {shlex.quote(self.config_file)}", + check=False, + timeout=60, + ) + # Filet : après un redémarrage du CLI, le fichier de configuration + # peut avoir disparu du tmpfs alors que l'interface tient toujours. + # `wg-quick down` échoue alors, et l'interface resterait là. + runner.cmd( + f"filet : retirer {self.iface} si elle est restée", + "sh -c {}".format( + shlex.quote( + f"ip link show {shlex.quote(self.iface)} >/dev/null 2>&1" + f" && ip link del {shlex.quote(self.iface)} || true" + ) + ), + check=False, + ) + runner.remove(self.secret_dir) + self.clear_state(runner, "iface") + runner.ok("Tunnel démonté, secrets effacés.") + return True + + # ------------------------------------------------------------------ + def status(self, runner): + iface = self.recorded_iface() or self.iface + code, out = runner.cmd( + "poignée de main WireGuard", + f"wg show {shlex.quote(iface)} latest-handshakes", + check=False, + capture=True, + ) + stamps = [ + int(part) + for line in out.splitlines() + for part in line.split()[1:2] + if part.isdigit() + ] + latest = max(stamps) if stamps else 0 + extra = [ + ( + "poignée de main", + bool(latest) if code == 0 else None, + ( + f"horodatage {latest}" + if latest + else (out.strip().splitlines() or ["wg muet (sudo ?)"])[ + -1 + ][:120] + ), + ) + ] + return self.standard_status(runner, extra=extra) + + def log_commands(self): + return [ + ( + f"état complet de {self.iface}", + f"wg show {shlex.quote(self.iface)}", + ), + ( + "journal du noyau (module wireguard)", + "journalctl -n 30 --no-pager -k -g wireguard", + ), + ] diff --git a/script/vpn/profiles.py b/script/vpn/profiles.py new file mode 100644 index 0000000..ece6f7c --- /dev/null +++ b/script/vpn/profiles.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Profils VPN : tout ce qui n'est PAS un secret. + +Le partage est net et c'est le cœur du dispositif : l'hôte, l'utilisateur +PPP, les routes et le MTU vivent ici, en JSON lisible ; la clé PSK et les +mots de passe vivent dans le coffre KeePassXC (voir `secrets.py`). Un profil +peut donc être lu, montré, comparé, versionné chez un client — sans jamais +exposer de quoi monter le tunnel. + +Le fichier d'écriture est `private/todo/todo_override_private.json`, le SEUL +des trois fichiers fusionnés par `ConfigFile.get_config` qui soit gitignored, +et que `set_config_value` écrit en 0600 atomique. La lecture, elle, passe par +la fusion : un profil peut aussi venir de `script/todo/todo.json` (partagé +par l'équipe) ou de `private/todo/todo_override.json`. + +Toute valeur est VALIDÉE avant d'être écrite : elle finira dans un fichier de +configuration et dans une ligne de commande lancée par sudo. Un nom d'hôte +avec une espace ou un point-virgule n'y arrivera pas. +""" +from __future__ import annotations + +import json +import os + +# Le MODULE, pas la constante : `CONFIG_OVERRIDE_PRIVATE_FILE` importée par +# valeur figerait le chemin à l'import, et les tests — qui le déplacent dans +# un répertoire temporaire — écriraient dans le vrai fichier de l'utilisateur. +from script.config import config_file as config_module +from script.config.config_file import ConfigFile +from script.vpn import valid +from script.vpn.valid import NAME_RE, SERVER_RE, ProfileError # noqa: F401 + +# La clé de section, dans les trois fichiers de configuration. +CONFIG_KEY = "vpn" + +# Valeurs par défaut COMMUNES à toutes les technologies. Ce qui n'appartient +# qu'à une seule vit dans les `defaults` de son pilote : un profil WireGuard +# n'a rien à faire d'un « port L2TP local », et une liste de champs qui les +# additionne tous devient illisible au troisième pilote. +# +# `default_route` est FAUX par défaut : un tunnel qui capte tout le trafic +# coupe la session SSH en cours et n'est pas ce qu'un déploiement ERPLibre +# distant demande. C'est un choix explicite. +DEFAULTS = { + "driver": "l2tp_ipsec", + "server": "", + "routes": [], + "default_route": False, + "mtu": 1280, + # Adresse TÉMOIN, joignable uniquement à travers le tunnel. Vide, + # « ça marche » reste une impression ; remplie, le diagnostic peut + # le PROUVER. + "probe": "", +} + + +def secret_title(name: str) -> str: + """Titre de l'entrée KeePassXC qui porte les secrets du profil. + + Dérivé du nom plutôt que stocké : deux sources de vérité pour un même + lien finissent toujours par diverger, et un profil renommé chercherait + un secret sous l'ancien titre sans le dire.""" + return f"ERPLibre VPN / {name}" + + +def load_all(config=None) -> list[dict]: + """Tous les profils, dans l'ordre de fusion. Jamais None.""" + cfg = config or ConfigFile() + data = cfg.get_config(CONFIG_KEY) + if not isinstance(data, list): + return [] + return [p for p in data if isinstance(p, dict) and p.get("name")] + + +def load(name: str, config=None) -> dict | None: + """Le profil `name`, complété par les défauts, ou None.""" + for profile in load_all(config): + if profile.get("name") == name: + return with_defaults(profile) + return None + + +def with_defaults(profile: dict) -> dict: + """Copie du profil où chaque clé connue a une valeur. + + Les défauts du PILOTE sont ajoutés à ceux du format : c'est ce qui + permet à chaque technologie d'avoir ses propres réglages sans que le + format les connaisse. Un pilote inconnu ne fait pas échouer la lecture — + `validate` le dira, avec la liste des pilotes connus. + """ + from script.vpn.drivers import get_driver + + full = dict(DEFAULTS) + driver = get_driver(str(profile.get("driver") or DEFAULTS["driver"])) + if driver is not None: + full.update(driver.defaults) + full.update({k: v for k, v in profile.items() if v is not None}) + return full + + +def names(config=None) -> list[str]: + return [p["name"] for p in load_all(config)] + + +def _load_private() -> dict: + """Contenu brut du fichier privé, {} s'il est absent ou illisible.""" + path = config_module.CONFIG_OVERRIDE_PRIVATE_FILE + if not os.path.exists(path): + return {} + try: + with open(path) as fh: + data = json.load(fh) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def private_profiles() -> list[dict]: + """Les profils du fichier privé SEULS. + + L'écriture doit repartir de cette liste et non de la fusion : réécrire + la fusion recopierait dans le fichier privé les profils venus de + `todo.json`, qui se retrouveraient alors en double à la lecture + suivante (la fusion étend les listes, elle ne les déduplique pas). + """ + data = _load_private().get(CONFIG_KEY) + return ( + [p for p in data if isinstance(p, dict)] + if isinstance(data, list) + else [] + ) + + +def save(profile: dict, config=None) -> dict: + """Valide puis écrit le profil dans le fichier privé. Rend le profil + normalisé. Lève ProfileError si quelque chose ne va pas.""" + clean = validate(profile) + cfg = config or ConfigFile() + profiles = [ + p for p in private_profiles() if p.get("name") != clean["name"] + ] + profiles.append(clean) + cfg.set_config_value([CONFIG_KEY], profiles) + return clean + + +def delete(name: str, config=None) -> bool: + """Retire le profil du fichier privé. Rend False s'il n'y était pas — + un profil venu de `todo.json` n'est pas supprimable d'ici, et le dire + vaut mieux que de faire semblant.""" + profiles = private_profiles() + kept = [p for p in profiles if p.get("name") != name] + if len(kept) == len(profiles): + return False + cfg = config or ConfigFile() + cfg.set_config_value([CONFIG_KEY], kept) + return True + + +def validate(profile: dict) -> dict: + """Profil normalisé, ou ProfileError. + + Deux étages : ce qui vaut pour toute technologie est jugé ici, le reste + par `validate_profile` du pilote — qui normalise ses champs en place. + """ + from script.vpn.drivers import driver_names, get_driver + + full = with_defaults(profile) + + valid.text(full, "name", "Nom de profil", pattern=NAME_RE) + + driver_name = str(full.get("driver") or "").strip() + driver = get_driver(driver_name) + if driver is None: + raise ProfileError( + f"Pilote inconnu : « {driver_name} »." + f" Connus : {', '.join(driver_names())}." + ) + full["driver"] = driver_name + + valid.text(full, "server", "Adresse du serveur", pattern=SERVER_RE) + full["routes"] = valid.cidr_list(full.get("routes")) + valid.flag(full, "default_route") + valid.integer(full, "mtu", "MTU", 576, 1500) + valid.ip_address(full, "probe", "Adresse témoin") + + if ( + driver.needs_routes + and not full["routes"] + and not full["default_route"] + ): + raise ProfileError( + "Un tunnel sans route ne sert à rien : déclarer au moins un" + " réseau à joindre, ou demander la route par défaut." + ) + + driver.validate_profile(full) + return full diff --git a/script/vpn/runner.py b/script/vpn/runner.py new file mode 100644 index 0000000..a63e046 --- /dev/null +++ b/script/vpn/runner.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'exécuteur : tout ce qui touche vraiment la machine passe par ici. + +Un pilote VPN ne lance jamais rien lui-même. Il DEMANDE — « écris ce +fichier », « lance cette commande » — et cet objet exécute, ou se contente +d'afficher quand on l'a lancé à blanc. Trois choses en découlent : + +1. `--dry-run` n'est pas une branche parallèle dans chaque pilote : c'est un + drapeau ici. Ce qui s'affiche est exactement ce qui s'exécuterait. +2. Chaque opération est ENREGISTRÉE dans `ops`. Les tests unitaires peuvent + donc vérifier, sans root et sans serveur en face, qu'aucun secret n'a + atterri dans une ligne de commande. +3. La règle « les secrets ne passent que par l'entrée standard » est tenue en + UN endroit, pas dans cinq pilotes. + +Pourquoi l'entrée standard : `/proc//cmdline` est lisible par tout +utilisateur de la machine, `/proc//environ` par le seul propriétaire du +processus. Un mot de passe en argument est visible de tous pendant toute la +durée de la commande. +""" +from __future__ import annotations + +import shlex +import subprocess +import sys + +# Marqueurs des blocs gérés dans les fichiers de configuration du système. +# Reconnaissables, uniques, et ils DISENT de ne pas éditer à la main. +BLOCK_BEGIN = "# >>> erplibre-vpn %s — généré, ne pas éditer" +BLOCK_END = "# <<< erplibre-vpn %s" + + +def replace_block(text: str, marker: str, body: str) -> str: + """`text` où le bloc `marker` vaut `body`. Ajouté à la fin s'il est + absent, retiré si `body` est vide. + + Fonction PURE : c'est elle qui décide de ce qu'on écrit dans + /etc/ipsec.conf, et un test doit pouvoir la juger sans /etc. + """ + begin = BLOCK_BEGIN % marker + end = BLOCK_END % marker + lines = text.splitlines() + out, inside, seen = [], False, False + for line in lines: + if line.strip() == begin: + inside, seen = True, True + if body: + out.append(begin) + out.extend(body.rstrip("\n").splitlines()) + out.append(end) + continue + if inside: + if line.strip() == end: + inside = False + continue + out.append(line) + if not seen and body: + if out and out[-1].strip(): + out.append("") + out.append(begin) + out.extend(body.rstrip("\n").splitlines()) + out.append(end) + return "\n".join(out).rstrip("\n") + "\n" if out or body else "" + + +class Runner: + """Exécute (ou montre) les opérations demandées par un pilote.""" + + def __init__(self, dry_run=False, quiet=False, redactor=None, sudo=True): + self.dry_run = dry_run + self.quiet = quiet + # `redactor` masque les secrets dans TOUT ce qui s'affiche. Sans lui + # rien n'est masqué : c'est voulu, l'appelant doit le fournir dès + # qu'un secret est en jeu, et un test l'oublie sans risque. + self.redactor = redactor or (lambda text: text) + self.use_sudo = sudo + self.ops: list[dict] = [] + self.failures: list[str] = [] + + # ------------------------------------------------------------------ + # Affichage + # ------------------------------------------------------------------ + def info(self, message): + if not self.quiet: + print(self.redactor(message)) + + def step(self, label): + self.info(f" → {label}") + + def ok(self, message): + self.info(f" ✓ {message}") + + def warn(self, message): + self.info(f" ! {message}") + + def fail(self, message): + self.failures.append(message) + self.info(f" ✗ {message}") + + # ------------------------------------------------------------------ + # Commandes + # ------------------------------------------------------------------ + def cmd( + self, + label, + command, + stdin=None, + secret_stdin=False, + check=True, + capture=False, + sudo=None, + timeout=None, + allow_fail_message=None, + ): + """Lance `command`. Rend (code de retour, sortie). + + `stdin` est le seul chemin par lequel un secret entre dans un + processus. `secret_stdin` ne change PAS l'exécution : il dit à + l'affichage et aux tests que ce contenu ne doit jamais être montré. + """ + full = command + if sudo is None: + sudo = self.use_sudo + if sudo: + full = f"sudo {command}" + self.ops.append( + { + "kind": "cmd", + "label": label, + "cmd": full, + "stdin": stdin, + "secret_stdin": secret_stdin, + } + ) + shown = full if not stdin else f"{full} « sur l'entrée standard »" + self.step(f"{label}\n {self.redactor(shown)}") + if self.dry_run: + return 0, "" + try: + proc = subprocess.run( + full, + shell=True, + input=stdin, + text=True, + timeout=timeout, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + ) + code, out = proc.returncode, proc.stdout or "" + except subprocess.TimeoutExpired: + code, out = 124, "" + self.fail(f"{label} : délai dépassé ({timeout} s)") + return code, out + if code != 0 and check: + self.fail(allow_fail_message or f"{label} (code {code})") + return code, out + + def read_root_file(self, path): + """Contenu d'un fichier que seul root peut lire, "" s'il n'existe + pas. Passe par `sudo cat` : /etc/ipsec.secrets est en 0600.""" + code, out = self.cmd( + f"lire {path}", + f"cat {shlex.quote(path)}", + check=False, + capture=True, + ) + return out if code == 0 else "" + + def propose(self, constat, command, sudo=True, question=None): + """Propose un correctif, l'applique si on l'accepte. + + Rend True seulement s'il a été appliqué ET a réussi. + + L'outil sait souvent quoi faire : renvoyer l'utilisateur taper la + commande lui-même, puis tout relancer, c'est lui faire porter un + travail qu'on a déjà identifié. On demande donc — on ne le fait pas + d'office : arrêter un service du système est une décision, pas un + détail d'implémentation. + + Refusé d'office à blanc, et quand l'entrée standard n'est pas un + terminal (cron, script, journal rejoué) : un outil qui modifie un + service parce que PERSONNE n'a répondu serait pire que le problème + qu'il résout. + """ + montrable = f"{'sudo ' if sudo else ''}{command}" + if self.dry_run: + self.info(f" (à blanc : proposerait « {montrable} »)") + return False + self.info(f" → Correctif proposé : {montrable}") + if not sys.stdin.isatty(): + self.warn( + "Pas de terminal pour demander : correctif NON appliqué." + ) + return False + if not self.confirm(question or "Appliquer maintenant ?"): + self.info(" Laissé en place.") + return False + code, _ = self.cmd( + f"appliquer le correctif : {constat}", + command, + sudo=sudo, + check=False, + ) + return code == 0 + + def confirm(self, question) -> bool: + """Pose `question` et rend vrai si la réponse est oui. + + La question est une ligne COMPLÈTE, terminée par une fin de ligne, + et non un prompt passé à `input`. Un lanceur qui relaie notre + sortie en la lisant ligne par ligne garde une ligne partielle dans + son tampon jusqu'à la fin de ligne suivante : la question reste + alors invisible jusqu'à ce que la réponse ait déjà été donnée, puis + ressort collée au texte qui la suit. C'est le cas du menu TODO, qui + lit par `readline` PARCE QUE le masquage des secrets travaille sur + une ligne entière — un secret à cheval sur deux morceaux passerait + au travers. La contrainte vient donc d'une garantie, elle ne se + contourne pas. + + Affichée même quand l'exécuteur est silencieux : on s'apprête à + BLOQUER dessus, et une question invisible est une attente sans + raison apparente. + """ + print(self.redactor(f" {question} [o/N]")) + return input().strip().lower() in ("o", "oui", "y", "yes") + + # ------------------------------------------------------------------ + # Fichiers + # ------------------------------------------------------------------ + def write(self, path, content, mode="0600", secret=False, label=None): + """Écrit `content` dans `path`, en root, de façon ATOMIQUE. + + Le contenu passe par l'entrée standard, jamais par la ligne de + commande. `umask` donne le bon mode dès la création, `chmod` le + rend déterministe même si le fichier existait, et `mv` publie le + résultat d'un coup — un fichier de configuration à moitié écrit + vaut souvent moins qu'un fichier absent. + """ + quoted = shlex.quote(path) + tmp = shlex.quote(f"{path}.erplibre-tmp") + umask = "077" if secret else "022" + script = ( + f"umask {umask}; cat > {tmp}" + f" && chmod {mode} {tmp}" + f" && mv -f {tmp} {quoted}" + ) + self.ops.append( + { + "kind": "write", + "path": path, + "content": content, + "mode": mode, + "secret": secret, + } + ) + self.step(label or f"écrire {path} ({mode})") + if self.dry_run: + body = "********" if secret else content + for line in body.rstrip("\n").splitlines(): + self.info(f" │ {line}") + return 0 + code, _ = self.cmd( + f"écrire {path}", + f"sh -c {shlex.quote(script)}", + stdin=content, + secret_stdin=secret, + check=True, + ) + return code + + def mkdir(self, path, mode="0700"): + return self.cmd( + f"créer {path} ({mode})", + f"install -d -m {mode} {shlex.quote(path)}", + )[0] + + def remove(self, path): + return self.cmd( + f"effacer {path}", f"rm -rf -- {shlex.quote(path)}", check=False + )[0] + + def backup_once(self, path): + """Copie `path` en `.erplibre.bak` s'il n'y en a pas encore. + + Une seule fois : la sauvegarde doit garder l'état ORIGINAL, pas + celui d'avant-hier. On touche à l'ipsec.conf de quelqu'un. + """ + backup = f"{path}.erplibre.bak" + source, target = shlex.quote(path), shlex.quote(backup) + script = ( + f"[ -f {source} ] && [ ! -f {target} ]" + f" && cp -p {source} {target} || true" + ) + return self.cmd( + f"sauvegarder {path} → {backup}", + f"sh -c {shlex.quote(script)}", + check=False, + )[0] + + def block(self, path, marker, body, mode="0644", secret=False): + """Pose (ou retire, si `body` est vide) un bloc marqué dans `path`. + + Rend True s'il a fallu écrire, False si le bloc était déjà en place. + L'appelant s'en sert pour ne recharger un démon que quand sa + configuration a réellement bougé. + + Le fichier est relu avant d'être réécrit : on ajoute une section à + la configuration de l'utilisateur, on ne la remplace pas. + """ + current = self.read_root_file(path) + if self.dry_run and not current: + current = f"# ({path} sera relu à l'exécution)\n" + new = replace_block(current, marker, body) + if new == current: + self.ok(f"{path} : bloc « {marker} » déjà à jour") + return False + self.backup_once(path) + self.write( + path, + new, + mode=mode, + secret=secret, + label=f"{'retirer' if not body else 'poser'} le bloc" + f" « {marker} » dans {path}", + ) + return True + + # ------------------------------------------------------------------ + # Logique Python (résolution, attente, routes) + # ------------------------------------------------------------------ + def call(self, label, function, dry_safe=False): + """Exécute une étape écrite en Python. + + `dry_safe` marque celles qui ne font que LIRE l'état de la machine + (résoudre un nom, lire une table de routage) : elles tournent même + à blanc, parce que sans elles le plan affiché serait creux. + """ + self.ops.append({"kind": "call", "label": label}) + self.step(label) + if self.dry_run and not dry_safe: + return None + return function() diff --git a/script/vpn/valid.py b/script/vpn/valid.py new file mode 100644 index 0000000..d08907d --- /dev/null +++ b/script/vpn/valid.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Validation des champs de profil, partagée par le format et les pilotes. + +Un module à part, et pour une raison précise : `profiles.py` valide ce qui +est commun, chaque pilote valide ses propres champs, et les deux ont besoin +des mêmes contrôles. Le mettre ici évite un import croisé entre le format et +les pilotes — et surtout, ces contrôles ne sont pas cosmétiques : chaque +valeur finit dans un fichier de configuration système et dans une ligne de +commande lancée par sudo. Un nom d'hôte avec un point-virgule doit être +refusé ICI, pas découvert par `sh`. + +Chaque fonction NORMALISE en place (`profile[key]` reçoit la valeur propre) +et lève `ProfileError` avec un message destiné à l'humain. +""" +from __future__ import annotations + +import ipaddress +import re + +# Le nom sert de nom de connexion, de répertoire et de nom de fichier : il +# reste dans un alphabet sans surprise. +NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$") +# Nom d'hôte ou adresse, avec un « utilisateur@ » facultatif — sshuttle vise +# une cible SSH, pas seulement une machine. Volontairement plus strict que la +# RFC : ce qui n'est ni lettre, ni chiffre, ni `.-_` est refusé. +SERVER_RE = re.compile( + r"^([A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]{0,252}$" +) +# Nom d'hôte seul (domaine de recherche DNS, alias) : pas d'« utilisateur@ ». +HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$") +# Clé WireGuard : 32 octets en base64, donc 43 caractères + « = ». +WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$") + + +class ProfileError(ValueError): + """Profil refusé. Le message est destiné à l'utilisateur.""" + + +def text(profile, key, label, required=True, pattern=None): + """Champ texte, sans espace de bord, refusé s'il sort du motif.""" + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : valeur obligatoire.") + profile[key] = "" + return "" + if "\n" in value or "\r" in value: + raise ProfileError(f"{label} : une seule ligne.") + if pattern and not pattern.match(value): + raise ProfileError(f"{label} : « {value} » refusé.") + profile[key] = value + return value + + +def path(profile, key, label, required=True): + """Chemin de fichier. L'existence n'est PAS exigée ici. + + Un profil peut être écrit sur une machine et joué sur une autre ; c'est + au montage de dire « ce fichier n'est pas là », avec le chemin sous les + yeux. Ce qui est refusé ici, c'est ce qui casserait un shell. + """ + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : chemin obligatoire.") + profile[key] = "" + return "" + if any(char in value for char in "\n\r\0"): + raise ProfileError(f"{label} : chemin illisible.") + profile[key] = value + return value + + +def integer(profile, key, label, low, high): + try: + value = int(profile.get(key)) + except (TypeError, ValueError): + raise ProfileError(f"{label} : nombre entier attendu.") + if not low <= value <= high: + raise ProfileError(f"{label} : hors bornes ({low}-{high}) : {value}.") + profile[key] = value + return value + + +def port(profile, key, label): + return integer(profile, key, label, 1, 65535) + + +def flag(profile, key): + profile[key] = bool(profile.get(key)) + return profile[key] + + +def ip_address(profile, key, label, required=False): + """Adresse IP nue (pas de préfixe).""" + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : adresse obligatoire.") + profile[key] = "" + return "" + try: + ipaddress.ip_address(value) + except ValueError: + raise ProfileError(f"{label} : « {value} » n'est pas une adresse IP.") + profile[key] = value + return value + + +def ip_interface(profile, key, label, required=True): + """Adresse AVEC préfixe (10.7.0.2/32) : c'est ce qu'une interface porte. + + Une adresse sans préfixe est acceptée et complétée en /32 — mais dire + « 10.7.0.2 » quand on veut dire « /24 » est une erreur silencieuse + coûteuse, alors le message le rappelle en cas de doute. + """ + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : adresse obligatoire.") + profile[key] = "" + return "" + try: + parsed = ipaddress.ip_interface(value) + except ValueError: + raise ProfileError( + f"{label} : « {value} » refusé. Attendu une adresse avec" + " préfixe, par exemple 10.7.0.2/32." + ) + profile[key] = str(parsed) + return profile[key] + + +def wg_key(profile, key, label, required=True): + """Clé publique WireGuard : 32 octets en base64. + + Vérifiée ici parce que `wg-quick` refuse la configuration ENTIÈRE sur + une clé mal formée, avec un message qui ne dit pas laquelle. + """ + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : clé obligatoire.") + profile[key] = "" + return "" + if not WG_KEY_RE.match(value): + raise ProfileError( + f"{label} : « {value[:12]}… » n'a pas la forme d'une clé" + " WireGuard (32 octets en base64, 44 caractères finissant par" + " « = »)." + ) + profile[key] = value + return value + + +def cidr_list(routes) -> list[str]: + """Réseaux normalisés en CIDR. Une adresse seule devient un /32.""" + if routes in (None, ""): + return [] + if isinstance(routes, str): + routes = [r for r in re.split(r"[\s,]+", routes) if r] + if not isinstance(routes, list): + raise ProfileError("Les routes doivent être une liste de réseaux.") + clean = [] + for route in routes: + try: + network = ipaddress.ip_network(str(route).strip(), strict=False) + except ValueError as error: + raise ProfileError(f"Route refusée : « {route} » ({error}).") + text_form = str(network) + if text_form not in clean: + clean.append(text_form) + return clean diff --git a/script/vpn/vault.py b/script/vpn/vault.py new file mode 100644 index 0000000..4853e07 --- /dev/null +++ b/script/vpn/vault.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les secrets VPN, dans le coffre KeePassXC. + +Nommé `vault` et non `secrets` : `secrets` est un module de la bibliothèque +standard, et masquer un nom de la stdlib dans un paquet importé partout se +paie tôt ou tard. + +Une entrée par profil, dans le groupe « ERPLibre VPN », titrée +« ERPLibre VPN / » : + + username / password les identifiants PPP (MS-CHAPv2) + propriété « psk » la clé pré-partagée IPsec, PROTÉGÉE + +« Protégée » veut dire chiffrée en mémoire par KeePassXC et masquée dans son +interface — c'est le même traitement que le champ mot de passe, appliqué à un +champ personnalisé. + +Ce module ne fait QUE lire et écrire. Il ne choisit jamais de créer un coffre +tout seul : `ensure_vault` demande, et une réponse vide fait renoncer. Un +outil qui crée silencieusement un fichier de mots de passe dans un répertoire +qu'on n'a pas choisi est un outil qu'on n'ose plus lancer. +""" +from __future__ import annotations + +import getpass +import os +import stat + +from script.todo.todo_i18n import t + +try: + from pykeepass import PyKeePass, create_database +except ModuleNotFoundError: # pragma: no cover - dépend de l'installation + PyKeePass = None + create_database = None + +# Groupe où les entrées sont rangées, pour que le coffre reste lisible dans +# l'interface KeePassXC. Le TITRE reste unique globalement : les autres +# lecteurs du dépôt (kdbx_config) cherchent par titre, sans notion de groupe. +VAULT_GROUP = "ERPLibre VPN" + +# Champ secret par défaut : celui de tous les pilotes à clé pré-partagée. +FIELD_PSK = "psk" + +MASK = "********" + +# Le menu a déjà le coffre ouvert quand il lance `vpn.py` : il lui passe les +# secrets par l'ENVIRONNEMENT plutôt que de le faire redemander le mot de +# passe maître — deux fois par connexion, puisqu'un essai à blanc précède le +# vrai montage. +# +# Par l'environnement et non par un argument : /proc//environ n'est +# lisible que par le propriétaire du processus, /proc//cmdline par tout +# utilisateur de la machine. Et `sudo` remet l'environnement à zéro, donc ces +# variables n'atteignent aucune commande privilégiée : les secrets qui vont à +# root passent, eux, par l'entrée standard. +ENV_MARKER = "EL_VPN_SECRETS_PROVIDED" +ENV_PREFIX = "EL_VPN_SECRET_" + + +def secrets_to_env(values: dict) -> dict: + """Variables d'environnement portant `values`, marqueur compris.""" + env = {ENV_MARKER: "1"} + for key, value in values.items(): + env[f"{ENV_PREFIX}{key.upper()}"] = value or "" + return env + + +def secrets_from_env(fields) -> dict | None: + """Secrets déposés par le processus appelant, ou None s'il n'y en a pas. + + Le marqueur est explicite : sans lui, un champ vide serait indistinguable + d'un champ absent, et on rouvrirait le coffre pour rien. + """ + if os.environ.get(ENV_MARKER) != "1": + return None + return { + field: os.environ.get(f"{ENV_PREFIX}{field.upper()}", "") + for field in fields + } + + +# Dit quand on trouve le coffre plus ouvert qu'il ne devrait : ce n'est pas +# nous qui l'avons laissé ainsi, et ça mérite d'être su. +LOOSE_VAULT_TIGHTENED = ( + "Vault permissions tightened to 0600, it was readable by others: " +) + + +class VaultError(RuntimeError): + """Le coffre n'a pas pu être ouvert ou écrit. Message pour l'humain.""" + + +class VpnVault: + """Pont entre les profils VPN et le coffre .kdbx. + + Prend le `ConfigFile` et le `KdbxManager` déjà construits par le CLI + TODO : le mot de passe maître n'est demandé qu'une fois par session, + et il n'y a pas deux caches de coffre qui pourraient diverger. + """ + + def __init__(self, config_file, kdbx_manager): + self._config = config_file + self._manager = kdbx_manager + + # ------------------------------------------------------------------ + # Le fichier de coffre + # ------------------------------------------------------------------ + def vault_path(self) -> str: + """Chemin configuré du coffre, "" s'il n'y en a pas.""" + kdbx = self._config.get_config("kdbx") + if not isinstance(kdbx, dict): + return "" + return str(kdbx.get("path") or "").strip() + + def master_password_is_stored(self) -> bool: + """Vrai si un mot de passe maître dort dans la configuration. + + C'est légal — `KdbxManager` le lit — mais c'est un mot de passe + maître en clair sur le disque : le CLI doit pouvoir le SIGNALER. + """ + kdbx = self._config.get_config("kdbx") + return bool(isinstance(kdbx, dict) and kdbx.get("password")) + + def ensure_vault(self, ask=input, default_path=None) -> str: + """Chemin d'un coffre utilisable, "" si l'utilisateur renonce. + + Trois cas : déjà configuré et présent (rien à faire) ; configuré + mais absent (on propose de le créer) ; pas configuré (on demande + où, puis on crée si le fichier n'existe pas). + """ + if create_database is None: + raise VaultError( + "pykeepass n'est pas installé : lancer l'installation" + " ERPLibre, ou `pip install pykeepass` dans" + " .venv.erplibre." + ) + path = self.vault_path() + if path and os.path.exists(os.path.expanduser(path)): + return path + + if not path: + default_path = default_path or os.path.expanduser( + "~/.erplibre/secrets.kdbx" + ) + answer = ask(f"Chemin du coffre KeePassXC [{default_path}] : ") + path = (answer or "").strip() or default_path + + path = os.path.expanduser(path) + if not os.path.exists(path): + answer = ask(f"Créer le coffre « {path} » ? [o/N] : ") + if (answer or "").strip().lower() not in ("o", "oui", "y", "yes"): + return "" + self._create(path) + self._config.set_config_value(["kdbx", "path"], path) + return path + + def _create(self, path: str) -> None: + """Crée un coffre vide en 0600, mot de passe saisi deux fois.""" + parent = os.path.dirname(path) or "." + os.makedirs(parent, exist_ok=True) + first = getpass.getpass("Mot de passe maître du coffre : ") + if not first: + raise VaultError("Mot de passe vide : coffre non créé.") + if first != getpass.getpass("Confirmer : "): + raise VaultError("Les deux saisies diffèrent : coffre non créé.") + # Créé puis restreint : `create_database` ne prend pas de mode, et + # un coffre lisible par tout le monde le reste jusqu'au chmod. La + # fenêtre existe, elle est d'un tour de boucle ; l'alternative + # serait de créer le fichier vide en 0600 d'abord, ce que + # pykeepass refuse (il veut écrire un fichier neuf). + kdbx = create_database(path, password=first) + self.protect(path) + # La base rendue par `create_database` est déjà ouverte : la confier + # au gestionnaire évite une troisième saisie du mot de passe maître, + # juste après les deux de la création. + self._manager.adopt(kdbx) + + def protect(self, path=None) -> bool: + """Remet le coffre en 0600. Rend True s'il fallait le resserrer. + + À appeler après CHAQUE écriture, et pas seulement à la création : + `PyKeePass.save()` réécrit le fichier et lui redonne le mode du + umask — 0664 sur Ubuntu. Un chmod fait une fois à la création ne + survit donc pas au premier enregistrement, et un coffre de mots de + passe devient lisible par toute la machine sans que personne ne + touche à rien. + """ + path = os.path.expanduser(path or self.vault_path()) + if not path: + return False + try: + current = stat.S_IMODE(os.stat(path).st_mode) + except OSError: + return False + if not current & 0o077: + return False + os.chmod(path, 0o600) + return True + + def _open(self): + """Coffre ouvert, ou VaultError. Passe par KdbxManager pour ne + demander le mot de passe maître qu'une fois par session.""" + if not self.vault_path(): + # Court-circuit VOULU : sans chemin, `KdbxManager` ouvre un + # sélecteur de fichiers graphique — et sur un serveur sans + # tkinter, il journalise une erreur au lieu de dire ce qui + # manque. Ici on le dit. + raise VaultError( + "Aucun coffre KeePassXC configuré. Le créer depuis TODO ›" + " Execute › Déploiement › VPN › « Déposer les secrets »." + ) + if self.protect(): + print(f"! {t(LOOSE_VAULT_TIGHTENED)}{self.vault_path()}") + kdbx = self._manager.get_kdbx() + if kdbx is None: + raise VaultError( + "Coffre KeePassXC indisponible : chemin non configuré, ou" + " mot de passe refusé." + ) + return kdbx + + # ------------------------------------------------------------------ + # Lecture / écriture d'un profil + # ------------------------------------------------------------------ + def read(self, title: str, fields=(FIELD_PSK,)) -> dict: + """{"username", "password", } pour l'entrée `title`. + + Une entrée absente rend un dictionnaire de chaînes vides plutôt + qu'une exception : « pas encore de secret » est un état NORMAL, + que l'appelant affiche (`[5] Déposer les secrets`) au lieu de le + traiter comme une panne. + """ + empty = {"username": "", "password": ""} + empty.update({f: "" for f in fields}) + kdbx = self._open() + entry = kdbx.find_entries_by_title(title, first=True) + if entry is None: + return empty + values = { + "username": entry.username or "", + "password": entry.password or "", + } + for field in fields: + # `username` et `password` sont des champs NATIFS de KeePassXC : + # les chercher parmi les propriétés personnalisées les + # écraserait par du vide. Un pilote peut légitimement déclarer + # `password` dans ses `secret_fields`. + if field in ("username", "password"): + continue + values[field] = entry.get_custom_property(field) or "" + return values + + def write(self, title: str, values: dict) -> None: + """Crée ou met à jour l'entrée `title`. + + Seules les clés PRÉSENTES dans `values` sont touchées : le menu + laisse passer un champ pour le garder tel quel. Une chaîne vide, + elle, efface — c'est une décision, pas un oubli. + """ + kdbx = self._open() + entry = kdbx.find_entries_by_title(title, first=True) + if entry is None: + entry = kdbx.add_entry( + self._group(kdbx), + title, + values.get("username", ""), + values.get("password", ""), + ) + else: + if "username" in values: + entry.username = values["username"] + if "password" in values: + entry.password = values["password"] + for field, value in values.items(): + if field in ("username", "password"): + continue + entry.set_custom_property(field, value or "", protect=True) + kdbx.save() + # Sans ceci, l'enregistrement qu'on vient de faire aurait rendu le + # coffre lisible par toute la machine. + self.protect() + + def _group(self, kdbx): + group = kdbx.find_groups(name=VAULT_GROUP, first=True) + if group is None: + group = kdbx.add_group(kdbx.root_group, VAULT_GROUP) + return group + + def exists(self, title: str) -> bool: + return ( + self._open().find_entries_by_title(title, first=True) is not None + ) + + +def redact(text: str, values) -> str: + """`text` avec chaque secret remplacé par des astérisques. + + Les plus longs d'abord : masquer « ab » avant « abcdef » laisserait la + fin de « abcdef » en clair. Les secrets de moins de quatre caractères + sont masqués aussi — un tel secret n'existe pas en pratique, et + préférer le faux positif au secret imprimé est le bon arbitrage. + """ + if not text: + return text + if isinstance(values, dict): + values = values.values() + for secret in sorted({str(v) for v in values if v}, key=len, reverse=True): + text = text.replace(secret, MASK) + return text diff --git a/script/vpn/vpn.py b/script/vpn/vpn.py new file mode 100755 index 0000000..c3753cc --- /dev/null +++ b/script/vpn/vpn.py @@ -0,0 +1,363 @@ +#!/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 = "" + + +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 » — 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()) diff --git a/test/test_vpn_drivers.py b/test/test_vpn_drivers.py new file mode 100644 index 0000000..b8cb0b4 --- /dev/null +++ b/test/test_vpn_drivers.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les cinq pilotes VPN : contrat commun, et ce qui est propre à chacun. + +Le test qui compte le plus est `NoSecretLeaksAnywhere` : il rejoue le plan de +montage de CHAQUE pilote avec de faux secrets et vérifie qu'aucun n'atteint +une ligne de commande. `/proc//cmdline` est lisible par tout utilisateur +de la machine ; un secret en argument est un secret public. Ce test est +table-orientée exprès : un sixième pilote ajouté au registre y entre tout +seul, et échoue s'il se croit dispensé de la règle. + +Ni root, ni réseau, ni serveur en face : le `Runner` à blanc n'exécute rien, +il enregistre. Tous les serveurs de test sont 127.0.0.1 pour qu'aucun test ne +dépende d'une résolution de nom. +""" + +import base64 +import io +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 profiles +from script.vpn.drivers import DRIVERS +from script.vpn.drivers.base import locate, which +from script.vpn.runner import Runner +from script.vpn.valid import ProfileError +from script.vpn.vault import redact + +# Des secrets reconnaissables, assez longs pour qu'aucun ne se retrouve par +# hasard dans un chemin ou une option. +SECRET = "S3cr3t-Qu3-P3rs0nn3-N3-D0it-V0ir" +WG_PRIVATE = base64.b64encode(bytes(range(32))).decode() +WG_PUBLIC = base64.b64encode(bytes(range(32, 64))).decode() +WG_PRESHARED = base64.b64encode(bytes(range(64, 96))).decode() + +# Un profil valide par pilote, et les secrets que ce pilote attend. +SAMPLES = { + "l2tp_ipsec": ( + { + "server": "127.0.0.1", + "ppp_user": "ACME\\user", + "routes": ["10.20.0.0/16"], + "probe": "10.20.0.1", + }, + {"psk": SECRET + "-psk", "password": SECRET + "-ppp"}, + ), + "wireguard": ( + { + "server": "127.0.0.1", + "wg_address": "10.7.0.2/32", + "wg_peer_key": WG_PUBLIC, + "routes": ["10.7.0.0/24"], + }, + {"wg_private_key": WG_PRIVATE, "wg_preshared_key": WG_PRESHARED}, + ), + "openvpn": ( + { + "server": "127.0.0.1", + "ovpn_config": "/tmp/acme/client.ovpn", + "ovpn_user": "user", + "routes": ["10.30.0.0/16"], + }, + {"password": SECRET + "-ovpn"}, + ), + "openconnect": ( + { + "server": "127.0.0.1", + "oc_user": "user", + "oc_protocol": "anyconnect", + "default_route": True, + }, + {"password": SECRET + "-oc"}, + ), + "sshuttle": ( + { + "server": "erplibre@127.0.0.1", + "routes": ["10.40.0.0/16"], + "probe": "10.40.0.1", + }, + {}, + ), +} + + +def build(driver_name, **overrides): + """(pilote instancié, exécuteur à blanc) pour `driver_name`.""" + fields, secrets = SAMPLES[driver_name] + profile = dict(fields, name=f"t-{driver_name}"[:31], driver=driver_name) + profile.update(overrides) + profile = profiles.validate(profile) + driver = DRIVERS[driver_name](profile, dict(secrets)) + runner = Runner( + dry_run=True, + quiet=True, + redactor=lambda text: redact(text, driver.secret_values()), + ) + return driver, runner + + +def commands(runner): + return [op["cmd"] for op in runner.ops if op["kind"] == "cmd"] + + +class DriverContract(unittest.TestCase): + """Ce que tout pilote doit déclarer pour que le menu et le CLI + fonctionnent sans le connaître.""" + + def test_every_driver_is_complete(self): + for name, cls in DRIVERS.items(): + with self.subTest(driver=name): + self.assertEqual(cls.name, name) + self.assertTrue(cls.label, "libellé vide") + self.assertLessEqual(len(cls.label), 34, "libellé trop long") + self.assertTrue(cls.hint, "aucun conseil de choix") + self.assertTrue(cls.binaries, "aucun binaire déclaré") + self.assertTrue(cls.server_label) + self.assertIsInstance(cls.proven, bool) + + def test_form_fields_are_well_formed(self): + for name, cls in DRIVERS.items(): + for field in cls.form_fields: + with self.subTest(driver=name, field=field): + key, label, kind, advanced = field + self.assertIn(kind, ("text", "int", "flag", "path")) + self.assertIn( + key, + cls.defaults, + "un champ demandé sans valeur par défaut", + ) + self.assertTrue(label) + self.assertIsInstance(advanced, bool) + + def test_secret_fields_are_well_formed(self): + for name, cls in DRIVERS.items(): + for key, label, required in cls.secret_fields: + with self.subTest(driver=name, secret=key): + self.assertTrue(label) + self.assertIsInstance(required, bool) + + def test_every_sample_profile_validates(self): + """Le jeu d'essai lui-même doit passer la validation : sinon les + tests suivants mesureraient un profil que personne ne pourrait + enregistrer.""" + for name in DRIVERS: + with self.subTest(driver=name): + driver, _ = build(name) + self.assertEqual(driver.profile["driver"], name) + + +class BinariesRootRunsAreNotOursToRun(unittest.TestCase): + """Un binaire lancé par root n'a pas à être exécutable par nous. + + `pppd` est installé en 4750 root:dip sur Debian et Ubuntu. Tout + utilisateur hors du groupe dip se voyait annoncer « pppd absent » sur + une machine où le paquet ppp était installé — et envoyé réinstaller ce + qui était déjà là. C'est l'EXISTENCE qui compte : xl2tpd, qui tourne en + root, l'exécute très bien. + """ + + def test_which_asks_about_us_and_locate_about_existence(self): + with tempfile.TemporaryDirectory() as directory: + faux = os.path.join(directory, "pppd") + open(faux, "w").close() + os.chmod(faux, 0o000) + with patch.dict(os.environ, {"PATH": directory}): + self.assertEqual(which("pppd"), "") + self.assertEqual(locate("pppd"), faux) + + def test_a_driver_sees_such_a_binary_as_present(self): + driver, _ = build("l2tp_ipsec") + with tempfile.TemporaryDirectory() as directory: + for binary in driver.binaries: + chemin = os.path.join(directory, binary) + open(chemin, "w").close() + os.chmod(chemin, 0o000) + with patch.dict(os.environ, {"PATH": directory}): + self.assertEqual(driver.missing_binaries(), []) + + def test_a_truly_absent_binary_is_still_reported(self): + driver, _ = build("l2tp_ipsec") + with tempfile.TemporaryDirectory() as directory: + with patch.dict(os.environ, {"PATH": directory}): + self.assertEqual( + sorted(driver.missing_binaries()), + sorted(driver.binaries), + ) + + +class NoSecretLeaksAnywhere(unittest.TestCase): + """Le test central : aucun secret dans une ligne de commande, pour + aucun pilote.""" + + def test_no_secret_in_any_command(self): + for name in DRIVERS: + driver, runner = build(name) + driver.up(runner) + with self.subTest(driver=name): + self.assertTrue(runner.ops, "plan vide") + for op in runner.ops: + if op["kind"] != "cmd": + continue + for secret in driver.secret_values(): + self.assertNotIn( + secret, + op["cmd"], + f"{name} : secret dans « {op['label']} »", + ) + + def test_a_secret_in_a_payload_is_always_marked(self): + """Un secret peut voyager par l'entrée standard ou dans un fichier + — mais l'opération doit être MARQUÉE, sinon l'affichage le + montrerait.""" + for name in DRIVERS: + driver, runner = build(name) + driver.up(runner) + for op in runner.ops: + payload = ( + op.get("stdin") + if op["kind"] == "cmd" + else op.get("content") + ) + if not payload: + continue + leaked = [s for s in driver.secret_values() if s in payload] + if leaked: + with self.subTest(driver=name, op=op.get("label")): + self.assertTrue( + op.get("secret_stdin") or op.get("secret"), + "secret non marqué", + ) + + def test_secret_files_are_owner_only_and_in_tmpfs(self): + for name in DRIVERS: + driver, runner = build(name) + driver.up(runner) + for op in runner.ops: + if op["kind"] == "write" and op["secret"]: + with self.subTest(driver=name, path=op["path"]): + self.assertEqual(op["mode"], "0600") + self.assertTrue(op["path"].startswith("/dev/shm/")) + + def test_down_erases_the_secret_directory(self): + """Sauf pour ceux qui n'écrivent aucun secret : il n'y a rien à + effacer, et prétendre le faire serait du théâtre.""" + writes_secrets = ("l2tp_ipsec", "wireguard", "openvpn") + for name in DRIVERS: + driver, runner = build(name) + driver.down(runner) + joined = " ".join(commands(runner)) + with self.subTest(driver=name): + if name in writes_secrets: + self.assertIn(f"rm -rf -- {driver.secret_dir}", joined) + + +class Wireguard(unittest.TestCase): + def test_config_holds_the_private_key_and_the_peer(self): + driver, _ = build("wireguard") + body = driver.config_body() + self.assertIn(f"PrivateKey = {WG_PRIVATE}", body) + self.assertIn(f"PublicKey = {WG_PUBLIC}", body) + self.assertIn(f"PresharedKey = {WG_PRESHARED}", body) + self.assertIn("Endpoint = 127.0.0.1:51820", body) + + def test_no_dns_line_in_the_configuration(self): + """`DNS =` fait appeler `resolvconf` par wg-quick, absent de + beaucoup d'installations systemd-resolved — et c'est la + configuration ENTIÈRE qui échoue alors.""" + driver, _ = build("wireguard", wg_dns="10.7.0.1") + self.assertNotIn("DNS =", driver.config_body()) + # …mais le DNS est bien appliqué, par resolvectl, après le montage. + driver, runner = build("wireguard", wg_dns="10.7.0.1") + driver.up(runner) + + def test_allowed_ips_carries_the_routing(self): + driver, _ = build("wireguard") + self.assertEqual(driver.allowed_ips, "10.7.0.0/24") + driver, _ = build("wireguard", default_route=True, routes=[]) + self.assertEqual(driver.allowed_ips, "0.0.0.0/0") + + def test_the_config_file_is_named_after_the_interface(self): + """`wg-quick` DÉDUIT le nom de l'interface du nom du fichier.""" + driver, _ = build("wireguard") + self.assertTrue( + driver.config_file.endswith(f"/{driver.iface}.conf"), + driver.config_file, + ) + self.assertLessEqual(len(driver.iface), 15) + + def test_no_manual_route_command(self): + """Les routes appartiennent à wg-quick, via AllowedIPs : en + ajouter ici entrerait en conflit avec les siennes.""" + driver, runner = build("wireguard") + driver.up(runner) + for command in commands(runner): + self.assertNotIn("ip route replace 10.7.0.0/24", command) + + def test_it_waits_for_a_handshake(self): + """L'interface monte même avec une clé fausse : sans cette attente, + « monté » ne voudrait dire que « l'interface existe ».""" + driver, runner = build("wireguard") + driver.up(runner) + self.assertTrue( + any("latest-handshakes" in c for c in commands(runner)), + "aucune attente de poignée de main", + ) + + def test_a_malformed_peer_key_is_refused(self): + """wg-quick refuse la configuration ENTIÈRE sur une clé mal formée, + avec un message qui ne dit pas laquelle. On le dit avant.""" + for bad in ("pas-une-cle", WG_PUBLIC[:-1], WG_PUBLIC + "x", ""): + with self.subTest(cle=bad): + with self.assertRaises(ProfileError): + build("wireguard", wg_peer_key=bad) + + def test_an_address_without_prefix_becomes_a_32(self): + driver, _ = build("wireguard", wg_address="10.7.0.9") + self.assertEqual(driver.profile["wg_address"], "10.7.0.9/32") + + +class Openvpn(unittest.TestCase): + def test_it_changes_directory_to_the_config(self): + """Un .ovpn référence ses fichiers voisins en relatif.""" + driver, _ = build("openvpn") + self.assertIn("--cd /tmp/acme", driver.command()) + + def test_config_comes_before_the_credentials(self): + """Ce qui suit `--config` l'emporte sur le contenu du fichier : un + `auth-user-pass` nu dedans ferait attendre une saisie qui ne + viendra jamais, le démon étant détaché.""" + command = build("openvpn")[0].command() + self.assertLess( + command.index("--config"), command.index("--auth-user-pass") + ) + + def test_credentials_are_two_lines_in_tmpfs(self): + driver, _ = build("openvpn") + self.assertEqual(driver.auth_body(), f"user\n{SECRET}-ovpn\n") + self.assertTrue(driver.auth_file.startswith("/dev/shm/")) + + def test_split_tunnel_asks_for_route_nopull(self): + self.assertIn("--route-nopull", build("openvpn")[0].command()) + + def test_full_tunnel_lets_the_server_push(self): + command = build("openvpn", default_route=True, routes=[])[0].command() + self.assertNotIn("--route-nopull", command) + + def test_no_credentials_no_auth_option(self): + """Un .ovpn qui s'authentifie par certificat ne doit pas se voir + imposer un fichier d'identifiants vide.""" + command = build("openvpn", ovpn_user="")[0].command() + self.assertNotIn("--auth-user-pass", command) + + def test_it_waits_for_the_initialisation_line(self): + driver, runner = build("openvpn") + driver.up(runner) + self.assertTrue( + any( + "Initialization Sequence Completed" in c + for c in commands(runner) + ) + ) + + def test_a_missing_config_path_is_refused(self): + with self.assertRaises(ProfileError): + build("openvpn", ovpn_config="") + + +class Openconnect(unittest.TestCase): + def test_the_password_never_touches_the_disk(self): + """Le seul pilote sans aucun fichier de secret.""" + driver, runner = build("openconnect") + driver.up(runner) + secret_writes = [ + op + for op in runner.ops + if op["kind"] == "write" and op.get("secret") + ] + self.assertEqual(secret_writes, []) + + def test_the_password_travels_on_marked_standard_input(self): + driver, runner = build("openconnect") + driver.up(runner) + launches = [ + op for op in runner.ops if op["kind"] == "cmd" and op.get("stdin") + ] + self.assertEqual(len(launches), 1) + self.assertTrue(launches[0]["secret_stdin"]) + self.assertIn(f"{SECRET}-oc", launches[0]["stdin"]) + self.assertIn("--passwd-on-stdin", launches[0]["cmd"]) + + def test_non_interactive_so_a_cert_prompt_cannot_eat_the_password(self): + self.assertIn("--non-inter", build("openconnect")[0].command()) + + def test_the_interface_is_named_not_discovered(self): + driver, _ = build("openconnect") + self.assertIn(f"--interface={driver.iface}", driver.command()) + self.assertLessEqual(len(driver.iface), 15) + + def test_an_unknown_protocol_is_refused(self): + with self.assertRaises(ProfileError): + build("openconnect", oc_protocol="carrier-pigeon") + + def test_routes_are_not_required(self): + """C'est le serveur qui les pousse : exiger une route déclarée + serait une fausse exigence.""" + driver, _ = build("openconnect", default_route=False, routes=[]) + self.assertEqual(driver.profile["routes"], []) + + +class OpenconnectSingleSignOn(unittest.TestCase): + """Le cas du « formulaire web » : le concentrateur délègue à un + fournisseur d'identité, et il n'y a aucun mot de passe à envoyer. + + Le client de Cisco exige alors un navigateur embarqué, donc un écran. + openconnect s'en passe : mesuré dans sa bibliothèque, il écoute sur le + port local 29786 et attend la redirection du navigateur, lequel peut + être celui de l'utilisateur, ailleurs, à travers un `ssh -L`. + """ + + def _sso(self, **overrides): + profile = dict( + SAMPLES["openconnect"][0], + name="t-sso", + driver="openconnect", + oc_sso=True, + oc_user="", + ) + profile.update(overrides) + return DRIVERS["openconnect"](profiles.validate(profile), {}) + + 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 : + exiger un utilisateur refuserait un profil parfaitement valide.""" + driver = self._sso() + self.assertEqual(driver.profile["oc_user"], "") + self.assertEqual(driver.missing_secrets(), []) + + def test_the_command_asks_for_an_external_browser(self): + command = self._sso().command() + self.assertIn("--external-browser=echo", command) + self.assertNotIn("--passwd-on-stdin", command) + self.assertNotIn("--user=", command) + + def test_no_non_inter_in_sso(self): + """L'échange avec le navigateur EST l'interaction : l'interdire + ferait échouer la seule étape qui compte.""" + self.assertNotIn("--non-inter", self._sso().command()) + + def test_a_chosen_browser_is_honoured(self): + driver = self._sso(oc_external_browser="/usr/bin/xdg-open") + self.assertIn("--external-browser=/usr/bin/xdg-open", driver.command()) + + def test_it_explains_the_round_trip_before_waiting(self): + """openconnect attend en silence : sans explication, l'attente + ressemble à un blocage, et la redirection ne revient jamais si + personne n'a monté le tunnel ssh.""" + driver = self._sso() + runner = Runner(dry_run=True) + buffer = io.StringIO() + with redirect_stdout(buffer): + driver.up(runner) + printed = buffer.getvalue() + self.assertIn("29786", printed) + self.assertIn("ssh -L", printed) + + def test_sso_writes_no_secret_and_sends_nothing_on_stdin(self): + driver = self._sso() + runner = Runner(dry_run=True, quiet=True) + driver.up(runner) + for op in runner.ops: + self.assertFalse(op.get("secret")) + self.assertFalse(op.get("stdin")) + + def test_classic_mode_still_needs_a_password(self): + """Sans SSO et sans mot de passe, on le dit au lieu de lancer une + commande qui échouera. + + Le binaire est réputé présent : sans ce bouchon, le test mesure les + paquets de la machine qui l'exécute et c'est « openconnect absent » + qui remonte, sur un pilote qui a pourtant raison de le dire. + """ + profile = profiles.validate( + dict( + SAMPLES["openconnect"][0], + name="t-clas", + driver="openconnect", + ) + ) + driver = DRIVERS["openconnect"](profile, {}) + runner = Runner(dry_run=False, quiet=True) + runner.cmd = lambda *a, **k: (0, "") + runner.mkdir = lambda *a, **k: 0 + with patch( + "script.vpn.drivers.base.locate", return_value="/usr/bin/x" + ): + self.assertFalse(driver.up(runner)) + self.assertTrue( + [m for m in runner.failures if "coffre" in m], runner.failures + ) + + +class Sshuttle(unittest.TestCase): + def test_it_is_not_launched_under_sudo(self): + """sshuttle n'élève que la partie pare-feu. Sous sudo, la session + SSH serait ouverte par root — avec les clés de root.""" + driver, runner = build("sshuttle") + driver.up(runner) + launches = [c for c in commands(runner) if "sshuttle --remote" in c] + self.assertEqual(len(launches), 1) + self.assertFalse(launches[0].startswith("sudo "), launches[0]) + + def test_the_pidfile_lives_in_the_home(self): + """/run/erplibre-vpn appartient à root : sshuttle tourne sous + l'utilisateur et ne pourrait pas y écrire.""" + driver, _ = build("sshuttle") + self.assertTrue(driver.pid_file.startswith(os.path.expanduser("~"))) + + def test_it_has_no_secret_at_all(self): + driver, _ = build("sshuttle") + self.assertEqual(driver.secret_fields, ()) + self.assertEqual(driver.missing_secrets(), []) + + def test_subnets_come_from_the_routes(self): + driver, _ = build("sshuttle") + self.assertEqual(driver.subnets, ["10.40.0.0/16"]) + driver, _ = build("sshuttle", default_route=True, routes=[]) + self.assertEqual(driver.subnets, ["0.0.0.0/0"]) + + def test_status_judges_on_the_witness_only(self): + """Sans interface ni route à vérifier, une vérification + d'interface rendrait un « ✗ » qui ne veut rien dire.""" + driver, runner = build("sshuttle") + runner.quiet = True + labels = [label for label, _, _ in driver.status(runner)] + self.assertFalse([label for label in labels if "interface" in label]) + self.assertTrue([label for label in labels if "témoin" in label]) + + def test_an_ssh_target_with_a_user_is_accepted(self): + driver, _ = build("sshuttle") + self.assertEqual(driver.profile["server"], "erplibre@127.0.0.1") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_vpn_profiles.py b/test/test_vpn_profiles.py new file mode 100644 index 0000000..f12c208 --- /dev/null +++ b/test/test_vpn_profiles.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Profils VPN : validation et aller-retour sur disque. + +Ni root, ni réseau, ni serveur VPN. Les trois fichiers de configuration +fusionnés sont déplacés dans un répertoire temporaire : un test qui écrirait +dans `private/todo/todo_override_private.json` détruirait les profils de la +personne qui le lance. +""" + +import json +import os +import stat +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.vpn import profiles +from script.vpn.profiles import ProfileError + +VALID = { + "name": "acme", + "driver": "l2tp_ipsec", + "server": "vpn.acme.example", + "ppp_user": "ACME\\user", + "routes": ["10.20.0.0/16"], +} + + +class VpnProfileConfig(unittest.TestCase): + """Fusion et écriture, avec les trois fichiers dans un temporaire.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = os.path.join(self.tmp.name, "todo.json") + with open(base, "w") as fh: + json.dump({"vpn": []}, fh) + self.private = os.path.join(self.tmp.name, "private.json") + self.patches = [ + 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", + self.private, + ), + ] + for item in self.patches: + item.start() + + def tearDown(self): + for item in self.patches: + item.stop() + self.tmp.cleanup() + + def test_save_then_load(self): + profiles.save(VALID) + loaded = profiles.load("acme") + self.assertEqual(loaded["server"], "vpn.acme.example") + self.assertEqual(loaded["routes"], ["10.20.0.0/16"]) + # Les défauts sont appliqués à la lecture. + self.assertEqual(loaded["mtu"], 1280) + self.assertFalse(loaded["default_route"]) + + def test_private_file_is_owner_only(self): + """Le fichier des profils est en 0600. + + Il ne contient pas de secret, mais il nomme les serveurs et les + utilisateurs d'un client : c'est une carte, et une carte se garde.""" + profiles.save(VALID) + mode = stat.S_IMODE(os.stat(self.private).st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + + def test_save_twice_updates_in_place(self): + profiles.save(VALID) + profiles.save(dict(VALID, server="autre.example")) + self.assertEqual(len(profiles.load_all()), 1) + self.assertEqual(profiles.load("acme")["server"], "autre.example") + + def test_delete(self): + profiles.save(VALID) + self.assertTrue(profiles.delete("acme")) + self.assertIsNone(profiles.load("acme")) + self.assertFalse(profiles.delete("acme")) + + def test_shared_profile_is_not_deletable(self): + """Un profil venu du fichier partagé se lit mais ne s'efface pas + d'ici : `delete` doit rendre False, pas faire semblant.""" + with open(os.path.join(self.tmp.name, "todo.json"), "w") as fh: + json.dump({"vpn": [dict(VALID, name="partage")]}, fh) + self.assertIsNotNone(profiles.load("partage")) + self.assertFalse(profiles.delete("partage")) + self.assertIsNotNone(profiles.load("partage")) + + def test_shared_and_private_do_not_duplicate(self): + """Écrire un profil privé ne doit pas recopier ceux du partagé. + + La fusion ÉTEND les listes : réécrire la vue fusionnée ferait + apparaître le profil partagé deux fois à la lecture suivante.""" + with open(os.path.join(self.tmp.name, "todo.json"), "w") as fh: + json.dump({"vpn": [dict(VALID, name="partage")]}, fh) + profiles.save(dict(VALID, name="prive")) + noms = profiles.names() + self.assertEqual(sorted(noms), ["partage", "prive"], noms) + + +class VpnProfileValidation(unittest.TestCase): + def test_valid(self): + clean = profiles.validate(VALID) + self.assertEqual(clean["name"], "acme") + + def test_name_must_be_tame(self): + """Le nom devient un nom de connexion IPsec, de répertoire et de + fichier : ce qui n'est pas dans l'alphabet prévu est refusé.""" + for bad in ("Acme", "a b", "../evil", "a;rm -rf /", "", "é"): + with self.assertRaises(ProfileError, msg=bad): + profiles.validate(dict(VALID, name=bad)) + + def test_server_refuses_shell_metacharacters(self): + for bad in ("vpn.example;reboot", "vpn example", "$(id)", "a|b"): + with self.assertRaises(ProfileError, msg=bad): + profiles.validate(dict(VALID, server=bad)) + + def test_unknown_driver(self): + with self.assertRaises(ProfileError): + profiles.validate(dict(VALID, driver="carrier-pigeon")) + + def test_routes_normalised_to_cidr(self): + clean = profiles.validate( + dict(VALID, routes="10.0.0.0/8, 192.168.1.5") + ) + self.assertEqual(clean["routes"], ["10.0.0.0/8", "192.168.1.5/32"]) + + def test_bad_route(self): + with self.assertRaises(ProfileError): + profiles.validate(dict(VALID, routes=["10.0.0.0/99"])) + + def test_a_tunnel_without_destination_is_accepted_where_it_helps(self): + """Ni route déclarée, ni route par défaut : accepté pour L2TP. + + Un site ne remet souvent qu'une passerelle et des identifiants. + Refuser ce profil laissait sans issue : il joint l'hôte distant, et + l'adresse qu'on y obtient dit quel réseau ajouter. Le menu le dit, + et le montage le suggère.""" + clean = profiles.validate(dict(VALID, routes=[], default_route=False)) + self.assertEqual(clean["routes"], []) + self.assertFalse(clean["default_route"]) + + def test_it_stays_refused_where_the_technology_cannot_do_without(self): + """WireGuard sans AllowedIPs : wg-quick refuse la configuration + entière. sshuttle sans réseau : rien à détourner. Là, l'exigence + reste dure.""" + for driver, extra in ( + ( + "wireguard", + { + "wg_address": "10.7.0.2/32", + "wg_peer_key": ( + "SGVsbG9Xb3JsZEV4YW1wbGVLZXkxMjM0NTY3ODkwYWI=" + ), + }, + ), + ("sshuttle", {}), + ): + with self.subTest(driver=driver): + with self.assertRaises(ProfileError): + profiles.validate( + dict( + VALID, + driver=driver, + routes=[], + default_route=False, + **extra, + ) + ) + + def test_default_route_alone_is_enough(self): + clean = profiles.validate(dict(VALID, routes=[], default_route=True)) + self.assertTrue(clean["default_route"]) + + def test_mtu_bounds(self): + for bad in (10, 9000, "beaucoup"): + with self.assertRaises(ProfileError, msg=str(bad)): + profiles.validate(dict(VALID, mtu=bad)) + + def test_probe_must_be_an_address(self): + with self.assertRaises(ProfileError): + profiles.validate(dict(VALID, probe="serveur-interne")) + self.assertEqual( + profiles.validate(dict(VALID, probe="10.20.0.1"))["probe"], + "10.20.0.1", + ) + + def test_l2tp_needs_a_ppp_user(self): + """Exigence propre au pilote, pas au format de profil.""" + with self.assertRaises(ProfileError): + profiles.validate(dict(VALID, ppp_user="")) + + def test_secret_title_is_derived_from_the_name(self): + self.assertEqual(profiles.secret_title("acme"), "ERPLibre VPN / acme") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_vpn_render.py b/test/test_vpn_render.py new file mode 100644 index 0000000..3807b36 --- /dev/null +++ b/test/test_vpn_render.py @@ -0,0 +1,870 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce que le pilote L2TP/IPsec écrit, et ce qu'il n'écrit JAMAIS. + +Le test central de ce fichier est `test_no_secret_reaches_a_command_line` : +il rejoue tout le plan de montage à blanc et vérifie qu'aucun secret n'a +atterri dans une ligne de commande. `/proc//cmdline` est lisible par +tout utilisateur de la machine ; un secret en argument est un secret public +pendant toute la durée de la commande. + +Aucun root, aucun serveur en face : le `Runner` à blanc n'exécute rien, il +enregistre. Le serveur du profil est 127.0.0.1 pour qu'aucun test ne dépende +d'une résolution de nom. +""" + +import io +import os +import sys +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 profiles +from script.vpn.drivers import base +from script.vpn.drivers.l2tp_ipsec import ( + APPARMOR_PROFILE, + L2tpIpsecDriver, +) +from script.vpn.runner import Runner, replace_block +from script.vpn.vault import redact + +PSK = "cl3-Pr3-P4rt4g33!" +PPP_PASSWORD = "m0tD3P4ss3-PPP" +SECRETS = {"psk": PSK, "password": PPP_PASSWORD} + +PROFILE = profiles.validate( + { + "name": "acme", + "driver": "l2tp_ipsec", + "server": "127.0.0.1", + "ppp_user": "ACME\\user", + "routes": ["10.20.0.0/16"], + "probe": "10.20.0.1", + } +) + + +def _driver(**overrides): + profile = dict(PROFILE) + profile.update(overrides) + return L2tpIpsecDriver(profile, SECRETS) + + +def _dry_runner(driver): + return Runner( + dry_run=True, + quiet=True, + redactor=lambda text: redact(text, driver.secret_values()), + ) + + +class RenderedFiles(unittest.TestCase): + def test_ipsec_conn_is_transport_mode(self): + """Mode TRANSPORT, pas tunnel. En mode tunnel, la SA monte et + aucune interface PPP n'apparaît jamais.""" + body = _driver().ipsec_conn_body() + self.assertIn("type=transport", body) + self.assertNotIn("type=tunnel", body) + + def test_ipsec_conn_accepts_any_local_port(self): + """`leftprotoport=17/%any` : derrière du NAT, le port source est + réécrit et une politique clouée sur 1701 ne s'applique plus.""" + self.assertIn("leftprotoport=17/%any", _driver().ipsec_conn_body()) + + def test_ipsec_conn_names_the_server_and_the_connection(self): + body = _driver().ipsec_conn_body() + self.assertIn("conn erplibre-acme", body) + self.assertIn("right=127.0.0.1", body) + self.assertIn("rightprotoport=17/1701", body) + + def test_psk_is_written_in_hexadecimal(self): + """Le PSK part en hexadécimal : mêmes octets pour strongSwan, et + plus aucune question d'échappement de guillemets.""" + body = _driver().ipsec_secrets_body("127.0.0.1") + self.assertIn(f"PSK 0x{PSK.encode('utf-8').hex()}", body) + self.assertNotIn(PSK, body) + self.assertIn("%any 127.0.0.1 :", body) + + def test_ppp_options_escape_the_domain_backslash(self): + """`ACME\\user` est la forme courante sur un concentrateur L2TP. + Sans échappement, pppd envoie `ACMEuser` et le serveur refuse + sans dire pourquoi.""" + body = _driver().ppp_options_body() + self.assertIn(r'name "ACME\\user"', body) + + def test_ppp_refuses_no_method_and_requires_nothing(self): + """Aucun `refuse-*`, et `noauth`. + + Mesuré sur un vrai concentrateur : il demande « », et un + `refuse-pap` y répond « ConfNak » — le serveur coupe + alors sur « peer refused to authenticate », où le « peer » est NOUS. + `require-mschap-v2`, symétriquement, exigerait que le SERVEUR + s'authentifie auprès de nous : aucun sens pour un client. + + PAP est en clair sur la liaison PPP, qui voyage dans l'ESP : c'est + IPsec qui protège l'authentification, et ce pilote ne lance jamais + L2TP sans SA établie.""" + body = _driver().ppp_options_body() + self.assertIn("noauth", body) + for refuse in ( + "refuse-pap", + "refuse-eap", + "refuse-chap", + "refuse-mschap", + "require-mschap-v2", + "require-chap", + ): + self.assertNotIn(refuse, body) + + def test_an_interface_without_an_address_is_a_failure(self): + """pppd crée l'interface AVANT qu'IPCP ait négocié l'adresse. + + Lire tout de suite annonçait « ppp0 : sans adresse » sur un tunnel + sain, et faisait chercher les DNS du pair avant que pppd les ait + écrits. On attend donc l'adresse — et son absence au bout du délai + est un échec, pas un détail d'affichage. + + Le mode à blanc rend la main avant cette étape : on joue donc le + chemin réel, avec l'exécuteur bouchonné. La sonde du noyau est + bouchonnée elle aussi : sans cela le test mesurerait l'IPsec de la + machine qui l'exécute, et un conteneur sans XFRM le ferait échouer + sur un plan de montage pourtant correct. + """ + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + module = "script.vpn.drivers.l2tp_ipsec" + with patch( + f"{module}.netlink_family_available", return_value=True + ), patch.object( + runner, "cmd", return_value=(0, "established successfully") + ), patch.object( + runner, "write", return_value=0 + ), patch.object( + runner, "block", return_value=False + ), patch.object( + runner, "mkdir", return_value=0 + ), patch( + "script.vpn.drivers.base.locate", return_value="/usr/bin/x" + ), patch( + f"{module}.locate", return_value="" + ), patch( + f"{module}.resolve", return_value="203.0.113.9" + ), patch( + f"{module}.ppp_interfaces", return_value=set() + ), patch( + f"{module}.wait_for_new_interface", return_value="ppp0" + ), patch( + f"{module}.wait_for_interface_address", return_value=[] + ) as attente: + self.assertFalse(driver.up(runner)) + attente.assert_called_once() + self.assertTrue( + [motif for motif in runner.failures if "adresse" in motif], + runner.failures, + ) + + def test_xl2tpd_requires_nothing_of_the_peer(self): + """« require chap » et « require authentication » font passer + « require-chap » et « auth » à pppd, c'est-à-dire « que le serveur + me prouve qui il est ». Le serveur refuse, et la liaison tombe.""" + body = _driver().xl2tpd_conf_body() + self.assertIn("require authentication = no", body) + self.assertIn("require chap = no", body) + + def test_xl2tpd_comments_use_a_semicolon(self): + """L'analyseur de xl2tpd ne connaît pas « # » : un dièse en tête + fait refuser le fichier ENTIER — « data '#…' occurs with no + context », puis « Unable to load config file ».""" + first = _driver().xl2tpd_conf_body().splitlines()[0] + self.assertTrue(first.startswith(";"), first) + self.assertNotIn("#", _driver().xl2tpd_conf_body()) + + def test_the_peer_identity_is_accepted_as_presented(self): + """Une passerelle s'annonce par son IP quand `right` est un nom : + sans `rightid=%any`, strongSwan refuse — « IDir '203.0.113.5' does + not match to 'vpn.exemple.com' ».""" + self.assertIn("rightid=%any", _driver().ipsec_conn_body()) + + def test_split_tunnel_by_default(self): + """Pas de `defaultroute` sans demande explicite : capter tout le + trafic couperait la session SSH en cours.""" + self.assertNotIn("defaultroute", _driver().ppp_options_body()) + self.assertIn( + "defaultroute", + _driver(default_route=True).ppp_options_body(), + ) + + def test_mtu_and_user_come_from_the_profile(self): + body = _driver(mtu=1400).ppp_options_body() + self.assertIn("mtu 1400", body) + self.assertIn("mru 1400", body) + + def test_xl2tpd_points_at_the_tmpfs_options(self): + body = _driver().xl2tpd_conf_body() + self.assertIn("[lac erplibre-acme]", body) + self.assertIn("lns = 127.0.0.1", body) + self.assertIn( + "pppoptfile = /dev/shm/erplibre-vpn/acme/ppp.options", body + ) + + +class TheOrderOfTheMountingPlan(unittest.TestCase): + """Trois étapes dont l'ordre a été payé cher sur une vraie machine.""" + + def _plan(self, apparmor=False): + """Le plan de montage à blanc : une entrée par opération, commande + ou chemin de fichier. + + `apparmor` fait exister le profil de charon. Il n'y en a que sur + Debian et Ubuntu ; sans ce bouchon, le test de l'ordre des étapes + mesure la distribution qui l'exécute et ne trouve pas une étape que + le pilote a raison de ne pas produire ailleurs. + """ + driver = _driver() + runner = _dry_runner(driver) + existe = os.path.exists + + def presente(chemin): + if apparmor and chemin == APPARMOR_PROFILE: + return True + return existe(chemin) + + with patch("os.path.exists", side_effect=presente): + driver.up(runner) + return [op.get("cmd", "") + op.get("path", "") for op in runner.ops] + + def test_apparmor_is_allowed_before_charon_reads_the_secrets(self): + """AppArmor confine charon par CHEMIN et refuse /dev/shm. La règle + doit être posée ET le profil rechargé avant que charon lise les + secrets, sinon le refus arrive du noyau et ressort trois étages + plus loin en « no shared key found ».""" + plan = self._plan(apparmor=True) + regle = next( + i for i, c in enumerate(plan) if "apparmor_parser -r" in c + ) + # À blanc, charon est réputé déjà lancé : le plan recharge au lieu + # de démarrer, et « rereadsecrets » est le moment où charon lit nos + # secrets. C'est lui qui doit venir après la règle. + secrets = next(i for i, c in enumerate(plan) if "rereadsecrets" in c) + self.assertLess(regle, secrets) + + def test_it_waits_for_the_connection_before_bringing_it_up(self): + """`ipsec start` rend la main avant que le starter ait poussé les + connexions : un « up » immédiat échoue sur « no match », sur une + configuration parfaitement valide.""" + plan = self._plan() + attente = next(i for i, c in enumerate(plan) if "statusall" in c) + montee = next( + i for i, c in enumerate(plan) if "ipsec up erplibre-" in c + ) + self.assertLess(attente, montee) + + def test_a_busy_l2tp_port_stops_the_plan(self): + """Continuer produirait un tube de contrôle qui n'apparaît jamais, + deux étapes plus loin. On regarde le PORT et non le nom du service : + sur Ubuntu, xl2tpd est un script SysV enveloppé que + « disable --now » n'arrête pas toujours.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + occupe = ( + "UNCONN 0 0 0.0.0.0:1701 0.0.0.0:*" + ' users:(("xl2tpd",pid=1234,fd=5))' + ) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", + return_value="/usr/bin/ss", + ): + with patch.object(runner, "cmd", return_value=(0, occupe)): + self.assertFalse(driver._l2tp_port_is_free(runner)) + self.assertTrue(runner.failures) + # Le verdict NOMME ce qui tient le port. + self.assertIn("xl2tpd", runner.failures[0]) + + def test_a_free_l2tp_port_lets_the_plan_through(self): + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", + return_value="/usr/bin/ss", + ): + with patch.object(runner, "cmd", return_value=(0, "\n")): + self.assertTrue(driver._l2tp_port_is_free(runner)) + self.assertFalse(runner.failures) + + def test_without_ss_the_plan_is_not_blocked(self): + """Un faux blocage serait pire qu'un échec tardif : sans `ss`, on + laisse passer et le tube de contrôle tranchera.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + with patch("script.vpn.drivers.l2tp_ipsec.locate", return_value=""): + self.assertTrue(driver._l2tp_port_is_free(runner)) + + +class NotSawingOffTheBranchYouSitOn(unittest.TestCase): + """En mode « tout le trafic », le retour de la session SSH qui donne + l'ordre part dans le tunnel. On perd la machine, le menu, et le moyen de + démonter ce qu'on vient de monter.""" + + def _routes(self, ssh_connection=None): + driver = _driver(default_route=True, routes=[]) + runner = _dry_runner(driver) + env = {"SSH_CONNECTION": ssh_connection} if ssh_connection else {} + with patch.dict(os.environ, env, clear=not ssh_connection): + driver.up(runner) + return [ + op["cmd"] + for op in runner.ops + if op["kind"] == "cmd" and "ip route replace" in op["cmd"] + ] + + def test_the_ssh_client_keeps_a_direct_route(self): + routes = self._routes("192.0.2.50 54321 198.51.100.7 22") + self.assertTrue( + any("192.0.2.50/32" in c for c in routes), + routes, + ) + # Celle du serveur reste, évidemment. + self.assertTrue(any("127.0.0.1/32" in c for c in routes), routes) + + def test_nothing_extra_outside_an_ssh_session(self): + routes = self._routes(None) + self.assertTrue(any("127.0.0.1/32" in c for c in routes), routes) + self.assertEqual(len(routes), 1, routes) + + def test_a_bogus_ssh_connection_is_ignored(self): + """`SSH_CONNECTION` mal formée ne doit pas produire une route + absurde ni faire échouer le montage.""" + routes = self._routes("pas-une-adresse 1 2 3") + self.assertEqual(len(routes), 1, routes) + + def test_the_teardown_removes_every_survival_route(self): + """Elles sont plusieurs : l'état en garde une par ligne.""" + driver = _driver(default_route=True, routes=[]) + runner = _dry_runner(driver) + with patch.object( + driver, "read_state", return_value="1.2.3.4/32\n5.6.7.8/32" + ): + driver.down(runner) + joined = " ".join( + op.get("cmd", "") for op in runner.ops if op["kind"] == "cmd" + ) + self.assertIn("ip route del 1.2.3.4/32", joined) + self.assertIn("ip route del 5.6.7.8/32", joined) + + +class WhenTheToolKnowsTheFixItOffersIt(unittest.TestCase): + """L'outil sait souvent quoi faire. Renvoyer l'utilisateur taper la + commande puis tout relancer, c'est lui faire porter un travail déjà + identifié — mais le faire d'office serait arrêter un service du système + sans le demander. Donc : proposer, appliquer, revérifier.""" + + def setUp(self): + """La question posée s'affiche même sur un exécuteur silencieux — + c'est voulu, on s'apprête à bloquer dessus. Elle n'a rien à faire + dans la sortie du lanceur de tests pour autant.""" + silence = redirect_stdout(io.StringIO()) + silence.__enter__() + self.addCleanup(silence.__exit__, None, None, None) + + def _runner(self, sortie_port): + """Exécuteur bouchonné dont `ss` rend `sortie_port` — une réponse par + interrogation du port, dans l'ordre : avant le correctif, puis après. + + Seules les commandes `ss` consomment la liste : l'application du + correctif est une commande comme une autre, et si elle en prenait + une, le test mesurerait autre chose que ce qu'il croit. + """ + runner = Runner(dry_run=False, quiet=True) + reponses = list(sortie_port) + + def cmd(label, command, *a, **k): + if "ss -lunp" in command: + return 0, reponses.pop(0) if reponses else "" + return 0, "" + + runner.cmd = cmd + return runner + + TENU = ( + "UNCONN 0 0 0.0.0.0:1701 0.0.0.0:*" + ' users:(("xl2tpd",pid=12314,fd=3))' + ) + + def test_it_offers_to_stop_xl2tpd_then_carries_on(self): + driver = _driver() + # `ss` dit « tenu », puis « libre » après le correctif. + runner = self._runner([self.TENU, ""]) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ): + self.assertTrue(driver._l2tp_port_is_free(runner)) + self.assertFalse(runner.failures) + + def test_a_refused_fix_leaves_the_failure_standing(self): + driver = _driver() + runner = self._runner([self.TENU]) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="n" + ): + self.assertFalse(driver._l2tp_port_is_free(runner)) + self.assertTrue(runner.failures) + + def test_a_fix_that_does_not_free_the_port_still_fails(self): + """Accepté, appliqué, et le port reste tenu : on ne prétend pas que + c'est réglé.""" + driver = _driver() + runner = self._runner([self.TENU, self.TENU]) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ): + self.assertFalse(driver._l2tp_port_is_free(runner)) + self.assertTrue(runner.failures) + + def test_a_leftover_of_the_same_profile_offers_our_own_down(self): + """Un montage précédent du MÊME profil n'a rien à faire décider : + le remède est notre propre « down », et on le propose.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + args = ( + "12314 xl2tpd -c /dev/shm/erplibre-vpn/acme/xl2tpd.conf" + " -C /run/erplibre-vpn/acme.control" + ) + appels = {"ss": 0} + + def cmd(label, command, *a, **k): + if "ss -lunp" in command: + appels["ss"] += 1 + # Tenu au premier regard, libre après le « down ». + return 0, self.TENU if appels["ss"] == 1 else "" + if "ps -o pid=" in command: + return 0, args + return 0, "" + + runner.cmd = cmd + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ): + self.assertTrue(driver._l2tp_port_is_free(runner)) + self.assertFalse(runner.failures) + + def test_a_sibling_tunnel_is_named_not_killed(self): + """Le port peut être tenu par un de NOS tunnels, sur un autre + profil. Le tuer couperait le sien : on le nomme et on s'arrête.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + args = ( + "12314 xl2tpd -c" + " /dev/shm/erplibre-vpn/autre-client/xl2tpd.conf" + " -C /run/erplibre-vpn/autre-client.control" + ) + + def cmd(label, command, *a, **k): + if "ss -lunp" in command: + return 0, self.TENU + if "ps -o pid=" in command: + return 0, args + return 0, "" + + runner.cmd = cmd + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("builtins.input") as demande: + self.assertFalse(driver._l2tp_port_is_free(runner)) + demande.assert_not_called() + self.assertTrue( + [m for m in runner.failures if "autre-client" in m], + runner.failures, + ) + + def test_another_daemon_is_named_not_stopped(self): + """Arrêter à l'aveugle un service qu'on ne connaît pas serait pire + que le blocage : on le nomme, et on s'arrête là.""" + driver = _driver() + autre = ( + "UNCONN 0 0 0.0.0.0:1701 0.0.0.0:*" + ' users:(("un-autre-truc",pid=999,fd=3))' + ) + runner = self._runner([autre]) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ) as demande: + self.assertFalse(driver._l2tp_port_is_free(runner)) + demande.assert_not_called() + self.assertTrue(runner.failures) + + def test_the_question_is_a_whole_line_not_an_input_prompt(self): + """Un lanceur qui relaie notre sortie en la lisant ligne par ligne + garde une ligne partielle dans son tampon. Une question passée en + prompt d'`input` reste donc invisible jusqu'à ce que la réponse ait + déjà été donnée, puis ressort collée au texte suivant — et on + répond à une question qu'on n'a pas lue.""" + runner = Runner(dry_run=False, quiet=True) + runner.cmd = lambda *a, **k: (0, "") + sortie = io.StringIO() + with patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ) as demande, redirect_stdout(sortie): + runner.propose("essai", "systemctl stop x", question="Arrêter ?") + # Rien ne doit être confié au prompt d'`input` : c'est lui qui ne + # porte pas de fin de ligne. + demande.assert_called_once_with() + self.assertIn("Arrêter ? [o/N]\n", sortie.getvalue()) + + def test_without_a_terminal_nothing_is_applied(self): + """Un outil qui arrête un service parce que PERSONNE n'a répondu + serait pire que le problème qu'il résout.""" + runner = Runner(dry_run=False, quiet=True) + applique = [] + runner.cmd = lambda *a, **k: (applique.append(a) or (0, "")) + with patch("sys.stdin.isatty", return_value=False): + self.assertFalse( + runner.propose("essai", "systemctl stop quelque-chose") + ) + self.assertEqual(applique, []) + + def test_dry_run_only_announces_the_fix(self): + runner = Runner(dry_run=True, quiet=True) + with patch("builtins.input") as demande: + self.assertFalse(runner.propose("essai", "systemctl stop x")) + demande.assert_not_called() + + +class TheInstallerShipsWhatTheNegotiationNeeds(unittest.TestCase): + def test_debian_gets_the_plugin_that_provides_3des(self): + """Sans le greffon openssl, charon ANNONCE 3DES, le concentrateur le + choisit — c'est souvent le seul qu'il connaisse — et la négociation + meurt sur « ENCRYPTION_ALGORITHM 3DES_CBC not supported! ».""" + with open("script/install/install_vpn.sh") as fh: + script = fh.read() + ligne = [ + line + for line in script.splitlines() + if "l2tp_ipsec:debian" in line or "strongswan-starter" in line + ] + self.assertTrue( + any("libstrongswan-standard-plugins" in line for line in ligne), + ligne, + ) + + +class SecretsStayOffTheCommandLine(unittest.TestCase): + def test_no_secret_reaches_a_command_line(self): + driver = _driver() + runner = _dry_runner(driver) + driver.up(runner) + self.assertTrue(runner.ops, "le plan est vide") + for op in runner.ops: + if op["kind"] != "cmd": + continue + for secret in (PSK, PPP_PASSWORD): + self.assertNotIn( + secret, + op["cmd"], + f"secret dans une commande : {op['label']}", + ) + + def test_secrets_travel_only_on_marked_standard_input(self): + """Un secret peut passer par l'entrée standard — mais alors + l'opération DOIT être marquée, sinon l'affichage le montrerait.""" + driver = _driver() + runner = _dry_runner(driver) + driver.up(runner) + for op in runner.ops: + content = ( + op.get("stdin") if op["kind"] == "cmd" else op.get("content") + ) + if not content: + continue + leaks = [s for s in (PSK, PPP_PASSWORD) if s in content] + if leaks: + marked = op.get("secret_stdin") or op.get("secret") + self.assertTrue( + marked, + f"secret non marqué dans {op.get('label') or op.get('path')}", + ) + + def test_the_files_that_hold_secrets_are_owner_only(self): + driver = _driver() + runner = _dry_runner(driver) + driver.up(runner) + writes = [op for op in runner.ops if op["kind"] == "write"] + secret_writes = [op for op in writes if op["secret"]] + self.assertEqual(len(secret_writes), 2, [w["path"] for w in writes]) + for op in secret_writes: + self.assertEqual(op["mode"], "0600", op["path"]) + self.assertTrue( + op["path"].startswith("/dev/shm/"), + f"{op['path']} n'est pas dans un tmpfs", + ) + + def test_dry_run_output_shows_no_secret(self): + """Ce que « Afficher la configuration rendue » imprime doit être + montrable à l'écran de quelqu'un d'autre.""" + driver = _driver() + runner = Runner( + dry_run=True, + redactor=lambda text: redact(text, driver.secret_values()), + ) + buffer = io.StringIO() + with redirect_stdout(buffer): + driver.up(runner) + printed = buffer.getvalue() + self.assertTrue(printed.strip()) + for secret in (PSK, PPP_PASSWORD): + self.assertNotIn(secret, printed) + self.assertIn("********", printed) + + def test_redact_masks_the_longest_first(self): + """Masquer « ab » avant « abcdef » laisserait « cdef » en clair.""" + masked = redact("abcdef et ab", {"a": "ab", "b": "abcdef"}) + self.assertNotIn("abcdef", masked) + self.assertEqual(masked, "******** et ********") + + +class DownOrder(unittest.TestCase): + def test_secrets_include_is_removed_before_the_file(self): + """L'ordre compte : un `include` qui pointe vers un fichier + disparu fait échouer TOUT rechargement de charon, y compris celui + d'une autre connexion.""" + driver = _driver() + runner = _dry_runner(driver) + driver.down(runner) + commands = [ + op.get("cmd", "") + op.get("path", "") for op in runner.ops + ] + read_secrets = next( + i for i, c in enumerate(commands) if "cat /etc/ipsec.secrets" in c + ) + remove_dir = next( + i + for i, c in enumerate(commands) + if "rm -rf -- /dev/shm/erplibre-vpn/acme" in c + ) + self.assertLess(read_secrets, remove_dir) + + def test_down_erases_the_secret_directory(self): + driver = _driver() + runner = _dry_runner(driver) + driver.down(runner) + joined = " ".join(op.get("cmd", "") for op in runner.ops) + self.assertIn("rm -rf -- /dev/shm/erplibre-vpn/acme", joined) + + +class MarkedBlocks(unittest.TestCase): + """`replace_block` décide de ce qu'on écrit dans /etc/ipsec.conf. + Elle est pure : elle se juge sans /etc.""" + + def test_appends_when_absent(self): + result = replace_block("config setup\n", "acme", "conn acme\n x=1") + self.assertIn("config setup", result) + self.assertIn(">>> erplibre-vpn acme", result) + self.assertIn("conn acme", result) + self.assertIn("<<< erplibre-vpn acme", result) + + def test_replaces_in_place_and_keeps_the_rest(self): + first = replace_block("avant\n", "acme", "un") + second = replace_block(first, "acme", "deux") + self.assertIn("avant", second) + self.assertIn("deux", second) + self.assertNotIn("un\n", second) + self.assertEqual(second.count(">>> erplibre-vpn acme"), 1) + + def test_removes_when_body_is_empty(self): + with_block = replace_block("avant\nautre\n", "acme", "un") + without = replace_block(with_block, "acme", "") + self.assertNotIn("erplibre-vpn acme", without) + self.assertIn("avant", without) + self.assertIn("autre", without) + + def test_two_profiles_coexist(self): + text = replace_block("", "acme", "un") + text = replace_block(text, "beta", "deux") + self.assertIn("erplibre-vpn acme", text) + self.assertIn("erplibre-vpn beta", text) + text = replace_block(text, "acme", "") + self.assertNotIn("erplibre-vpn acme", text) + self.assertIn("erplibre-vpn beta", text) + + +class WhatTheKernelGivesAndWhatOnlyARebootGivesBack(unittest.TestCase): + """Un module inaccessible se manifeste trois étages plus haut : charon + démarre, abandonne à l'initialisation, et l'attente de la connexion + accuse un bloc de configuration parfaitement formé. La vérification la + plus basse est donc celle qui doit parler la première.""" + + PRESENT = ("XFRM d'essai", lambda: True) + ABSENT = ("XFRM d'essai", lambda: False) + + def setUp(self): + """La question posée s'affiche même sur un exécuteur silencieux — + c'est voulu, on s'apprête à bloquer dessus. Elle n'a rien à faire + dans la sortie du lanceur de tests pour autant.""" + silence = redirect_stdout(io.StringIO()) + silence.__enter__() + self.addCleanup(silence.__exit__, None, None, None) + + def _kernel(self, driver, features, stale): + driver.kernel_features = features + return patch( + "script.vpn.drivers.base.stale_kernel", return_value=stale + ) + + def test_netlink_route_answers_on_any_linux(self): + """La sonde n'exige aucun droit : elle ouvre et referme.""" + self.assertTrue(base.netlink_family_available(0)) + + def test_a_refused_family_is_absent_not_an_exception(self): + with patch("socket.socket", side_effect=OSError(93, "nope")): + self.assertFalse(base.netlink_family_available(base.NETLINK_XFRM)) + + def test_a_kernel_without_any_module_tree_is_not_stale(self): + """Un noyau compilé sans modules n'a rien à redémarrer : le + déclarer périmé enverrait redémarrer pour rien.""" + with patch("os.path.isdir", return_value=False), patch( + "os.listdir", return_value=[] + ): + self.assertEqual(base.stale_kernel(), "") + + def test_the_running_tree_gone_while_another_stands_is_stale(self): + with patch("os.path.isdir", return_value=False), patch( + "os.listdir", return_value=["1.2.3-neuf"] + ), patch("platform.release", return_value="1.2.2-vieux"): + self.assertEqual(base.stale_kernel(), "1.2.2-vieux") + + def test_a_present_tree_is_never_stale(self): + with patch("os.path.isdir", return_value=True): + self.assertEqual(base.stale_kernel(), "") + + def test_missing_and_stale_names_the_reboot(self): + driver = _driver() + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"): + (label, ok, detail) = driver.check_kernel()[0] + self.assertTrue(driver.needs_reboot()) + self.assertEqual((label, ok), ("noyau", False)) + self.assertIn("1.2.2-vieux", detail) + self.assertIn("redémarrer", detail) + + def test_missing_on_a_current_kernel_offers_no_reboot(self): + """Redémarrer ne fait pas apparaître ce que le noyau n'a pas.""" + driver = _driver() + with self._kernel(driver, (self.ABSENT,), ""): + (_, ok, detail) = driver.check_kernel()[0] + self.assertFalse(driver.needs_reboot()) + self.assertFalse(ok) + self.assertNotIn("redémarrer", detail) + + def test_a_stale_tree_alone_is_a_warning_not_a_failure(self): + """La capacité répond : un tunnel déjà monté fonctionne, et un + « ✗ » ferait mentir le diagnostic.""" + driver = _driver() + with self._kernel(driver, (self.PRESENT,), "1.2.2-vieux"): + (_, ok, detail) = driver.check_kernel()[0] + self.assertFalse(driver.needs_reboot()) + self.assertIs(ok, True) + self.assertIn("1.2.2-vieux", detail) + + def test_a_healthy_kernel_says_so_once(self): + driver = _driver() + with self._kernel(driver, (self.PRESENT,), ""): + checks = driver.check_kernel() + self.assertEqual(len(checks), 1) + self.assertIs(checks[0][1], True) + + def test_a_driver_that_asks_nothing_of_the_kernel_stays_silent(self): + driver = _driver() + with self._kernel(driver, (), ""): + self.assertEqual(driver.check_kernel(), []) + + def test_the_reboot_is_offered_and_applied_when_accepted(self): + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + lancees = [] + runner.cmd = lambda label, command, **k: ( + lancees.append(command) or (0, "") + ) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "sys.stdin.isatty", return_value=True + ), patch("builtins.input", return_value="o"): + self.assertTrue(driver.propose_reboot(runner)) + self.assertEqual(lancees, ["systemctl reboot"]) + + def test_nothing_reboots_without_a_terminal_to_answer(self): + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + lancees = [] + runner.cmd = lambda label, command, **k: ( + lancees.append(command) or (0, "") + ) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "sys.stdin.isatty", return_value=False + ): + self.assertFalse(driver.propose_reboot(runner)) + self.assertEqual(lancees, []) + + def test_nothing_reboots_on_a_dry_run(self): + driver = _driver() + runner = _dry_runner(driver) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "builtins.input" + ) as demande: + self.assertFalse(driver.propose_reboot(runner)) + demande.assert_not_called() + + def test_an_accepted_reboot_still_stops_the_mount(self): + """La machine met quelques secondes à s'arrêter. Monter un tunnel + dans l'intervalle serait le monter sur le noyau qu'on quitte.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + runner.cmd = lambda label, command, **k: (0, "") + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "sys.stdin.isatty", return_value=True + ), patch("builtins.input", return_value="o"): + self.assertFalse(driver.ensure_ready(runner)) + self.assertTrue(runner.failures) + + def test_a_faulty_kernel_stops_the_mount_before_it_writes(self): + """Rien ne doit atterrir dans /etc quand l'étage du dessous est à + terre : les blocs posés là survivent au redémarrage et la + configuration de quelqu'un a été touchée pour rien.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "sys.stdin.isatty", return_value=False + ): + self.assertFalse(driver.up(runner)) + touches = [ + op + for op in runner.ops + if "/etc" in op.get("cmd", "") + op.get("path", "") + ] + self.assertEqual(touches, []) + self.assertTrue(runner.failures) + + def test_the_kernel_is_judged_before_the_packages(self): + """Du plus bas au plus haut : la première ligne fausse doit être la + CAUSE, pas une conséquence.""" + driver = _driver() + runner = Runner(dry_run=True, quiet=True) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"): + labels = [label for label, _, _ in driver.standard_status(runner)] + self.assertEqual(labels[0], "noyau") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_vpn_vault.py b/test/test_vpn_vault.py new file mode 100644 index 0000000..159b6ee --- /dev/null +++ b/test/test_vpn_vault.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le coffre KeePassXC : aller-retour d'un PSK, et permissions. + +Un vrai fichier .kdbx est créé dans un répertoire temporaire — pykeepass fait +tout hors ligne, donc ces tests ne demandent ni root, ni réseau, ni coffre de +l'utilisateur. Ils sont ignorés (et le disent) si pykeepass n'est pas +installé, plutôt que de passer en silence. +""" + +import io +import json +import os +import stat +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.config.config_file import ConfigFile +from script.todo.kdbx_manager import KdbxManager +from script.vpn.vault import ( + VaultError, + VpnVault, + secrets_from_env, + secrets_to_env, +) + +try: + from pykeepass import create_database +except ModuleNotFoundError: + create_database = None + +MASTER = "coffre-de-test" +PSK = "cl3-Pr3-P4rt4g33!" +PPP_PASSWORD = "m0tD3P4ss3-PPP" +TITLE = "ERPLibre VPN / acme" + + +@unittest.skipUnless(create_database, "pykeepass n'est pas installé") +class VpnVaultRoundTrip(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.kdbx_path = os.path.join(self.tmp.name, "secrets.kdbx") + create_database(self.kdbx_path, password=MASTER) + # 0600 d'emblée : sinon chaque test verrait le coffre resserré à + # l'ouverture et l'annoncerait, ce qui noierait la sortie de la + # suite sous un avertissement qui n'est le sujet que d'un test. + os.chmod(self.kdbx_path, 0o600) + + # Le mot de passe maître est mis dans la configuration UNIQUEMENT + # ici : c'est ce qui permet au test de tourner sans saisie. Le CLI, + # lui, signale cette situation à l'utilisateur. + self.base = os.path.join(self.tmp.name, "todo.json") + with open(self.base, "w") as fh: + json.dump( + {"kdbx": {"path": self.kdbx_path, "password": MASTER}}, fh + ) + self.private = os.path.join(self.tmp.name, "private.json") + self.patches = [ + patch("script.config.config_file.CONFIG_FILE", self.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", + self.private, + ), + ] + for item in self.patches: + item.start() + config = ConfigFile() + self.vault = VpnVault(config, KdbxManager(config)) + + def tearDown(self): + for item in self.patches: + item.stop() + self.tmp.cleanup() + + def test_write_then_read(self): + self.vault.write( + TITLE, + {"username": "ACME\\user", "password": "ppp", "psk": PSK}, + ) + values = self.vault.read(TITLE, fields=("psk", "password")) + self.assertEqual(values["psk"], PSK) + self.assertEqual(values["password"], "ppp") + self.assertEqual(values["username"], "ACME\\user") + + def test_password_is_the_native_field_not_a_custom_one(self): + """`password` est un champ NATIF de KeePassXC. Le chercher parmi les + propriétés personnalisées le rendrait vide, alors qu'un pilote a le + droit de le déclarer dans ses `secret_fields`.""" + self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) + entry = self.vault._open().find_entries_by_title(TITLE, first=True) + self.assertEqual(entry.password, "ppp") + self.assertIsNone(entry.get_custom_property("password")) + + def test_psk_is_a_protected_property(self): + """Protégée = chiffrée en mémoire et masquée dans KeePassXC, comme + le champ mot de passe.""" + self.vault.write(TITLE, {"psk": PSK}) + entry = self.vault._open().find_entries_by_title(TITLE, first=True) + self.assertTrue(entry.is_custom_property_protected("psk")) + + def test_entry_lands_in_its_own_group(self): + self.vault.write(TITLE, {"psk": PSK}) + entry = self.vault._open().find_entries_by_title(TITLE, first=True) + self.assertEqual(entry.group.name, "ERPLibre VPN") + + def test_update_keeps_the_untouched_fields(self): + """Une réponse vide dans le menu ne doit pas effacer l'autre + secret : `write` ne touche que les clés qu'on lui donne.""" + self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) + self.vault.write(TITLE, {"password": "nouveau"}) + values = self.vault.read(TITLE, fields=("psk", "password")) + self.assertEqual(values["password"], "nouveau") + self.assertEqual(values["psk"], PSK) + + def test_missing_entry_reads_as_empty(self): + """« Pas encore de secret » est un état normal, pas une panne.""" + values = self.vault.read("ERPLibre VPN / inconnu", fields=("psk",)) + self.assertEqual(values, {"username": "", "password": "", "psk": ""}) + self.assertFalse(self.vault.exists("ERPLibre VPN / inconnu")) + + def test_the_vault_stays_owner_only_after_a_write(self): + """LE test de non-régression. + + `PyKeePass.save()` réécrit le fichier et lui redonne le mode du + umask — 0664 sur Ubuntu. Un chmod fait une fois à la création ne + survivait pas au premier enregistrement : le coffre devenait + lisible par toute la machine sans que personne ne touche à rien. + """ + os.chmod(self.kdbx_path, 0o600) + self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) + mode = stat.S_IMODE(os.stat(self.kdbx_path).st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + + def test_a_loose_vault_is_tightened_and_it_says_so(self): + """Trouvé desserré, il ne l'est pas resté — et ce n'est pas nous qui + l'avions laissé ainsi, donc on le dit.""" + os.chmod(self.kdbx_path, 0o664) + buffer = io.StringIO() + with redirect_stdout(buffer): + self.vault.read(TITLE, fields=("psk",)) + mode = stat.S_IMODE(os.stat(self.kdbx_path).st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + self.assertIn("0600", buffer.getvalue()) + + def test_protect_leaves_an_already_tight_vault_alone(self): + os.chmod(self.kdbx_path, 0o600) + self.assertFalse(self.vault.protect()) + + def test_stored_master_password_is_reported(self): + self.assertTrue(self.vault.master_password_is_stored()) + + +@unittest.skipUnless(create_database, "pykeepass n'est pas installé") +class VpnVaultBootstrap(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = os.path.join(self.tmp.name, "todo.json") + with open(self.base, "w") as fh: + json.dump({"kdbx": {"path": "", "password": ""}}, fh) + self.private = os.path.join(self.tmp.name, "private.json") + self.patches = [ + patch("script.config.config_file.CONFIG_FILE", self.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", + self.private, + ), + ] + for item in self.patches: + item.start() + config = ConfigFile() + self.vault = VpnVault(config, KdbxManager(config)) + self.target = os.path.join(self.tmp.name, "nouveau.kdbx") + + def tearDown(self): + for item in self.patches: + item.stop() + self.tmp.cleanup() + + def _answers(self, *values): + answers = iter(values) + return lambda _prompt: next(answers) + + def test_creates_the_vault_and_remembers_where(self): + with patch("getpass.getpass", return_value=MASTER): + path = self.vault.ensure_vault(ask=self._answers(self.target, "o")) + self.assertEqual(path, self.target) + self.assertTrue(os.path.exists(self.target)) + # Le chemin est retenu dans le fichier PRIVÉ, le seul gitignored. + with open(self.private) as fh: + self.assertEqual(json.load(fh)["kdbx"]["path"], self.target) + + def test_new_vault_is_owner_only(self): + with patch("getpass.getpass", return_value=MASTER): + self.vault.ensure_vault(ask=self._answers(self.target, "o")) + mode = stat.S_IMODE(os.stat(self.target).st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + + def test_refusing_creates_nothing(self): + path = self.vault.ensure_vault(ask=self._answers(self.target, "n")) + self.assertEqual(path, "") + self.assertFalse(os.path.exists(self.target)) + + def test_mismatched_confirmation_creates_nothing(self): + with patch("getpass.getpass", side_effect=[MASTER, "autre"]): + with self.assertRaises(VaultError): + self.vault.ensure_vault(ask=self._answers(self.target, "o")) + self.assertFalse(os.path.exists(self.target)) + + def test_reading_without_a_configured_vault_says_so(self): + """Sans chemin, `KdbxManager` ouvrirait un sélecteur graphique et, + sur un serveur sans tkinter, journaliserait une erreur au lieu de + dire ce qui manque.""" + with self.assertRaises(VaultError) as caught: + self.vault.read(TITLE, fields=("psk",)) + self.assertIn("coffre", str(caught.exception).lower()) + + +class SecretsHandedOverByEnvironment(unittest.TestCase): + """Le menu tient déjà le coffre ouvert quand il lance `vpn.py`. + + Sans ce passage, le mot de passe maître était redemandé DEUX fois par + connexion — un essai à blanc précède le montage. Par l'environnement et + non par un argument : /proc//environ n'est lisible que par le + propriétaire, /proc//cmdline par tout le monde. + """ + + def test_round_trip(self): + env = secrets_to_env({"psk": PSK, "password": PPP_PASSWORD}) + with patch.dict(os.environ, env, clear=False): + got = secrets_from_env(("psk", "password")) + self.assertEqual(got, {"psk": PSK, "password": PPP_PASSWORD}) + + def test_without_the_marker_nothing_is_claimed(self): + """Un champ vide serait indistinguable d'un champ absent : le + marqueur tranche, et on rouvre le coffre plutôt que de deviner.""" + env = secrets_to_env({"psk": PSK}) + del env["EL_VPN_SECRETS_PROVIDED"] + with patch.dict(os.environ, env, clear=False): + self.assertIsNone(secrets_from_env(("psk",))) + + def test_an_empty_secret_survives_the_trip(self): + env = secrets_to_env({"psk": PSK, "wg_preshared_key": ""}) + with patch.dict(os.environ, env, clear=False): + got = secrets_from_env(("psk", "wg_preshared_key")) + self.assertEqual(got["wg_preshared_key"], "") + self.assertEqual(got["psk"], PSK) + + def test_no_secret_in_a_variable_name(self): + """Les noms de variables partent dans l'environnement d'un + sous-processus : ils ne doivent porter que la CLÉ, jamais la + valeur.""" + env = secrets_to_env({"psk": PSK}) + for name in env: + self.assertNotIn(PSK, name) + + +@unittest.skipUnless(create_database, "pykeepass n'est pas installé") +class VaultToTunnel(unittest.TestCase): + """La couture complète : coffre → profil → plan de montage. + + Les autres tests injectent les secrets à la main. Celui-ci les fait + VRAIMENT sortir d'un .kdbx, comme le CLI, et vérifie qu'ils n'ont pas + fui en chemin. C'est le seul qui juge l'ensemble. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.kdbx_path = os.path.join(self.tmp.name, "secrets.kdbx") + create_database(self.kdbx_path, password=MASTER) + # 0600 d'emblée : sinon chaque test verrait le coffre resserré à + # l'ouverture et l'annoncerait, ce qui noierait la sortie de la + # suite sous un avertissement qui n'est le sujet que d'un test. + os.chmod(self.kdbx_path, 0o600) + self.base = os.path.join(self.tmp.name, "todo.json") + with open(self.base, "w") as fh: + json.dump( + { + "kdbx": {"path": self.kdbx_path, "password": MASTER}, + "vpn": [], + }, + fh, + ) + self.patches = [ + patch("script.config.config_file.CONFIG_FILE", self.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_secret_goes_from_the_vault_to_the_plan_without_leaking(self): + from script.vpn import profiles + from script.vpn.drivers.l2tp_ipsec import L2tpIpsecDriver + from script.vpn.runner import Runner + from script.vpn.vault import redact + + config = ConfigFile() + vault = VpnVault(config, KdbxManager(config)) + profile = profiles.save( + { + "name": "acme", + "driver": "l2tp_ipsec", + "server": "127.0.0.1", + "ppp_user": "ACME\\user", + "routes": ["10.20.0.0/16"], + } + ) + vault.write( + profiles.secret_title("acme"), + { + "username": profile["ppp_user"], + "password": PPP_PASSWORD, + "psk": PSK, + }, + ) + + secrets = vault.read( + profiles.secret_title("acme"), fields=("psk", "password") + ) + self.assertEqual(secrets["psk"], PSK) + self.assertEqual(secrets["password"], PPP_PASSWORD) + + driver = L2tpIpsecDriver(profiles.load("acme"), secrets) + runner = Runner( + dry_run=True, + quiet=True, + redactor=lambda text: redact(text, driver.secret_values()), + ) + self.assertTrue(driver.up(runner)) + + # Le PSK atteint le fichier de secrets, en hexadécimal, et rien + # d'autre. + secret_writes = [ + op for op in runner.ops if op["kind"] == "write" and op["secret"] + ] + hex_psk = PSK.encode("utf-8").hex() + self.assertTrue( + any(hex_psk in op["content"] for op in secret_writes), + "le PSK n'a pas atteint le fichier de secrets", + ) + for op in runner.ops: + if op["kind"] == "cmd": + self.assertNotIn(PSK, op["cmd"]) + self.assertNotIn(PPP_PASSWORD, op["cmd"]) + # Le PSK en clair n'apparaît dans AUCUN contenu écrit : c'est sa + # forme hexadécimale qui voyage. + for op in secret_writes: + self.assertNotIn(PSK, op["content"]) + + +if __name__ == "__main__": + unittest.main() From 315e45e5f0a11e1d2556ac85264798c1b05e6e6d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 03:43:02 +0000 Subject: [PATCH 6/7] =?UTF-8?q?[ADD]=20menu=20vpn=20:=20cr=C3=A9er=20un=20?= =?UTF-8?q?profil,=20d=C3=A9poser=20les=20secrets,=20monter=20le=20tunnel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `vpn.py` savait tout faire en ligne de commande, mais il fallait écrire le profil JSON à la main et déposer les secrets dans KeePassXC soi-même. Le menu pose les questions que le pilote choisi déclare, et lui seul : ajouter une technologie n'ajoute pas une ligne ici. Le coffre est manipulé EN PROCESSUS — le mot de passe maître est déjà en mémoire, le redemander à un sous-processus serait une saisie de plus à chaque geste. Montage, démontage et diagnostic passent au contraire par `vpn.py` en sous-processus : ils durent, ils parlent, et la sortie en direct est ce qui rend une montée de tunnel suivable. Vérifié : 37 tests sur le menu, aucune saisie réelle. --- EN --- `vpn.py` could already do everything from the command line, but the JSON profile had to be written by hand and the secrets filed into KeePassXC by hand. The menu asks the questions the chosen driver declares, and only those: adding a technology adds no line here. The vault is handled IN PROCESS — the master password is already in memory, and asking a subprocess for it again would be one more entry at every step. Raising, tearing down and diagnosing go through `vpn.py` as a subprocess instead: they last, they talk, and live output is what makes a tunnel coming up followable. Checked: 37 tests on the menu, no real input. Assisted-by: Claude Opus 5 --- script/todo/todo.json | 1 + script/todo/todo.py | 18 + script/todo/todo_example.json | 40 +++ script/todo/vpn_menu.py | 513 +++++++++++++++++++++++++++++ test/test_vpn_menu.py | 601 ++++++++++++++++++++++++++++++++++ 5 files changed, 1173 insertions(+) create mode 100644 script/todo/vpn_menu.py create mode 100644 test/test_vpn_menu.py diff --git a/script/todo/todo.json b/script/todo/todo.json index acf06bc..4bc76d8 100644 --- a/script/todo/todo.json +++ b/script/todo/todo.json @@ -62,6 +62,7 @@ "bash_command": "PATCH=\"/tmp/patch_$(date +%Y%m%d_%H%M%S).patch\"; git -C \"$(pwd)\" diff HEAD > \"$PATCH\" && printf \"\\n✓ Patch created: %s\\n\\n=== Guide to apply the patch ===\\n Check compatibility : git apply --check %s\\n Apply (git) : git apply %s\\n Apply (patch) : patch -p1 < %s\\n Revert (git) : git apply -R %s\\n\" \"$PATCH\" \"$PATCH\" \"$PATCH\" \"$PATCH\" \"$PATCH\"" } ], + "vpn": [], "qemu_from_makefile": [ { "prompt_description_key": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)", diff --git a/script/todo/todo.py b/script/todo/todo.py index e579ad1..eeed290 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -37,6 +37,8 @@ from script.todo.qemu_install import QemuInstallMixin from script.todo.qemu_manage import QemuManageMixin from script.todo.qemu_menu import QemuMenuMixin from script.todo.qemu_recover import QemuRecoverMixin +from script.todo.vpn_menu import VpnMenuMixin +from script.todo.kdbx_manager import KdbxManager from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t from script.todo.version_manager import get_odoo_version @@ -100,6 +102,7 @@ class TODO( QemuAccessMixin, ProxmoxMenuMixin, LongTestMenuMixin, + VpnMenuMixin, ): def __init__(self): self.dir_path = None @@ -971,6 +974,12 @@ class TODO( "Deploy - Install NTFY notification server" ) }, + {"section": t("VPN & tunnels")}, + { + "prompt_description": t( + "VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)" + ) + }, ] help_info = self.fill_help_info(choices) @@ -993,6 +1002,8 @@ class TODO( self.prompt_execute_proxmox() elif status == "7": self._deploy_ntfy_server() + elif status == "8": + self.prompt_execute_vpn() else: print(t("Command not found !")) @@ -4942,6 +4953,11 @@ class TODO( "Network performance request per second" ) }, + { + "prompt_description": t( + "VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)" + ) + }, ] help_info = self.fill_help_info(choices) @@ -4954,6 +4970,8 @@ class TODO( self.generate_network_port_forwarding() elif status == "2": self.generate_network_performance_test() + elif status == "3": + self.prompt_execute_vpn() else: print(t("Command not found !")) diff --git a/script/todo/todo_example.json b/script/todo/todo_example.json index c56739c..243f2a7 100644 --- a/script/todo/todo_example.json +++ b/script/todo/todo_example.json @@ -8,6 +8,46 @@ "kdbx_key": "OpenAI api" } }, + "vpn": [ + { + "name": "client-acme", + "driver": "l2tp_ipsec", + "server": "vpn.acme.example", + "ppp_user": "ACME\\user", + "routes": [ + "10.20.0.0/16" + ], + "default_route": false, + "mtu": 1280, + "use_peer_dns": true, + "dns_search": "acme.example", + "probe": "10.20.0.1" + }, + { + "name": "client-beta", + "driver": "wireguard", + "server": "vpn.beta.example", + "port": 51820, + "wg_address": "10.7.0.2/32", + "wg_peer_key": "SGVsbG9Xb3JsZEV4YW1wbGVLZXkxMjM0NTY3ODkwYWI=", + "routes": [ + "10.7.0.0/24", + "192.168.80.0/24" + ], + "probe": "10.7.0.1" + }, + { + "name": "bastion-gamma", + "driver": "sshuttle", + "server": "user@bastion.gamma.example", + "port": 22, + "ssh_dns": true, + "routes": [ + "10.90.0.0/16" + ], + "probe": "10.90.0.1" + } + ], "instance": [ { "prompt_description": "Test - Instance de base minimale", diff --git a/script/todo/vpn_menu.py b/script/todo/vpn_menu.py new file mode 100644 index 0000000..2457e39 --- /dev/null +++ b/script/todo/vpn_menu.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le menu VPN : profils, secrets, montée, diagnostic. + +La frontière avec `script/vpn/` est nette : ici on DEMANDE (quel profil, +quelle adresse, quel PSK) et on affiche ; là-bas on décide et on exécute. Ce +fichier ne connaît ni ipsec.conf, ni xl2tpd, ni aucun chemin système. + +Deux chemins d'exécution, pour une raison : + +· les profils et les secrets sont manipulés EN PROCESSUS, par les modules + `script.vpn.profiles` et `script.vpn.vault` — le mot de passe maître du + coffre est déjà en mémoire ici, le redemander à un sous-processus serait + une saisie de plus à chaque geste ; +· le montage, la descente et le diagnostic passent par `script/vpn/vpn.py` + en sous-processus — ils durent, ils parlent, et ils appellent sudo. La + sortie en direct est ce qui rend un « ipsec up » suivable. +""" + +import getpass + +import click + +from script.todo.todo_i18n import t +from script.vpn import profiles +from script.vpn.drivers import DRIVERS, get_driver +from script.vpn.vault import VaultError, VpnVault, secrets_to_env + +# `-u` : sans lui, la sortie du script est mise en tampon par blocs dès +# qu'elle est redirigée, et une montée de tunnel de trente secondes +# n'afficherait rien avant la fin. +VPN_CLI = "./.venv.erplibre/bin/python -u ./script/vpn/vpn.py" + +# Nommé pour que la clé i18n tienne sur une ligne lisible — c'est la même +# chaîne que celle du CLI, et elle est longue parce qu'elle doit dire quoi +# faire, pas seulement que quelque chose ne va pas. +# Ce qu'on dit à qui n'a reçu du site qu'une passerelle et des +# identifiants : le profil est utilisable, et le premier montage dira +# lui-même quel réseau ajouter. +NO_ROUTE_NOTE = ( + "No network routed yet: this tunnel will only reach the remote host." + " Connect once — the address you get tells you which network to add." +) + +# La légende de l'étoile posée sur les technologies non éprouvées. Elle dit +# ce qui manque — la confrontation au terrain — et non que le code serait +# douteux : les tests unitaires, eux, sont là. +UNPROVEN_NOTE = "never mounted against a real server: only unit tests cover it" + +MASTER_PASSWORD_WARNING = ( + "The vault MASTER password is stored in the configuration in clear" + " text. Remove it and type it on demand." +) + + +# Les technologies se choisissent par LETTRE. Le menu qui précède numérote +# ses entrées ; une seconde liste numérotée juste après invite à retaper un +# numéro de menu — et c'est exactement ce qui s'est produit. La lettre dit +# « autre question ». +DRIVER_LETTERS = "abcdefghijklmnopqrstuvwxyz" + + +def match_driver(answer, names): + """Le pilote désigné par `answer`. + + Rend le nom du pilote, "" si rien ne correspond, ou la LISTE des + candidats quand c'est ambigu — le dire vaut mieux qu'en choisir un. + + Trois formes, dans cet ordre : la lettre affichée ; le rang, parce que + quelqu'un tapera un chiffre et qu'il a raison de le faire vu le menu qui + précède ; et un début de libellé, parce que devant « L2TP/IPsec PSK » on + tape « L ». « open » désigne deux pilotes : celui-là est refusé en le + nommant. + """ + answer = (answer or "").strip().lower() + if not answer: + return "" + if len(answer) == 1 and answer in DRIVER_LETTERS: + index = DRIVER_LETTERS.index(answer) + if index < len(names): + return names[index] + if answer.isdigit(): + index = int(answer) - 1 + return names[index] if 0 <= index < len(names) else "" + matches = [ + name + for name in names + if DRIVERS[name].label.lower().startswith(answer) + or name.startswith(answer) + ] + if len(matches) == 1: + return matches[0] + return matches or "" + + +class VpnMenuMixin: + # ------------------------------------------------------------------ + # Menu + # ------------------------------------------------------------------ + def prompt_execute_vpn(self): + print(f"🔐 {t('VPN tunnels: connect, profiles, vault secrets')}") + choices = [ + {"section": t("Connection")}, + {"prompt_description": t("VPN - Connect a profile")}, + {"prompt_description": t("VPN - Disconnect a profile")}, + {"prompt_description": t("VPN - Status and diagnosis")}, + {"section": t("Profiles & secrets")}, + {"prompt_description": t("VPN - Add or edit a profile")}, + {"prompt_description": t("VPN - Store secrets in the vault")}, + { + "prompt_description": t( + "VPN - Show the rendered configuration (dry-run)" + ) + }, + {"prompt_description": t("VPN - Delete a profile")}, + {"section": t("Host")}, + {"prompt_description": t("VPN - Install the client packages")}, + {"prompt_description": t("VPN - What can this machine do?")}, + ] + help_info = self.fill_help_info(choices) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._vpn_connect() + elif status == "2": + self._vpn_disconnect() + elif status == "3": + self._vpn_diagnose() + elif status == "4": + self._vpn_edit_profile() + elif status == "5": + self._vpn_store_secrets() + elif status == "6": + self._vpn_show_config() + elif status == "7": + self._vpn_delete_profile() + elif status == "8": + self._vpn_install() + elif status == "9": + self._vpn_check() + else: + print(t("Command not found !")) + + # ------------------------------------------------------------------ + # Actions déléguées au CLI + # ------------------------------------------------------------------ + def _vpn_cli(self, arguments, secrets_env=None): + self.execute.exec_command_live( + f"{VPN_CLI} {arguments}", + source_erplibre=False, + new_env=secrets_env or None, + ) + + def _vpn_secrets_env(self, name): + """Secrets du profil, prêts pour l'environnement du sous-processus. + + Le coffre est déjà ouvert ici : le faire rouvrir par `vpn.py` ferait + retaper le mot de passe maître deux fois par connexion, puisqu'un + essai à blanc précède le montage. Rend {} quand le coffre n'est pas + joignable — `vpn.py` demandera alors lui-même, et le dira. + """ + profile = profiles.load(name) + driver_cls = get_driver(profile["driver"]) if profile else None + if driver_cls is None or not driver_cls.secret_fields: + return {} + fields = tuple(key for key, _, _ in driver_cls.secret_fields) + vault = VpnVault(self.config_file, self.kdbx_manager) + if not vault.vault_path(): + return {} + try: + values = vault.read(profiles.secret_title(name), fields=fields) + except VaultError as error: + print(f"! {error}") + return {} + return secrets_to_env({key: values.get(key, "") for key in fields}) + + def _vpn_connect(self): + name = self._vpn_select_profile() + if not name: + return + # Lu UNE fois pour les deux exécutions qui suivent. + secrets_env = self._vpn_secrets_env(name) + # Le plan d'abord, l'exécution ensuite : monter un tunnel réécrit + # /etc/ipsec.conf et la table de routage. Le voir avant coûte une + # touche et évite de découvrir une faute de frappe dans un journal. + self._vpn_cli(f"up --profile {name} --dry-run", secrets_env) + if not self._is_yes(input(f"\n{t('Run this plan? (y/N): ')}")): + return + self._vpn_cli(f"up --profile {name}", secrets_env) + + def _vpn_disconnect(self): + name = self._vpn_select_profile() + if name: + self._vpn_cli(f"down --profile {name}") + + def _vpn_diagnose(self): + name = self._vpn_select_profile() + if name: + self._vpn_cli(f"diagnose --profile {name}") + + def _vpn_show_config(self): + name = self._vpn_select_profile() + if name: + self._vpn_cli( + f"up --profile {name} --dry-run", + self._vpn_secrets_env(name), + ) + + def _vpn_check(self): + self._vpn_cli("check") + + def _vpn_install(self): + driver_cls = self._vpn_pick_driver(None) + if driver_cls is None: + return + print(f"\n{t('The installation requires sudo.')}") + self._vpn_cli(f"install --driver {driver_cls.name}") + + # ------------------------------------------------------------------ + # Profils + # ------------------------------------------------------------------ + def _vpn_select_profile(self): + """Nom du profil choisi, "" si l'utilisateur renonce.""" + all_profiles = [profiles.with_defaults(p) for p in profiles.load_all()] + if not all_profiles: + print(t("No VPN profile yet: create one first.")) + return "" + for index, profile in enumerate(all_profiles, start=1): + target = ( + t("all traffic") + if profile["default_route"] + else ", ".join(profile["routes"]) + ) + print( + f"[{index}] {profile['name']:<20}" + f" {profile['server']:<26} {target}" + ) + answer = input(f"{t('Profile number (0 to go back)')} : ").strip() + if not answer.isdigit() or not 1 <= int(answer) <= len(all_profiles): + if answer not in ("0", ""): + print(t("Unknown choice.")) + return "" + return all_profiles[int(answer) - 1]["name"] + + def _vpn_edit_profile(self): + """Crée ou modifie un profil, quelle que soit la technologie. + + Les questions viennent du PILOTE (`form_fields`) : ce menu ne sait + pas qu'un profil L2TP a un utilisateur PPP ni qu'un profil WireGuard + a une clé de pair. Ajouter une technologie n'ajoute donc pas une + ligne ici. + + Une réponse vide garde la valeur actuelle : modifier une seule route + ne doit pas obliger à ressaisir tout le reste. + """ + name = input(f"{t('Profile name (lowercase, digits, - or _)')} : ") + name = name.strip() + if not name: + return + current = profiles.load(name) or {"name": name} + driver_cls = self._vpn_pick_driver(current.get("driver")) + if driver_cls is None: + return + + # Les défauts DU PILOTE CHOISI, pour que chaque question ait un + # défaut sensé même sur un profil qui change de technologie. + draft = profiles.with_defaults(dict(current, driver=driver_cls.name)) + + draft["server"] = self._vpn_ask( + t(driver_cls.server_label), draft.get("server", "") + ) + # L'identité d'abord, le routage ensuite : c'est l'ordre du document + # que le site remet — passerelle, utilisateur, mot de passe, clé — + # et le routage est une question à part, à laquelle ce document ne + # répond souvent pas. + self._vpn_ask_fields(draft, driver_cls, advanced=False) + draft["routes"] = self._vpn_ask( + t("Networks to reach, comma-separated"), + ", ".join(draft.get("routes", [])), + ) + draft["default_route"] = self._vpn_ask_flag( + t("Send ALL traffic through the tunnel?"), + draft.get("default_route", False), + ) + draft["probe"] = self._vpn_ask( + t("Witness address reachable only through the tunnel (optional)"), + draft.get("probe", ""), + ) + if self._is_yes(input(f"{t('Advanced settings? (y/N)')} : ")): + if driver_cls.uses_mtu: + draft["mtu"] = self._vpn_ask( + t("MTU"), str(draft.get("mtu", 1280)) + ) + self._vpn_ask_fields(draft, driver_cls, advanced=True) + + try: + saved = profiles.save(draft) + except profiles.ProfileError as error: + print(f"\n✗ {t('Profile refused: ')}{error}") + return + print(f"\n✓ {t('Profile saved: ')}{saved['name']}") + if not saved["routes"] and not saved["default_route"]: + print(f" {t(NO_ROUTE_NOTE)}") + if driver_cls.secret_fields: + print(f" {t('Next step: store its secrets in the vault.')}") + else: + print( + f" {t('No secret to store: this one authenticates over SSH.')}" + ) + + def _vpn_ask_fields(self, draft, driver_cls, advanced): + """Déroule les champs déclarés par le pilote.""" + for key, label, kind, is_advanced in driver_cls.form_fields: + if bool(is_advanced) != advanced: + continue + if kind == "flag": + draft[key] = self._vpn_ask_flag( + t(label), draft.get(key, False) + ) + else: + draft[key] = self._vpn_ask( + t(label), str(draft.get(key, "") or "") + ) + + @staticmethod + def _vpn_pick_driver(current): + """La technologie, par lettre, avec un conseil par ligne. + + C'est la seule décision du formulaire où l'utilisateur a besoin + d'aide : le reste se déduit de ce que le site lui a donné. + + Une étoile marque les technologies qu'aucun serveur réel n'a + encore validées, et une légende dit ce qu'elle signifie : la liste + montre autrement cinq choix d'apparence égale. + + `[0] Retour` est là comme dans tous les menus de ce CLI : sans lui, + on est coincé dans le formulaire dès qu'on a tapé un nom de profil. + """ + names = list(DRIVERS) + if len(names) == 1: + return DRIVERS[names[0]] + default = current if current in DRIVERS else names[0] + print(f"\n{t('Which technology?')}") + unproven = False + for letter, name in zip(DRIVER_LETTERS, names): + driver_cls = DRIVERS[name] + mark = " ←" if name == default else "" + # L'étoile occupe une colonne à elle : sans cela, les lignes + # marquées décaleraient leur conseil et la liste se lirait mal. + star = " " if driver_cls.proven else "*" + unproven = unproven or not driver_cls.proven + print( + f"[{letter}] {driver_cls.label:<16}{star}" + f" {t(driver_cls.hint)}{mark}" + ) + if unproven: + print(f" * {t(UNPROVEN_NOTE)}") + print(f"[0] {t('Back')}") + default_letter = DRIVER_LETTERS[names.index(default)] + answer = input( + f"{t('Choice')} [{default_letter} = {DRIVERS[default].label}] : " + ).strip() + if not answer: + return DRIVERS[default] + if answer == "0": + return None + chosen = match_driver(answer, names) + if isinstance(chosen, list): + labels = ", ".join(DRIVERS[name].label for name in chosen) + print(f"{t('Several technologies match: ')}{labels}") + return None + if not chosen: + print(t("Unknown choice.")) + return None + return DRIVERS[chosen] + + def _vpn_delete_profile(self): + name = self._vpn_select_profile() + if not name: + return + if not self._is_yes( + input(f"{t('Delete profile')} « {name} » ? (y/N) : ") + ): + return + if profiles.delete(name): + print(f"✓ {t('Profile deleted.')}") + print(f" {t('Its vault entry is kept: delete it in KeePassXC.')}") + else: + message = t( + "Not deletable here: this profile comes from a shared" + " configuration file." + ) + print(f"✗ {message}") + + # ------------------------------------------------------------------ + # Secrets + # ------------------------------------------------------------------ + def _vpn_store_secrets(self): + name = self._vpn_select_profile() + if not name: + return + profile = profiles.load(name) + driver_cls = get_driver(profile["driver"]) + if driver_cls is None: + print(f"✗ {t('Unknown driver: ')}{profile['driver']}") + return + if not driver_cls.secret_fields: + print( + f"{t('No secret to store: this one authenticates over SSH.')}" + ) + return + vault = VpnVault(self.config_file, self.kdbx_manager) + try: + path = vault.ensure_vault(ask=input) + except VaultError as error: + print(f"✗ {error}") + return + if not path: + print(t("No vault: nothing stored.")) + return + if vault.master_password_is_stored(): + print(f"\n! {t(MASTER_PASSWORD_WARNING)}") + + title = profiles.secret_title(name) + fields = tuple(key for key, _, _ in driver_cls.secret_fields) + # Lu AVANT les invites, pour deux raisons : le mot de passe maître + # est alors demandé avant qu'on tape des secrets, et non après ; et + # chaque invite peut dire s'il y a déjà quelque chose derrière. + # « Une réponse vide garde la valeur en place » est un piège quand + # il n'y a rien en place. + try: + existing = vault.read(title, fields=fields) + except VaultError as error: + print(f"✗ {error}") + return + + print(f"\n{t('Vault entry')} : {title}") + print(f"{t('An empty answer keeps the stored value.')}\n") + values = {} + if driver_cls.user_field: + # Recopié pour que le coffre reste LISIBLE dans KeePassXC ; le + # profil reste la source de vérité de l'identifiant. + values["username"] = profile.get(driver_cls.user_field, "") + for key, label, _required in driver_cls.secret_fields: + state = t("already set") if existing.get(key) else t("empty") + secret = self._vpn_ask_secret(f"{t(label)} [{state}]") + if secret is None: + return + if secret: + values[key] = secret + try: + vault.write(title, values) + except VaultError as error: + print(f"✗ {error}") + return + print(f"\n✓ {t('Secrets stored in the vault.')}") + + # Ce qui reste vide et qui est OBLIGATOIRE : le dire ici, pas au + # premier montage raté. + absents = [ + t(label) + for key, label, required in driver_cls.secret_fields + if required and not (values.get(key) or existing.get(key)) + ] + if absents: + print( + f"✗ {t('Still missing, the tunnel will not come up: ')}" + f"{', '.join(absents)}" + ) + + @staticmethod + def _vpn_ask_secret(label): + """Un secret, saisi deux fois, jamais affiché. + + Deux fois parce qu'une faute de frappe dans un PSK ne se voit pas : + elle ressort en « no matching proposal » côté IKE, trois étages plus + loin, et fait chercher au mauvais endroit pendant une heure. + + Rend "" pour « garder la valeur en place », None pour renoncer. + """ + first = getpass.getpass(f"{label} : ") + if not first: + return "" + if first != getpass.getpass(f"{t('Confirm')} : "): + print(f"✗ {t('The two entries differ, nothing stored.')}") + return None + return first + + # ------------------------------------------------------------------ + @staticmethod + def _vpn_ask_flag(label, current): + """Question oui/non dont le défaut est la valeur ACTUELLE. + + Une réponse vide garde ce qui est en place : rééditer un profil pour + changer une route ne doit pas remettre le mode de routage à zéro. + """ + answer = input(f"{label} [{'O/n' if current else 'o/N'}] : ") + answer = answer.strip().lower() + if not answer: + return bool(current) + return answer in ("y", "yes", "o", "oui") + + @staticmethod + def _vpn_ask(label, default): + """Question à réponse par défaut. Vide = on garde `default`.""" + shown = f" [{default}]" if default else "" + answer = input(f"{label}{shown} : ").strip() + return answer or default diff --git a/test/test_vpn_menu.py b/test/test_vpn_menu.py new file mode 100644 index 0000000..665653c --- /dev/null +++ b/test/test_vpn_menu.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le menu VPN : le chemin que l'utilisateur emprunte vraiment. + +Les pilotes sont testés ailleurs. Ici on vérifie que le FORMULAIRE reste +agnostique : il déroule les questions déclarées par le pilote choisi, sans +rien savoir de L2TP ni de WireGuard. C'est ce qui fait qu'ajouter une +technologie n'ajoute pas une ligne au menu — et c'est donc ce qui doit +casser bruyamment si quelqu'un y remet un cas particulier. + +Aucune saisie réelle : `input` est remplacé par une liste de réponses, dans +l'ordre où les questions sont posées. +""" + +import base64 +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__), "..")) +) +sys.argv = ["todo.py"] + +from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 +from script.todo.vpn_menu import ( # noqa: E402 + DRIVER_LETTERS, + UNPROVEN_NOTE, + match_driver, +) +from script.vpn import profiles # noqa: E402 +from script.vpn.drivers import DRIVERS # noqa: E402 + +WG_PUBLIC = base64.b64encode(bytes(range(32, 64))).decode() + + +class MenuBase(unittest.TestCase): + """Les trois fichiers de configuration dans un temporaire : un test qui + écrirait dans le fichier privé détruirait les profils de qui le lance.""" + + def setUp(self): + self.todo = TODO() + self.tmp = tempfile.TemporaryDirectory() + base = os.path.join(self.tmp.name, "todo.json") + with open(base, "w") as fh: + json.dump({"vpn": [], "kdbx": {"path": "", "password": ""}}, fh) + self.patches = [ + 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 answering(self, *answers): + """Remplace `input` par une suite de réponses. Une question de plus + que prévu lève StopIteration — et c'est bien : cela veut dire que le + formulaire a changé sans que le test le sache.""" + return patch("builtins.input", side_effect=list(answers)) + + +class MatchDriver(unittest.TestCase): + """La correspondance est pure : elle se juge sans menu ni saisie. + + Elle existe parce qu'une liste numérotée juste après un menu numéroté + fait taper un numéro de menu — et que devant « [L2TP/IPsec PSK] », on + tape « L ». Les deux doivent marcher. + """ + + def setUp(self): + self.names = list(DRIVERS) + + def test_the_displayed_letter(self): + for index, name in enumerate(self.names): + with self.subTest(name=name): + letter = DRIVER_LETTERS[index] + self.assertEqual(match_driver(letter, self.names), name) + self.assertEqual( + match_driver(letter.upper(), self.names), name + ) + + def test_the_rank_still_works(self): + """Quelqu'un tapera un chiffre, et il a raison de le faire vu le + menu qui précède.""" + for index, name in enumerate(self.names, start=1): + with self.subTest(rang=index): + self.assertEqual(match_driver(str(index), self.names), name) + + def test_the_start_of_the_label(self): + """« L » devant « L2TP/IPsec PSK » : le geste qui a motivé tout + ceci.""" + for answer, expected in ( + ("L", "l2tp_ipsec"), + ("l2tp", "l2tp_ipsec"), + ("w", "wireguard"), + ("WireG", "wireguard"), + ("openv", "openvpn"), + ("openc", "openconnect"), + ("ssh", "sshuttle"), + ): + with self.subTest(answer=answer): + self.assertEqual(match_driver(answer, self.names), expected) + + def test_an_ambiguous_prefix_names_the_candidates(self): + """« open » désigne deux pilotes : le dire vaut mieux qu'en choisir + un au hasard.""" + result = match_driver("open", self.names) + self.assertIsInstance(result, list) + self.assertEqual(sorted(result), ["openconnect", "openvpn"]) + + def test_nothing_matches_nothing(self): + for answer in ("x", "9", "0", "", " ", "carrier-pigeon"): + with self.subTest(answer=answer): + self.assertEqual(match_driver(answer, self.names), "") + + +class DriverPicker(MenuBase): + def test_an_empty_answer_keeps_the_current_driver(self): + with self.answering(""): + with redirect_stdout(io.StringIO()): + chosen = self.todo._vpn_pick_driver("wireguard") + self.assertEqual(chosen.name, "wireguard") + + def test_a_letter_picks_from_the_list(self): + names = list(DRIVERS) + with self.answering("c"): + with redirect_stdout(io.StringIO()): + chosen = self.todo._vpn_pick_driver(None) + self.assertEqual(chosen.name, names[2]) + + def test_a_number_still_picks_from_the_list(self): + names = list(DRIVERS) + with self.answering("3"): + with redirect_stdout(io.StringIO()): + chosen = self.todo._vpn_pick_driver(None) + self.assertEqual(chosen.name, names[2]) + + def test_the_start_of_a_label_picks_too(self): + with self.answering("L"): + with redirect_stdout(io.StringIO()): + chosen = self.todo._vpn_pick_driver(None) + self.assertEqual(chosen.name, "l2tp_ipsec") + + def test_the_list_is_lettered_and_offers_a_way_back(self): + buffer = io.StringIO() + with self.answering(""): + with redirect_stdout(buffer): + self.todo._vpn_pick_driver(None) + printed = buffer.getvalue() + for letter in DRIVER_LETTERS[: len(DRIVERS)]: + self.assertIn(f"[{letter}]", printed) + self.assertNotIn("[1]", printed) + self.assertIn("[0]", printed) + + def test_zero_goes_back_without_scolding(self): + """Sans sortie explicite, on est coincé dans le formulaire dès + qu'on a tapé un nom de profil.""" + buffer = io.StringIO() + with self.answering("0"): + with redirect_stdout(buffer): + self.assertIsNone(self.todo._vpn_pick_driver(None)) + self.assertNotIn("✗", buffer.getvalue()) + self.assertNotIn("inconnu", buffer.getvalue().lower()) + + def test_an_ambiguous_answer_says_which_ones(self): + buffer = io.StringIO() + with self.answering("open"): + with redirect_stdout(buffer): + self.assertIsNone(self.todo._vpn_pick_driver(None)) + printed = buffer.getvalue() + self.assertIn("OpenVPN", printed) + self.assertIn("OpenConnect", printed) + + def test_an_out_of_range_answer_gives_up(self): + with self.answering("99"): + with redirect_stdout(io.StringIO()): + self.assertIsNone(self.todo._vpn_pick_driver(None)) + + def test_every_driver_shows_its_hint(self): + """C'est la seule décision où l'utilisateur a besoin d'un conseil.""" + buffer = io.StringIO() + with self.answering(""): + with redirect_stdout(buffer): + self.todo._vpn_pick_driver(None) + printed = buffer.getvalue() + for cls in DRIVERS.values(): + self.assertIn(cls.label, printed) + + def test_only_the_unproven_technologies_wear_a_star(self): + """Sans marque, la liste montre des choix d'apparence égale, et + rien ne dit lequel a déjà abouti contre un vrai serveur.""" + buffer = io.StringIO() + with self.answering(""): + with redirect_stdout(buffer): + self.todo._vpn_pick_driver(None) + starred = { + line.split("]")[1].strip().split(" ")[0] + for line in buffer.getvalue().splitlines() + if line.startswith("[") and "*" in line + } + expected = { + cls.label.split(" ")[0] + for cls in DRIVERS.values() + if not cls.proven + } + self.assertEqual(starred, expected) + + def test_the_star_is_explained(self): + """Une marque sans légende inquiète sans informer.""" + buffer = io.StringIO() + with self.answering(""): + with redirect_stdout(buffer): + self.todo._vpn_pick_driver(None) + self.assertIn(t(UNPROVEN_NOTE), buffer.getvalue()) + + +class TheFormIsDriverAgnostic(MenuBase): + def test_it_builds_a_wireguard_profile_from_typed_answers(self): + names = list(DRIVERS) + answers = [ + "acme-wg", # nom du profil + DRIVER_LETTERS[names.index("wireguard")], # technologie + "vpn.acme.example", # serveur + "10.7.0.2/32", # wg_address + WG_PUBLIC, # wg_peer_key + "10.7.0.0/24", # réseaux + "", # tout le trafic ? défaut non + "", # témoin + "n", # réglages avancés ? + ] + with self.answering(*answers): + with redirect_stdout(io.StringIO()): + self.todo._vpn_edit_profile() + saved = profiles.load("acme-wg") + self.assertIsNotNone(saved, "profil non enregistré") + self.assertEqual(saved["driver"], "wireguard") + self.assertEqual(saved["wg_address"], "10.7.0.2/32") + self.assertEqual(saved["wg_peer_key"], WG_PUBLIC) + self.assertEqual(saved["routes"], ["10.7.0.0/24"]) + self.assertFalse(saved["default_route"]) + # Le défaut du pilote, jamais demandé, doit être là quand même. + self.assertEqual(saved["port"], 51820) + + def test_it_builds_an_sshuttle_profile_with_no_secret_question(self): + names = list(DRIVERS) + answers = [ + "acme-ssh", + DRIVER_LETTERS[names.index("sshuttle")], + "erplibre@bastion.acme.example", + "10.40.0.0/16", + "", + "10.40.0.1", # témoin + "n", # pas de réglages avancés + ] + with self.answering(*answers): + with redirect_stdout(io.StringIO()): + self.todo._vpn_edit_profile() + saved = profiles.load("acme-ssh") + self.assertIsNotNone(saved) + self.assertEqual(saved["server"], "erplibre@bastion.acme.example") + self.assertEqual(saved["probe"], "10.40.0.1") + + def test_the_mtu_is_not_asked_when_the_driver_ignores_it(self): + """sshuttle ne prend pas le MTU du profil : le demander serait une + question sans effet. Si le formulaire le demandait, la liste de + réponses serait épuisée et le test lèverait StopIteration.""" + names = list(DRIVERS) + answers = [ + "acme-ssh2", + str(names.index("sshuttle") + 1), + "bastion.acme.example", + "10.41.0.0/16", + "", + "", + "o", # réglages avancés OUI + "2222", # port SSH + "", # DNS dans le tunnel : défaut + ] + with self.answering(*answers): + with redirect_stdout(io.StringIO()): + self.todo._vpn_edit_profile() + saved = profiles.load("acme-ssh2") + self.assertIsNotNone(saved) + self.assertEqual(saved["port"], 2222) + + def test_editing_keeps_what_is_not_retyped(self): + profiles.save( + { + "name": "acme-wg", + "driver": "wireguard", + "server": "vpn.acme.example", + "wg_address": "10.7.0.2/32", + "wg_peer_key": WG_PUBLIC, + "routes": ["10.7.0.0/24"], + "wg_keepalive": 17, + } + ) + names = list(DRIVERS) + answers = [ + "acme-wg", + str(names.index("wireguard") + 1), + "", # serveur inchangé + "", # wg_address inchangée + "", # wg_peer_key inchangée + "10.7.0.0/24, 10.9.0.0/16", # routes élargies + "", + "", + "n", + ] + with self.answering(*answers): + with redirect_stdout(io.StringIO()): + self.todo._vpn_edit_profile() + saved = profiles.load("acme-wg") + self.assertEqual(saved["server"], "vpn.acme.example") + self.assertEqual(saved["wg_peer_key"], WG_PUBLIC) + self.assertEqual(saved["routes"], ["10.7.0.0/24", "10.9.0.0/16"]) + # Un réglage avancé jamais réaffiché ne doit pas être perdu. + self.assertEqual(saved["wg_keepalive"], 17) + + def test_a_refused_profile_saves_nothing(self): + names = list(DRIVERS) + answers = [ + "acme-bad", + str(names.index("wireguard") + 1), + "vpn.acme.example", + "10.7.0.2/32", + "pas-une-cle", # clé de pair invalide + "10.7.0.0/24", + "", + "", + "n", + ] + buffer = io.StringIO() + with self.answering(*answers): + with redirect_stdout(buffer): + self.todo._vpn_edit_profile() + self.assertIsNone(profiles.load("acme-bad")) + self.assertIn("✗", buffer.getvalue()) + + +class OnlyWhatTheSiteGaveYou(MenuBase): + """Le cas réel : le site remet une passerelle, un utilisateur, un mot de + passe et une clé. Rien sur les réseaux derrière. + + Ce profil DOIT s'enregistrer. Il ne joint que l'hôte distant, le menu le + dit, et le premier montage proposera le réseau que l'adresse révèle — + refuser l'enregistrement laissait sans issue. + """ + + def test_a_profile_without_routes_is_accepted_and_flagged(self): + names = list(DRIVERS) + answers = [ + "novipro", + DRIVER_LETTERS[names.index("l2tp_ipsec")], + "vpn.novipro.example", # la passerelle + "user", # l'utilisateur PPP + "", # réseaux : le site n'en a pas donné + "", # tout le trafic ? non + "", # témoin + "n", + ] + buffer = io.StringIO() + with self.answering(*answers): + with redirect_stdout(buffer): + self.todo._vpn_edit_profile() + saved = profiles.load("novipro") + self.assertIsNotNone(saved, "profil refusé alors qu'il est utilisable") + self.assertEqual(saved["routes"], []) + self.assertFalse(saved["default_route"]) + printed = buffer.getvalue() + self.assertIn("✓", printed) + self.assertIn("hôte distant", printed) + + def test_the_first_mount_suggests_the_network(self): + """Sans route déclarée, le montage propose le /24 de l'adresse + obtenue — en disant que c'est une hypothèse.""" + from script.vpn.drivers.l2tp_ipsec import L2tpIpsecDriver + from script.vpn.runner import Runner + + profile = profiles.validate( + { + "name": "novipro", + "driver": "l2tp_ipsec", + "server": "127.0.0.1", + "ppp_user": "user", + } + ) + driver = L2tpIpsecDriver(profile, {"psk": "x", "password": "y"}) + runner = Runner(dry_run=True) + buffer = io.StringIO() + with patch( + "script.vpn.drivers.base.interface_addresses", + return_value=["192.168.50.20", "192.168.50.1"], + ): + with redirect_stdout(buffer): + driver.suggest_routes(runner, "ppp0") + printed = buffer.getvalue() + self.assertIn("192.168.50.0/24", printed) + self.assertIn("hypothèse", printed) + + def test_nothing_is_suggested_when_routes_are_declared(self): + """La suggestion ne s'invite pas quand la question est réglée.""" + from script.vpn.drivers.l2tp_ipsec import L2tpIpsecDriver + from script.vpn.runner import Runner + + profile = profiles.validate( + { + "name": "novipro", + "driver": "l2tp_ipsec", + "server": "127.0.0.1", + "ppp_user": "user", + "routes": ["10.20.0.0/16"], + } + ) + driver = L2tpIpsecDriver(profile, {"psk": "x", "password": "y"}) + buffer = io.StringIO() + with redirect_stdout(buffer): + driver.suggest_routes(Runner(dry_run=True), "ppp0") + self.assertEqual(buffer.getvalue(), "") + + def test_wireguard_still_requires_its_allowed_ips(self): + """L'exigence reste DURE là où elle l'est vraiment : sans + AllowedIPs, wg-quick refuse la configuration entière.""" + from script.vpn.valid import ProfileError + + with self.assertRaises(ProfileError): + profiles.validate( + { + "name": "beta", + "driver": "wireguard", + "server": "127.0.0.1", + "wg_address": "10.7.0.2/32", + "wg_peer_key": WG_PUBLIC, + } + ) + + +class SecretsOnlyWhenThereAreSome(MenuBase): + def test_a_driver_without_secrets_does_not_open_the_vault(self): + """sshuttle s'authentifie par SSH. Demander le mot de passe maître + du coffre pour lui serait une saisie pour rien.""" + profiles.save( + { + "name": "acme-ssh", + "driver": "sshuttle", + "server": "bastion.acme.example", + "routes": ["10.40.0.0/16"], + } + ) + buffer = io.StringIO() + with patch.object( + self.todo, "_vpn_select_profile", return_value="acme-ssh" + ): + with patch.object(self.todo.kdbx_manager, "get_kdbx") as opened: + with redirect_stdout(buffer): + self.todo._vpn_store_secrets() + opened.assert_not_called() + self.assertIn("SSH", buffer.getvalue()) + + def test_creating_the_vault_does_not_reask_the_master_password(self): + """Six saisies masquées, pas sept. + + Deux pour créer le coffre, quatre pour les deux secrets confirmés. + `create_database` rend la base DÉJÀ ouverte : sans l'adopter, le mot + de passe maître était redemandé dans la seconde suivant les deux + saisies de la création. + """ + profiles.save( + { + "name": "novipro", + "driver": "l2tp_ipsec", + "server": "vpn.novipro.example", + "ppp_user": "user", + } + ) + coffre = os.path.join(self.tmp.name, "secrets.kdbx") + typed = [] + + def masked(prompt=""): + typed.append(prompt) + if len(typed) <= 2: + return "maitre" + return "secret" + + with patch.object( + self.todo, "_vpn_select_profile", return_value="novipro" + ): + with self.answering(coffre, "o"): + with patch("getpass.getpass", masked): + with redirect_stdout(io.StringIO()): + self.todo._vpn_store_secrets() + self.assertEqual(len(typed), 6, typed) + self.assertTrue(os.path.exists(coffre)) + + def test_an_empty_answer_on_an_empty_field_is_reported(self): + """« Une réponse vide garde la valeur en place » est un piège quand + il n'y a RIEN en place : le secret restait vide en silence, et le + premier montage échouait sur « Secrets manquants ». L'invite dit + maintenant l'état, et le bilan nomme ce qui manque encore. + """ + profiles.save( + { + "name": "novipro", + "driver": "l2tp_ipsec", + "server": "vpn.novipro.example", + "ppp_user": "user", + } + ) + coffre = os.path.join(self.tmp.name, "secrets.kdbx") + typed = [] + + def masked(prompt=""): + typed.append(prompt) + if len(typed) <= 2: + return "maitre" + # La PSK et sa confirmation, puis Entrée sur le mot de passe. + return "LaClePSK" if len(typed) <= 4 else "" + + buffer = io.StringIO() + with patch.object( + self.todo, "_vpn_select_profile", return_value="novipro" + ): + with self.answering(coffre, "o"): + with patch("getpass.getpass", masked): + with redirect_stdout(buffer): + self.todo._vpn_store_secrets() + printed = buffer.getvalue() + self.assertIn("Mot de passe PPP", printed) + self.assertIn("Toujours manquant", printed) + # Chaque invite annonce ce qu'il y a derrière. + self.assertTrue( + [p for p in typed if "[vide]" in p], + typed, + ) + + def test_a_field_already_set_says_so(self): + profiles.save( + { + "name": "novipro", + "driver": "l2tp_ipsec", + "server": "vpn.novipro.example", + "ppp_user": "user", + } + ) + coffre = os.path.join(self.tmp.name, "secrets.kdbx") + typed = [] + + def masked(prompt=""): + typed.append(prompt) + if len(typed) <= 2: + return "maitre" + return "valeur" + + with patch.object( + self.todo, "_vpn_select_profile", return_value="novipro" + ): + with self.answering(coffre, "o"): + with patch("getpass.getpass", masked): + with redirect_stdout(io.StringIO()): + self.todo._vpn_store_secrets() + # Deuxième passage : tout est en place, et les invites le disent. + typed.clear() + with patch( + "getpass.getpass", + lambda prompt="": (typed.append(prompt) or ""), + ): + with redirect_stdout(io.StringIO()): + self.todo._vpn_store_secrets() + self.assertTrue([p for p in typed if "[déjà en place]" in p], typed) + self.assertFalse([p for p in typed if "[vide]" in p], typed) + + def test_a_mismatched_confirmation_stores_nothing(self): + with patch("getpass.getpass", side_effect=["un", "deux"]): + with redirect_stdout(io.StringIO()): + self.assertIsNone(self.todo._vpn_ask_secret("PSK")) + + def test_an_empty_answer_keeps_the_stored_value(self): + with patch("getpass.getpass", return_value=""): + self.assertEqual(self.todo._vpn_ask_secret("PSK"), "") + + +if __name__ == "__main__": + unittest.main() From 2d66dda12903736385afe6f06119ae5e52203f8f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 03:43:11 +0000 Subject: [PATCH 7/7] [UPD] changelog : l'outil VPN, son diagnostic, le coffre sur un serveur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La branche part de master : elle livre l'outil entier, pas des retouches. Deux puces sous Ajouté — l'outil et son diagnostic étagé — et une sous Corrigé pour le coffre KeePassXC, qui ne doit rien au VPN et servait déjà ailleurs. Ce qu'une branche corrige de son propre travail n'y figure pas : le lanceur de tests et ses huit fichiers muets sont revenus à l'état de master, et une puce les annonçant décrirait un aller-retour invisible du dehors. --- EN --- The branch forks from master: it delivers the whole tool, not touch-ups. Two bullets under Added — the tool and its staged diagnosis — and one under Fixed for the KeePassXC vault, which owes nothing to the VPN and already served elsewhere. What a branch fixes in its own work is absent: the test launcher and its eight silent files are back to master's state, and a bullet announcing them would describe a round trip invisible from outside. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 6 ++++++ CHANGELOG.fr.md | 3 +++ CHANGELOG.md | 3 +++ 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 9b46999..6cb30b5 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -41,6 +41,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi ## Ajouté +- A VPN tool, five free technologies at the menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — reachable from **TODO › Execute › Network › VPN** and from the Deployment section. What is not secret (host, user, routes, MTU) lives in readable JSON and the pre-shared keys and passwords in a KeePassXC vault, so a profile can be shown, compared and shared without handing over the means to raise the tunnel. Secrets are written to tmpfs at 0700 and never to a persistent disk; `--dry-run` shows every privileged step without running one, and the tool runs as yourself, each step calling sudo on its own. Asking for all traffic through the tunnel no longer cuts the SSH session that gave the order — the operator's address, read from `SSH_CONNECTION`, gets a survival route of its own and every one of them is withdrawn on teardown. Only L2TP/IPsec has been raised against a real concentrator; the four others are starred at the picker as covered by unit tests alone +- `diagnose` names the failing stage lowest first, so the first false line is the cause and not a consequence: what the kernel exposes · packages · the technology's own check · interface and addresses · each declared route · a witness address that answers only through the tunnel · the journals. The kernel stage catches what no configuration can fix: upgrading the kernel package replaces `/lib/modules/` and the running kernel can load no further module, so IPsec turns unavailable on a kernel that supports it, charon aborts at initialisation, and the symptom surfaces three stages higher as a connection never loaded. The only remedy is a reboot, and it is OFFERED, never done: not on a dry run, not without a terminal to answer, and only when the capability is missing AND the modules are gone - 3D acceleration for QEMU VMs, ticked at creation and settable afterwards, even on a VM with NO virtual screen — `auto` never grants one there, abstaining rather than adding a video device nobody asked for, while an off-screen render or an emulator inside the VM wants exactly that. A render node can exist while EGL refuses to start on it: QEMU then rejects the domain and the VM stays unusable until someone undoes the setting, so creation falls back to software rendering and an existing VM is offered the removal. Inside the guest the render node is `root:render` at 0660 and the account was not in it, so every GL application fell back to software rendering although VIRGL negotiation had succeeded, with nothing to say so; `render` and `video` are now declared BEFORE use, an unknown group name making cloud-init create no account at all — no password, no SSH key, a VM that boots unreachable - A QEMU diagnostic report, written to one file to hand to someone who has no access to the machine: twenty-one read-only probes — host, hypervisor, GPU, tools present, storage — each time-bounded, since a command that hangs must not hold the report, and each section isolated, the file being written in one block at the end. It states the 3D condition of every VM from its PERSISTENT definition, where three values answer together and none alone: video type, `accel3d`, and the device libvirt pinned. That last one, an attribute added in libvirt 12.5.0 to keep the guest ABI stable across restarts, OUTRANKS `accel3d`: a VM first started without 3D keeps the non-GL device, and ticking the box afterwards writes an intent nothing applies. The report also offers the tools missing from it, showing the full command before asking, and the device list QEMU may open — libvirt adds the render node when the domain declares it, never a proprietary card's own nodes, which that stack opens too. It names the host, its paths and its addresses, and says so before it is shared - Recovering files from the disk of a VM that no longer boots, libguestfs mounting its qcow2 without it. Every command carries `--ro`, and that is what changes the manoeuvre: opening the disk of a running machine for writing corrupts its filesystem. Partitions are listed, then the directories to copy out, the `copy-out` commands being shown rather than guessed at @@ -139,6 +141,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi +- Un outil VPN, cinq technologies libres au menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — accessible depuis **TODO › Execute › Réseau › VPN** et depuis la section Déploiement. Ce qui n'est pas secret (hôte, utilisateur, routes, MTU) vit dans une configuration JSON lisible et les clés pré-partagées comme les mots de passe dans un coffre KeePassXC : un profil peut donc être montré, comparé et partagé sans donner de quoi monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700 et jamais sur un disque persistant ; `--dry-run` montre chaque geste privilégié sans en exécuter un, et l'outil tourne sous votre identité, chaque étape appelant sudo d'elle-même. Demander tout le trafic par le tunnel ne coupe plus la session SSH qui vient d'en donner l'ordre — l'adresse de l'opérateur, lue dans `SSH_CONNECTION`, reçoit sa propre route de survie, et toutes sont retirées au démontage. Seul L2TP/IPsec a monté un tunnel contre un concentrateur réel ; les quatre autres portent au choix une étoile disant que seuls des tests unitaires les couvrent +- `diagnose` nomme l'étage fautif du plus bas au plus haut, pour que la première ligne fausse soit la cause et non une conséquence : ce que le noyau expose · les paquets · la vérification propre à la technologie · interface et adresses · chaque route déclarée · une adresse témoin qui ne répond qu'à travers le tunnel · les journaux. L'étage du noyau attrape ce qu'aucune configuration ne rattrape : mettre à jour le paquet du noyau remplace `/lib/modules/` et le noyau qui tourne ne peut plus charger aucun module, si bien que l'IPsec devient indisponible sur un noyau qui le prend en charge, que charon abandonne à l'initialisation et que le symptôme ressort trois étages plus haut en connexion jamais chargée. Le seul remède est un redémarrage, et il est PROPOSÉ, jamais fait : ni à blanc, ni sans terminal pour répondre, et seulement quand la capacité manque ET que les modules ont disparu - L'accélération 3D des VM QEMU, cochée à la création et réglable ensuite, même sur une VM SANS écran virtuel — « auto » ne l'accorde jamais là, s'abstenant plutôt que de poser un périphérique vidéo que personne n'a demandé, alors qu'un rendu hors écran ou un émulateur tournant dedans veut exactement cela. Un nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le domaine et la VM reste inutilisable jusqu'à ce que quelqu'un défasse le réglage, d'où un repli sur le rendu logiciel à la création et le retrait proposé sur une VM existante. Dans l'invité, le nœud de rendu appartient à « root:render » en 0660 et le compte n'y était pas : toute application GL retombait sur le rendu logiciel alors que la négociation VIRGL avait réussi, sans que rien ne le signale ; « render » et « video » sont désormais déclarés AVANT usage, un nom de groupe inconnu faisant que cloud-init ne crée aucun compte — ni mot de passe, ni clé SSH, une VM qui démarre injoignable - Un diagnostic QEMU, écrit dans un fichier unique à transmettre à quelqu'un qui n'a pas accès à la machine : vingt et une sondes en lecture — hôte, hyperviseur, GPU, outils présents, stockage — chacune bornée dans le temps, une commande qui pend ne devant pas retenir le rapport, et chaque section isolée, le fichier s'écrivant d'un bloc à la fin. Il dit l'état 3D de chaque VM d'après sa définition PERSISTANTE, où trois valeurs répondent ensemble et aucune seule : le type de vidéo, « accel3d », et le device figé par libvirt. Ce dernier, un attribut arrivé avec libvirt 12.5.0 pour tenir l'ABI de l'invité stable d'un démarrage à l'autre, L'EMPORTE sur « accel3d » : une VM démarrée une première fois sans 3D garde le device sans GL, et cocher la case ensuite écrit une intention que rien n'applique. Le rapport propose aussi les outils qui lui manquent, la commande complète affichée avant la question, et la liste des périphériques que QEMU peut ouvrir — libvirt y met le nœud de rendu quand le domaine le déclare, jamais les nœuds propres d'une carte propriétaire, que sa pile ouvre pourtant. Il porte le nom de l'hôte, ses chemins et ses adresses, et le dit avant qu'on l'envoie - La récupération de fichiers dans le disque d'une VM qui ne démarre plus, libguestfs montant son qcow2 sans elle. Toute commande porte « --ro », et c'est ce qui change la manœuvre : ouvrir en écriture le disque d'une machine allumée corrompt son système de fichiers. Les partitions sont listées, puis les répertoires à extraire, les commandes « copy-out » étant montrées plutôt que devinées @@ -267,6 +271,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi ## Corrigé +- The KeePassXC vault opens on a machine without tkinter, which is every server. Both imports shared a single `try`, so a missing tkinter set PyKeePass to None as well: the vault stayed unopenable even with path and password configured, while the log said `pykeepass is not installed` and pykeepass 4.2 was there. tkinter serves only the file picker, when no path is configured. The prompt also names the vault before asking for its password, rather than after - The QEMU menu goes through the `libvirt` group rather than sudo, which added no right and asked for a password at every entry; membership is settled by TRYING, never by reading /etc/group. The libvirt URI is named explicitly: without `--connect`, a non-root virsh targets `qemu:///session`, a SEPARATE hypervisor where no system VM exists, and `list --all` returns an empty list with no error — root's default URI had masked the omission - System tools launched from the menu no longer inherit the venv at the head of their PATH. A Python tool bootstrapped by `env python3` started in an interpreter without the distribution's modules and died on `No module named 'gi'` - Accepting to install the QEMU packages no longer reboots the host without asking: `--assume-yes` covered the package manager, and the command silently added `--reboot-if-needed`. One constant served both the disposable guest and the workstation @@ -292,6 +297,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation +- Le coffre KeePassXC s'ouvre sur une machine sans tkinter, c'est-à-dire sur tout serveur. Les deux imports partageaient un seul `try`, si bien que l'absence de tkinter mettait aussi PyKeePass à None : le coffre restait inouvrable même avec chemin et mot de passe configurés, alors que le journal annonçait « pykeepass is not installed » et que pykeepass 4.2 était là. tkinter ne sert qu'au sélecteur de fichier, quand aucun chemin n'est configuré. L'invite nomme aussi le coffre avant d'en demander le mot de passe, et non après - Le menu QEMU passe par le groupe « libvirt » plutôt que par sudo, qui n'ajoutait aucun droit et réclamait un mot de passe à chaque entrée ; l'appartenance se tranche en ESSAYANT, jamais en lisant /etc/group. L'URI libvirt est nommée explicitement : sans « --connect », un virsh non root vise « qemu:///session », un hyperviseur SÉPARÉ où aucune VM du système n'existe, et « list --all » y rend une liste vide sans erreur — l'URI par défaut de root masquait l'omission - Les outils système lancés depuis le menu n'héritent plus du venv en tête de leur PATH. Un outil écrit en Python et amorcé par « env python3 » démarrait dans un interpréteur privé des modules de la distribution et sortait sur « No module named 'gi' » - Accepter d'installer les paquets QEMU ne redémarre plus l'hôte sans demander : « --assume-yes » couvrait le gestionnaire de paquets, et la commande y ajoutait « --reboot-if-needed » en silence. Une seule constante servait l'invité jetable et le poste de travail diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 8b8aca6..b959160 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -15,6 +15,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi ## Ajouté +- Un outil VPN, cinq technologies libres au menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — accessible depuis **TODO › Execute › Réseau › VPN** et depuis la section Déploiement. Ce qui n'est pas secret (hôte, utilisateur, routes, MTU) vit dans une configuration JSON lisible et les clés pré-partagées comme les mots de passe dans un coffre KeePassXC : un profil peut donc être montré, comparé et partagé sans donner de quoi monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700 et jamais sur un disque persistant ; `--dry-run` montre chaque geste privilégié sans en exécuter un, et l'outil tourne sous votre identité, chaque étape appelant sudo d'elle-même. Demander tout le trafic par le tunnel ne coupe plus la session SSH qui vient d'en donner l'ordre — l'adresse de l'opérateur, lue dans `SSH_CONNECTION`, reçoit sa propre route de survie, et toutes sont retirées au démontage. Seul L2TP/IPsec a monté un tunnel contre un concentrateur réel ; les quatre autres portent au choix une étoile disant que seuls des tests unitaires les couvrent +- `diagnose` nomme l'étage fautif du plus bas au plus haut, pour que la première ligne fausse soit la cause et non une conséquence : ce que le noyau expose · les paquets · la vérification propre à la technologie · interface et adresses · chaque route déclarée · une adresse témoin qui ne répond qu'à travers le tunnel · les journaux. L'étage du noyau attrape ce qu'aucune configuration ne rattrape : mettre à jour le paquet du noyau remplace `/lib/modules/` et le noyau qui tourne ne peut plus charger aucun module, si bien que l'IPsec devient indisponible sur un noyau qui le prend en charge, que charon abandonne à l'initialisation et que le symptôme ressort trois étages plus haut en connexion jamais chargée. Le seul remède est un redémarrage, et il est PROPOSÉ, jamais fait : ni à blanc, ni sans terminal pour répondre, et seulement quand la capacité manque ET que les modules ont disparu - L'accélération 3D des VM QEMU, cochée à la création et réglable ensuite, même sur une VM SANS écran virtuel — « auto » ne l'accorde jamais là, s'abstenant plutôt que de poser un périphérique vidéo que personne n'a demandé, alors qu'un rendu hors écran ou un émulateur tournant dedans veut exactement cela. Un nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le domaine et la VM reste inutilisable jusqu'à ce que quelqu'un défasse le réglage, d'où un repli sur le rendu logiciel à la création et le retrait proposé sur une VM existante. Dans l'invité, le nœud de rendu appartient à « root:render » en 0660 et le compte n'y était pas : toute application GL retombait sur le rendu logiciel alors que la négociation VIRGL avait réussi, sans que rien ne le signale ; « render » et « video » sont désormais déclarés AVANT usage, un nom de groupe inconnu faisant que cloud-init ne crée aucun compte — ni mot de passe, ni clé SSH, une VM qui démarre injoignable - Un diagnostic QEMU, écrit dans un fichier unique à transmettre à quelqu'un qui n'a pas accès à la machine : vingt et une sondes en lecture — hôte, hyperviseur, GPU, outils présents, stockage — chacune bornée dans le temps, une commande qui pend ne devant pas retenir le rapport, et chaque section isolée, le fichier s'écrivant d'un bloc à la fin. Il dit l'état 3D de chaque VM d'après sa définition PERSISTANTE, où trois valeurs répondent ensemble et aucune seule : le type de vidéo, « accel3d », et le device figé par libvirt. Ce dernier, un attribut arrivé avec libvirt 12.5.0 pour tenir l'ABI de l'invité stable d'un démarrage à l'autre, L'EMPORTE sur « accel3d » : une VM démarrée une première fois sans 3D garde le device sans GL, et cocher la case ensuite écrit une intention que rien n'applique. Le rapport propose aussi les outils qui lui manquent, la commande complète affichée avant la question, et la liste des périphériques que QEMU peut ouvrir — libvirt y met le nœud de rendu quand le domaine le déclare, jamais les nœuds propres d'une carte propriétaire, que sa pile ouvre pourtant. Il porte le nom de l'hôte, ses chemins et ses adresses, et le dit avant qu'on l'envoie - La récupération de fichiers dans le disque d'une VM qui ne démarre plus, libguestfs montant son qcow2 sans elle. Toute commande porte « --ro », et c'est ce qui change la manœuvre : ouvrir en écriture le disque d'une machine allumée corrompt son système de fichiers. Les partitions sont listées, puis les répertoires à extraire, les commandes « copy-out » étant montrées plutôt que devinées @@ -125,6 +127,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi ## Corrigé +- Le coffre KeePassXC s'ouvre sur une machine sans tkinter, c'est-à-dire sur tout serveur. Les deux imports partageaient un seul `try`, si bien que l'absence de tkinter mettait aussi PyKeePass à None : le coffre restait inouvrable même avec chemin et mot de passe configurés, alors que le journal annonçait « pykeepass is not installed » et que pykeepass 4.2 était là. tkinter ne sert qu'au sélecteur de fichier, quand aucun chemin n'est configuré. L'invite nomme aussi le coffre avant d'en demander le mot de passe, et non après - Le menu QEMU passe par le groupe « libvirt » plutôt que par sudo, qui n'ajoutait aucun droit et réclamait un mot de passe à chaque entrée ; l'appartenance se tranche en ESSAYANT, jamais en lisant /etc/group. L'URI libvirt est nommée explicitement : sans « --connect », un virsh non root vise « qemu:///session », un hyperviseur SÉPARÉ où aucune VM du système n'existe, et « list --all » y rend une liste vide sans erreur — l'URI par défaut de root masquait l'omission - Les outils système lancés depuis le menu n'héritent plus du venv en tête de leur PATH. Un outil écrit en Python et amorcé par « env python3 » démarrait dans un interpréteur privé des modules de la distribution et sortait sur « No module named 'gi' » - Accepter d'installer les paquets QEMU ne redémarre plus l'hôte sans demander : « --assume-yes » couvrait le gestionnaire de paquets, et la commande y ajoutait « --reboot-if-needed » en silence. Une seule constante servait l'invité jetable et le poste de travail diff --git a/CHANGELOG.md b/CHANGELOG.md index e79ca7f..e8eee5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Recreating the virtual environment, use installation guide from tool `make`. ## Added +- A VPN tool, five free technologies at the menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — reachable from **TODO › Execute › Network › VPN** and from the Deployment section. What is not secret (host, user, routes, MTU) lives in readable JSON and the pre-shared keys and passwords in a KeePassXC vault, so a profile can be shown, compared and shared without handing over the means to raise the tunnel. Secrets are written to tmpfs at 0700 and never to a persistent disk; `--dry-run` shows every privileged step without running one, and the tool runs as yourself, each step calling sudo on its own. Asking for all traffic through the tunnel no longer cuts the SSH session that gave the order — the operator's address, read from `SSH_CONNECTION`, gets a survival route of its own and every one of them is withdrawn on teardown. Only L2TP/IPsec has been raised against a real concentrator; the four others are starred at the picker as covered by unit tests alone +- `diagnose` names the failing stage lowest first, so the first false line is the cause and not a consequence: what the kernel exposes · packages · the technology's own check · interface and addresses · each declared route · a witness address that answers only through the tunnel · the journals. The kernel stage catches what no configuration can fix: upgrading the kernel package replaces `/lib/modules/` and the running kernel can load no further module, so IPsec turns unavailable on a kernel that supports it, charon aborts at initialisation, and the symptom surfaces three stages higher as a connection never loaded. The only remedy is a reboot, and it is OFFERED, never done: not on a dry run, not without a terminal to answer, and only when the capability is missing AND the modules are gone - 3D acceleration for QEMU VMs, ticked at creation and settable afterwards, even on a VM with NO virtual screen — `auto` never grants one there, abstaining rather than adding a video device nobody asked for, while an off-screen render or an emulator inside the VM wants exactly that. A render node can exist while EGL refuses to start on it: QEMU then rejects the domain and the VM stays unusable until someone undoes the setting, so creation falls back to software rendering and an existing VM is offered the removal. Inside the guest the render node is `root:render` at 0660 and the account was not in it, so every GL application fell back to software rendering although VIRGL negotiation had succeeded, with nothing to say so; `render` and `video` are now declared BEFORE use, an unknown group name making cloud-init create no account at all — no password, no SSH key, a VM that boots unreachable - A QEMU diagnostic report, written to one file to hand to someone who has no access to the machine: twenty-one read-only probes — host, hypervisor, GPU, tools present, storage — each time-bounded, since a command that hangs must not hold the report, and each section isolated, the file being written in one block at the end. It states the 3D condition of every VM from its PERSISTENT definition, where three values answer together and none alone: video type, `accel3d`, and the device libvirt pinned. That last one, an attribute added in libvirt 12.5.0 to keep the guest ABI stable across restarts, OUTRANKS `accel3d`: a VM first started without 3D keeps the non-GL device, and ticking the box afterwards writes an intent nothing applies. The report also offers the tools missing from it, showing the full command before asking, and the device list QEMU may open — libvirt adds the render node when the domain declares it, never a proprietary card's own nodes, which that stack opens too. It names the host, its paths and its addresses, and says so before it is shared - Recovering files from the disk of a VM that no longer boots, libguestfs mounting its qcow2 without it. Every command carries `--ro`, and that is what changes the manoeuvre: opening the disk of a running machine for writing corrupts its filesystem. Partitions are listed, then the directories to copy out, the `copy-out` commands being shown rather than guessed at @@ -123,6 +125,7 @@ Recreating the virtual environment, use installation guide from tool `make`. ## Fixed +- The KeePassXC vault opens on a machine without tkinter, which is every server. Both imports shared a single `try`, so a missing tkinter set PyKeePass to None as well: the vault stayed unopenable even with path and password configured, while the log said `pykeepass is not installed` and pykeepass 4.2 was there. tkinter serves only the file picker, when no path is configured. The prompt also names the vault before asking for its password, rather than after - The QEMU menu goes through the `libvirt` group rather than sudo, which added no right and asked for a password at every entry; membership is settled by TRYING, never by reading /etc/group. The libvirt URI is named explicitly: without `--connect`, a non-root virsh targets `qemu:///session`, a SEPARATE hypervisor where no system VM exists, and `list --all` returns an empty list with no error — root's default URI had masked the omission - System tools launched from the menu no longer inherit the venv at the head of their PATH. A Python tool bootstrapped by `env python3` started in an interpreter without the distribution's modules and died on `No module named 'gi'` - Accepting to install the QEMU packages no longer reboots the host without asking: `--assume-yes` covered the package manager, and the command silently added `--reboot-if-needed`. One constant served both the disposable guest and the workstation