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
379 lines
15 KiB
Python
379 lines
15 KiB
Python
#!/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/<pid>/environ n'est lisible que par le
|
|
propriétaire, /proc/<pid>/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()
|