[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
This commit is contained in:
Mathieu Benoit 2026-09-04 03:42:49 +00:00
parent 8181e693a2
commit 197d19d61e
24 changed files with 7598 additions and 0 deletions

View file

@ -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,

189
script/install/install_vpn.sh Executable file
View file

@ -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 <pilote>
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 <nom>"
}
main "$@"

View file

@ -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]:

View file

@ -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.",
},
}

407
script/vpn/README.base.md Normal file
View file

@ -0,0 +1,407 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# 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.
<!-- [fr] -->
# 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.
<!-- [en] -->
## 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 |
<!-- [fr] -->
## 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 |
<!-- [en] -->
## Commands
<!-- [fr] -->
## Commandes
<!-- [common] -->
```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
```
<!-- [en] -->
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.
<!-- [fr] -->
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.
<!-- [en] -->
## 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 / <profile>` |
| `/dev/shm/erplibre-vpn/<profile>/` | 0700 root — **the secrets**, in tmpfs, erased on `down` |
| `/run/erplibre-vpn/<profile>.*` | non-secret state (chosen interface, pid, log), readable without sudo |
| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP only: a marked block, removed on `down` |
<!-- [fr] -->
## 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 / <profil>` |
| `/dev/shm/erplibre-vpn/<profil>/` | 0700 root — **les secrets**, en tmpfs, effacés au `down` |
| `/run/erplibre-vpn/<profil>.*` | 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` |
<!-- [en] -->
## The three security rules
1. **No secret in an argument.** `/proc/<pid>/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.
<!-- [fr] -->
## Les trois règles de sécurité
1. **Aucun secret en argument.** `/proc/<pid>/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.
<!-- [en] -->
## 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 <the client machine>
```
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.
<!-- [fr] -->
## 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 <la machine cliente>
```
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.
<!-- [en] -->
## 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/<version>` 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.
<!-- [fr] -->
## 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/<version>` 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.
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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.

202
script/vpn/README.fr.md Normal file
View file

@ -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 / <profil>` |
| `/dev/shm/erplibre-vpn/<profil>/` | 0700 root — **les secrets**, en tmpfs, effacés au `down` |
| `/run/erplibre-vpn/<profil>.*` | 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/<pid>/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 <la machine cliente>
```
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/<version>` 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.

193
script/vpn/README.md Normal file
View file

@ -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 / <profile>` |
| `/dev/shm/erplibre-vpn/<profile>/` | 0700 root — **the secrets**, in tmpfs, erased on `down` |
| `/run/erplibre-vpn/<profile>.*` | 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/<pid>/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 <the client machine>
```
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/<version>` 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.

0
script/vpn/__init__.py Normal file
View file

View file

@ -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)

845
script/vpn/drivers/base.py Normal file
View file

@ -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/<profil>/ 0700 root — LES SECRETS. tmpfs : rien
n'est écrit sur un disque persistant, et
un redémarrage efface tout.
/run/erplibre-vpn/<profil>.* 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/<version>` 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 « <client> <port> <serveur> <port> ». 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

View file

@ -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/<profil>/ 0700 root — LES SECRETS. tmpfs : rien
n'est écrit sur un disque persistant, et
un redémarrage efface tout.
/run/erplibre-vpn/<profil>.* 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 <local/…>` : 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<hex>`, 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 …
# <auth pap> …] », auquel un `refuse-pap` répond
# « ConfNak <auth chap MD5> » — 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/<profil>/.
"""
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}"'

View file

@ -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/<pid>/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 <la machine cliente>
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 <cette machine>"
)
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",
)
]

View file

@ -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 <fichier>`
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)}",
)
]

View file

@ -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'\"",
)
]

View file

