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
212 lines
7.9 KiB
Python
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()
|