erplibre/test/test_vpn_profiles.py
Mathieu Benoit 197d19d61e [ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.

Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.

--- EN ---

The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.

What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.

Assisted-by: Claude Opus 5
2026-09-04 03:42:49 +00:00

212 lines
7.9 KiB
Python

#!/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()