@ -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 `<interface>.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",
),
]

200
script/vpn/profiles.py Normal file
View file

@ -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

344
script/vpn/runner.py Normal file
View file

@ -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/<pid>/cmdline` est lisible par tout
utilisateur de la machine, `/proc/<pid>/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()

175
script/vpn/valid.py Normal file
View file

@ -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

312
script/vpn/vault.py Normal file
View file

@ -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 / <profil> » :
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/<pid>/environ n'est
# lisible que par le propriétaire du processus, /proc/<pid>/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", <champs>} 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

363
script/vpn/vpn.py Executable file
View file

@ -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 = "<secret-du-coffre>"
def _vault():
cfg = config_file.ConfigFile()
return VpnVault(cfg, KdbxManager(cfg)), cfg
def _load_secrets(profile, driver_cls, required=True):
"""Secrets du profil, ou {} si on renonce.
`required=False` sert le mode à blanc : montrer le plan ne justifie pas
d'exiger le mot de passe maître, et un plan avec des marqueurs à la
place des secrets reste un plan juste.
"""
fields = tuple(key for key, _, _ in driver_cls.secret_fields)
# Déjà fournis par le menu, qui tient le coffre ouvert ? Alors ne pas
# redemander le mot de passe maître.
deja = secrets_from_env(fields)
if deja is not None:
return deja
vault, _ = _vault()
title = profiles.secret_title(profile["name"])
try:
values = vault.read(title, fields=fields)
except VaultError as err:
if required:
raise
print(f" ! {err}")
print(f" ! plan rendu avec « {PLACEHOLDER} » à la place des secrets")
return {field: PLACEHOLDER for field in fields}
if vault.master_password_is_stored():
print(
" ! le mot de passe MAÎTRE du coffre est écrit dans la"
" configuration : le retirer et le saisir à la demande"
)
return values
def _build(args, want_secrets=True, secrets_required=True):
"""(profil, pilote, exécuteur) ou (None, None, None) après un message."""
profile = profiles.load(args.profile)
if profile is None:
known = ", ".join(profiles.names()) or "aucun"
print(f"✗ Profil « {args.profile} » inconnu. Connus : {known}.")
return None, None, None
driver_cls = get_driver(profile["driver"])
if driver_cls is None:
print(
f"✗ Le profil « {args.profile} » demande le pilote"
f" « {profile['driver']} », qui n'existe pas."
f" Connus : {', '.join(driver_names())}."
)
return None, None, None
secrets = {}
if want_secrets:
try:
secrets = _load_secrets(
profile, driver_cls, required=secrets_required
)
except VaultError as err:
print(f"✗ {err}")
return None, None, None
driver = driver_cls(profile, secrets)
values = driver.secret_values()
runner = Runner(
dry_run=getattr(args, "dry_run", False),
redactor=lambda text: redact(text, values),
)
return profile, driver, runner
def _prime_sudo(runner):
"""Demande le mot de passe sudo UNE fois, au début.
Sans cela, l'invite surgit au milieu de la séquence — entre le « ipsec
up » et le « c <lac> » — là où une saisie lente fait expirer le tunnel.
"""
if runner.dry_run:
return
runner.cmd("autoriser sudo", "-v", sudo=True, check=False)
# ----------------------------------------------------------------------
# Commandes
# ----------------------------------------------------------------------
def cmd_list(args):
all_profiles = profiles.load_all()
if not all_profiles:
print(
"Aucun profil VPN. En créer un depuis TODO › Execute ›"
" Déploiement › VPN › « Ajouter / modifier un profil »."
)
return 0
for raw in all_profiles:
profile = profiles.with_defaults(raw)
mode = (
"tout le trafic"
if profile["default_route"]
else ", ".join(profile["routes"]) or "aucune route"
)
print(
f" {profile['name']:<20} {profile['driver']:<12}"
f" {profile['server']:<28} {mode}"
)
return 0
def cmd_up(args):
profile, driver, runner = _build(
args, secrets_required=not getattr(args, "dry_run", False)
)
if driver is None:
return 1
title = "Plan de montage (à blanc)" if runner.dry_run else "Montage"
print(f"\n{title} — {profile['name']} ({driver.label})\n")
_prime_sudo(runner)
ok = driver.up(runner)
print()
if ok and not runner.failures:
print(
"✓ Tunnel monté."
if not runner.dry_run
else "✓ Plan complet, rien n'a été exécuté."
)
return 0
print("✗ Montage incomplet :")
for failure in runner.failures:
print(f" · {failure}")
print(
" Démonter proprement avant de réessayer :"
f" ./script/vpn/vpn.py down --profile {profile['name']}"
)
return 1
def cmd_down(args):
profile, driver, runner = _build(args, want_secrets=False)
if driver is None:
return 1
print(f"\nDémontage — {profile['name']}\n")
_prime_sudo(runner)
driver.down(runner)
return 0
def cmd_status(args):
if not args.profile:
return cmd_list(args)
profile, driver, runner = _build(args, want_secrets=False)
if driver is None:
return 1
runner.quiet = True
print(f"\nÉtat — {profile['name']} ({driver.label})\n")
verdicts = driver.status(runner)
_print_verdicts(verdicts)
return 0 if all(ok for _, ok, _ in verdicts if ok is not None) else 1
def cmd_diagnose(args):
profile, driver, runner = _build(args, want_secrets=False)
if driver is None:
return 1
runner.quiet = True
print(f"\nDiagnostic — {profile['name']} ({driver.label})\n")
verdicts = driver.status(runner)
_print_verdicts(verdicts)
print()
# Avant les journaux : quand le noyau est la cause, ils sont vides —
# le démon n'a pas vécu assez longtemps pour écrire — et faire lire
# soixante lignes de rien avant d'annoncer le remède n'aide personne.
if driver.needs_reboot():
runner.quiet = False
driver.propose_reboot(runner)
runner.quiet = True
print()
for label, command in driver.log_commands():
print(f"── {label} ──")
runner.quiet = False
runner.cmd(label, command, check=False)
runner.quiet = True
print()
failed = [label for label, ok, _ in verdicts if ok is False]
if failed:
print(f"✗ En défaut : {', '.join(failed)}")
return 1
print("✓ Tous les étages répondent.")
return 0
def cmd_check(args):
"""Ce que la machine sait faire, avant tout profil."""
names = [args.driver] if args.driver else driver_names()
code = 0
for name in names:
driver_cls = get_driver(name)
if driver_cls is None:
print(f"✗ Pilote inconnu : {name}")
code = 1
continue
driver = driver_cls({"name": "check"})
missing = driver.missing_binaries()
broken = [d for _, ok, d in driver.check_kernel() if ok is False]
if missing:
code = 1
_line("✗", driver_cls.label, f"absents : {', '.join(missing)}")
print(f" {INSTALL_SCRIPT} {name}")
elif broken:
# Les paquets sont là et le noyau ne suit pas : « prêt » serait
# faux, et l'installateur n'y changerait rien.
code = 1
_line("✗", driver_cls.label, "; ".join(broken))
else:
_line("✓", driver_cls.label, "prêt")
vault, _ = _vault()
path = vault.vault_path()
if not path:
_line("!", "coffre KeePassXC", "non configuré")
else:
exists = os.path.exists(os.path.expanduser(path))
_line("✓" if exists else "✗", "coffre KeePassXC", path)
if not exists:
code = 1
if vault.master_password_is_stored():
_line(
"!",
"mot de passe maître",
"stocké en clair dans la configuration : le retirer",
)
return code
def cmd_install(args):
"""Une SEULE invocation, même pour plusieurs pilotes : le script fait
un `apt-get update` par appel, et cinq appels le referaient cinq
fois."""
names = [args.driver] if args.driver else driver_names()
runner = Runner()
code, _ = runner.cmd(
f"installer les paquets de : {', '.join(names)}",
f"bash {INSTALL_SCRIPT} {' '.join(names)}",
check=True,
)
return code
def _line(mark, label, detail):
"""Une ligne de verdict, en colonnes. Un seul endroit décide de la
largeur : sinon les listes de `check` et de `status` cessent de
s'aligner entre elles."""
print(f" {mark} {label:<34} {detail}")
def _print_verdicts(verdicts):
for label, ok, detail in verdicts:
_line("✓" if ok else ("?" if ok is None else "✗"), label, detail)
def build_parser():
parser = argparse.ArgumentParser(
prog="vpn.py",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="Lister les profils")
for name, help_text, with_dry in (
("up", "Monter le tunnel", True),
("down", "Démonter le tunnel", True),
("status", "État du tunnel", False),
("diagnose", "État détaillé + journaux", False),
):
sp = sub.add_parser(name, help=help_text)
sp.add_argument(
"--profile",
required=name not in ("status",),
help="Nom du profil VPN",
)
if with_dry:
sp.add_argument(
"--dry-run",
action="store_true",
help="Montrer le plan, secrets masqués, sans rien exécuter",
)
for name, help_text in (
("check", "Vérifier ce que la machine sait faire"),
("install", "Installer les paquets client"),
):
sp = sub.add_parser(name, help=help_text)
sp.add_argument(
"--driver",
choices=sorted(DRIVERS),
help="Se limiter à ce pilote",
)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
handlers = {
"list": cmd_list,
"up": cmd_up,
"down": cmd_down,
"status": cmd_status,
"diagnose": cmd_diagnose,
"check": cmd_check,
"install": cmd_install,
}
return handlers[args.command](args)
if __name__ == "__main__":
sys.exit(main())

553
test/test_vpn_drivers.py Normal file
View file

@ -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/<pid>/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()

212
test/test_vpn_profiles.py Normal file
View file

@ -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()

870
test/test_vpn_render.py Normal file
View file

@ -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/<pid>/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 « <auth pap> », et un
`refuse-pap` y répond « ConfNak <auth chap MD5> » — 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()

379
test/test_vpn_vault.py Normal file
View file

@ -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/<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()