diff --git a/.claude/skills/erplibre-deployment/SKILL.md b/.claude/skills/erplibre-deployment/SKILL.md index 22f6f01..552e18d 100644 --- a/.claude/skills/erplibre-deployment/SKILL.md +++ b/.claude/skills/erplibre-deployment/SKILL.md @@ -12,6 +12,9 @@ description: >- - **Nginx** : `script/nginx/` pour le reverse proxy - **SSL** : Certbot pour les certificats - **DNS** : `script/deployment/update_dns_cloudflare.py` +- **VPN** : `script/vpn/` — cinq pilotes (L2TP/IPsec PSK, WireGuard, + OpenVPN, OpenConnect, sshuttle), profils en JSON et secrets dans un + coffre KeePassXC. Mode d'emploi : `script/vpn/README.md`. Plateformes supportées : Ubuntu 24.04 / 25.10 / 26.04, Linux Mint 22.3, Debian 12, AlmaLinux 9+, Rocky Linux 9+, openSUSE Leap 16 et Tumbleweed, diff --git a/script/install/install_vpn.sh b/script/install/install_vpn.sh new file mode 100755 index 0000000..60e95aa --- /dev/null +++ b/script/install/install_vpn.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Paquets client VPN, par pilote de script/vpn/drivers/. +# +# sudo bash script/install/install_vpn.sh l2tp_ipsec +# +# Un groupe de paquets par pilote : le nom passé en argument est le `name` du +# pilote, et non un nom de paquet. C'est le CLI (script/vpn/vpn.py install) +# qui appelle ce script, et il ne connaît que les noms de pilotes. +# +# Ce script INSTALLE et ne configure rien : la configuration est rendue au +# montage du tunnel, dans un tmpfs, par le pilote. Il désactive tout de même +# le démarrage automatique des services — un strongSwan ou un xl2tpd lancé au +# boot tiendrait UDP 500/1701 et empêcherait l'instance dédiée au profil de +# s'attacher. +set -euo pipefail + +log() { echo "[VPN] $*"; } +die() { echo "[VPN] ERREUR: $*" >&2; exit 1; } + +usage() { + cat <<'USAGE' +Usage : sudo bash script/install/install_vpn.sh + +Pilotes connus : + l2tp_ipsec strongSwan + xl2tpd + pppd (L2TP/IPsec à clé pré-partagée) + wireguard wireguard-tools + openvpn openvpn + openconnect openconnect + vpnc-scripts + sshuttle sshuttle (sur RHEL/Rocky/Alma : dépôt EPEL requis) + +Sans argument : tous les pilotes. +USAGE +} + +check_root() { + [ "$(id -u)" -eq 0 ] || die "à lancer en root : sudo bash $0 $*" +} + +detect_os() { + [ -f /etc/os-release ] || die "OS indéterminable (pas de /etc/os-release)" + # shellcheck disable=SC1091 + . /etc/os-release + OS="${ID}" + OS_LIKE="${ID_LIKE:-}" + log "OS détecté : ${OS}" +} + +family() { + case "$OS" in + ubuntu|debian|linuxmint|pop|elementary|raspbian) echo debian; return ;; + arch|manjaro|endeavouros|artix|garuda) echo arch; return ;; + fedora|rhel|centos|almalinux|rocky) echo rhel; return ;; + opensuse*|sles|sled) echo suse; return ;; + esac + case "$OS_LIKE" in + *debian*|*ubuntu*) echo debian; return ;; + *arch*) echo arch; return ;; + *rhel*|*fedora*) echo rhel; return ;; + *suse*) echo suse; return ;; + esac + die "famille de distribution inconnue : ${OS} (ID_LIKE=${OS_LIKE})" +} + +# Les paquets, par pilote puis par famille. `strongswan-starter` fournit la +# commande `ipsec` et le démon starter, que le pilote L2TP utilise ; les +# paquets `charon-systemd`/`swanctl` seuls ne la fournissent PAS. +packages_for() { + local driver="$1" fam="$2" + case "${driver}:${fam}" in + # libstrongswan-standard-plugins apporte le greffon openssl, et + # avec lui 3DES. Sans ce paquet, charon ANNONCE 3DES, le + # concentrateur le choisit — c'est souvent le seul qu'il connaisse — + # et la négociation meurt sur « ENCRYPTION_ALGORITHM 3DES_CBC not + # supported! ». Mesuré sur Ubuntu 24.04 : greffons chargés sans lui, + # « aes md5 rc2 sha1 », donc pas de 3DES. + l2tp_ipsec:debian) + echo "strongswan strongswan-starter libstrongswan-standard-plugins libcharon-extra-plugins xl2tpd ppp" ;; + l2tp_ipsec:arch) echo "strongswan xl2tpd ppp" ;; + l2tp_ipsec:rhel) echo "strongswan xl2tpd ppp" ;; + l2tp_ipsec:suse) echo "strongswan xl2tpd ppp" ;; + + # wireguard-tools fournit wg ET wg-quick. Le module noyau est dans + # Linux depuis 5.6 : rien à compiler sur les distributions visées. + wireguard:*) echo "wireguard-tools" ;; + + openvpn:*) echo "openvpn" ;; + + # vpnc-scripts porte le script que openconnect appelle pour poser + # les routes et le DNS. Sans lui, la session s'ouvre et la machine + # ne voit rien passer. + openconnect:debian) echo "openconnect vpnc-scripts" ;; + openconnect:*) echo "openconnect" ;; + + sshuttle:*) echo "sshuttle" ;; + + *) die "pilote inconnu : ${driver}. Voir --help." ;; + esac +} + +install_packages() { + local fam="$1"; shift + log "Installation : $*" + case "$fam" in + debian) + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + # shellcheck disable=SC2086 + apt-get install -y --no-install-recommends $* + ;; + arch) + # shellcheck disable=SC2086 + pacman -Sy --needed --noconfirm $* + ;; + rhel) + # shellcheck disable=SC2086 + { command -v dnf >/dev/null && dnf install -y $*; } \ + || yum install -y $* + ;; + suse) + # shellcheck disable=SC2086 + zypper --non-interactive install $* + ;; + esac +} + +disable_autostart() { + # Un service lancé au boot tient le port et fait échouer l'instance + # dédiée au profil. On les arrête et on les désactive : le pilote + # démarre ce dont il a besoin, quand il en a besoin. + command -v systemctl >/dev/null || return 0 + for unit in xl2tpd strongswan-starter strongswan ipsec; do + if systemctl list-unit-files "${unit}.service" >/dev/null 2>&1 \ + && systemctl is-enabled "${unit}.service" >/dev/null 2>&1; then + log "désactivation de ${unit}.service (le pilote le pilote)" + systemctl disable --now "${unit}.service" >/dev/null 2>&1 || true + fi + done +} + +# Les binaires que chaque pilote exige, en miroir de `binaries` dans +# script/vpn/drivers/. Un paquet installé sans son binaire (nom changé, +# dépôt incomplet) doit être vu ICI, pas au premier montage. +binaries_for() { + case "$1" in + l2tp_ipsec) echo "ipsec xl2tpd pppd ip" ;; + wireguard) echo "wg wg-quick ip" ;; + openvpn) echo "openvpn ip" ;; + openconnect) echo "openconnect ip" ;; + sshuttle) echo "sshuttle ssh" ;; + esac +} + +verify() { + local driver="$1" missing="" + for b in $(binaries_for "$driver"); do + command -v "$b" >/dev/null || missing="${missing} ${b}" + done + if [ -n "$missing" ]; then + die "toujours absents après installation :${missing}" + fi + log "vérifié : tout est en place pour ${driver}" +} + +ALL_DRIVERS="l2tp_ipsec wireguard openvpn openconnect sshuttle" + +main() { + case "${1:-}" in + -h|--help) usage; exit 0 ;; + esac + check_root "$@" + detect_os + local fam drivers + fam="$(family)" + # Sans argument : tout. C'est ce que « [8] Installer les paquets + # client » demande quand on ne choisit pas de technologie. + drivers="${*:-${ALL_DRIVERS}}" + for driver in ${drivers}; do + log "── ${driver} ──" + install_packages "$fam" "$(packages_for "$driver" "$fam")" + verify "$driver" + done + disable_autostart + log "Terminé. Monter un tunnel : ./script/vpn/vpn.py up --profile " +} + +main "$@" diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index edcb6e4..6e9c782 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -101,6 +101,16 @@ class KdbxManager: print(t("kdbx_give_up")) return None + def adopt(self, kdbx) -> None: + """Prend pour la session une base DÉJÀ ouverte. + + Sert au moment où le coffre vient d'être CRÉÉ : `create_database` + rend la base ouverte, et sans cela le mot de passe maître serait + redemandé dans la seconde qui suit — à quelqu'un qui vient de le + taper deux fois. + """ + self._kdbx = kdbx + def get_extra_command_user( self, kdbx_key: str | list | None ) -> tuple[str | list, dict]: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4339082..b138590 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -11356,6 +11356,351 @@ TRANSLATIONS = { "fr": "intactes :", "en": "alone:", }, + # VPN (script/todo/vpn_menu.py, script/vpn/) + "VPN & tunnels": { + "fr": "🔐 VPN et tunnels", + "en": "🔐 VPN & tunnels", + }, + "VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)": { + "fr": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)", + "en": "🚇 VPN - Tunnels (L2TP/IPsec, WireGuard, OpenVPN...)", + }, + "VPN tunnels: connect, profiles, vault secrets": { + "fr": "Tunnels VPN : connexion, profils, secrets du coffre", + "en": "VPN tunnels: connect, profiles, vault secrets", + }, + "Connection": { + "fr": "🔗 Connexion", + "en": "🔗 Connection", + }, + "VPN - Connect a profile": { + "fr": "🟢 VPN - Connecter un profil", + "en": "🟢 VPN - Connect a profile", + }, + "VPN - Disconnect a profile": { + "fr": "🔴 VPN - Déconnecter un profil", + "en": "🔴 VPN - Disconnect a profile", + }, + "VPN - Status and diagnosis": { + "fr": "🩺 VPN - État et diagnostic", + "en": "🩺 VPN - Status and diagnosis", + }, + "Profiles & secrets": { + "fr": "🗂 Profils et secrets", + "en": "🗂 Profiles & secrets", + }, + "VPN - Add or edit a profile": { + "fr": "📝 VPN - Ajouter ou modifier un profil", + "en": "📝 VPN - Add or edit a profile", + }, + "VPN - Store secrets in the vault": { + "fr": "🔑 VPN - Déposer les secrets dans le coffre", + "en": "🔑 VPN - Store secrets in the vault", + }, + "VPN - Show the rendered configuration (dry-run)": { + "fr": "🔍 VPN - Afficher la configuration rendue (à blanc)", + "en": "🔍 VPN - Show the rendered configuration (dry-run)", + }, + "VPN - Delete a profile": { + "fr": "🗑 VPN - Supprimer un profil", + "en": "🗑 VPN - Delete a profile", + }, + "VPN - Install the client packages": { + "fr": "📦 VPN - Installer les paquets client", + "en": "📦 VPN - Install the client packages", + }, + "VPN - What can this machine do?": { + "fr": "🧰 VPN - Ce que cette machine sait faire", + "en": "🧰 VPN - What can this machine do?", + }, + "Which technology?": { + "fr": "Quelle technologie ?", + "en": "Which technology?", + }, + "No VPN profile yet: create one first.": { + "fr": "Aucun profil VPN : en créer un d'abord.", + "en": "No VPN profile yet: create one first.", + }, + "all traffic": { + "fr": "tout le trafic", + "en": "all traffic", + }, + "Driver": { + "fr": "Pilote", + "en": "Driver", + }, + "PPP user (the one the server authenticates)": { + "fr": "Utilisateur PPP (celui que le serveur authentifie)", + "en": "PPP user (the one the server authenticates)", + }, + "MTU": { + "fr": "MTU", + "en": "MTU", + }, + "Local L2TP port": { + "fr": "Port L2TP local", + "en": "Local L2TP port", + }, + "Use the DNS pushed by the peer?": { + "fr": "Utiliser les DNS poussés par le pair ?", + "en": "Use the DNS pushed by the peer?", + }, + "DNS search domain (optional)": { + "fr": "Domaine de recherche DNS (facultatif)", + "en": "DNS search domain (optional)", + }, + "Server address (hostname or IP)": { + "fr": "Adresse du serveur (nom d'hôte ou IP)", + "en": "Server address (hostname or IP)", + }, + "Networks to reach, comma-separated": { + "fr": "Réseaux à joindre, séparés par des virgules", + "en": "Networks to reach, comma-separated", + }, + "Send ALL traffic through the tunnel?": { + "fr": "Envoyer TOUT le trafic dans le tunnel ?", + "en": "Send ALL traffic through the tunnel?", + }, + "Witness address reachable only through the tunnel (optional)": { + "fr": "Adresse témoin joignable seulement par le tunnel (facultatif)", + "en": "Witness address reachable only through the tunnel (optional)", + }, + "Advanced settings (MTU, L2TP port, DNS)? (y/N)": { + "fr": "Réglages avancés (MTU, port L2TP, DNS) ? (o/N)", + "en": "Advanced settings (MTU, L2TP port, DNS)? (y/N)", + }, + "Profile name (lowercase, digits, - or _)": { + "fr": "Nom du profil (minuscules, chiffres, « - » ou « _ »)", + "en": "Profile name (lowercase, digits, - or _)", + }, + "Profile number (0 to go back)": { + "fr": "Numéro du profil (0 pour revenir)", + "en": "Profile number (0 to go back)", + }, + "Profile saved: ": { + "fr": "Profil enregistré : ", + "en": "Profile saved: ", + }, + "Profile refused: ": { + "fr": "Profil refusé : ", + "en": "Profile refused: ", + }, + "Profile deleted.": { + "fr": "Profil supprimé.", + "en": "Profile deleted.", + }, + "Delete profile": { + "fr": "Supprimer le profil", + "en": "Delete profile", + }, + "Next step: store its secrets in the vault.": { + "fr": "Étape suivante : déposer ses secrets dans le coffre.", + "en": "Next step: store its secrets in the vault.", + }, + "Its vault entry is kept: delete it in KeePassXC.": { + "fr": "Son entrée du coffre est conservée : la supprimer dans KeePassXC.", + "en": "Its vault entry is kept: delete it in KeePassXC.", + }, + "Not deletable here: this profile comes from a shared configuration file.": { + "fr": "Pas supprimable d'ici : ce profil vient d'un fichier de configuration partagé.", + "en": "Not deletable here: this profile comes from a shared configuration file.", + }, + "Unknown driver: ": { + "fr": "Pilote inconnu : ", + "en": "Unknown driver: ", + }, + "Unknown choice.": { + "fr": "Choix inconnu.", + "en": "Unknown choice.", + }, + "The installation requires sudo.": { + "fr": "L'installation demande sudo.", + "en": "The installation requires sudo.", + }, + "Run this plan? (y/N): ": { + "fr": "Exécuter ce plan ? (o/N) : ", + "en": "Run this plan? (y/N): ", + }, + "No vault: nothing stored.": { + "fr": "Pas de coffre : rien n'a été déposé.", + "en": "No vault: nothing stored.", + }, + "Vault entry": { + "fr": "Entrée du coffre", + "en": "Vault entry", + }, + "An empty answer keeps the stored value.": { + "fr": "Une réponse vide garde la valeur déjà en place.", + "en": "An empty answer keeps the stored value.", + }, + "Secrets stored in the vault.": { + "fr": "Secrets déposés dans le coffre.", + "en": "Secrets stored in the vault.", + }, + "Confirm": { + "fr": "Confirmer", + "en": "Confirm", + }, + "The two entries differ, nothing stored.": { + "fr": "Les deux saisies diffèrent, rien n'a été déposé.", + "en": "The two entries differ, nothing stored.", + }, + "The vault MASTER password is stored in the configuration in clear text. Remove it and type it on demand.": { + "fr": "Le mot de passe MAÎTRE du coffre est écrit en clair dans la configuration. Le retirer et le saisir à la demande.", + "en": "The vault MASTER password is stored in the configuration in clear text. Remove it and type it on demand.", + }, + # VPN — pilotes de la phase 2 et 3 (WireGuard, OpenVPN, OpenConnect, sshuttle) + "never mounted against a real server: only unit tests cover it": { + "fr": ( + "jamais monté contre un vrai serveur : seuls les tests" + " unitaires le couvrent" + ), + "en": "never mounted against a real server: only unit tests cover it", + }, + "When the far side imposes it: a router, a firewall, Windows RRAS": { + "fr": "Quand le site l'impose : un routeur, un pare-feu, Windows RRAS", + "en": "When the far side imposes it: a router, a firewall, Windows RRAS", + }, + "When you control both ends: the fastest and the simplest": { + "fr": "Quand on tient les deux bouts : le plus rapide et le plus simple", + "en": "When you control both ends: the fastest and the simplest", + }, + "When the site handed you a .ovpn file": { + "fr": "Quand le site a fourni un fichier .ovpn", + "en": "When the site handed you a .ovpn file", + }, + "Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances": { + "fr": "Boîtiers Cisco AnyConnect, Pulse, GlobalProtect, Fortinet", + "en": "Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances", + }, + "When all you have is SSH access: nothing to install on the far side": { + "fr": "Quand on n'a qu'un accès SSH : rien à installer en face", + "en": "When all you have is SSH access: nothing to install on the far side", + }, + "IPsec pre-shared key (PSK)": { + "fr": "Clé pré-partagée IPsec (PSK)", + "en": "IPsec pre-shared key (PSK)", + }, + "PPP password": { + "fr": "Mot de passe PPP", + "en": "PPP password", + }, + "WireGuard private key of this machine": { + "fr": "Clé privée WireGuard de cette machine", + "en": "WireGuard private key of this machine", + }, + "WireGuard pre-shared key (optional)": { + "fr": "Clé pré-partagée WireGuard (facultative)", + "en": "WireGuard pre-shared key (optional)", + }, + "OpenVPN password": { + "fr": "Mot de passe OpenVPN", + "en": "OpenVPN password", + }, + "VPN password": { + "fr": "Mot de passe du VPN", + "en": "VPN password", + }, + "Address of this machine inside the tunnel (10.7.0.2/32)": { + "fr": "Adresse de cette machine dans le tunnel (10.7.0.2/32)", + "en": "Address of this machine inside the tunnel (10.7.0.2/32)", + }, + "Public key of the peer": { + "fr": "Clé publique du pair", + "en": "Public key of the peer", + }, + "WireGuard endpoint port": { + "fr": "Port de l'endpoint WireGuard", + "en": "WireGuard endpoint port", + }, + "DNS server inside the tunnel (optional)": { + "fr": "Serveur DNS dans le tunnel (facultatif)", + "en": "DNS server inside the tunnel (optional)", + }, + "PersistentKeepalive, in seconds": { + "fr": "PersistentKeepalive, en secondes", + "en": "PersistentKeepalive, in seconds", + }, + "Path to the .ovpn file provided by the site": { + "fr": "Chemin du fichier .ovpn fourni par le site", + "en": "Path to the .ovpn file provided by the site", + }, + "OpenVPN user (empty if the file authenticates by certificate)": { + "fr": "Utilisateur OpenVPN (vide si le fichier authentifie par certificat)", + "en": "OpenVPN user (empty if the file authenticates by certificate)", + }, + "VPN user": { + "fr": "Utilisateur du VPN", + "en": "VPN user", + }, + "Protocol (anyconnect, nc, pulse, gp, f5, fortinet, array)": { + "fr": "Protocole (anyconnect, nc, pulse, gp, f5, fortinet, array)", + "en": "Protocol (anyconnect, nc, pulse, gp, f5, fortinet, array)", + }, + "Authentication group / realm (optional)": { + "fr": "Groupe d'authentification / royaume (facultatif)", + "en": "Authentication group / realm (optional)", + }, + "Pinned server certificate (sha256:... , printed on first refusal)": { + "fr": "Certificat serveur épinglé (sha256:… , imprimé au premier refus)", + "en": "Pinned server certificate (sha256:... , printed on first refusal)", + }, + "HTTPS port": { + "fr": "Port HTTPS", + "en": "HTTPS port", + }, + "SSH port": { + "fr": "Port SSH", + "en": "SSH port", + }, + "SSH target (user@host, or a ~/.ssh/config alias)": { + "fr": "Cible SSH (utilisateur@hôte, ou un alias de ~/.ssh/config)", + "en": "SSH target (user@host, or a ~/.ssh/config alias)", + }, + "Also send DNS queries through the tunnel?": { + "fr": "Envoyer aussi les requêtes DNS dans le tunnel ?", + "en": "Also send DNS queries through the tunnel?", + }, + "Advanced settings? (y/N)": { + "fr": "Réglages avancés ? (o/N)", + "en": "Advanced settings? (y/N)", + }, + "No secret to store: this one authenticates over SSH.": { + "fr": "Aucun secret à déposer : celui-là s'authentifie par SSH.", + "en": "No secret to store: this one authenticates over SSH.", + }, + "Several technologies match: ": { + "fr": "Plusieurs technologies correspondent : ", + "en": "Several technologies match: ", + }, + "Authentication through a web form (SAML / SSO)?": { + "fr": "Authentification par formulaire web (SAML / SSO) ?", + "en": "Authentication through a web form (SAML / SSO)?", + }, + "Browser command for SSO (empty: show the URL to open yourself)": { + "fr": "Programme navigateur pour le SSO (vide : afficher l'URL à ouvrir soi-même)", + "en": "Browser command for SSO (empty: show the URL to open yourself)", + }, + "Vault permissions tightened to 0600, it was readable by others: ": { + "fr": "Permissions du coffre resserrées à 0600, il était lisible par d'autres : ", + "en": "Vault permissions tightened to 0600, it was readable by others: ", + }, + "already set": { + "fr": "déjà en place", + "en": "already set", + }, + "empty": { + "fr": "vide", + "en": "empty", + }, + "Still missing, the tunnel will not come up: ": { + "fr": "Toujours manquant, le tunnel ne montera pas : ", + "en": "Still missing, the tunnel will not come up: ", + }, + "No network routed yet: this tunnel will only reach the remote host. Connect once — the address you get tells you which network to add.": { + "fr": "Aucun réseau routé pour l'instant : ce tunnel ne joindra que l'hôte distant. Monter une fois — l'adresse obtenue dira quel réseau ajouter.", + "en": "No network routed yet: this tunnel will only reach the remote host. Connect once — the address you get tells you which network to add.", + }, } diff --git a/script/vpn/README.base.md b/script/vpn/README.base.md new file mode 100644 index 0000000..ce8fcee --- /dev/null +++ b/script/vpn/README.base.md @@ -0,0 +1,407 @@ + + + + + + +# VPN — five open tunnels, secrets in a KeePassXC vault + +`vpn.py` brings up, tears down and diagnoses a VPN tunnel. One driver per +technology, five of them, all free software. + +The split is the whole design: what is *not* secret (host, user, routes, MTU) +lives in readable JSON configuration; the pre-shared keys and the passwords +live in a KeePassXC `.kdbx` vault. A profile can therefore be shown, compared +and shared without handing over the means to bring the tunnel up. + + +# VPN — cinq tunnels ouverts, secrets dans un coffre KeePassXC + +`vpn.py` monte, démonte et diagnostique un tunnel VPN. Un pilote par +technologie, cinq en tout, tous libres. + +Le partage est tout le dispositif : ce qui n'est PAS secret (hôte, +utilisateur, routes, MTU) vit dans une configuration JSON lisible ; les clés +pré-partagées et les mots de passe vivent dans un coffre KeePassXC `.kdbx`. Un +profil peut donc être montré, comparé, partagé — sans donner de quoi monter le +tunnel. + + +## Which one to pick + +| Driver | Pick it when | Secrets in the vault | +|--------|--------------|----------------------| +| `l2tp_ipsec` | the far side imposes it: a router, a firewall, Windows RRAS | PSK + PPP password | +| `wireguard` | you control both ends — fastest, simplest | private key (+ optional PSK) | +| `openvpn` | the site handed you a `.ovpn` file | password, if the file needs one | +| `openconnect` | Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances | password | +| `sshuttle` | all you have is SSH access — nothing to install on the far side | none: SSH keys do the work | + + +## Lequel choisir + +| Pilote | À prendre quand | Secrets dans le coffre | +|--------|-----------------|------------------------| +| `l2tp_ipsec` | le site l'impose : un routeur, un pare-feu, Windows RRAS | PSK + mot de passe PPP | +| `wireguard` | on tient les deux bouts — le plus rapide, le plus simple | clé privée (+ PSK facultative) | +| `openvpn` | le site a fourni un fichier `.ovpn` | mot de passe, si le fichier en veut un | +| `openconnect` | boîtiers Cisco AnyConnect, Pulse, GlobalProtect, Fortinet | mot de passe | +| `sshuttle` | on n'a qu'un accès SSH — rien à installer en face | aucun : les clés SSH suffisent | + + +## Commands + + +## Commandes + + +```bash +./script/vpn/vpn.py check # ce que la machine sait faire +sudo bash script/install/install_vpn.sh wireguard # ou : tous, sans argument +./script/vpn/vpn.py list +./script/vpn/vpn.py up --profile acme --dry-run +./script/vpn/vpn.py up --profile acme +./script/vpn/vpn.py status --profile acme +./script/vpn/vpn.py diagnose --profile acme +./script/vpn/vpn.py down --profile acme +``` + + +Everything is also reachable from the CLI: **TODO › Execute › Deployment › +VPN**, and from **TODO › Execute › Network › VPN** — a tunnel gets looked for +in both places. The menu is where profiles are created and secrets are typed +in; `vpn.py` is what the menu runs. Connecting from the menu shows the plan +first and asks before running it. + +Run it as **yourself, not under sudo**: the vault lives in your home and its +master password is yours to type. Each privileged step calls `sudo` on its +own, and `--dry-run` shows every one of them without running any. + + +Tout est aussi accessible depuis le CLI : **TODO › Execute › Déploiement › +VPN**, et depuis **TODO › Execute › Réseau › VPN** — un tunnel se cherche aux +deux endroits. Le menu est là pour créer les profils et saisir les secrets ; +`vpn.py` est ce que le menu lance. Se connecter depuis le menu montre d'abord +le plan, et demande avant de l'exécuter. + +À lancer en tant qu'**utilisateur, pas sous sudo** : le coffre est dans votre +home et son mot de passe maître est le vôtre. Chaque étape privilégiée appelle +`sudo` séparément, et `--dry-run` les montre toutes sans en exécuter aucune. + + +## Where things live + +| Path | Content | +|------|---------| +| `private/todo/todo_override_private.json` | your profiles — gitignored, 0600 | +| `script/todo/todo.json` | the `vpn` section, empty: profiles shared by a team can go here | +| your `.kdbx` vault | one entry per profile, `ERPLibre VPN / ` | +| `/dev/shm/erplibre-vpn//` | 0700 root — **the secrets**, in tmpfs, erased on `down` | +| `/run/erplibre-vpn/.*` | non-secret state (chosen interface, pid, log), readable without sudo | +| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP only: a marked block, removed on `down` | + + +## Où vivent les choses + +| Chemin | Contenu | +|--------|---------| +| `private/todo/todo_override_private.json` | vos profils — gitignored, 0600 | +| `script/todo/todo.json` | la section `vpn`, vide : les profils partagés par une équipe peuvent y aller | +| votre coffre `.kdbx` | une entrée par profil, `ERPLibre VPN / ` | +| `/dev/shm/erplibre-vpn//` | 0700 root — **les secrets**, en tmpfs, effacés au `down` | +| `/run/erplibre-vpn/.*` | l'état non secret (interface retenue, pid, journal), lisible sans sudo | +| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP seulement : un bloc marqué, retiré au `down` | + + +## The three security rules + +1. **No secret in an argument.** `/proc//cmdline` is readable by every + user of the machine. Secrets travel on standard input only; a single place + (`runner.py`) holds that rule, and a unit test replays the plan of **every** + driver and fails if a secret ever reaches a command line. +2. **No secret on persistent storage.** The files a technology insists on are + written 0600 into tmpfs and erased on `down`. Two drivers need none at all: + OpenConnect passes the password on standard input (`--passwd-on-stdin`), and + sshuttle has no secret to begin with. One residual, stated rather than + hidden: while an L2TP tunnel is up, root can read the pppd options file. + pppd takes a password from a file or nothing. +3. **The master password is written nowhere.** Leave `kdbx.password` empty; it + is asked once per session. Only the vault *path* is stored, in the single + gitignored file. The CLI says so when it finds a master password in the + configuration. + +The L2TP PSK reaches strongSwan **hex-encoded** (`PSK 0x…`): same bytes, and +no question of escaping a `"` or a `\` inside a pre-shared key. + + +## Les trois règles de sécurité + +1. **Aucun secret en argument.** `/proc//cmdline` est lisible par tout + utilisateur de la machine. Les secrets ne passent que par l'entrée + standard ; un seul endroit (`runner.py`) porte cette règle, et un test + unitaire rejoue le plan de **chaque** pilote et échoue si un secret atteint + une ligne de commande. +2. **Aucun secret sur un disque persistant.** Les fichiers qu'une technologie + exige sont écrits en 0600 dans un tmpfs et effacés au `down`. Deux pilotes + n'en ont aucun : OpenConnect passe le mot de passe par l'entrée standard + (`--passwd-on-stdin`), et sshuttle n'a pas de secret du tout. Un résiduel, + dit plutôt que caché : tant qu'un tunnel L2TP est monté, root peut lire le + fichier d'options pppd. pppd prend un mot de passe dans un fichier, ou pas + du tout. +3. **Le mot de passe maître ne s'écrit nulle part.** Laisser `kdbx.password` + vide ; il est demandé une fois par session. Seul le *chemin* du coffre est + retenu, dans le seul fichier gitignored. Le CLI le signale s'il trouve un + mot de passe maître dans la configuration. + +Le PSK L2TP arrive à strongSwan **en hexadécimal** (`PSK 0x…`) : mêmes octets, +et plus aucune question d'échappement d'un `"` ou d'un `\` dans une clé +pré-partagée. + + +## What each driver settles for you + +**L2TP/IPsec** — three stages, and all three are needed for an interface: +IPsec in **transport** mode protects UDP 1701, L2TP opens a session inside it, +PPP authenticates. Six pitfalls are handled here, all six found by connecting +to a real concentrator: + +- `charon { install_routes = no }`, otherwise charon installs a route that + captures the L2TP traffic — the classic *"the SA is established, ppp0 never + appears"*. +- An **AppArmor** rule. AppArmor confines charon by path and `/dev/shm` is not + in its profile, so charon is denied the secrets file by the kernel and fails + three stages later on *"no shared key found"* — with the PSK sitting there, + correct. Only `journalctl -k | grep DENIED` says so. The rule goes in the + `local/` file Debian and Ubuntu provide for exactly this. +- **`rightid=%any`**. A gateway announces itself by its IP even when `right` + is a name; without this, strongSwan refuses: *"IDir '203.0.113.5' does not + match to 'vpn.example.com'"*. +- **A wait for the connection to load.** `ipsec start` returns before the + starter has pushed the connections; an immediate `ipsec up` fails on *"no + match"* — on a perfectly valid configuration, the most misleading error of + the sequence. +- **The direction of authentication.** `require chap` / `require + authentication` (xl2tpd) and `require-mschap-v2` (pppd) all mean *require + the PEER to authenticate to us*. A client must not: the server refuses, and + pppd tears the link down with *"LCP terminated by peer (peer refused to + authenticate)"*. What a client wants is `refuse-pap` and `refuse-eap` — + which speak about **us**. +- A `/32` survival route to the server (in all-traffic mode the ESP packets + would enter the tunnel they carry), and `resolvectl`, because + systemd-resolved ignores `/etc/ppp/resolv.conf`. + +One packaging note that costs an hour if missed: without the **openssl** +plugin (`libstrongswan-standard-plugins`), charon advertises 3DES, the +concentrator picks it — often the only cipher it knows — and the negotiation +dies on *"ENCRYPTION_ALGORITHM 3DES_CBC not supported!"*. The installer ships +it. + +**WireGuard** — it has no session, so `wg-quick up` succeeds even with a wrong +peer key or an unreachable endpoint. Nothing says no, because nobody is there +to say it. This driver therefore **waits for a handshake** before calling the +tunnel up. Routes come from `AllowedIPs` and belong to `wg-quick`; the driver +does not double its work. No `DNS =` line either: wg-quick hands that to +`resolvconf`, missing from many systemd-resolved installs, and the whole +configuration fails when it is. + +**OpenVPN** — it starts from the `.ovpn` the site gave you; this driver does +not invent one. Two things that are not obvious: `--cd`, because a `.ovpn` +references its neighbours relatively; and option order, because what follows +`--config` overrides the file — a bare `auth-user-pass` inside would otherwise +wait for a keystroke that never comes, the daemon being detached. Split tunnel +is asked for with `--route-nopull`, which also drops the pushed DNS; the driver +says so when it takes it. + +**OpenConnect** — `--non-inter` is deliberate in password mode. Without it an +unknown server certificate raises a question, and openconnect would read the +answer from the standard input the password arrives on. With it, openconnect +refuses at once **and** prints the `--servercert sha256:…` line to paste into +the profile's `oc_servercert`. Routes belong to the server, through +`vpnc-script`; the profile can add to them, not replace them. + +Set **`oc_sso`** when the concentrator authenticates through a **web form** +(SAML / SSO — Azure AD, Okta, Duo). There is then no password to send, and +Cisco's own client needs a screen for its embedded WebKit browser — often +with `WEBKIT_DISABLE_DMABUF_RENDERER=1` for it to render at all; its CLI +cannot do this flow. openconnect can, with no screen on the client machine: +measured in its library, it listens on **local port 29786** and waits for the +browser's redirect after launching `--external-browser` with the login URL. +On a server that "browser" is a plain `echo`, so the URL is printed for you to +open in **your own** browser — bring the redirect back with + +```bash +ssh -L 29786:localhost:29786 +``` + +before opening it. The password never leaves your own workstation. Both +timeouts differ on purpose: two minutes for a password, five for a human +walking through an identity provider. + +**sshuttle** — no interface at all: it redirects through the firewall. Every +interface and routing check is therefore silent for it, and the **witness +address** is the only judge — this driver is the reason the `probe` field +exists. It also insists on being run by *you*: it calls sudo itself, for the +firewall only. Running it under sudo would open the SSH session as root, with +root's keys. + + +## Ce que chaque pilote règle pour vous + +**L2TP/IPsec** — trois étages, et il faut les trois pour avoir une interface : +IPsec en mode **transport** protège l'UDP 1701, L2TP ouvre une session dedans, +PPP authentifie. Six pièges réglés ici, les six trouvés en montant un tunnel +vers un vrai concentrateur : + +- `charon { install_routes = no }`, sinon charon pose une route qui capte le + trafic L2TP — le classique *« la SA est établie, ppp0 n'apparaît pas »*. +- Une règle **AppArmor**. AppArmor confine charon par chemin et `/dev/shm` + n'est pas dans son profil : le noyau lui refuse le fichier de secrets, et + l'échec ressort trois étages plus loin en *« no shared key found »* — avec + le PSK bien là, bien formé. Seul `journalctl -k | grep DENIED` le dit. La + règle va dans le fichier `local/` que Debian et Ubuntu prévoient pour ça. +- **`rightid=%any`**. Une passerelle s'annonce par son IP même quand `right` + est un nom ; sans cela, strongSwan refuse : *« IDir '203.0.113.5' does not + match to 'vpn.exemple.com' »*. +- **Une attente du chargement de la connexion.** `ipsec start` rend la main + avant que le starter ait poussé les connexions ; un `ipsec up` immédiat + échoue sur *« no match »* — sur une configuration parfaitement valide, + l'erreur la plus trompeuse de la séquence. +- **Le sens de l'authentification.** `require chap` / `require + authentication` (xl2tpd) et `require-mschap-v2` (pppd) veulent tous dire + *exiger que le PAIR s'authentifie auprès de nous*. Un client ne doit pas : + le serveur refuse, et pppd coupe la liaison — *« LCP terminated by peer + (peer refused to authenticate) »*. Ce qu'un client veut, c'est `refuse-pap` + et `refuse-eap`, qui parlent de **nous**. +- Une route de survie `/32` vers le serveur (en mode « tout le trafic », les + paquets ESP entreraient dans le tunnel qu'ils portent), et `resolvectl`, + parce que systemd-resolved ignore `/etc/ppp/resolv.conf`. + +Une note d'empaquetage qui coûte une heure si on la manque : sans le greffon +**openssl** (`libstrongswan-standard-plugins`), charon annonce 3DES, le +concentrateur le choisit — c'est souvent le seul qu'il connaisse — et la +négociation meurt sur *« ENCRYPTION_ALGORITHM 3DES_CBC not supported! »*. +L'installateur le livre. + +**WireGuard** — il n'a pas de session, donc `wg-quick up` réussit même avec +une clé de pair fausse ou un endpoint injoignable. Rien ne dit non, parce +qu'il n'y a personne pour le dire. Ce pilote **attend donc une poignée de +main** avant de déclarer le tunnel monté. Les routes viennent d'`AllowedIPs` +et appartiennent à `wg-quick` ; le pilote ne double pas son travail. Pas de +ligne `DNS =` non plus : wg-quick la confie à `resolvconf`, absent de beaucoup +d'installations systemd-resolved, et c'est la configuration entière qui échoue +alors. + +**OpenVPN** — il part du `.ovpn` que le site a fourni ; ce pilote n'en +fabrique pas. Deux choses qu'on aurait tort de croire évidentes : `--cd`, +parce qu'un `.ovpn` référence ses voisins en relatif ; et l'ordre des options, +parce que ce qui suit `--config` l'emporte sur le fichier — un +`auth-user-pass` nu dedans ferait sinon attendre une saisie qui ne viendra +jamais, le démon étant détaché. Le tunnel scindé se demande par +`--route-nopull`, qui écarte aussi le DNS poussé ; le pilote le dit quand il +le prend. + +**OpenConnect** — `--non-inter` est voulu en mode mot de passe. Sans lui, un +certificat serveur inconnu déclenche une question, et openconnect la lirait +sur l'entrée standard par laquelle arrive le mot de passe. Avec lui, +openconnect refuse tout de suite **et** imprime la ligne +`--servercert sha256:…` à recopier dans le champ `oc_servercert` du profil. +Les routes appartiennent au serveur, via `vpnc-script` ; le profil peut en +ajouter, pas les remplacer. + +Cocher **`oc_sso`** quand le concentrateur authentifie par un **formulaire +web** (SAML / SSO — Azure AD, Okta, Duo). Il n'y a alors aucun mot de passe à +envoyer, et le client de Cisco réclame un écran pour son navigateur WebKit +embarqué — souvent avec `WEBKIT_DISABLE_DMABUF_RENDERER=1` pour qu'il +s'affiche ; son CLI, lui, ne sait pas faire cet échange. openconnect le fait +sans écran sur la machine cliente : mesuré dans sa bibliothèque, il écoute sur +le **port local 29786** et attend la redirection du navigateur, après avoir +lancé `--external-browser` avec l'URL de connexion. Sur un serveur, ce +« navigateur » est un simple `echo` : l'URL s'affiche, et on l'ouvre dans +**son propre** navigateur — en faisant revenir la redirection par + +```bash +ssh -L 29786:localhost:29786 +``` + +avant de l'ouvrir. Le mot de passe ne quitte jamais votre poste. Les deux +délais diffèrent exprès : deux minutes pour un mot de passe, cinq pour un +humain qui traverse un fournisseur d'identité. + +**sshuttle** — aucune interface : il détourne par le pare-feu. Toutes les +vérifications d'interface et de routage sont donc muettes pour lui, et +l'**adresse témoin** est le seul juge — ce pilote est la raison d'être du +champ `probe`. Il exige aussi d'être lancé par *vous* : il appelle sudo +lui-même, pour le pare-feu seulement. Le lancer sous sudo ferait ouvrir la +session SSH par root, avec les clés de root. + + +## Diagnosing + +`diagnose` chains the checks and names the failing stage, lowest first, so +that the first false line is the cause and not a consequence: what the +**kernel** exposes · packages present · the technology's own check (IPsec SA, +WireGuard handshake, daemon alive, OpenVPN initialisation) · interface and +addresses · each declared route · the witness address that only answers +through the tunnel · the last lines of the relevant journal. Set `probe` in +the profile to an address reachable only through the tunnel — without it, +*"it works"* stays an impression, and for sshuttle there is nothing else to +go on. + +The kernel stage catches a failure no configuration can fix. Upgrading the +kernel package replaces `/lib/modules/` with the new version's: +the running kernel keeps the modules already loaded and can load no other. +IPsec then becomes unavailable on a kernel that supports it, charon aborts +at initialisation on a missing `kernel-ipsec`, and the symptom surfaces three +stages higher as a connection never loaded. `diagnose` and `up` name the +version whose modules are gone and offer the only remedy — a reboot. It is +offered, never done: nothing is applied on a dry run, nor without a terminal +to answer. + + +## Diagnostiquer + +`diagnose` enchaîne les vérifications et nomme l'étage fautif, du plus bas au +plus haut pour que la première ligne fausse soit la cause et non une +conséquence : ce que le **noyau** expose · paquets présents · la vérification +propre à la technologie (SA IPsec, poignée de main WireGuard, démon vivant, +initialisation OpenVPN) · interface et adresses · chaque route déclarée · +l'adresse témoin qui ne répond qu'à travers le tunnel · les dernières lignes +du journal concerné. Mettre `probe` dans le profil à une adresse joignable +seulement par le tunnel — sans elle, *« ça marche »* reste une impression, et +pour sshuttle il n'y a rien d'autre. + +L'étage du noyau attrape une panne qu'aucune configuration ne rattrape. +Mettre à jour le paquet du noyau remplace `/lib/modules/` par celle +de la version neuve : le noyau qui tourne garde les modules déjà chargés et +ne peut plus en charger aucun autre. L'IPsec devient alors indisponible sur +un noyau qui le prend en charge, charon abandonne à l'initialisation sur un +`kernel-ipsec` manquant, et le symptôme ressort trois étages plus haut en +connexion jamais chargée. `diagnose` et `up` nomment la version dont les +modules ont disparu et proposent le seul remède : redémarrer. C'est proposé, +jamais fait : rien n'est appliqué à blanc, ni sans terminal pour répondre. + + +## Adding a driver + +`drivers/base.py` states the contract *and* carries everything true of all +technologies: directory layout, state kept between processes, routes, +systemd-resolved, the standard status checks. A new driver declares what is +its own — packages, secrets, profile fields, the form the menu unrolls, the +sequence up and down — and executes nothing: it asks a `Runner`, which either +runs or merely shows. Registering it is one line in `drivers/__init__.py`, and +`test_vpn_drivers.py` picks it up from the registry: the no-secret-on-a-command +-line rule applies to it whether or not anyone thought about it. + + +## Ajouter un pilote + +`drivers/base.py` énonce le contrat *et* porte tout ce qui est vrai de toutes +les technologies : disposition des répertoires, état gardé entre deux +processus, routes, systemd-resolved, vérifications d'état habituelles. Un +pilote nouveau déclare ce qui lui est propre — paquets, secrets, champs de +profil, formulaire que le menu déroule, séquence de montée et de descente — et +n'exécute rien : il demande à un `Runner`, qui exécute ou se contente de +montrer. L'enregistrer tient en une ligne dans `drivers/__init__.py`, et +`test_vpn_drivers.py` le prend depuis le registre : la règle « aucun secret +dans une ligne de commande » s'applique à lui, que quelqu'un y ait pensé ou +non. diff --git a/script/vpn/README.fr.md b/script/vpn/README.fr.md new file mode 100644 index 0000000..cb8e5b2 --- /dev/null +++ b/script/vpn/README.fr.md @@ -0,0 +1,202 @@ + +# VPN — cinq tunnels ouverts, secrets dans un coffre KeePassXC + +`vpn.py` monte, démonte et diagnostique un tunnel VPN. Un pilote par +technologie, cinq en tout, tous libres. + +Le partage est tout le dispositif : ce qui n'est PAS secret (hôte, +utilisateur, routes, MTU) vit dans une configuration JSON lisible ; les clés +pré-partagées et les mots de passe vivent dans un coffre KeePassXC `.kdbx`. Un +profil peut donc être montré, comparé, partagé — sans donner de quoi monter le +tunnel. + +## Lequel choisir + +| Pilote | À prendre quand | Secrets dans le coffre | +|--------|-----------------|------------------------| +| `l2tp_ipsec` | le site l'impose : un routeur, un pare-feu, Windows RRAS | PSK + mot de passe PPP | +| `wireguard` | on tient les deux bouts — le plus rapide, le plus simple | clé privée (+ PSK facultative) | +| `openvpn` | le site a fourni un fichier `.ovpn` | mot de passe, si le fichier en veut un | +| `openconnect` | boîtiers Cisco AnyConnect, Pulse, GlobalProtect, Fortinet | mot de passe | +| `sshuttle` | on n'a qu'un accès SSH — rien à installer en face | aucun : les clés SSH suffisent | + +## Commandes + +```bash +./script/vpn/vpn.py check # ce que la machine sait faire +sudo bash script/install/install_vpn.sh wireguard # ou : tous, sans argument +./script/vpn/vpn.py list +./script/vpn/vpn.py up --profile acme --dry-run +./script/vpn/vpn.py up --profile acme +./script/vpn/vpn.py status --profile acme +./script/vpn/vpn.py diagnose --profile acme +./script/vpn/vpn.py down --profile acme +``` + +Tout est aussi accessible depuis le CLI : **TODO › Execute › Déploiement › +VPN**, et depuis **TODO › Execute › Réseau › VPN** — un tunnel se cherche aux +deux endroits. Le menu est là pour créer les profils et saisir les secrets ; +`vpn.py` est ce que le menu lance. Se connecter depuis le menu montre d'abord +le plan, et demande avant de l'exécuter. + +À lancer en tant qu'**utilisateur, pas sous sudo** : le coffre est dans votre +home et son mot de passe maître est le vôtre. Chaque étape privilégiée appelle +`sudo` séparément, et `--dry-run` les montre toutes sans en exécuter aucune. + +## Où vivent les choses + +| Chemin | Contenu | +|--------|---------| +| `private/todo/todo_override_private.json` | vos profils — gitignored, 0600 | +| `script/todo/todo.json` | la section `vpn`, vide : les profils partagés par une équipe peuvent y aller | +| votre coffre `.kdbx` | une entrée par profil, `ERPLibre VPN / ` | +| `/dev/shm/erplibre-vpn//` | 0700 root — **les secrets**, en tmpfs, effacés au `down` | +| `/run/erplibre-vpn/.*` | l'état non secret (interface retenue, pid, journal), lisible sans sudo | +| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP seulement : un bloc marqué, retiré au `down` | + +## Les trois règles de sécurité + +1. **Aucun secret en argument.** `/proc//cmdline` est lisible par tout + utilisateur de la machine. Les secrets ne passent que par l'entrée + standard ; un seul endroit (`runner.py`) porte cette règle, et un test + unitaire rejoue le plan de **chaque** pilote et échoue si un secret atteint + une ligne de commande. +2. **Aucun secret sur un disque persistant.** Les fichiers qu'une technologie + exige sont écrits en 0600 dans un tmpfs et effacés au `down`. Deux pilotes + n'en ont aucun : OpenConnect passe le mot de passe par l'entrée standard + (`--passwd-on-stdin`), et sshuttle n'a pas de secret du tout. Un résiduel, + dit plutôt que caché : tant qu'un tunnel L2TP est monté, root peut lire le + fichier d'options pppd. pppd prend un mot de passe dans un fichier, ou pas + du tout. +3. **Le mot de passe maître ne s'écrit nulle part.** Laisser `kdbx.password` + vide ; il est demandé une fois par session. Seul le *chemin* du coffre est + retenu, dans le seul fichier gitignored. Le CLI le signale s'il trouve un + mot de passe maître dans la configuration. + +Le PSK L2TP arrive à strongSwan **en hexadécimal** (`PSK 0x…`) : mêmes octets, +et plus aucune question d'échappement d'un `"` ou d'un `\` dans une clé +pré-partagée. + +## Ce que chaque pilote règle pour vous + +**L2TP/IPsec** — trois étages, et il faut les trois pour avoir une interface : +IPsec en mode **transport** protège l'UDP 1701, L2TP ouvre une session dedans, +PPP authentifie. Six pièges réglés ici, les six trouvés en montant un tunnel +vers un vrai concentrateur : + +- `charon { install_routes = no }`, sinon charon pose une route qui capte le + trafic L2TP — le classique *« la SA est établie, ppp0 n'apparaît pas »*. +- Une règle **AppArmor**. AppArmor confine charon par chemin et `/dev/shm` + n'est pas dans son profil : le noyau lui refuse le fichier de secrets, et + l'échec ressort trois étages plus loin en *« no shared key found »* — avec + le PSK bien là, bien formé. Seul `journalctl -k | grep DENIED` le dit. La + règle va dans le fichier `local/` que Debian et Ubuntu prévoient pour ça. +- **`rightid=%any`**. Une passerelle s'annonce par son IP même quand `right` + est un nom ; sans cela, strongSwan refuse : *« IDir '203.0.113.5' does not + match to 'vpn.exemple.com' »*. +- **Une attente du chargement de la connexion.** `ipsec start` rend la main + avant que le starter ait poussé les connexions ; un `ipsec up` immédiat + échoue sur *« no match »* — sur une configuration parfaitement valide, + l'erreur la plus trompeuse de la séquence. +- **Le sens de l'authentification.** `require chap` / `require + authentication` (xl2tpd) et `require-mschap-v2` (pppd) veulent tous dire + *exiger que le PAIR s'authentifie auprès de nous*. Un client ne doit pas : + le serveur refuse, et pppd coupe la liaison — *« LCP terminated by peer + (peer refused to authenticate) »*. Ce qu'un client veut, c'est `refuse-pap` + et `refuse-eap`, qui parlent de **nous**. +- Une route de survie `/32` vers le serveur (en mode « tout le trafic », les + paquets ESP entreraient dans le tunnel qu'ils portent), et `resolvectl`, + parce que systemd-resolved ignore `/etc/ppp/resolv.conf`. + +Une note d'empaquetage qui coûte une heure si on la manque : sans le greffon +**openssl** (`libstrongswan-standard-plugins`), charon annonce 3DES, le +concentrateur le choisit — c'est souvent le seul qu'il connaisse — et la +négociation meurt sur *« ENCRYPTION_ALGORITHM 3DES_CBC not supported! »*. +L'installateur le livre. + +**WireGuard** — il n'a pas de session, donc `wg-quick up` réussit même avec +une clé de pair fausse ou un endpoint injoignable. Rien ne dit non, parce +qu'il n'y a personne pour le dire. Ce pilote **attend donc une poignée de +main** avant de déclarer le tunnel monté. Les routes viennent d'`AllowedIPs` +et appartiennent à `wg-quick` ; le pilote ne double pas son travail. Pas de +ligne `DNS =` non plus : wg-quick la confie à `resolvconf`, absent de beaucoup +d'installations systemd-resolved, et c'est la configuration entière qui échoue +alors. + +**OpenVPN** — il part du `.ovpn` que le site a fourni ; ce pilote n'en +fabrique pas. Deux choses qu'on aurait tort de croire évidentes : `--cd`, +parce qu'un `.ovpn` référence ses voisins en relatif ; et l'ordre des options, +parce que ce qui suit `--config` l'emporte sur le fichier — un +`auth-user-pass` nu dedans ferait sinon attendre une saisie qui ne viendra +jamais, le démon étant détaché. Le tunnel scindé se demande par +`--route-nopull`, qui écarte aussi le DNS poussé ; le pilote le dit quand il +le prend. + +**OpenConnect** — `--non-inter` est voulu en mode mot de passe. Sans lui, un +certificat serveur inconnu déclenche une question, et openconnect la lirait +sur l'entrée standard par laquelle arrive le mot de passe. Avec lui, +openconnect refuse tout de suite **et** imprime la ligne +`--servercert sha256:…` à recopier dans le champ `oc_servercert` du profil. +Les routes appartiennent au serveur, via `vpnc-script` ; le profil peut en +ajouter, pas les remplacer. + +Cocher **`oc_sso`** quand le concentrateur authentifie par un **formulaire +web** (SAML / SSO — Azure AD, Okta, Duo). Il n'y a alors aucun mot de passe à +envoyer, et le client de Cisco réclame un écran pour son navigateur WebKit +embarqué — souvent avec `WEBKIT_DISABLE_DMABUF_RENDERER=1` pour qu'il +s'affiche ; son CLI, lui, ne sait pas faire cet échange. openconnect le fait +sans écran sur la machine cliente : mesuré dans sa bibliothèque, il écoute sur +le **port local 29786** et attend la redirection du navigateur, après avoir +lancé `--external-browser` avec l'URL de connexion. Sur un serveur, ce +« navigateur » est un simple `echo` : l'URL s'affiche, et on l'ouvre dans +**son propre** navigateur — en faisant revenir la redirection par + +```bash +ssh -L 29786:localhost:29786 +``` + +avant de l'ouvrir. Le mot de passe ne quitte jamais votre poste. Les deux +délais diffèrent exprès : deux minutes pour un mot de passe, cinq pour un +humain qui traverse un fournisseur d'identité. + +**sshuttle** — aucune interface : il détourne par le pare-feu. Toutes les +vérifications d'interface et de routage sont donc muettes pour lui, et +l'**adresse témoin** est le seul juge — ce pilote est la raison d'être du +champ `probe`. Il exige aussi d'être lancé par *vous* : il appelle sudo +lui-même, pour le pare-feu seulement. Le lancer sous sudo ferait ouvrir la +session SSH par root, avec les clés de root. + +## Diagnostiquer + +`diagnose` enchaîne les vérifications et nomme l'étage fautif, du plus bas au +plus haut pour que la première ligne fausse soit la cause et non une +conséquence : ce que le **noyau** expose · paquets présents · la vérification +propre à la technologie (SA IPsec, poignée de main WireGuard, démon vivant, +initialisation OpenVPN) · interface et adresses · chaque route déclarée · +l'adresse témoin qui ne répond qu'à travers le tunnel · les dernières lignes +du journal concerné. Mettre `probe` dans le profil à une adresse joignable +seulement par le tunnel — sans elle, *« ça marche »* reste une impression, et +pour sshuttle il n'y a rien d'autre. + +L'étage du noyau attrape une panne qu'aucune configuration ne rattrape. +Mettre à jour le paquet du noyau remplace `/lib/modules/` par celle +de la version neuve : le noyau qui tourne garde les modules déjà chargés et +ne peut plus en charger aucun autre. L'IPsec devient alors indisponible sur +un noyau qui le prend en charge, charon abandonne à l'initialisation sur un +`kernel-ipsec` manquant, et le symptôme ressort trois étages plus haut en +connexion jamais chargée. `diagnose` et `up` nomment la version dont les +modules ont disparu et proposent le seul remède : redémarrer. C'est proposé, +jamais fait : rien n'est appliqué à blanc, ni sans terminal pour répondre. + +## Ajouter un pilote + +`drivers/base.py` énonce le contrat *et* porte tout ce qui est vrai de toutes +les technologies : disposition des répertoires, état gardé entre deux +processus, routes, systemd-resolved, vérifications d'état habituelles. Un +pilote nouveau déclare ce qui lui est propre — paquets, secrets, champs de +profil, formulaire que le menu déroule, séquence de montée et de descente — et +n'exécute rien : il demande à un `Runner`, qui exécute ou se contente de +montrer. L'enregistrer tient en une ligne dans `drivers/__init__.py`, et +`test_vpn_drivers.py` le prend depuis le registre : la règle « aucun secret +dans une ligne de commande » s'applique à lui, que quelqu'un y ait pensé ou +non. \ No newline at end of file diff --git a/script/vpn/README.md b/script/vpn/README.md new file mode 100644 index 0000000..8c8f04a --- /dev/null +++ b/script/vpn/README.md @@ -0,0 +1,193 @@ + +# VPN — five open tunnels, secrets in a KeePassXC vault + +`vpn.py` brings up, tears down and diagnoses a VPN tunnel. One driver per +technology, five of them, all free software. + +The split is the whole design: what is *not* secret (host, user, routes, MTU) +lives in readable JSON configuration; the pre-shared keys and the passwords +live in a KeePassXC `.kdbx` vault. A profile can therefore be shown, compared +and shared without handing over the means to bring the tunnel up. + +## Which one to pick + +| Driver | Pick it when | Secrets in the vault | +|--------|--------------|----------------------| +| `l2tp_ipsec` | the far side imposes it: a router, a firewall, Windows RRAS | PSK + PPP password | +| `wireguard` | you control both ends — fastest, simplest | private key (+ optional PSK) | +| `openvpn` | the site handed you a `.ovpn` file | password, if the file needs one | +| `openconnect` | Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances | password | +| `sshuttle` | all you have is SSH access — nothing to install on the far side | none: SSH keys do the work | + +## Commands + +```bash +./script/vpn/vpn.py check # ce que la machine sait faire +sudo bash script/install/install_vpn.sh wireguard # ou : tous, sans argument +./script/vpn/vpn.py list +./script/vpn/vpn.py up --profile acme --dry-run +./script/vpn/vpn.py up --profile acme +./script/vpn/vpn.py status --profile acme +./script/vpn/vpn.py diagnose --profile acme +./script/vpn/vpn.py down --profile acme +``` + +Everything is also reachable from the CLI: **TODO › Execute › Deployment › +VPN**, and from **TODO › Execute › Network › VPN** — a tunnel gets looked for +in both places. The menu is where profiles are created and secrets are typed +in; `vpn.py` is what the menu runs. Connecting from the menu shows the plan +first and asks before running it. + +Run it as **yourself, not under sudo**: the vault lives in your home and its +master password is yours to type. Each privileged step calls `sudo` on its +own, and `--dry-run` shows every one of them without running any. + +## Where things live + +| Path | Content | +|------|---------| +| `private/todo/todo_override_private.json` | your profiles — gitignored, 0600 | +| `script/todo/todo.json` | the `vpn` section, empty: profiles shared by a team can go here | +| your `.kdbx` vault | one entry per profile, `ERPLibre VPN / ` | +| `/dev/shm/erplibre-vpn//` | 0700 root — **the secrets**, in tmpfs, erased on `down` | +| `/run/erplibre-vpn/.*` | non-secret state (chosen interface, pid, log), readable without sudo | +| `/etc/ipsec.conf`, `/etc/ipsec.secrets` | L2TP only: a marked block, removed on `down` | + +## The three security rules + +1. **No secret in an argument.** `/proc//cmdline` is readable by every + user of the machine. Secrets travel on standard input only; a single place + (`runner.py`) holds that rule, and a unit test replays the plan of **every** + driver and fails if a secret ever reaches a command line. +2. **No secret on persistent storage.** The files a technology insists on are + written 0600 into tmpfs and erased on `down`. Two drivers need none at all: + OpenConnect passes the password on standard input (`--passwd-on-stdin`), and + sshuttle has no secret to begin with. One residual, stated rather than + hidden: while an L2TP tunnel is up, root can read the pppd options file. + pppd takes a password from a file or nothing. +3. **The master password is written nowhere.** Leave `kdbx.password` empty; it + is asked once per session. Only the vault *path* is stored, in the single + gitignored file. The CLI says so when it finds a master password in the + configuration. + +The L2TP PSK reaches strongSwan **hex-encoded** (`PSK 0x…`): same bytes, and +no question of escaping a `"` or a `\` inside a pre-shared key. + +## What each driver settles for you + +**L2TP/IPsec** — three stages, and all three are needed for an interface: +IPsec in **transport** mode protects UDP 1701, L2TP opens a session inside it, +PPP authenticates. Six pitfalls are handled here, all six found by connecting +to a real concentrator: + +- `charon { install_routes = no }`, otherwise charon installs a route that + captures the L2TP traffic — the classic *"the SA is established, ppp0 never + appears"*. +- An **AppArmor** rule. AppArmor confines charon by path and `/dev/shm` is not + in its profile, so charon is denied the secrets file by the kernel and fails + three stages later on *"no shared key found"* — with the PSK sitting there, + correct. Only `journalctl -k | grep DENIED` says so. The rule goes in the + `local/` file Debian and Ubuntu provide for exactly this. +- **`rightid=%any`**. A gateway announces itself by its IP even when `right` + is a name; without this, strongSwan refuses: *"IDir '203.0.113.5' does not + match to 'vpn.example.com'"*. +- **A wait for the connection to load.** `ipsec start` returns before the + starter has pushed the connections; an immediate `ipsec up` fails on *"no + match"* — on a perfectly valid configuration, the most misleading error of + the sequence. +- **The direction of authentication.** `require chap` / `require + authentication` (xl2tpd) and `require-mschap-v2` (pppd) all mean *require + the PEER to authenticate to us*. A client must not: the server refuses, and + pppd tears the link down with *"LCP terminated by peer (peer refused to + authenticate)"*. What a client wants is `refuse-pap` and `refuse-eap` — + which speak about **us**. +- A `/32` survival route to the server (in all-traffic mode the ESP packets + would enter the tunnel they carry), and `resolvectl`, because + systemd-resolved ignores `/etc/ppp/resolv.conf`. + +One packaging note that costs an hour if missed: without the **openssl** +plugin (`libstrongswan-standard-plugins`), charon advertises 3DES, the +concentrator picks it — often the only cipher it knows — and the negotiation +dies on *"ENCRYPTION_ALGORITHM 3DES_CBC not supported!"*. The installer ships +it. + +**WireGuard** — it has no session, so `wg-quick up` succeeds even with a wrong +peer key or an unreachable endpoint. Nothing says no, because nobody is there +to say it. This driver therefore **waits for a handshake** before calling the +tunnel up. Routes come from `AllowedIPs` and belong to `wg-quick`; the driver +does not double its work. No `DNS =` line either: wg-quick hands that to +`resolvconf`, missing from many systemd-resolved installs, and the whole +configuration fails when it is. + +**OpenVPN** — it starts from the `.ovpn` the site gave you; this driver does +not invent one. Two things that are not obvious: `--cd`, because a `.ovpn` +references its neighbours relatively; and option order, because what follows +`--config` overrides the file — a bare `auth-user-pass` inside would otherwise +wait for a keystroke that never comes, the daemon being detached. Split tunnel +is asked for with `--route-nopull`, which also drops the pushed DNS; the driver +says so when it takes it. + +**OpenConnect** — `--non-inter` is deliberate in password mode. Without it an +unknown server certificate raises a question, and openconnect would read the +answer from the standard input the password arrives on. With it, openconnect +refuses at once **and** prints the `--servercert sha256:…` line to paste into +the profile's `oc_servercert`. Routes belong to the server, through +`vpnc-script`; the profile can add to them, not replace them. + +Set **`oc_sso`** when the concentrator authenticates through a **web form** +(SAML / SSO — Azure AD, Okta, Duo). There is then no password to send, and +Cisco's own client needs a screen for its embedded WebKit browser — often +with `WEBKIT_DISABLE_DMABUF_RENDERER=1` for it to render at all; its CLI +cannot do this flow. openconnect can, with no screen on the client machine: +measured in its library, it listens on **local port 29786** and waits for the +browser's redirect after launching `--external-browser` with the login URL. +On a server that "browser" is a plain `echo`, so the URL is printed for you to +open in **your own** browser — bring the redirect back with + +```bash +ssh -L 29786:localhost:29786 +``` + +before opening it. The password never leaves your own workstation. Both +timeouts differ on purpose: two minutes for a password, five for a human +walking through an identity provider. + +**sshuttle** — no interface at all: it redirects through the firewall. Every +interface and routing check is therefore silent for it, and the **witness +address** is the only judge — this driver is the reason the `probe` field +exists. It also insists on being run by *you*: it calls sudo itself, for the +firewall only. Running it under sudo would open the SSH session as root, with +root's keys. + +## Diagnosing + +`diagnose` chains the checks and names the failing stage, lowest first, so +that the first false line is the cause and not a consequence: what the +**kernel** exposes · packages present · the technology's own check (IPsec SA, +WireGuard handshake, daemon alive, OpenVPN initialisation) · interface and +addresses · each declared route · the witness address that only answers +through the tunnel · the last lines of the relevant journal. Set `probe` in +the profile to an address reachable only through the tunnel — without it, +*"it works"* stays an impression, and for sshuttle there is nothing else to +go on. + +The kernel stage catches a failure no configuration can fix. Upgrading the +kernel package replaces `/lib/modules/` with the new version's: +the running kernel keeps the modules already loaded and can load no other. +IPsec then becomes unavailable on a kernel that supports it, charon aborts +at initialisation on a missing `kernel-ipsec`, and the symptom surfaces three +stages higher as a connection never loaded. `diagnose` and `up` name the +version whose modules are gone and offer the only remedy — a reboot. It is +offered, never done: nothing is applied on a dry run, nor without a terminal +to answer. + +## Adding a driver + +`drivers/base.py` states the contract *and* carries everything true of all +technologies: directory layout, state kept between processes, routes, +systemd-resolved, the standard status checks. A new driver declares what is +its own — packages, secrets, profile fields, the form the menu unrolls, the +sequence up and down — and executes nothing: it asks a `Runner`, which either +runs or merely shows. Registering it is one line in `drivers/__init__.py`, and +`test_vpn_drivers.py` picks it up from the registry: the no-secret-on-a-command +-line rule applies to it whether or not anyone thought about it. diff --git a/script/vpn/__init__.py b/script/vpn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/script/vpn/drivers/__init__.py b/script/vpn/drivers/__init__.py new file mode 100644 index 0000000..f4025b7 --- /dev/null +++ b/script/vpn/drivers/__init__.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Registre des pilotes VPN. + +Un pilote par technologie, et RIEN d'autre ici : le registre est la seule +chose que le menu, le CLI et les tests ont besoin de connaître pour lister +les technologies disponibles. Ajouter un pilote, c'est une ligne. +""" + +from script.vpn.drivers.base import VpnDriver # noqa: F401 +from script.vpn.drivers.l2tp_ipsec import L2tpIpsecDriver +from script.vpn.drivers.openconnect import OpenconnectDriver +from script.vpn.drivers.openvpn import OpenvpnDriver +from script.vpn.drivers.sshuttle import SshuttleDriver +from script.vpn.drivers.wireguard import WireguardDriver + +# Nom technique -> classe. Le nom se retrouve dans `driver` du profil et +# dans le paquet à installer : il ne change pas. +# +# L'ordre est celui du plus contraint au plus libre — c'est aussi celui dans +# lequel le menu les propose, et il aide à choisir : L2TP/IPsec quand le +# site l'impose, WireGuard quand on tient les deux bouts, sshuttle quand il +# n'y a qu'un accès SSH. +DRIVERS = { + L2tpIpsecDriver.name: L2tpIpsecDriver, + WireguardDriver.name: WireguardDriver, + OpenvpnDriver.name: OpenvpnDriver, + OpenconnectDriver.name: OpenconnectDriver, + SshuttleDriver.name: SshuttleDriver, +} + + +def get_driver(name): + """Classe du pilote `name`, ou None. Ne lève pas : un profil peut + nommer un pilote retiré, et le CLI doit pouvoir le DIRE.""" + return DRIVERS.get(name) + + +def driver_names(): + """Les noms dans l'ordre du registre, pas dans l'ordre alphabétique : + cet ordre est un conseil de choix, voir le commentaire de DRIVERS.""" + return list(DRIVERS) diff --git a/script/vpn/drivers/base.py b/script/vpn/drivers/base.py new file mode 100644 index 0000000..46a9e78 --- /dev/null +++ b/script/vpn/drivers/base.py @@ -0,0 +1,845 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'un pilote VPN doit savoir faire, et tout ce qu'ils partagent. + +Un pilote décrit UNE technologie : quels paquets, quels secrets, quels +fichiers, quelle séquence pour monter, laquelle pour descendre, et comment +savoir si c'est monté. Il n'exécute rien : il demande à un `Runner` (voir +`runner.py`), qui exécute ou se contente de montrer. + +Ce fichier porte aussi tout ce qui est VRAI pour toutes les technologies : la +disposition des répertoires, l'état retenu entre deux processus, les routes, +le DNS de systemd-resolved, et les vérifications d'état. Un pilote nouveau +n'a donc à écrire que ce qui lui est propre — et quand une de ces mécaniques +se révèle fausse, elle se corrige à un seul endroit. + +Où vivent les fichiers, pour tous les pilotes : + + /dev/shm/erplibre-vpn// 0700 root — LES SECRETS. tmpfs : rien + n'est écrit sur un disque persistant, et + un redémarrage efface tout. + /run/erplibre-vpn/.* 0755 — l'état NON secret (interface + retenue, pid, route ajoutée). Lisible + sans sudo : `status` en a besoin, et il + tourne dans un autre processus que `up`. +""" +from __future__ import annotations + +import ipaddress +import os +import platform +import re +import shlex +import shutil +import socket +import subprocess +import time + +# Les `secret_fields` d'un pilote portent des clés i18n : affichées brutes, +# elles mettaient de l'anglais au milieu d'une phrase française. +from script.todo.todo_i18n import t + +# Un seul endroit nomme l'installateur : `vpn.py` l'importe d'ici. +INSTALL_SCRIPT = "./script/install/install_vpn.sh" + +SECRET_DIR = "/dev/shm/erplibre-vpn" +STATE_DIR = "/run/erplibre-vpn" + + +# ---------------------------------------------------------------------- +# Lecture de l'état de la machine — sans sudo, sans rien modifier +# ---------------------------------------------------------------------- +def which(binary: str) -> str: + """Chemin du binaire si NOUS pouvons l'exécuter, "" sinon. + + À réserver aux commandes lancées sous notre propre identité + (systemctl is-active, resolvectl, sshuttle). Pour celles que root + lance, voir `locate`. + """ + return shutil.which(binary) or "" + + +def locate(binary: str) -> str: + """Chemin du binaire s'il EXISTE dans le PATH, même si nous n'avons pas + le droit de l'exécuter. + + C'est la bonne question pour un binaire que ROOT lance. `pppd` est en + 4750 root:dip sur Debian et Ubuntu : `which` le déclare absent à tout + utilisateur hors du groupe dip, alors que xl2tpd — qui tourne en root — + l'exécute très bien. Confondre les deux fait annoncer « pppd absent » + sur une machine où le paquet ppp est installé, et envoie chercher au + mauvais endroit. + """ + return shutil.which(binary, mode=os.F_OK) or "" + + +# Famille netlink de l'IPsec du noyau. charon et `ip xfrm` n'ont pas +# d'autre porte : quand elle est fermée, aucune configuration ne rattrape. +NETLINK_XFRM = 6 + + +def netlink_family_available(protocol: int) -> bool: + """Le noyau expose-t-il cette famille netlink ? + + L'OUVERTURE suffit à répondre : le noyau charge à la demande le module + qui sert la famille, et rend `EPROTONOSUPPORT` quand il ne le trouve + pas. Aucune donnée n'est lue, aucun droit root n'est requis. C'est le + premier geste de charon, et ce qui lui fait dire « unable to create + netlink socket » avant d'abandonner sur `kernel-ipsec` manquant. + """ + try: + sock = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, protocol) + except OSError: + return False + sock.close() + return True + + +def stale_kernel() -> str: + """Version du noyau en cours d'exécution quand ses modules ont disparu, + "" quand ils sont là. + + Mettre à jour le paquet du noyau remplace `/lib/modules/` par + celle de la version neuve. Le noyau DÉJÀ démarré perd alors l'accès à + tous ses modules : ceux qui étaient chargés continuent, aucun autre ne + peut l'être. Une capacité que le noyau prend pourtant en charge devient + donc indisponible jusqu'au redémarrage, et rien d'autre ne la rétablit. + + L'absence est jugée RELATIVEMENT aux autres arborescences : un noyau + compilé sans modules n'en a aucune, et le déclarer périmé enverrait + redémarrer pour rien. + """ + release = platform.release() + if os.path.isdir(f"/lib/modules/{release}"): + return "" + try: + return release if os.listdir("/lib/modules") else "" + except OSError: + return "" + + +def resolve(host: str) -> str: + """Première adresse IPv4 de `host`, ou "" — et `host` lui-même s'il EST + déjà une adresse. Sans elle, impossible de préserver la route vers le + serveur quand on remplace la route par défaut.""" + try: + infos = socket.getaddrinfo(host, None, socket.AF_INET) + except (socket.gaierror, UnicodeError): + return "" + return infos[0][4][0] if infos else "" + + +def _ip(args: list[str]) -> str: + """Sortie de `ip …`, "" en cas d'échec. Lecture seule, sans sudo.""" + try: + proc = subprocess.run( + ["ip"] + args, capture_output=True, text=True, timeout=10 + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return proc.stdout if proc.returncode == 0 else "" + + +def interfaces(kind: str | None = None) -> set[str]: + """Interfaces existantes, éventuellement d'un seul type (ppp, tun, + wireguard). + + L'ensemble AVANT/APRÈS est ce qui permet de nommer l'interface qu'un + tunnel vient de créer : pppd n'annonce pas « ppp3 » à qui l'a lancé, et + supposer « ppp0 » est faux dès qu'un autre tunnel est déjà là. + """ + args = ["-o", "link", "show"] + if kind: + args += ["type", kind] + out = _ip(args) + return set(re.findall(r"^\d+:\s+([^:@]+)", out, re.MULTILINE)) + + +def ppp_interfaces() -> set[str]: + return interfaces("ppp") + + +def wait_for_new_interface(before: set, kind: str, timeout=25, interval=0.5): + """Nom de la première interface `kind` apparue depuis `before`, ou "". + + L'attente est nécessaire : entre la demande de session et l'interface + configurée, il y a la négociation — quelques secondes, parfois vingt sur + une liaison lente. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + new = interfaces(kind) - before + if new: + return sorted(new)[0] + time.sleep(interval) + return "" + + +def wait_for_interface_address(iface: str, timeout=25, interval=0.5): + """Attend que `iface` porte une adresse IPv4. Rend la liste, ou []. + + Une interface PPP existe dès que pppd la crée, bien avant qu'IPCP ait + négocié l'adresse. Lire trop tôt donne « sans adresse » sur un tunnel + parfaitement sain, et fait chercher les DNS du pair avant que pppd les + ait écrits. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + addresses = interface_addresses(iface) + if addresses: + return addresses + time.sleep(interval) + return [] + + +def interface_addresses(iface: str) -> list[str]: + out = _ip(["-brief", "addr", "show", "dev", iface]) + return re.findall(r"(\d+\.\d+\.\d+\.\d+)(?:/\d+)?", out) + + +def interface_exists(iface: str) -> bool: + return bool(iface) and bool(_ip(["-o", "link", "show", "dev", iface])) + + +def route_to(target: str) -> dict: + """{"via", "dev", "src"} de la route actuelle vers `target`, {} si + indéterminée. Sert à garder joignable le serveur VPN lui-même.""" + out = _ip(["route", "get", target]) + if not out: + return {} + result = {} + for key in ("via", "dev", "src"): + found = re.search(rf"\b{key}\s+(\S+)", out) + if found: + result[key] = found.group(1) + return result + + +def ssh_client_address() -> str: + """Adresse du client SSH de la session courante, "" si on n'est pas + dans une session SSH. + + `SSH_CONNECTION` vaut « ». Elle sert à + ne PAS scier la branche sur laquelle on est assis : piloter un client + VPN par SSH et lui faire capter tout le trafic coupe la session qui + donne l'ordre — et le menu, et le tunnel avec. + """ + connexion = os.environ.get("SSH_CONNECTION", "").split() + if not connexion: + return "" + adresse = connexion[0] + try: + ipaddress.ip_address(adresse) + except ValueError: + return "" + return adresse + + +def pppd_dns() -> list[str]: + """Serveurs DNS poussés par le pair, lus dans /etc/ppp/resolv.conf. + + pppd écrit LÀ et nulle part ailleurs quand on lui demande `usepeerdns` ; + c'est systemd-resolved qui ignore ce fichier, d'où l'étape `resolvectl` + du pilote.""" + try: + with open("/etc/ppp/resolv.conf") as fh: + content = fh.read() + except OSError: + return [] + return re.findall(r"nameserver\s+(\S+)", content) + + +class VpnDriver: + """Contrat d'un pilote, et les mécaniques communes.""" + + # Nom technique : valeur du champ `driver` d'un profil, et argument de + # `script/install/install_vpn.sh`. + name = "" + # Libellé montré à l'humain. + label = "" + # Binaires sans lesquels rien ne marche. + binaries: tuple[str, ...] = () + # Ce que le NOYAU doit exposer : (libellé, sonde). La sonde lit l'état + # de la machine, sans root et sans rien modifier. Vide quand tout se + # joue en espace utilisateur — un tunnel TLS n'exige rien du noyau. + # N'y mettre qu'une sonde dont le faux NÉGATIF est impossible : celle + # qui répond « absent » sur une machine saine fait proposer un + # redémarrage inutile, ce qui est pire que le diagnostic qu'elle rend. + kernel_features: tuple = () + # Secrets attendus dans le coffre : (clé, libellé, obligatoire). + # « password » et « username » désignent les champs NATIFS de + # KeePassXC ; tout autre nom devient une propriété protégée. + secret_fields: tuple[tuple[str, str, bool], ...] = () + # Champs de profil PROPRES à cette technologie : défauts, et le + # formulaire que le menu déroule. + defaults: dict = {} + # (clé, libellé i18n, type, avancé) ; type ∈ text|int|flag|path. + form_fields: tuple[tuple[str, str, str, bool], ...] = () + # Faux quand c'est le SERVEUR qui décide des routes : exiger une route + # déclarée serait alors une fausse exigence. + needs_routes = True + # Type d'interface que la technologie crée, "" quand elle n'en crée pas + # (sshuttle détourne par le pare-feu, sans interface). + iface_kind = "" + # Libellé i18n du champ « serveur » : une cible SSH ne se demande pas + # comme l'adresse d'un concentrateur. + server_label = "Server address (hostname or IP)" + # Libellé i18n d'une ligne : QUAND choisir cette technologie. C'est la + # seule décision où l'utilisateur a vraiment besoin d'un conseil. + hint = "" + # Vrai quand la technologie a été montée contre un vrai concentrateur. + # Faux quand seuls les tests unitaires la couvrent : le menu marque + # alors la ligne d'une étoile. Ce que l'utilisateur risque autrement, + # c'est de lire cinq choix d'apparence égale et de partir en production + # sur celui que personne n'a jamais vu aboutir. + proven = False + # Champ de profil qui porte l'identifiant, recopié dans le champ + # `username` de l'entrée du coffre — pour que le coffre reste lisible + # dans KeePassXC. Le profil reste la source de vérité. + user_field = "" + # Faux quand la technologie ne prend pas le MTU du profil — le demander + # serait une question sans effet. + uses_mtu = True + + def __init__(self, profile: dict, secrets: dict | None = None): + self.profile = profile + # `secrets` absent = mode « description » : on peut rendre les + # fichiers non secrets, lister les étapes, vérifier l'état. Monter + # le tunnel, non. + self.secrets = secrets or {} + self.name_tag = profile.get("name", "") + + # ------------------------------------------------------------------ + # À redéfinir + # ------------------------------------------------------------------ + @classmethod + def validate_profile(cls, profile: dict) -> None: + """Valide et NORMALISE en place les champs propres au pilote. + + Lève `valid.ProfileError`. Les contrôles communs (nom, serveur, + routes, MTU, témoin) sont déjà faits par `profiles.validate`. + """ + + def up(self, runner) -> bool: + raise NotImplementedError + + def down(self, runner) -> bool: + raise NotImplementedError + + def status(self, runner) -> list: + """Liste de (libellé, verdict, détail). `None` en verdict veut dire + « indéterminable » — pas « faux ».""" + raise NotImplementedError + + def log_commands(self) -> list: + """(libellé, commande) à montrer dans le diagnostic.""" + return [] + + # ------------------------------------------------------------------ + # Chemins et état + # ------------------------------------------------------------------ + @property + def secret_dir(self): + return f"{SECRET_DIR}/{self.name_tag}" + + @property + def pid_file(self): + return self.state_file("pid") + + def state_file(self, key): + return f"{STATE_DIR}/{self.name_tag}.{key}" + + def prepare_dirs(self, runner, secrets=True): + """Le répertoire des secrets en 0700, celui de l'état en 0755. + + Deux modes différents parce que deux usages différents : un secret + ne se lit que par root, l'état doit se lire par `status` lancé sans + sudo. + + Pas de répertoire de secrets pour un pilote qui n'écrit pas de + secret : sshuttle s'authentifie par clé SSH, et openconnect passe le + mot de passe par l'entrée standard — aucun des deux n'a de fichier à + y mettre. D'où `secrets=False`. + """ + if secrets and self.secret_fields: + runner.mkdir(self.secret_dir, "0700") + runner.mkdir(STATE_DIR, "0755") + + def write_state(self, runner, key, value): + runner.write(self.state_file(key), f"{value}\n", mode="0644") + + def read_state(self, key): + """Valeur retenue au montage, "" sinon. + + Retenue dans un fichier et non devinée : `status` tourne dans un + autre processus que `up`.""" + try: + with open(self.state_file(key)) as fh: + return fh.read().strip() + except OSError: + return "" + + def clear_state(self, runner, *keys): + for key in keys: + runner.remove(self.state_file(key)) + + def recorded_iface(self): + return self.read_state("iface") + + # ------------------------------------------------------------------ + # Prérequis + # ------------------------------------------------------------------ + def missing_binaries(self) -> list: + """Les binaires qui manquent VRAIMENT. + + `locate` et non `which` : ces binaires sont lancés par root, et + « puis-je l'exécuter ? » est la mauvaise question — voir `locate`. + """ + return [b for b in self.binaries if not locate(b)] + + def missing_secrets(self) -> list: + return [ + label + for key, label, required in self.secret_fields + if required and not self.secrets.get(key) + ] + + def secret_values(self) -> list: + """Les valeurs à masquer dans tout affichage.""" + return [v for v in self.secrets.values() if v] + + def ensure_ready(self, runner) -> bool: + """Noyau, binaires et secrets présents ? + + À blanc, un prérequis absent est un AVERTISSEMENT : montrer le plan + sur une machine où le client n'est pas encore installé est justement + à quoi sert le mode à blanc — c'est là qu'on relit une configuration + avant de la poser. + """ + report = runner.warn if runner.dry_run else runner.fail + ready = True + # Le noyau d'abord : quand c'est LUI qui manque, installer un paquet + # n'y changerait rien, et l'annoncer en premier évite de chercher la + # cause dans l'étage du dessus. + for _, ok, detail in self.check_kernel(): + if ok is False: + # Proposé avant d'être constaté, comme pour les paquets — + # mais l'échec est constaté MÊME si le redémarrage est + # accepté : la machine met quelques secondes à s'arrêter, et + # monter un tunnel dans cet intervalle serait le monter sur + # le noyau qu'on quitte. + self.propose_reboot(runner) + report(detail) + ready = False + missing = self.missing_binaries() + if missing: + # Proposé AVANT de constater l'échec : si le correctif passe, il + # n'y a plus d'échec à annoncer. Constater puis réparer laisserait + # un « montage incomplet » sur un montage qui a réussi. + runner.info(f" Binaires absents : {', '.join(missing)}") + if runner.propose( + f"paquets client de {self.name}", + f"bash {INSTALL_SCRIPT} {self.name}", + question="Installer les paquets client maintenant ?", + ): + missing = self.missing_binaries() + if not missing: + runner.ok("Paquets installés.") + if missing: + report( + f"Binaires absents : {', '.join(missing)}. Installer :" + f" sudo bash {INSTALL_SCRIPT} {self.name}" + ) + ready = False + missing_secret = self.missing_secrets() + if missing_secret: + labels = ", ".join(t(label) for label in missing_secret) + report( + f"Secrets manquants dans le coffre : {labels}. Les déposer :" + " TODO › Execute › Déploiement › VPN › « Déposer les" + " secrets dans le coffre »." + ) + ready = False + return ready or runner.dry_run + + def needs_reboot(self) -> bool: + """Un redémarrage est-il le SEUL remède à ce qui manque ? + + La conjonction qui le dit : une capacité du noyau manque ET les + modules du noyau qui tourne ont disparu. Le module ne peut plus être + chargé et aucune configuration n'y changera rien ; la version + installée, elle, porte la capacité. Un noyau qui ne l'expose pas du + tout ne gagnerait rien à redémarrer, et des modules périmés dont + rien ne manque encore ne pressent pas : ni l'un ni l'autre ne rend + vrai. + """ + return bool(self.missing_kernel_features() and stale_kernel()) + + def propose_reboot(self, runner) -> bool: + """Propose le redémarrage quand `needs_reboot` le dit. + + Les garde-fous de `Runner.propose` valent ici : on demande, rien + n'est appliqué à blanc ni sans terminal pour répondre. + """ + if not self.needs_reboot(): + return False + runner.info( + " Le noyau qui tourne n'a plus ses modules : le paquet du" + " noyau a été mis à jour depuis le démarrage. Redémarrer les" + " rétablit, et c'est le seul remède — toutes les sessions" + " ouvertes seront coupées." + ) + return runner.propose( + "modules du noyau inaccessibles", + "systemctl reboot", + question="Redémarrer la machine maintenant ?", + ) + + # ------------------------------------------------------------------ + # Étapes communes + # ------------------------------------------------------------------ + def add_routes(self, runner, iface): + """Les routes déclarées, par l'interface du tunnel. + + `replace` et non `add` : une route déjà là ne doit pas faire + échouer un remontage. Elles disparaissent avec l'interface, donc + rien à défaire au « down ».""" + for route in self.profile.get("routes", []): + runner.cmd( + f"router {route} par {iface}", + f"ip route replace {shlex.quote(route)}" + f" dev {shlex.quote(iface)}", + check=False, + ) + + def suggest_routes(self, runner, iface): + """Quand rien n'est routé, proposer le réseau de l'adresse obtenue. + + Le site ne remet souvent qu'une passerelle et des identifiants, et + personne ne sait quel réseau est derrière. Le premier montage, lui, + le dit : le concentrateur nous place dans le réseau qu'on cherchait + à joindre. + + Le /24 est une HYPOTHÈSE, annoncée comme telle — le préfixe réel ne + se déduit pas d'une adresse. C'est le point de départ d'une + question au site, pas une réponse. + """ + if self.profile.get("routes") or self.profile.get("default_route"): + return + addresses = interface_addresses(iface) + if not addresses: + return + try: + network = ipaddress.ip_network(f"{addresses[0]}/24", strict=False) + except ValueError: + return + runner.warn( + "Aucun réseau routé : ce tunnel ne joint que l'hôte distant." + ) + runner.info( + f" Adresse obtenue {addresses[0]}. Si le réseau du site" + f" est un /24 — hypothèse, pas déduction — ajouter" + f" « {network} » aux réseaux du profil." + ) + + def set_resolved_dns(self, runner, iface, servers, search=""): + """Donne les serveurs DNS du tunnel à systemd-resolved. + + Sans cet appel, le tunnel est monté et aucun nom interne ne résout : + resolved ne lit pas les fichiers que pppd ou vpnc-script écrivent. + """ + if not servers: + return + if not which("resolvectl"): + runner.warn( + "resolvectl absent : les DNS du tunnel ne sont pas" + " appliqués. Vérifier /etc/resolv.conf à la main." + ) + return + runner.cmd( + f"DNS de {iface} : {' '.join(servers)}", + f"resolvectl dns {shlex.quote(iface)} " + + " ".join(shlex.quote(s) for s in servers), + check=False, + ) + if search: + runner.cmd( + f"domaine de recherche {search} sur {iface}", + f"resolvectl domain {shlex.quote(iface)}" + f" {shlex.quote('~' + search)}", + check=False, + ) + + def kill_pidfile(self, runner, label="arrêter le démon", sudo=True): + """Tue le processus dont le pid est dans le fichier de pid. + + `|| true` : au « down », le démon est souvent DÉJÀ tombé — c'est + même la raison la plus fréquente d'un « down ». Ce n'est pas un + échec. + + `sudo=False` pour un démon lancé SOUS l'utilisateur : sshuttle + n'élève que la partie pare-feu, son processus principal est le + nôtre, et root n'a pas à s'en mêler. + """ + pid = shlex.quote(self.pid_file) + runner.cmd( + label, + "sh -c {}".format( + shlex.quote(f"[ -f {pid} ] && kill $(cat {pid}) || true") + ), + check=False, + sudo=sudo, + ) + + def pid_alive(self): + """Vrai/faux si le pid du fichier tourne, None si indéterminable. + + Pas de fichier de pid = FAUX, pas « on ne sait pas » : le démon + écrit ce fichier au démarrage, son absence est une réponse. `None` + est réservé au vrai doute — fichier illisible, pid corrompu. + + `os.kill(pid, 0)` ne tue rien : il demande au noyau si le processus + existe. `PermissionError` veut dire qu'il existe mais ne nous + appartient pas — donc vivant. + """ + try: + with open(self.pid_file) as fh: + pid = int(fh.read().strip()) + except FileNotFoundError: + return False + except (OSError, ValueError): + return None + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return None + return True + + def add_host_route(self, runner, server_ip, raison="le serveur"): + """Route /32 vers `server_ip` par la passerelle ACTUELLE. + + Posée avant de monter, retirée au « down ». Sans elle, dès que le + tunnel capte la route par défaut, les paquets à destination de cette + adresse entrent dans le tunnel — les paquets chiffrés vers le + concentrateur, qui transportent le tunnel, et ceux de la session SSH + qui donne l'ordre. + + Plusieurs adresses peuvent avoir besoin de cette protection : l'état + garde donc une LISTE, une par ligne.""" + info = ( + runner.call( + f"lire la route actuelle vers {server_ip}", + lambda: route_to(server_ip), + dry_safe=True, + ) + or {} + ) + via, dev = info.get("via"), info.get("dev") + if not dev: + runner.warn( + f"Route actuelle vers {server_ip} indéterminée : la route de" + f" survie de {raison} n'est pas posée. En mode « tout le" + " trafic », le tunnel peut se couper lui-même." + ) + return + spec = f"{server_ip}/32" + command = f"ip route replace {spec} dev {shlex.quote(dev)}" + if via: + command = ( + f"ip route replace {spec} via {shlex.quote(via)}" + f" dev {shlex.quote(dev)}" + ) + runner.cmd( + f"poser la route de survie {spec} ({raison})", + command, + check=False, + ) + gardees = [ + ligne + for ligne in self.read_state("hostroute").splitlines() + if ligne.strip() and ligne.strip() != spec + ] + gardees.append(spec) + self.write_state(runner, "hostroute", "\n".join(gardees)) + + def protect_the_ssh_session(self, runner): + """Garde joignable le client SSH qui donne l'ordre. + + Piloter un client VPN par SSH et lui faire capter TOUT le trafic + coupe la session qui vient de lancer la commande : le retour part + dans le tunnel. On perd la machine, le menu, et le moyen de démonter + ce qu'on vient de monter.""" + adresse = ssh_client_address() + if not adresse: + return + runner.info( + f" Session SSH depuis {adresse} : on lui garde une route" + " directe, sinon « tout le trafic » la couperait." + ) + self.add_host_route(runner, adresse, raison="la session SSH") + + def del_host_route(self, runner): + for spec in self.read_state("hostroute").splitlines(): + spec = spec.strip() + if not spec: + continue + runner.cmd( + f"retirer la route de survie {spec}", + f"ip route del {shlex.quote(spec)}", + check=False, + ) + + # ------------------------------------------------------------------ + # Vérifications d'état, communes + # ------------------------------------------------------------------ + def missing_kernel_features(self) -> list: + """Libellés des capacités du noyau que la machine n'expose pas.""" + return [label for label, probe in self.kernel_features if not probe()] + + def check_kernel(self) -> list: + """L'étage le plus bas : ce que le noyau donne, et ce qui l'en + empêche. + + Sans cette vérification, un module inaccessible se manifeste trois + étages plus haut et sous un autre nom — charon démarre, abandonne à + l'initialisation, et l'attente de la connexion accuse le bloc de + `/etc/ipsec.conf`, qui est pourtant bien formé. + + Le verdict distingue deux causes que le même symptôme recouvre. Les + modules du noyau qui tourne ont disparu : la capacité EXISTE dans ce + noyau et un redémarrage la rend. Ce noyau ne l'expose pas : rien à + redémarrer, c'est le noyau qu'il faut changer. Rend une liste vide + quand le pilote n'exige rien du noyau et qu'il n'y a rien à signaler. + """ + missing = self.missing_kernel_features() + stale = stale_kernel() + names = ", ".join(label for label, _ in self.kernel_features) + if missing: + absent = ", ".join(missing) + if stale: + return [ + ( + "noyau", + False, + f"{absent} : indisponible — les modules du noyau" + f" {stale} ont disparu, redémarrer", + ) + ] + return [("noyau", False, f"{absent} : absent de ce noyau")] + if stale: + # Les capacités sondées répondent, mais elles sont les SEULES : + # les modules qu'une négociation charge ensuite (ESP, AH, ppp) + # ne peuvent plus l'être. Signalé, jamais compté en échec — un + # tunnel déjà monté, lui, continue de fonctionner. + detail = f"modules du noyau {stale} disparus — redémarrer" + if not names: + return [("noyau", None, detail)] + return [("noyau", True, f"{names} présent, mais {detail}")] + if not names: + return [] + return [("noyau", True, f"{names} : présent")] + + def check_binaries(self): + missing = self.missing_binaries() + return ( + "paquets client", + not missing, + "présents" if not missing else f"absents : {', '.join(missing)}", + ) + + def check_mounted(self): + iface = self.recorded_iface() + return ( + "profil monté (état /run)", + bool(iface), + f"interface {iface}" if iface else "aucun état : non connecté", + ) + + def check_daemon(self, label="démon"): + alive = self.pid_alive() + if alive is None: + detail = f"fichier de pid illisible : {self.pid_file}" + elif alive: + detail = f"vivant (pid dans {self.pid_file})" + elif os.path.exists(self.pid_file): + detail = "pid connu mais processus absent — démontage inachevé" + else: + detail = "aucun fichier de pid : non connecté" + return (label, alive, detail) + + def check_iface(self, iface): + exists = interface_exists(iface) + addresses = interface_addresses(iface) if exists else [] + return ( + f"interface {iface}", + exists and bool(addresses), + ", ".join(addresses) if addresses else "absente ou sans adresse", + ) + + def check_routes(self, iface): + checks = [] + for route in self.profile.get("routes", []): + info = route_to(route.split("/")[0]) + ok = info.get("dev") == iface + checks.append( + ( + f"route {route}", + ok, + f"via {info.get('dev', '?')}" + + (f" (attendu {iface})" if not ok else ""), + ) + ) + if self.profile.get("default_route"): + info = route_to("1.1.1.1") + checks.append( + ( + "route par défaut", + info.get("dev") == iface, + f"via {info.get('dev', '?')}", + ) + ) + return checks + + def check_probe(self, runner): + """Le témoin : la seule vérification qui PROUVE que ça marche. + + Tout le reste dit que les tuyaux sont en place ; celle-ci dit qu'un + paquet est allé au bout et revenu.""" + probe = self.profile.get("probe") + if not probe: + return [] + code, _ = runner.cmd( + f"joindre {probe} à travers le tunnel", + f"ping -c 2 -W 3 {shlex.quote(probe)}", + sudo=False, + check=False, + capture=True, + ) + return [ + ( + f"témoin {probe}", + code == 0, + "répond" if code == 0 else "ne répond pas", + ) + ] + + def standard_status(self, runner, extra=()): + """L'enchaînement habituel : noyau, paquets, état, interface, + routes, témoin — du plus bas au plus haut, pour que la première + ligne fausse soit la CAUSE et non une conséquence. Un pilote insère + ses propres vérifications par `extra`, une liste de (libellé, + verdict, détail).""" + checks = self.check_kernel() + checks.extend([self.check_binaries(), self.check_mounted()]) + checks.extend(extra) + iface = self.recorded_iface() + if iface: + checks.append(self.check_iface(iface)) + checks.extend(self.check_routes(iface)) + checks.extend(self.check_probe(runner)) + return checks diff --git a/script/vpn/drivers/l2tp_ipsec.py b/script/vpn/drivers/l2tp_ipsec.py new file mode 100644 index 0000000..6990337 --- /dev/null +++ b/script/vpn/drivers/l2tp_ipsec.py @@ -0,0 +1,898 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L2TP sur IPsec, clé pré-partagée : strongSwan + xl2tpd + pppd. + +Trois étages, et il faut les trois pour avoir une interface : + + 1. IPsec en mode TRANSPORT protège l'UDP 1701 entre nous et le serveur. + Mode transport, pas tunnel : c'est L2TP qui encapsule, IPsec ne fait + que chiffrer. Un `type=tunnel` ici ne monte jamais. + 2. L2TP (xl2tpd) ouvre une session dans ce canal protégé. + 3. PPP s'authentifie (MS-CHAPv2) et crée l'interface ppp*. + +Trois pièges connus, réglés ici et pas ailleurs : + + · `charon { install_routes = no }` — sinon charon pose lui-même une + route pour la SA, qui détourne le trafic L2TP et le tunnel n'aboutit + jamais. C'est LE symptôme classique « la SA est établie, ppp0 + n'apparaît pas ». + · Route de survie vers le serveur. En mode « tout le trafic », la route + par défaut part dans ppp0 — y compris les paquets ESP à destination du + serveur, qui se retrouvent à passer par le tunnel qu'ils portent. Une + route /32 vers le serveur via la passerelle d'origine évite ce + serpent qui se mord la queue. + · systemd-resolved ignore /etc/ppp/resolv.conf. `usepeerdns` remplit ce + fichier, personne ne le lit, et « le VPN marche mais aucun nom ne + résout ». D'où l'appel `resolvectl` explicite. + +Où vivent les fichiers, et pourquoi : + + /dev/shm/erplibre-vpn// 0700 root — LES SECRETS. tmpfs : rien + n'est écrit sur un disque persistant, et + un redémarrage efface tout. + /run/erplibre-vpn/.* 0755 — l'état non secret (interface + retenue, pid, route ajoutée). Lisible + sans sudo : `status` en a besoin. + /etc/ipsec.conf, /etc/ipsec.secrets un bloc marqué, retiré au « down ». + /etc/strongswan.d/erplibre-vpn.conf le réglage install_routes. +""" +from __future__ import annotations + +import os +import re +import shlex +import sys + +from script.vpn import valid +from script.vpn.drivers.base import ( + NETLINK_XFRM, + SECRET_DIR, + STATE_DIR, + VpnDriver, + interface_addresses, + locate, + netlink_family_available, + ppp_interfaces, + pppd_dns, + resolve, + wait_for_interface_address, + wait_for_new_interface, + which, +) + +IPSEC_CONF = "/etc/ipsec.conf" +IPSEC_SECRETS = "/etc/ipsec.secrets" +STRONGSWAN_DROPIN = "/etc/strongswan.d/erplibre-vpn.conf" +# AppArmor confine charon par CHEMIN sur Debian et Ubuntu. Le profil se +# termine par `#include ` : c'est le point d'extension prévu, et le +# fichier local existe déjà, vide. +APPARMOR_PROFILE = "/etc/apparmor.d/usr.lib.ipsec.charon" +APPARMOR_LOCAL = "/etc/apparmor.d/local/usr.lib.ipsec.charon" + +# Le drop-in est PARTAGÉ par tous les profils : c'est un réglage de charon, +# pas d'une connexion. Il vaut pour toute SA de la machine, et c'est assumé — +# sur un poste client, aucune SA ne veut que charon pose ses routes. +DROPIN_BODY = """# Généré par ERPLibre (script/vpn) — ne pas éditer. +# +# charon poserait sinon une route pour chaque SA. En L2TP/IPsec, cette route +# détourne le trafic UDP 1701 et le tunnel ne monte jamais : la SA s'établit, +# ppp0 n'apparaît pas. xl2tpd et pppd posent les routes dont on a besoin. +charon { + install_routes = no +} +""" + + +# Ce que charon dit, et ce que ça veut dire. Ces cinq messages sont ceux +# qu'on rencontre en montant un tunnel L2TP/IPsec, et aucun ne se comprend +# seul : le premier a coûté une heure de recherche du côté du PSK, alors que +# le PSK était juste et le refus venait d'AppArmor. +IPSEC_HINTS = ( + ( + "no shared key found", + "charon n'a pas trouvé le PSK. Le fichier est là, mais un refus" + " AppArmor sur /dev/shm l'empêche de le LIRE :" + " journalctl -k | grep DENIED.", + ), + ( + "not supported!", + "charon a retenu un algorithme qu'il ne sait pas exécuter — le" + " greffon manque. Sur Debian et Ubuntu, 3DES vient du greffon" + " openssl : paquet libstrongswan-standard-plugins.", + ), + ( + "does not match to", + "l'identité annoncée par la passerelle diffère de celle attendue." + " `rightid=%any` l'accepte : le bloc de /etc/ipsec.conf est-il à" + " jour ? Un « down » puis un « up » le réécrit.", + ), + ( + "AUTHENTICATION_FAILED", + "la passerelle a refusé la clé pré-partagée.", + ), + ( + "NO_PROPOSAL_CHOSEN", + "la passerelle refuse toutes nos propositions de chiffrement.", + ), +) + + +class L2tpIpsecDriver(VpnDriver): + name = "l2tp_ipsec" + label = "L2TP/IPsec PSK" + binaries = ("ipsec", "xl2tpd", "pppd", "ip") + # L'IPsec du noyau est la première condition de tout : sans la famille + # netlink XFRM, charon s'arrête à l'initialisation sur « kernel-ipsec » + # manquant, et l'échec se lit ensuite comme une connexion jamais + # chargée. La sonde ne rend « absent » que si le noyau refuse la + # famille, ce qu'aucun droit ni aucun réglage ne provoque. + kernel_features = ( + ( + "XFRM (IPsec du noyau)", + lambda: netlink_family_available(NETLINK_XFRM), + ), + ) + secret_fields = ( + ("psk", "IPsec pre-shared key (PSK)", True), + ("password", "PPP password", True), + ) + iface_kind = "ppp" + # Faux : un profil sans route reste utilisable — il joint l'hôte + # distant, et l'adresse qu'on y obtient dit quel réseau ajouter. Le + # site ne donne souvent qu'une passerelle et des identifiants. + needs_routes = False + hint = "When the far side imposes it: a router, a firewall, Windows RRAS" + proven = True + user_field = "ppp_user" + defaults = { + "ppp_user": "", + "use_peer_dns": True, + "dns_search": "", + # Le port L2TP LOCAL. 1701 est la valeur attendue ; le déplacer est + # le remède quand un xl2tpd du système tient déjà le port. + "l2tp_local_port": 1701, + } + form_fields = ( + ( + "ppp_user", + "PPP user (the one the server authenticates)", + "text", + False, + ), + ("l2tp_local_port", "Local L2TP port", "int", True), + ("use_peer_dns", "Use the DNS pushed by the peer?", "flag", True), + ("dns_search", "DNS search domain (optional)", "text", True), + ) + + # ------------------------------------------------------------------ + # Chemins et noms dérivés du profil + # ------------------------------------------------------------------ + @property + def conn(self): + """Nom de la connexion IPsec ET du LAC xl2tpd. Préfixé pour ne + jamais entrer en collision avec une connexion de l'utilisateur.""" + return f"erplibre-{self.name_tag}" + + @property + def secrets_file(self): + return f"{self.secret_dir}/ipsec.secrets" + + @property + def xl2tpd_conf(self): + return f"{self.secret_dir}/xl2tpd.conf" + + @property + def ppp_options(self): + return f"{self.secret_dir}/ppp.options" + + @property + def control_file(self): + return f"{STATE_DIR}/{self.name_tag}.control" + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + @classmethod + def validate_profile(cls, profile): + valid.text( + profile, + "ppp_user", + "Utilisateur PPP (c'est lui que le serveur authentifie)", + ) + valid.port(profile, "l2tp_local_port", "Port L2TP local") + valid.flag(profile, "use_peer_dns") + valid.text( + profile, + "dns_search", + "Domaine de recherche DNS", + required=False, + pattern=valid.HOST_RE, + ) + + # ------------------------------------------------------------------ + # Rendu des fichiers + # ------------------------------------------------------------------ + def ipsec_conn_body(self): + """Le bloc `conn` pour /etc/ipsec.conf. + + `leftprotoport=17/%any` et non `17/1701` : derrière du NAT, le port + source local est réécrit, et une politique clouée sur 1701 ne + s'applique alors plus aux paquets qui sortent. `%any` couvre les + deux cas — dont 1701. + + Les propositions incluent 3DES et modp1024 : c'est vieux, et c'est + exactement ce que servent les concentrateurs L2TP qu'on rencontre. + Les listes sont ordonnées, le meilleur d'abord. + """ + p = self.profile + return "\n".join( + [ + f"conn {self.conn}", + " keyexchange=ikev1", + " authby=secret", + " type=transport", + " left=%defaultroute", + " leftprotoport=17/%any", + f" right={p['server']}", + # La passerelle s'annonce comme elle veut : par son IP, par + # un FQDN, parfois par autre chose. Sans `rightid=%any`, + # strongSwan déduit l'identité attendue de `right` et refuse + # tout ce qui en diffère — « IDir '203.0.113.5' does not + # match to 'vpn.exemple.com' », sur une configuration par + # ailleurs juste. En PSK, c'est la CLÉ qui protège, pas + # l'identité annoncée par le pair. + " rightid=%any", + " rightprotoport=17/1701", + " ike=aes256-sha1-modp1024,aes128-sha1-modp1024," + "3des-sha1-modp1024!", + " esp=aes256-sha1,aes128-sha1,3des-sha1!", + " dpdaction=clear", + " dpddelay=30s", + " auto=add", + ] + ) + + def ipsec_secrets_body(self, server_ip): + """Le PSK, en HEXADÉCIMAL. + + strongSwan accepte `PSK "texte"` ou `PSK 0x`, et les deux + donnent les mêmes octets. L'hexadécimal évite toute question + d'échappement : un PSK contenant `"` ou `\\` casse la forme citée, + et un PSK est justement ce qu'on ne veut pas voir se faire tronquer + en silence. + """ + psk = self.secrets.get("psk", "") + as_hex = psk.encode("utf-8").hex() + return "\n".join( + [ + "# Généré par ERPLibre (script/vpn). tmpfs : jamais sur", + "# disque, effacé au « down » et à l'extinction.", + f"%any {server_ip} : PSK 0x{as_hex}", + ] + ) + + def xl2tpd_conf_body(self): + p = self.profile + # « ; » et non « # » : l'analyseur de xl2tpd ne connaît que le + # point-virgule, et refuse le fichier ENTIER sur un « # » en tête — + # « data '#…' occurs with no context », suivi de « Unable to load + # config file ». Un commentaire mal marqué cassait tout. + return "\n".join( + [ + "; Généré par ERPLibre (script/vpn).", + "[global]", + f"port = {p['l2tp_local_port']}", + "access control = no", + "", + f"[lac {self.conn}]", + f"lns = {p['server']}", + # Rien à EXIGER du pair : « require chap » et « require + # authentication » font passer « require-chap » et « auth » + # à pppd, c'est-à-dire « que le serveur s'authentifie + # auprès de moi ». Le serveur refuse, à juste titre, et pppd + # coupe : « LCP terminated by peer (peer refused to + # authenticate) ». La politique d'authentification est celle + # de ppp.options, et elle ne parle que de NOUS. + "require chap = no", + "refuse pap = no", + "require authentication = no", + "ppp debug = no", + f"pppoptfile = {self.ppp_options}", + "length bit = yes", + "redial = no", + ] + ) + + def ppp_options_body(self): + """Les options pppd, mot de passe compris. + + C'est le seul secret que la technologie oblige à poser dans un + fichier : pppd ne lit un mot de passe ni d'une variable + d'environnement, ni d'un argument. Le fichier est donc en 0600 + dans un tmpfs 0700 appartenant à root, et il est effacé au « down ». + Résiduel assumé : tant que le tunnel est monté, root peut le lire. + """ + p = self.profile + lines = [ + "# Généré par ERPLibre (script/vpn). tmpfs, 0600, effacé au down.", + "ipcp-accept-local", + "ipcp-accept-remote", + # AUCUN `refuse-*`. La méthode est celle que le concentrateur + # demande, et mesuré sur un vrai : « rcvd [LCP ConfReq … + # …] », auquel un `refuse-pap` répond + # « ConfNak » — le serveur coupe alors la + # liaison sur « peer refused to authenticate », et le « peer » + # de ce message, c'est NOUS. + # + # PAP envoie le mot de passe en clair SUR LA LIAISON PPP, qui + # voyage dans la session L2TP, elle-même dans l'ESP. C'est le + # dispositif même de L2TP/IPsec : c'est IPsec qui protège + # l'authentification PPP. Ce pilote ne lance jamais L2TP sans SA + # IPsec établie — il abandonne avant —, donc le mot de passe ne + # sort jamais en clair du poste. + "noccp", + # Ne rien exiger du pair. Un client n'authentifie pas son + # concentrateur en PPP : c'est IPsec qui l'a fait, avant. + "noauth", + "noipdefault", + f"mtu {p['mtu']}", + f"mru {p['mtu']}", + "connect-delay 5000", + "lcp-echo-interval 30", + "lcp-echo-failure 4", + f"name {_pppd_quote(p['ppp_user'])}", + f"password {_pppd_quote(self.secrets.get('password', ''))}", + ] + if p.get("use_peer_dns"): + lines.append("usepeerdns") + if p.get("default_route"): + # `replacedefaultroute` remet l'ancienne route en descendant : + # sans lui, une déconnexion brutale laisse la machine sans + # route par défaut du tout. + lines.append("defaultroute") + lines.append("replacedefaultroute") + return "\n".join(lines) + + # ------------------------------------------------------------------ + # Montée + # ------------------------------------------------------------------ + def up(self, runner): + p = self.profile + if not self.ensure_ready(runner): + return False + + server_ip = runner.call( + f"résoudre {p['server']}", + lambda: resolve(p["server"]), + dry_safe=True, + ) + if not server_ip: + runner.fail( + f"« {p['server']} » ne résout pas : sans son adresse, ni le" + " PSK ni la route de survie ne peuvent être posés." + ) + return False + runner.ok(f"{p['server']} → {server_ip}") + + before = ( + runner.call( + "relever les interfaces PPP existantes", + ppp_interfaces, + dry_safe=True, + ) + or set() + ) + + if not self._l2tp_port_is_free(runner): + return False + + self.prepare_dirs(runner) + runner.write( + self.secrets_file, + self.ipsec_secrets_body(server_ip) + "\n", + mode="0600", + secret=True, + ) + runner.write( + self.xl2tpd_conf, self.xl2tpd_conf_body() + "\n", mode="0600" + ) + runner.write( + self.ppp_options, + self.ppp_options_body() + "\n", + mode="0600", + secret=True, + ) + runner.write(STRONGSWAN_DROPIN, DROPIN_BODY, mode="0644") + self._allow_apparmor_to_read_secrets(runner) + runner.block(IPSEC_CONF, self.name_tag, self.ipsec_conn_body(), "0644") + runner.block( + IPSEC_SECRETS, + self.name_tag, + f"include {self.secrets_file}", + "0600", + ) + + self._reload_charon(runner) + if not self._wait_for_conn(runner): + return False + + if p.get("default_route"): + self.add_host_route(runner, server_ip) + self.protect_the_ssh_session(runner) + + # Capturé, et non affiché en direct : cette sortie EST le + # diagnostic. Une négociation IKE tient en une vingtaine de lignes, + # et c'est la dernière qui dit pourquoi ça a échoué. + code, out = runner.cmd( + f"monter la SA IPsec ({self.conn})", + f"ipsec up {shlex.quote(self.conn)}", + timeout=120, + check=False, + capture=True, + ) + if runner.dry_run: + pass + elif code != 0 or "established successfully" not in out: + for line in out.strip().splitlines()[-6:]: + runner.info(f" │ {line}") + runner.fail("La SA IPsec n'est pas montée.") + for motif, explication in IPSEC_HINTS: + if motif in out: + runner.info(f" → {explication}") + if not any(motif in out for motif, _ in IPSEC_HINTS): + runner.info( + " → Causes usuelles restantes : UDP 500/4500" + " filtré, ou passerelle injoignable. Voir" + " « diagnostic »." + ) + return False + else: + runner.ok("SA IPsec établie.") + + runner.cmd( + "lancer xl2tpd (instance dédiée à ce profil)", + "xl2tpd -c {} -C {} -p {}".format( + shlex.quote(self.xl2tpd_conf), + shlex.quote(self.control_file), + shlex.quote(self.pid_file), + ), + ) + if not self._wait_for_control(runner): + return False + runner.cmd( + "demander la session L2TP", + "sh -c {}".format( + shlex.quote( + f'echo "c {self.conn}" > {shlex.quote(self.control_file)}' + ) + ), + timeout=30, + ) + + iface = runner.call( + "attendre l'interface PPP", + lambda: wait_for_new_interface(before, "ppp"), + ) + if runner.dry_run: + runner.info(" (à blanc : l'interface serait nommée ici)") + return True + if not iface: + runner.fail( + "Aucune interface PPP n'est apparue. La SA IPsec est" + " montée : le refus vient de L2TP ou de PPP" + " (identifiants, MS-CHAPv2). Voir « diagnostic »." + ) + return False + # L'interface EXISTE dès que pppd la crée, bien avant qu'IPCP ait + # négocié l'adresse. La lire tout de suite annonçait « ppp0 : sans + # adresse » sur un tunnel qui allait très bien — et faisait chercher + # les DNS du pair avant que pppd les ait écrits. + addresses = runner.call( + f"attendre l'adresse de {iface}", + lambda: wait_for_interface_address(iface), + ) + if not addresses: + runner.fail( + f"{iface} est apparue sans obtenir d'adresse : IPCP n'a pas" + " abouti. L'authentification PPP a-t-elle réussi ? Le" + " journal de pppd le dit — voir « diagnostic »." + ) + return False + runner.ok(f"interface {iface} : {', '.join(addresses)}") + self.write_state(runner, "iface", iface) + self.add_routes(runner, iface) + self.suggest_routes(runner, iface) + if p.get("use_peer_dns"): + servers = runner.call( + "lire les DNS poussés par le pair", pppd_dns, dry_safe=True + ) + if servers: + self.set_resolved_dns( + runner, iface, servers, p.get("dns_search", "") + ) + else: + runner.warn("Le pair n'a poussé aucun DNS.") + return True + + # ------------------------------------------------------------------ + # Descente + # ------------------------------------------------------------------ + def down(self, runner): + """Défait tout, dans l'ordre inverse, sans s'arrêter au premier + échec : une descente doit nettoyer ce qu'elle PEUT nettoyer, même + si un étage est déjà tombé de lui-même.""" + runner.cmd( + "fermer la session L2TP", + "sh -c {}".format( + shlex.quote( + f"[ -p {shlex.quote(self.control_file)} ] &&" + f' echo "d {self.conn}" >' + f" {shlex.quote(self.control_file)} || true" + ) + ), + check=False, + timeout=20, + ) + self.kill_pidfile(runner, "arrêter xl2tpd") + runner.cmd( + f"descendre la SA IPsec ({self.conn})", + f"ipsec down {shlex.quote(self.conn)}", + check=False, + ) + self.del_host_route(runner) + # Les blocs AVANT les fichiers : un `include` qui pointe vers un + # fichier disparu fait échouer tout rechargement de charon, y + # compris ceux d'une autre connexion. + runner.block(IPSEC_SECRETS, self.name_tag, "", "0600") + runner.block(IPSEC_CONF, self.name_tag, "", "0644") + runner.remove(self.secret_dir) + self.clear_state(runner, "iface", "hostroute", "pid", "control") + self._reload_charon(runner, check=False) + runner.ok("Tunnel démonté, secrets effacés.") + return True + + # ------------------------------------------------------------------ + # État + # ------------------------------------------------------------------ + def status(self, runner): + # `statusall` et non `status` : `status` ne montre que les SA, et son + # « no match » est la réponse NORMALE d'un tunnel démonté. Il ne dit + # rien de la connexion elle-même — confondre les deux envoyait + # chercher une configuration absente alors qu'elle était chargée. + code, out = runner.cmd( + "état de charon", + f"ipsec statusall {shlex.quote(self.conn)}", + check=False, + capture=True, + ) + if code != 0: + unreachable = "charon injoignable (arrêté ? sudo refusé ?)" + return self.standard_status( + runner, + extra=[ + ("connexion chargée", None, unreachable), + ("SA IPsec", None, unreachable), + ], + ) + loaded = f"{self.conn}:" in out + established = "ESTABLISHED" in out + extra = [ + ( + "connexion chargée", + loaded, + self.conn if loaded else "absente de charon", + ), + ( + "SA IPsec", + established, + "établie" if established else "aucune SA (tunnel démonté)", + ), + ] + return self.standard_status(runner, extra=extra) + + def log_commands(self): + """L'unité strongSwan n'a pas le même nom partout : on essaie les + deux plutôt que de deviner la distribution.""" + return [ + ( + "journal strongSwan / xl2tpd", + "journalctl -n 40 --no-pager -u strongswan-starter" + " -u strongswan -u xl2tpd", + ), + ("journal pppd", "journalctl -n 20 --no-pager -t pppd"), + # Le seul endroit où un refus AppArmor apparaît. Sans cette + # ligne, un « no shared key found » reste inexplicable. + ( + "refus AppArmor (noyau)", + 'sh -c "journalctl -k --no-pager -n 200' + " | grep -i 'apparmor=\\\"DENIED\\\"' | tail -5" + " || echo 'aucun refus AppArmor récent'\"", + ), + ] + + # ------------------------------------------------------------------ + # Détails propres à L2TP/IPsec + # ------------------------------------------------------------------ + def _wait_for_control(self, runner): + """Attend que xl2tpd crée son tube de contrôle. + + xl2tpd se détache immédiatement et crée le FIFO ensuite. Écrire + dedans sans attendre échoue sur « No such file or directory », une + erreur qui ne dit rien du vrai problème — lequel est presque + toujours un xl2tpd qui n'a pas pu s'attacher au port. + """ + control = shlex.quote(self.control_file) + script = ( + f"for i in $(seq 1 40); do [ -p {control} ] && exit 0;" + " sleep 0.25; done; exit 1" + ) + code, _ = runner.cmd( + "attendre le tube de contrôle de xl2tpd", + f"sh -c {shlex.quote(script)}", + check=False, + timeout=25, + ) + if code == 0 or runner.dry_run: + return True + runner.fail( + "xl2tpd n'a pas créé son tube de contrôle : il n'a pas" + " démarré. Cause la plus fréquente, UDP" + f" {self.profile['l2tp_local_port']} déjà pris — voir" + " « diagnostic »." + ) + return False + + def _l2tp_port_is_free(self, runner): + """Faux si le port L2TP local est encore tenu après remèdes. + + La question n'est PAS « le service xl2tpd est-il actif ? » mais « le + port est-il libre ? » : un xl2tpd orphelin tient UDP 1701 alors que + `systemctl is-active xl2tpd` répond « inactive », et le service en + relance un second par-dessus. Le nom du service ne dit rien ; le + port, tout. + + On classe donc pid par pid, parce que les détenteurs peuvent être de + natures différentes en même temps : + + · un reste d'un montage précédent de CE profil → on propose notre + propre « down » ; + · un xl2tpd qui n'est pas à nous (service, orphelin) → on propose de + l'arrêter et de le terminer ; + · le tunnel d'un AUTRE de nos profils → on le nomme et on s'arrête : + couper le sien est une décision qui revient à son propriétaire ; + · autre chose → on le nomme, on n'y touche pas. + + Sans `ss`, on ne bloque pas : l'absence du tube de contrôle de + xl2tpd le dira deux étapes plus loin, et un faux blocage serait pire + qu'un échec tardif. + """ + port = self.profile["l2tp_local_port"] + if not locate("ss"): + return True + report = runner.warn if runner.dry_run else runner.fail + + holders, tenu = self._port_holders(runner, port) + if not tenu: + return True + + voisin = self._sibling_profile(holders) + if voisin: + report(f"UDP {port} est tenu par notre tunnel « {voisin} ».") + runner.info( + " → Le démonter d'abord :" + f" ./script/vpn/vpn.py down --profile {voisin}" + " (deux profils L2TP ne partagent pas le même port, et" + " couper le sien est votre décision, pas la nôtre)." + ) + return runner.dry_run + + # Remède 1 : un reste de CE profil. + if self._mine(holders): + runner.info( + f" Un montage précédent de « {self.name_tag} » n'a pas" + " été démonté." + ) + if runner.propose( + f"reste du montage précédent de {self.name_tag}", + f"{sys.executable} -u ./script/vpn/vpn.py down --profile" + f" {shlex.quote(self.name_tag)}", + sudo=False, + question="Démonter ce reste et réessayer ?", + ): + holders, tenu = self._port_holders(runner, port) + if not tenu: + runner.ok(f"Reste démonté, UDP {port} libre.") + return True + + # Remède 2 : des xl2tpd qui ne sont pas à nous. + etrangers = self._foreign_xl2tpd(holders) + if etrangers: + if runner.propose( + f"UDP {port} tenu par xl2tpd", + "sh -c {}".format( + shlex.quote( + "systemctl stop xl2tpd 2>/dev/null;" + f" kill {' '.join(etrangers)} 2>/dev/null; true" + ) + ), + question=( + "Arrêter le service xl2tpd et terminer les processus" + f" restants ({', '.join(etrangers)}), puis réessayer ?" + ), + ): + holders, tenu = self._port_holders(runner, port) + if not tenu: + runner.ok(f"UDP {port} libéré.") + runner.info( + " (le service reviendra au prochain démarrage :" + " sudo systemctl disable xl2tpd)" + ) + return True + + report(f"UDP {port} est encore tenu : {tenu.splitlines()[0][:110]}") + inconnus = [ + args for _pid, args in holders if "xl2tpd" not in args and args + ] + if inconnus: + runner.info( + f" → « {inconnus[0][:60]} » n'est pas à nous :" + " l'arrêter demande votre décision, ou changer" + " « l2tp_local_port » dans le profil." + ) + return runner.dry_run + + def _who_holds_the_port(self, runner, port): + """Ligne(s) de `ss` décrivant qui tient UDP `port`, "" si personne.""" + script = f"ss -lunp 2>/dev/null | grep ':{port} ' || true" + _, out = runner.cmd( + f"UDP {port} est-il déjà tenu ?", + f"sh -c {shlex.quote(script)}", + check=False, + capture=True, + ) + return out.strip() + + def _port_holders(self, runner, port): + """([(pid, ligne de commande)], sortie brute de `ss`). + + La ligne de commande est ce qui distingue nos instances des autres : + les nôtres portent leur configuration dans SECRET_DIR//. + """ + tenu = self._who_holds_the_port(runner, port) + pids = re.findall(r"pid=(\d+)", tenu) + if not pids: + return [], tenu + _, out = runner.cmd( + "à qui appartiennent ces processus ?", + f"ps -o pid=,args= -p {' '.join(pids)}", + sudo=False, + check=False, + capture=True, + ) + vu = {} + for line in (out or "").splitlines(): + morceaux = line.strip().split(None, 1) + if len(morceaux) == 2: + vu[morceaux[0]] = morceaux[1] + # `ss` nomme déjà le processus : c'est le repli quand `ps` ne dit + # rien (hidepid, processus disparu entre les deux appels). Sans ce + # repli on perdrait la classification, donc le remède, sur une + # information qu'on avait pourtant déjà. + noms = dict( + (pid, nom) + for nom, pid in re.findall(r'\("([^"]+)",pid=(\d+)', tenu) + ) + return [(pid, vu.get(pid) or noms.get(pid, "")) for pid in pids], tenu + + def _mine(self, holders): + """Pids qui sont des instances de CE profil.""" + marque = f"{SECRET_DIR}/{self.name_tag}/" + return [pid for pid, args in holders if marque in args] + + def _sibling_profile(self, holders): + """Nom d'un AUTRE de nos profils tenant le port, "" sinon.""" + for _pid, args in holders: + trouve = re.search( + rf"{re.escape(SECRET_DIR)}/([^/\s]+)/", args or "" + ) + if trouve and trouve.group(1) != self.name_tag: + return trouve.group(1) + return "" + + def _foreign_xl2tpd(self, holders): + """Pids xl2tpd qui ne sont pas des instances à nous.""" + return [ + pid + for pid, args in holders + if "xl2tpd" in args and SECRET_DIR not in args + ] + + def _allow_apparmor_to_read_secrets(self, runner): + """Autorise charon à lire nos secrets en tmpfs. + + AppArmor confine charon par CHEMIN, et `/dev/shm` ne figure pas dans + son profil. Sans cette règle, charon lit /etc/ipsec.secrets, suit + notre `include`, et se fait refuser le fichier par le noyau — pour + échouer trois étages plus loin sur « no shared key found », alors + que le PSK est là et bien formé. Seul `journalctl -k` le dit, en + clair : `apparmor="DENIED" … denied_mask="r"`. + + Le fichier `local/` est le point d'extension prévu par Debian et + Ubuntu ; il ne contient aucun secret, seulement un chemin. Il n'est + PAS retiré au démontage : c'est une permission de chemin, valable + pour tous les profils, et inoffensive quand le répertoire est vide. + """ + if not os.path.exists(APPARMOR_PROFILE): + # Ni Debian ni Ubuntu : pas de profil charon à étendre. + return + changed = runner.block( + APPARMOR_LOCAL, + "secrets", + f"{SECRET_DIR}/** r,", + "0644", + ) + if not changed: + return + # Le rechargement s'applique aussi au charon DÉJÀ lancé : sans lui, + # la règle n'entrerait en vigueur qu'au prochain démarrage. + runner.cmd( + "recharger le profil AppArmor de charon", + f"apparmor_parser -r {shlex.quote(APPARMOR_PROFILE)}", + check=False, + ) + + def _wait_for_conn(self, runner): + """Attend que la connexion soit chargée dans charon. + + `ipsec start` rend la main tout de suite : charon met une fraction + de seconde à démarrer, et le starter lui pousse les connexions + ENSUITE. Un `ipsec up` lancé dans l'instant échoue sur « no match » + — la connexion étant parfaitement valide, c'est l'erreur la plus + trompeuse de toute la séquence. + """ + conn = shlex.quote(self.conn) + script = ( + f"for i in $(seq 1 40); do ipsec statusall {conn} 2>/dev/null" + f" | grep -q {shlex.quote(self.conn + ':')} && exit 0;" + " sleep 0.25; done; exit 1" + ) + code, _ = runner.cmd( + "attendre que charon ait chargé la connexion", + f"sh -c {shlex.quote(script)}", + check=False, + timeout=20, + ) + if code == 0 or runner.dry_run: + return True + runner.fail( + f"charon n'a pas chargé « {self.conn} » en 10 s. Le bloc de" + " /etc/ipsec.conf est-il bien formé ? Voir le journal de" + " strongSwan." + ) + return False + + def _reload_charon(self, runner, check=True): + code, _ = runner.cmd( + "charon tourne-t-il ?", + "ipsec status", + check=False, + capture=True, + ) + if code != 0: + runner.cmd("démarrer charon", "ipsec start", check=check) + return + runner.cmd("recharger ipsec.conf", "ipsec reload", check=check) + runner.cmd("relire les secrets", "ipsec rereadsecrets", check=check) + + +def _pppd_quote(value: str) -> str: + """`value` cité pour un fichier d'options pppd. + + pppd lit des chaînes entre guillemets doubles et y traite `\\` comme + échappement. Un utilisateur de la forme `DOMAINE\\prenom` est courant sur + les concentrateurs L2TP : sans cet échappement, pppd envoie + `DOMAINEprenom` et le serveur refuse sans dire pourquoi. + """ + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' diff --git a/script/vpn/drivers/openconnect.py b/script/vpn/drivers/openconnect.py new file mode 100644 index 0000000..bda50ad --- /dev/null +++ b/script/vpn/drivers/openconnect.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""OpenConnect : le seul pilote où aucun secret ne touche un fichier. + +`--passwd-on-stdin` fait lire le mot de passe sur l'entrée standard. Il ne +passe donc ni par un argument (`/proc//cmdline`, lisible par tous), ni +par un fichier, même en tmpfs. C'est le cas idéal, et il vaut la peine d'être +nommé : les autres pilotes composent avec des technologies qui EXIGENT un +fichier, celui-ci n'en a pas besoin. + +Un client, plusieurs protocoles : AnyConnect (Cisco), Pulse/Juniper, +GlobalProtect (Palo Alto), Fortinet, F5, Array. `--protocol` décide. + +`--non-inter` est passé volontairement. Sans lui, un certificat serveur +inconnu déclenche une question — et openconnect la lirait sur l'entrée +standard, celle par laquelle arrive justement le mot de passe. Le tunnel +échouerait sur un « certificate verify failed » incompréhensible. Avec +`--non-inter`, openconnect refuse tout de suite ET imprime la ligne +`--servercert sha256:…` à recopier dans le champ `oc_servercert` du profil. + +Les routes appartiennent au serveur : c'est `vpnc-script` qui les pose, à +partir de ce que le concentrateur pousse. Le profil peut en AJOUTER, il ne +les remplace pas — d'où `needs_routes = False`. + +SSO / SAML — le cas du « formulaire web » +----------------------------------------- +Quand le concentrateur authentifie par un fournisseur d'identité (Azure AD, +Okta, Duo…), il n'y a pas de mot de passe à envoyer : il faut une page web. +Le client de Cisco la rend dans un navigateur WebKit embarqué — donc un +écran, et sur bien des postes la variable `WEBKIT_DISABLE_DMABUF_RENDERER=1` +en prime pour qu'elle s'affiche. Son CLI, lui, ne sait pas le faire. + +openconnect le fait sans écran sur la machine cliente. Mesuré dans sa +bibliothèque : il ÉCOUTE sur le port local 29786 et attend la redirection +(« Accepted incoming external-browser connection on port 29786 »), après +avoir lancé le programme donné à `--external-browser` avec l'URL de +connexion. Sur un serveur, ce « navigateur » est un simple `echo` : l'URL +s'affiche, on l'ouvre dans SON navigateur, et un + + ssh -L 29786:localhost:29786 + +fait revenir la redirection à openconnect. Aucun écran là-bas, et le mot de +passe ne quitte jamais le poste de l'utilisateur. +""" +from __future__ import annotations + +import shlex + +from script.vpn import valid +from script.vpn.drivers.base import ( + VpnDriver, + interface_addresses, + interface_exists, +) + +# Ce que ce client sait parler. La liste vient de `openconnect --protocol`. +PROTOCOLS = ("anyconnect", "nc", "pulse", "gp", "f5", "fortinet", "array") + + +class OpenconnectDriver(VpnDriver): + name = "openconnect" + label = "OpenConnect" + binaries = ("openconnect", "ip") + # Non obligatoire : en SSO il n'y a AUCUN mot de passe à déposer, et le + # menu ne doit pas réclamer un secret que la méthode n'utilise pas. La + # vraie exigence dépend du mode, elle est donc dans `up`. + secret_fields = (("password", "VPN password", False),) + iface_kind = "tun" + hint = "Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances" + user_field = "oc_user" + # Le MTU vient du serveur (ou du .ovpn), pas du profil. + uses_mtu = False + # Le serveur pousse les routes : exiger une route déclarée serait une + # fausse exigence. + needs_routes = False + defaults = { + "port": 443, + "oc_user": "", + "oc_protocol": "anyconnect", + "oc_authgroup": "", + "oc_servercert": "", + # SSO : le concentrateur authentifie par un fournisseur d'identité, + # dans un navigateur. Voir l'en-tête du fichier. + "oc_sso": False, + # Programme lancé avec l'URL de connexion. Vide = `echo`, qui + # l'affiche : c'est ce qu'on veut sur une machine sans écran. + "oc_external_browser": "", + } + form_fields = ( + ("oc_user", "VPN user", "text", False), + ( + "oc_protocol", + "Protocol (anyconnect, nc, pulse, gp, f5, fortinet, array)", + "text", + False, + ), + ( + "oc_authgroup", + "Authentication group / realm (optional)", + "text", + True, + ), + ( + "oc_servercert", + "Pinned server certificate (sha256:... , printed on first refusal)", + "text", + True, + ), + ( + "oc_sso", + "Authentication through a web form (SAML / SSO)?", + "flag", + False, + ), + ( + "oc_external_browser", + "Browser command for SSO (empty: show the URL to open yourself)", + "path", + True, + ), + ("port", "HTTPS port", "int", True), + ) + + # ------------------------------------------------------------------ + @property + def iface(self): + """Interface NOMMÉE, et non découverte : openconnect sait le faire + (`--interface`), et un nom connu d'avance rend `status` fiable même + après un redémarrage du CLI. Tronqué à 15 caractères.""" + return f"vpn-{self.name_tag}"[:15] + + @classmethod + def validate_profile(cls, profile): + # Le mode d'abord : en SSO, c'est le fournisseur d'identité qui + # décide de qui on est, et exiger un utilisateur ici refuserait un + # profil parfaitement valide. + valid.flag(profile, "oc_sso") + valid.text( + profile, + "oc_user", + "Utilisateur VPN", + required=not profile["oc_sso"], + ) + protocol = valid.text(profile, "oc_protocol", "Protocole") + if protocol not in PROTOCOLS: + raise valid.ProfileError( + f"Protocole inconnu : « {protocol} »." + f" Connus : {', '.join(PROTOCOLS)}." + ) + valid.text( + profile, + "oc_authgroup", + "Groupe d'authentification", + required=False, + ) + valid.text( + profile, + "oc_servercert", + "Empreinte du certificat serveur", + required=False, + ) + valid.port(profile, "port", "Port HTTPS") + valid.path( + profile, + "oc_external_browser", + "Programme navigateur", + required=False, + ) + + @property + def browser(self): + """Programme lancé avec l'URL de connexion SSO. + + `echo` par défaut : sur une machine sans écran, afficher l'URL est + exactement ce qu'on veut — openconnect attend ensuite la redirection + sur son port 29786.""" + return self.profile.get("oc_external_browser") or "echo" + + def command(self): + """La ligne de commande, dans l'une de ses deux formes. + + Classique : le mot de passe arrive par l'entrée standard, grâce à + `--passwd-on-stdin` — il n'est jamais dans la ligne de commande. + + SSO : il n'y a pas de mot de passe. Pas de `--non-inter` non plus, + car l'échange avec le navigateur EST l'interaction ; l'interdire + ferait échouer la seule étape qui compte. + """ + p = self.profile + parts = [ + "openconnect", + f"--protocol={shlex.quote(p['oc_protocol'])}", + ] + if p["oc_user"]: + parts.append(f"--user={shlex.quote(p['oc_user'])}") + if p["oc_sso"]: + parts.append(f"--external-browser={shlex.quote(self.browser)}") + else: + parts += ["--passwd-on-stdin", "--non-inter"] + parts += [ + "--background", + f"--pid-file={shlex.quote(self.pid_file)}", + f"--interface={shlex.quote(self.iface)}", + ] + if p.get("oc_authgroup"): + parts.append(f"--authgroup={shlex.quote(p['oc_authgroup'])}") + if p.get("oc_servercert"): + parts.append(f"--servercert={shlex.quote(p['oc_servercert'])}") + parts.append(shlex.quote(f"{p['server']}:{p['port']}")) + return " ".join(parts) + + # ------------------------------------------------------------------ + def up(self, runner): + p = self.profile + if not self.ensure_ready(runner): + return False + # `secrets=False` : ce pilote n'écrit AUCUN fichier de secret, et + # créer un répertoire pour rien serait laisser croire qu'il en a un. + self.prepare_dirs(runner, secrets=False) + + if p["oc_sso"]: + self._explain_the_sso_round_trip(runner) + mot_de_passe, delai = None, 300 + else: + if not self.secrets.get("password"): + report = runner.warn if runner.dry_run else runner.fail + report( + "Aucun mot de passe dans le coffre, et le profil n'est" + " pas en SSO : les déposer, ou cocher « formulaire web »." + ) + if not runner.dry_run: + return False + mot_de_passe = self.secrets.get("password", "") + "\n" + delai = 120 + + code, _ = runner.cmd( + f"ouvrir la session {p['oc_protocol']} sur {p['server']}", + self.command(), + stdin=mot_de_passe, + secret_stdin=bool(mot_de_passe), + check=False, + timeout=delai, + ) + if code != 0 and not runner.dry_run: + runner.fail("openconnect a refusé.") + if p["oc_sso"]: + runner.info( + " → En SSO, les deux causes sont : la redirection" + " n'est jamais revenue sur le port 29786 (redirection" + " ssh en place ?), ou le délai de 5 minutes a expiré" + " avant la fin de l'authentification." + ) + else: + runner.info( + " → Causes usuelles : identifiants, certificat" + " serveur non épinglé (recopier la ligne" + " « --servercert sha256:… » ci-dessus dans le champ" + " oc_servercert), groupe d'authentification absent." + ) + return False + if runner.dry_run: + runner.info(f" (à blanc : l'interface serait {self.iface})") + return True + + if not interface_exists(self.iface): + runner.fail( + f"openconnect s'est lancé mais {self.iface} n'existe pas." + " vpnc-script est-il installé ? (paquet vpnc-scripts)" + ) + return False + addresses = ( + ", ".join(interface_addresses(self.iface)) or "sans adresse" + ) + runner.ok(f"interface {self.iface} : {addresses}") + self.write_state(runner, "iface", self.iface) + # Les routes du serveur sont déjà posées par vpnc-script. Celles du + # profil s'AJOUTENT : un réseau que le concentrateur ne pousse pas + # mais qu'on sait joignable. + self.add_routes(runner, self.iface) + return True + + def _explain_the_sso_round_trip(self, runner): + """Dit à l'humain ce qu'il va devoir faire, AVANT de le bloquer. + + openconnect va afficher une URL puis attendre, silencieusement, sur + son port 29786. Sans cette explication, l'attente ressemble à un + blocage — et la redirection ne revient jamais si personne n'a monté + le tunnel ssh.""" + runner.info( + " Authentification par formulaire web. openconnect va" + " afficher une URL, puis attendre la redirection sur son port" + " local 29786." + ) + runner.info( + " Depuis VOTRE poste, avant d'ouvrir l'URL :" + " ssh -L 29786:localhost:29786 " + ) + if self.browser == "echo": + runner.info( + " L'URL s'affichera ici : l'ouvrir dans votre propre" + " navigateur. Le mot de passe ne quitte pas votre poste." + ) + else: + runner.info(f" Navigateur lancé sur place : {self.browser}") + + def down(self, runner): + # SIGTERM : openconnect rappelle vpnc-script, qui défait les routes + # et le DNS qu'il avait posés. Un « kill -9 » les laisserait en + # place, et la machine resterait à moitié dans le tunnel. + self.kill_pidfile(runner, "arrêter openconnect (SIGTERM)") + self.clear_state(runner, "iface", "pid") + runner.ok( + "Session fermée. Aucun secret à effacer : il n'a jamais" + " touché le disque." + ) + return True + + def status(self, runner): + return self.standard_status( + runner, extra=[self.check_daemon("processus openconnect")] + ) + + def log_commands(self): + return [ + ( + "journal openconnect", + "journalctl -n 40 --no-pager -t openconnect", + ) + ] diff --git a/script/vpn/drivers/openvpn.py b/script/vpn/drivers/openvpn.py new file mode 100644 index 0000000..4407fcb --- /dev/null +++ b/script/vpn/drivers/openvpn.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""OpenVPN : on part du fichier `.ovpn` que le client a fourni. + +Ce pilote ne fabrique PAS de configuration OpenVPN. Un `.ovpn` porte une +autorité de certification, un certificat, une clé privée, des directives de +compression et de chiffrement : le modéliser dans un profil JSON serait +recopier un format qui existe déjà, et le recopier moins bien. Le profil +pointe donc vers le fichier, et ce pilote y ajoute ce que le fichier ne peut +pas contenir sans devenir un secret de plus : les identifiants, lus dans le +coffre et posés dans un tmpfs. + +Deux choses qu'on aurait tort de croire évidentes : + +· **`--cd`.** Un `.ovpn` référence ses fichiers voisins en relatif (`ca.crt`, + `client.key`). Lancé depuis la racine du dépôt, openvpn ne les trouve pas + et se plaint d'un certificat manquant, pas d'un répertoire. On se place + donc dans le répertoire du fichier. +· **L'ordre des options.** Ce qui suit `--config` sur la ligne de commande + l'emporte sur le contenu du fichier. Notre `--auth-user-pass ` + doit donc venir APRÈS, sinon un `auth-user-pass` nu dans le `.ovpn` fait + attendre une saisie qui ne viendra jamais — le démon est détaché. + +Le tunnel scindé se demande à OpenVPN par `--route-nopull` : ignorer les +routes poussées, puis poser les nôtres. C'est un gros marteau — il ignore +aussi le DNS poussé — et le pilote le dit quand il le prend. +""" +from __future__ import annotations + +import os +import shlex + +from script.vpn import valid +from script.vpn.drivers.base import ( + STATE_DIR, + VpnDriver, + interface_addresses, + interfaces, + wait_for_new_interface, +) + + +class OpenvpnDriver(VpnDriver): + name = "openvpn" + label = "OpenVPN" + binaries = ("openvpn", "ip") + # Pas obligatoire : beaucoup de `.ovpn` s'authentifient par certificat + # seul. `up` exige le mot de passe seulement si un utilisateur est + # déclaré. + secret_fields = (("password", "OpenVPN password", False),) + iface_kind = "tun" + # Faux : un profil sans route reste utilisable — il joint l'hôte + # distant, et l'adresse qu'on y obtient dit quel réseau ajouter. Le + # site ne donne souvent qu'une passerelle et des identifiants. + needs_routes = False + hint = "When the site handed you a .ovpn file" + user_field = "ovpn_user" + # Le MTU vient du serveur (ou du .ovpn), pas du profil. + uses_mtu = False + defaults = {"ovpn_config": "", "ovpn_user": ""} + form_fields = ( + ( + "ovpn_config", + "Path to the .ovpn file provided by the site", + "path", + False, + ), + ( + "ovpn_user", + "OpenVPN user (empty if the file authenticates by certificate)", + "text", + False, + ), + ) + + # ------------------------------------------------------------------ + @property + def auth_file(self): + return f"{self.secret_dir}/auth.txt" + + @property + def log_file(self): + """Journal en tmpfs, lisible sans sudo : c'est lui qui dit pourquoi + une connexion a échoué, et `diagnose` doit pouvoir le montrer.""" + return f"{STATE_DIR}/{self.name_tag}.log" + + @classmethod + def validate_profile(cls, profile): + valid.path(profile, "ovpn_config", "Fichier .ovpn") + valid.text(profile, "ovpn_user", "Utilisateur OpenVPN", required=False) + + def auth_body(self): + """Le format attendu par `--auth-user-pass` : deux lignes.""" + return "{}\n{}\n".format( + self.profile.get("ovpn_user", ""), + self.secrets.get("password", ""), + ) + + def command(self): + """La ligne de commande, sans aucun secret : le mot de passe est + dans le fichier d'authentification, pas ici.""" + p = self.profile + config = p["ovpn_config"] + parts = [ + "openvpn", + f"--config {shlex.quote(config)}", + f"--cd {shlex.quote(os.path.dirname(config) or '.')}", + f"--daemon erplibre-{self.name_tag}", + f"--writepid {shlex.quote(self.pid_file)}", + f"--log {shlex.quote(self.log_file)}", + ] + if p.get("ovpn_user"): + parts.append(f"--auth-user-pass {shlex.quote(self.auth_file)}") + # Le fichier est relu à chaque renégociation : rien à garder en + # mémoire, et un secret de moins qui traîne dans le processus. + parts.append("--auth-nocache") + if not p.get("default_route") and p.get("routes"): + parts.append("--route-nopull") + return " ".join(parts) + + # ------------------------------------------------------------------ + def up(self, runner): + p = self.profile + if not self.ensure_ready(runner): + return False + if p.get("ovpn_user") and not self.secrets.get("password"): + report = runner.warn if runner.dry_run else runner.fail + report( + f"Le profil déclare l'utilisateur « {p['ovpn_user']} » mais" + " aucun mot de passe n'est dans le coffre." + ) + if not runner.dry_run: + return False + self._warn_about_config_file(runner) + + before = ( + runner.call( + "relever les interfaces tun/tap existantes", + lambda: interfaces("tun"), + dry_safe=True, + ) + or set() + ) + + self.prepare_dirs(runner) + if p.get("ovpn_user"): + runner.write( + self.auth_file, self.auth_body(), mode="0600", secret=True + ) + if not p.get("default_route") and p.get("routes"): + runner.warn( + "Tunnel scindé par --route-nopull : les routes ET le DNS" + " poussés par le serveur sont ignorés. Seules les routes du" + " profil sont posées." + ) + + code, _ = runner.cmd("lancer openvpn", self.command(), timeout=60) + if code != 0 and not runner.dry_run: + runner.fail( + "openvpn n'a pas démarré. Le journal dit pourquoi :" + f" {self.log_file}" + ) + return False + + if not self._wait_for_init(runner): + return False + if runner.dry_run: + runner.info(" (à blanc : l'interface serait nommée ici)") + return True + + iface = wait_for_new_interface(before, "tun", timeout=10) + if not iface: + runner.fail( + "OpenVPN dit s'être initialisé, mais aucune interface" + " tun/tap n'est apparue. Cas rare : configuration en mode" + " pont (tap) sans interface propre." + ) + return False + addresses = ", ".join(interface_addresses(iface)) or "sans adresse" + runner.ok(f"interface {iface} : {addresses}") + self.write_state(runner, "iface", iface) + if not p.get("default_route"): + self.add_routes(runner, iface) + self.suggest_routes(runner, iface) + return True + + def _wait_for_init(self, runner): + """Attend « Initialization Sequence Completed » dans le journal. + + C'est LE signal de succès d'OpenVPN. Le processus détaché existe + bien avant : se contenter de son pid ferait dire « monté » à un + client encore en train de se faire refuser ses certificats. + """ + log = shlex.quote(self.log_file) + script = ( + "for i in $(seq 1 120); do" + f" grep -q 'Initialization Sequence Completed' {log}" + " 2>/dev/null && exit 0; sleep 0.5; done; exit 1" + ) + code, _ = runner.cmd( + "attendre l'initialisation d'OpenVPN", + f"sh -c {shlex.quote(script)}", + check=False, + timeout=75, + ) + if code == 0 or runner.dry_run: + return True + runner.fail( + "OpenVPN ne s'est pas initialisé en 60 s. Les dernières lignes" + f" de {self.log_file} disent laquelle des trois étapes a" + " échoué : TLS, authentification, ou pose des routes." + ) + return False + + def _warn_about_config_file(self, runner): + """Un `.ovpn` embarque souvent la clé privée du client. + + Le fichier appartient à l'utilisateur, pas à nous : on ne le + déplace pas, on ne le réécrit pas. Mais lisible par tout le monde, + il vaut la peine d'être signalé — c'est une clé privée. + """ + config = self.profile.get("ovpn_config", "") + try: + mode = os.stat(config).st_mode + except OSError: + runner.warn( + f"Fichier de configuration introuvable : {config}." + " Le montage échouera." + ) + return + if mode & 0o077: + runner.warn( + f"{config} est lisible au-delà de son propriétaire" + f" (mode {oct(mode & 0o777)}). Un .ovpn embarque souvent la" + " clé privée du client : chmod 600 est de rigueur." + ) + + # ------------------------------------------------------------------ + def down(self, runner): + self.kill_pidfile(runner, "arrêter openvpn") + runner.remove(self.secret_dir) + # Le journal SURVIT au démontage, volontairement : c'est juste + # après un « down » qu'on cherche pourquoi ça n'allait pas. Il est + # en tmpfs, donc il part au redémarrage de la machine. + self.clear_state(runner, "iface", "pid") + runner.ok( + f"Tunnel démonté, secrets effacés. Journal gardé : {self.log_file}" + ) + return True + + # ------------------------------------------------------------------ + def status(self, runner): + extra = [self.check_daemon("processus openvpn")] + try: + with open(self.log_file) as fh: + lines = [line.strip() for line in fh if line.strip()] + except OSError: + lines = [] + initialised = any( + "Initialization Sequence Completed" in line for line in lines + ) + extra.append( + ( + "initialisation OpenVPN", + initialised if lines else None, + lines[-1][:120] if lines else "aucun journal", + ) + ) + return self.standard_status(runner, extra=extra) + + def log_commands(self): + return [ + ( + f"journal OpenVPN ({self.log_file})", + f"tail -n 40 {shlex.quote(self.log_file)}", + ) + ] diff --git a/script/vpn/drivers/sshuttle.py b/script/vpn/drivers/sshuttle.py new file mode 100644 index 0000000..8182d30 --- /dev/null +++ b/script/vpn/drivers/sshuttle.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""sshuttle : un VPN sur une simple session SSH, sans rien à installer en face. + +Le pilote le plus utile quand il n'y a PAS de concentrateur : si on a un accès +SSH sur une machine du réseau visé, on a déjà tout. Rien à installer côté +serveur, aucune clé à échanger, aucun secret à ranger — l'authentification est +celle de SSH, donc `secret_fields` est vide et le coffre n'est même pas +ouvert. C'est le seul pilote qui n'en a pas besoin. + +Deux différences qui changent le code, et pas seulement les commandes : + +· **Pas d'interface.** sshuttle détourne le trafic par le pare-feu + (iptables/nftables), il ne crée pas de `tun`. Toutes les vérifications + d'interface et de table de routage sont donc muettes ici : c'est l'adresse + TÉMOIN qui dit si ça marche, et rien d'autre. Ce pilote est la raison d'être + du champ `probe`. +· **Il s'élève tout seul.** sshuttle veut être lancé par l'UTILISATEUR : il + n'appelle sudo que pour la partie pare-feu. Le lancer sous sudo ferait + ouvrir la session SSH par root, avec les clés de root — c'est-à-dire aucune. + D'où `sudo=False`, et un fichier de pid dans le home plutôt que dans /run. +""" +from __future__ import annotations + +import os +import shlex + +from script.vpn import valid +from script.vpn.drivers.base import VpnDriver + + +class SshuttleDriver(VpnDriver): + name = "sshuttle" + label = "sshuttle" + binaries = ("sshuttle", "ssh") + # Aucun. L'authentification est celle de SSH. + secret_fields = () + iface_kind = "" + hint = ( + "When all you have is SSH access: nothing to install on the far side" + ) + server_label = "SSH target (user@host, or a ~/.ssh/config alias)" + uses_mtu = False + defaults = {"port": 22, "ssh_dns": True} + form_fields = ( + ("port", "SSH port", "int", True), + ("ssh_dns", "Also send DNS queries through the tunnel?", "flag", True), + ) + + # ------------------------------------------------------------------ + @property + def pid_file(self): + """Dans le home, pas dans /run. + + sshuttle tourne sous l'utilisateur et écrit ce fichier lui-même : + /run/erplibre-vpn appartient à root, il ne pourrait pas.""" + return os.path.expanduser(f"~/.erplibre/vpn-{self.name_tag}.pid") + + @property + def subnets(self): + """Ce qui entre dans le tunnel. `0.0.0.0/0` pour « tout ».""" + if self.profile.get("default_route"): + return ["0.0.0.0/0"] + return list(self.profile.get("routes", [])) + + @classmethod + def validate_profile(cls, profile): + valid.port(profile, "port", "Port SSH") + valid.flag(profile, "ssh_dns") + + def command(self): + p = self.profile + remote = p["server"] + if int(p.get("port", 22)) != 22: + remote = f"{remote}:{p['port']}" + parts = [ + "sshuttle", + f"--remote {shlex.quote(remote)}", + "--daemon", + f"--pidfile {shlex.quote(self.pid_file)}", + ] + if p.get("ssh_dns"): + parts.append("--dns") + parts.extend(shlex.quote(subnet) for subnet in self.subnets) + return " ".join(parts) + + # ------------------------------------------------------------------ + def up(self, runner): + if not self.ensure_ready(runner): + return False + if not self.subnets: + runner.fail("Aucun réseau à détourner : rien à faire.") + return False + + runner.call( + f"préparer {os.path.dirname(self.pid_file)}", + lambda: os.makedirs(os.path.dirname(self.pid_file), exist_ok=True), + ) + # SANS sudo : sudo est demandé par sshuttle lui-même, et seulement + # pour le pare-feu. Voir l'en-tête du fichier. + code, _ = runner.cmd( + f"détourner {', '.join(self.subnets)} par {self.profile['server']}", + self.command(), + sudo=False, + check=False, + timeout=90, + ) + if code != 0 and not runner.dry_run: + runner.fail( + "sshuttle n'a pas démarré. Les causes usuelles : SSH qui ne" + f" passe pas vers {self.profile['server']} (l'essayer à la" + " main), python absent sur la machine distante, ou sudo" + " local refusé." + ) + return False + if runner.dry_run: + return True + if self.pid_alive() is not True: + runner.fail( + "sshuttle s'est lancé puis a rendu la main sans laisser de" + " processus vivant. Le relancer sans --daemon montre ce" + " qu'il refuse." + ) + return False + runner.ok( + "Détournement actif. Pas d'interface à montrer : sshuttle passe" + " par le pare-feu." + ) + return True + + def down(self, runner): + # Sans sudo, comme au montage : c'est notre processus. + self.kill_pidfile(runner, "arrêter sshuttle", sudo=False) + runner.ok("Détournement arrêté.") + return True + + def status(self, runner): + """Ni interface, ni route à vérifier : le témoin est le seul juge. + + On ne réutilise donc PAS `standard_status` — ses vérifications + d'interface et de routes rendraient des « ✗ » qui n'ont aucun sens + pour un détournement par le pare-feu.""" + checks = self.check_kernel() + checks += [ + self.check_binaries(), + self.check_daemon("processus sshuttle"), + ( + "réseaux détournés", + bool(self.subnets), + ", ".join(self.subnets) or "aucun", + ), + ] + probe = self.check_probe(runner) + if not probe: + checks.append( + ( + "témoin", + None, + "aucune adresse témoin : renseigner « probe » dans le" + " profil, c'est la seule preuve possible ici", + ) + ) + checks.extend(probe) + return checks + + def log_commands(self): + return [ + ( + "règles de détournement", + # Le nom de la chaîne porte le port choisi par sshuttle + # (12300 par défaut, mais pas toujours) : on cherche le + # motif plutôt que de parier sur le nom. + 'sh -c "iptables -t nat -S 2>/dev/null | grep -i sshuttle' + " || nft list ruleset 2>/dev/null | grep -i sshuttle" + " || echo 'aucune règle sshuttle visible'\"", + ) + ] diff --git a/script/vpn/drivers/wireguard.py b/script/vpn/drivers/wireguard.py new file mode 100644 index 0000000..00d1770 --- /dev/null +++ b/script/vpn/drivers/wireguard.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""WireGuard : une configuration, une commande, et c'est monté. + +Le plus simple des pilotes — et c'est justement là qu'il faut se méfier. +WireGuard n'a pas de session : `wg-quick up` réussit et l'interface apparaît +même si la clé du pair est fausse, même si l'endpoint est injoignable. Rien +ne dit non, parce qu'il n'y a personne à qui dire non. + +Ce pilote attend donc une POIGNÉE DE MAIN avant de déclarer le tunnel monté. +Sans cette attente, « ✓ Tunnel monté » voudrait dire « l'interface existe », +ce qui n'est pas la même chose et ne se découvre qu'au premier paquet perdu. + +Les routes viennent d'`AllowedIPs` et c'est `wg-quick` qui les pose — y +compris, en « tout le trafic », l'astuce de marquage (fwmark) qui garde +l'endpoint joignable. On ne double donc PAS son travail : un `ip route` de +plus ici entrerait en conflit avec le sien. +""" +from __future__ import annotations + +import shlex + +from script.vpn import valid +from script.vpn.drivers.base import VpnDriver, interface_addresses + + +class WireguardDriver(VpnDriver): + name = "wireguard" + label = "WireGuard" + binaries = ("wg", "wg-quick", "ip") + secret_fields = ( + ("wg_private_key", "WireGuard private key of this machine", True), + ("wg_preshared_key", "WireGuard pre-shared key (optional)", False), + ) + iface_kind = "wireguard" + hint = "When you control both ends: the fastest and the simplest" + defaults = { + "port": 51820, + "wg_address": "", + "wg_peer_key": "", + "wg_dns": "", + "wg_keepalive": 25, + } + form_fields = ( + ( + "wg_address", + "Address of this machine inside the tunnel (10.7.0.2/32)", + "text", + False, + ), + ("wg_peer_key", "Public key of the peer", "text", False), + ("port", "WireGuard endpoint port", "int", True), + ("wg_dns", "DNS server inside the tunnel (optional)", "text", True), + ("wg_keepalive", "PersistentKeepalive, in seconds", "int", True), + ) + + # ------------------------------------------------------------------ + @property + def iface(self): + """Nom de l'interface, et donc du fichier de configuration. + + `wg-quick` DÉDUIT le nom de l'interface du nom du fichier : le + fichier doit s'appeler `.conf`. Tronqué à 15 caractères, + limite du noyau pour un nom d'interface. + """ + return f"wg-{self.name_tag}"[:15] + + @property + def config_file(self): + return f"{self.secret_dir}/{self.iface}.conf" + + @property + def allowed_ips(self): + """`AllowedIPs` : ce qui entre dans le tunnel. + + C'est le champ le plus mal compris de WireGuard — il sert à LA FOIS + de filtre de trafic et de table de routage. `0.0.0.0/0` veut donc + dire « tout le trafic », et rien d'autre n'est nécessaire pour cela. + """ + if self.profile.get("default_route"): + return "0.0.0.0/0" + return ", ".join(self.profile.get("routes", [])) + + # ------------------------------------------------------------------ + @classmethod + def validate_profile(cls, profile): + valid.ip_interface( + profile, "wg_address", "Adresse de cette machine dans le tunnel" + ) + valid.wg_key(profile, "wg_peer_key", "Clé publique du pair") + valid.port(profile, "port", "Port de l'endpoint WireGuard") + valid.ip_address( + profile, "wg_dns", "Serveur DNS dans le tunnel", required=False + ) + valid.integer(profile, "wg_keepalive", "PersistentKeepalive", 0, 65535) + + def config_body(self): + p = self.profile + lines = [ + "# Généré par ERPLibre (script/vpn). tmpfs, 0600, effacé au down.", + "[Interface]", + f"PrivateKey = {self.secrets.get('wg_private_key', '')}", + f"Address = {p['wg_address']}", + f"MTU = {p['mtu']}", + "", + "[Peer]", + f"PublicKey = {p['wg_peer_key']}", + ] + preshared = self.secrets.get("wg_preshared_key") + if preshared: + lines.append(f"PresharedKey = {preshared}") + lines += [ + f"Endpoint = {p['server']}:{p['port']}", + f"AllowedIPs = {self.allowed_ips}", + ] + if p.get("wg_keepalive"): + # Indispensable derrière du NAT : sans trafic, la traduction + # expire et le pair ne sait plus où nous joindre. + lines.append(f"PersistentKeepalive = {p['wg_keepalive']}") + # Pas de « DNS = » : wg-quick le confie à `resolvconf`, absent de + # beaucoup d'installations systemd-resolved, et la configuration + # ENTIÈRE échoue alors. On appelle resolvectl nous-mêmes. + return "\n".join(lines) + + # ------------------------------------------------------------------ + def up(self, runner): + p = self.profile + if not self.ensure_ready(runner): + return False + if not self.allowed_ips: + runner.fail( + "Aucun réseau à router : AllowedIPs serait vide et le" + " tunnel ne porterait rien." + ) + return False + + self.prepare_dirs(runner) + runner.write( + self.config_file, + self.config_body() + "\n", + mode="0600", + secret=True, + ) + code, _ = runner.cmd( + f"monter {self.iface}", + f"wg-quick up {shlex.quote(self.config_file)}", + timeout=60, + ) + if code != 0 and not runner.dry_run: + runner.fail( + "wg-quick a refusé. Causes usuelles : clé mal formée," + " adresse déjà prise, module wireguard absent du noyau." + ) + return False + self.write_state(runner, "iface", self.iface) + + if not self._wait_for_handshake(runner): + return False + if runner.dry_run: + return True + + addresses = ( + ", ".join(interface_addresses(self.iface)) or "sans adresse" + ) + runner.ok(f"interface {self.iface} : {addresses}") + # Les routes appartiennent à wg-quick, via AllowedIPs. Rien à + # ajouter ici — voir l'en-tête du fichier. + if p.get("wg_dns"): + self.set_resolved_dns(runner, self.iface, [p["wg_dns"]]) + return True + + def _wait_for_handshake(self, runner): + """Attend une poignée de main avec le pair. + + `wg show … latest-handshakes` rend un horodatage par pair, à zéro + tant que rien n'a abouti. C'est le SEUL signe que la clé et + l'endpoint sont bons : l'interface, elle, monte de toute façon. + """ + script = ( + "for i in $(seq 1 20); do" + f" wg show {shlex.quote(self.iface)} latest-handshakes" + " | awk '$2 > 0 { found = 1 } END { exit !found }'" + " && exit 0; sleep 0.5; done; exit 1" + ) + code, _ = runner.cmd( + "attendre la poignée de main du pair", + f"sh -c {shlex.quote(script)}", + check=False, + timeout=30, + ) + if code == 0 or runner.dry_run: + return True + runner.fail( + "Aucune poignée de main en 10 s. L'interface est montée — c'est" + " toujours le cas avec WireGuard — mais le pair n'a pas" + " répondu : clé publique du pair, PSK, endpoint ou UDP" + f" {self.profile['port']} filtré." + ) + return False + + # ------------------------------------------------------------------ + def down(self, runner): + runner.cmd( + f"démonter {self.iface}", + f"wg-quick down {shlex.quote(self.config_file)}", + check=False, + timeout=60, + ) + # Filet : après un redémarrage du CLI, le fichier de configuration + # peut avoir disparu du tmpfs alors que l'interface tient toujours. + # `wg-quick down` échoue alors, et l'interface resterait là. + runner.cmd( + f"filet : retirer {self.iface} si elle est restée", + "sh -c {}".format( + shlex.quote( + f"ip link show {shlex.quote(self.iface)} >/dev/null 2>&1" + f" && ip link del {shlex.quote(self.iface)} || true" + ) + ), + check=False, + ) + runner.remove(self.secret_dir) + self.clear_state(runner, "iface") + runner.ok("Tunnel démonté, secrets effacés.") + return True + + # ------------------------------------------------------------------ + def status(self, runner): + iface = self.recorded_iface() or self.iface + code, out = runner.cmd( + "poignée de main WireGuard", + f"wg show {shlex.quote(iface)} latest-handshakes", + check=False, + capture=True, + ) + stamps = [ + int(part) + for line in out.splitlines() + for part in line.split()[1:2] + if part.isdigit() + ] + latest = max(stamps) if stamps else 0 + extra = [ + ( + "poignée de main", + bool(latest) if code == 0 else None, + ( + f"horodatage {latest}" + if latest + else (out.strip().splitlines() or ["wg muet (sudo ?)"])[ + -1 + ][:120] + ), + ) + ] + return self.standard_status(runner, extra=extra) + + def log_commands(self): + return [ + ( + f"état complet de {self.iface}", + f"wg show {shlex.quote(self.iface)}", + ), + ( + "journal du noyau (module wireguard)", + "journalctl -n 30 --no-pager -k -g wireguard", + ), + ] diff --git a/script/vpn/profiles.py b/script/vpn/profiles.py new file mode 100644 index 0000000..ece6f7c --- /dev/null +++ b/script/vpn/profiles.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Profils VPN : tout ce qui n'est PAS un secret. + +Le partage est net et c'est le cœur du dispositif : l'hôte, l'utilisateur +PPP, les routes et le MTU vivent ici, en JSON lisible ; la clé PSK et les +mots de passe vivent dans le coffre KeePassXC (voir `secrets.py`). Un profil +peut donc être lu, montré, comparé, versionné chez un client — sans jamais +exposer de quoi monter le tunnel. + +Le fichier d'écriture est `private/todo/todo_override_private.json`, le SEUL +des trois fichiers fusionnés par `ConfigFile.get_config` qui soit gitignored, +et que `set_config_value` écrit en 0600 atomique. La lecture, elle, passe par +la fusion : un profil peut aussi venir de `script/todo/todo.json` (partagé +par l'équipe) ou de `private/todo/todo_override.json`. + +Toute valeur est VALIDÉE avant d'être écrite : elle finira dans un fichier de +configuration et dans une ligne de commande lancée par sudo. Un nom d'hôte +avec une espace ou un point-virgule n'y arrivera pas. +""" +from __future__ import annotations + +import json +import os + +# Le MODULE, pas la constante : `CONFIG_OVERRIDE_PRIVATE_FILE` importée par +# valeur figerait le chemin à l'import, et les tests — qui le déplacent dans +# un répertoire temporaire — écriraient dans le vrai fichier de l'utilisateur. +from script.config import config_file as config_module +from script.config.config_file import ConfigFile +from script.vpn import valid +from script.vpn.valid import NAME_RE, SERVER_RE, ProfileError # noqa: F401 + +# La clé de section, dans les trois fichiers de configuration. +CONFIG_KEY = "vpn" + +# Valeurs par défaut COMMUNES à toutes les technologies. Ce qui n'appartient +# qu'à une seule vit dans les `defaults` de son pilote : un profil WireGuard +# n'a rien à faire d'un « port L2TP local », et une liste de champs qui les +# additionne tous devient illisible au troisième pilote. +# +# `default_route` est FAUX par défaut : un tunnel qui capte tout le trafic +# coupe la session SSH en cours et n'est pas ce qu'un déploiement ERPLibre +# distant demande. C'est un choix explicite. +DEFAULTS = { + "driver": "l2tp_ipsec", + "server": "", + "routes": [], + "default_route": False, + "mtu": 1280, + # Adresse TÉMOIN, joignable uniquement à travers le tunnel. Vide, + # « ça marche » reste une impression ; remplie, le diagnostic peut + # le PROUVER. + "probe": "", +} + + +def secret_title(name: str) -> str: + """Titre de l'entrée KeePassXC qui porte les secrets du profil. + + Dérivé du nom plutôt que stocké : deux sources de vérité pour un même + lien finissent toujours par diverger, et un profil renommé chercherait + un secret sous l'ancien titre sans le dire.""" + return f"ERPLibre VPN / {name}" + + +def load_all(config=None) -> list[dict]: + """Tous les profils, dans l'ordre de fusion. Jamais None.""" + cfg = config or ConfigFile() + data = cfg.get_config(CONFIG_KEY) + if not isinstance(data, list): + return [] + return [p for p in data if isinstance(p, dict) and p.get("name")] + + +def load(name: str, config=None) -> dict | None: + """Le profil `name`, complété par les défauts, ou None.""" + for profile in load_all(config): + if profile.get("name") == name: + return with_defaults(profile) + return None + + +def with_defaults(profile: dict) -> dict: + """Copie du profil où chaque clé connue a une valeur. + + Les défauts du PILOTE sont ajoutés à ceux du format : c'est ce qui + permet à chaque technologie d'avoir ses propres réglages sans que le + format les connaisse. Un pilote inconnu ne fait pas échouer la lecture — + `validate` le dira, avec la liste des pilotes connus. + """ + from script.vpn.drivers import get_driver + + full = dict(DEFAULTS) + driver = get_driver(str(profile.get("driver") or DEFAULTS["driver"])) + if driver is not None: + full.update(driver.defaults) + full.update({k: v for k, v in profile.items() if v is not None}) + return full + + +def names(config=None) -> list[str]: + return [p["name"] for p in load_all(config)] + + +def _load_private() -> dict: + """Contenu brut du fichier privé, {} s'il est absent ou illisible.""" + path = config_module.CONFIG_OVERRIDE_PRIVATE_FILE + if not os.path.exists(path): + return {} + try: + with open(path) as fh: + data = json.load(fh) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def private_profiles() -> list[dict]: + """Les profils du fichier privé SEULS. + + L'écriture doit repartir de cette liste et non de la fusion : réécrire + la fusion recopierait dans le fichier privé les profils venus de + `todo.json`, qui se retrouveraient alors en double à la lecture + suivante (la fusion étend les listes, elle ne les déduplique pas). + """ + data = _load_private().get(CONFIG_KEY) + return ( + [p for p in data if isinstance(p, dict)] + if isinstance(data, list) + else [] + ) + + +def save(profile: dict, config=None) -> dict: + """Valide puis écrit le profil dans le fichier privé. Rend le profil + normalisé. Lève ProfileError si quelque chose ne va pas.""" + clean = validate(profile) + cfg = config or ConfigFile() + profiles = [ + p for p in private_profiles() if p.get("name") != clean["name"] + ] + profiles.append(clean) + cfg.set_config_value([CONFIG_KEY], profiles) + return clean + + +def delete(name: str, config=None) -> bool: + """Retire le profil du fichier privé. Rend False s'il n'y était pas — + un profil venu de `todo.json` n'est pas supprimable d'ici, et le dire + vaut mieux que de faire semblant.""" + profiles = private_profiles() + kept = [p for p in profiles if p.get("name") != name] + if len(kept) == len(profiles): + return False + cfg = config or ConfigFile() + cfg.set_config_value([CONFIG_KEY], kept) + return True + + +def validate(profile: dict) -> dict: + """Profil normalisé, ou ProfileError. + + Deux étages : ce qui vaut pour toute technologie est jugé ici, le reste + par `validate_profile` du pilote — qui normalise ses champs en place. + """ + from script.vpn.drivers import driver_names, get_driver + + full = with_defaults(profile) + + valid.text(full, "name", "Nom de profil", pattern=NAME_RE) + + driver_name = str(full.get("driver") or "").strip() + driver = get_driver(driver_name) + if driver is None: + raise ProfileError( + f"Pilote inconnu : « {driver_name} »." + f" Connus : {', '.join(driver_names())}." + ) + full["driver"] = driver_name + + valid.text(full, "server", "Adresse du serveur", pattern=SERVER_RE) + full["routes"] = valid.cidr_list(full.get("routes")) + valid.flag(full, "default_route") + valid.integer(full, "mtu", "MTU", 576, 1500) + valid.ip_address(full, "probe", "Adresse témoin") + + if ( + driver.needs_routes + and not full["routes"] + and not full["default_route"] + ): + raise ProfileError( + "Un tunnel sans route ne sert à rien : déclarer au moins un" + " réseau à joindre, ou demander la route par défaut." + ) + + driver.validate_profile(full) + return full diff --git a/script/vpn/runner.py b/script/vpn/runner.py new file mode 100644 index 0000000..a63e046 --- /dev/null +++ b/script/vpn/runner.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'exécuteur : tout ce qui touche vraiment la machine passe par ici. + +Un pilote VPN ne lance jamais rien lui-même. Il DEMANDE — « écris ce +fichier », « lance cette commande » — et cet objet exécute, ou se contente +d'afficher quand on l'a lancé à blanc. Trois choses en découlent : + +1. `--dry-run` n'est pas une branche parallèle dans chaque pilote : c'est un + drapeau ici. Ce qui s'affiche est exactement ce qui s'exécuterait. +2. Chaque opération est ENREGISTRÉE dans `ops`. Les tests unitaires peuvent + donc vérifier, sans root et sans serveur en face, qu'aucun secret n'a + atterri dans une ligne de commande. +3. La règle « les secrets ne passent que par l'entrée standard » est tenue en + UN endroit, pas dans cinq pilotes. + +Pourquoi l'entrée standard : `/proc//cmdline` est lisible par tout +utilisateur de la machine, `/proc//environ` par le seul propriétaire du +processus. Un mot de passe en argument est visible de tous pendant toute la +durée de la commande. +""" +from __future__ import annotations + +import shlex +import subprocess +import sys + +# Marqueurs des blocs gérés dans les fichiers de configuration du système. +# Reconnaissables, uniques, et ils DISENT de ne pas éditer à la main. +BLOCK_BEGIN = "# >>> erplibre-vpn %s — généré, ne pas éditer" +BLOCK_END = "# <<< erplibre-vpn %s" + + +def replace_block(text: str, marker: str, body: str) -> str: + """`text` où le bloc `marker` vaut `body`. Ajouté à la fin s'il est + absent, retiré si `body` est vide. + + Fonction PURE : c'est elle qui décide de ce qu'on écrit dans + /etc/ipsec.conf, et un test doit pouvoir la juger sans /etc. + """ + begin = BLOCK_BEGIN % marker + end = BLOCK_END % marker + lines = text.splitlines() + out, inside, seen = [], False, False + for line in lines: + if line.strip() == begin: + inside, seen = True, True + if body: + out.append(begin) + out.extend(body.rstrip("\n").splitlines()) + out.append(end) + continue + if inside: + if line.strip() == end: + inside = False + continue + out.append(line) + if not seen and body: + if out and out[-1].strip(): + out.append("") + out.append(begin) + out.extend(body.rstrip("\n").splitlines()) + out.append(end) + return "\n".join(out).rstrip("\n") + "\n" if out or body else "" + + +class Runner: + """Exécute (ou montre) les opérations demandées par un pilote.""" + + def __init__(self, dry_run=False, quiet=False, redactor=None, sudo=True): + self.dry_run = dry_run + self.quiet = quiet + # `redactor` masque les secrets dans TOUT ce qui s'affiche. Sans lui + # rien n'est masqué : c'est voulu, l'appelant doit le fournir dès + # qu'un secret est en jeu, et un test l'oublie sans risque. + self.redactor = redactor or (lambda text: text) + self.use_sudo = sudo + self.ops: list[dict] = [] + self.failures: list[str] = [] + + # ------------------------------------------------------------------ + # Affichage + # ------------------------------------------------------------------ + def info(self, message): + if not self.quiet: + print(self.redactor(message)) + + def step(self, label): + self.info(f" → {label}") + + def ok(self, message): + self.info(f" ✓ {message}") + + def warn(self, message): + self.info(f" ! {message}") + + def fail(self, message): + self.failures.append(message) + self.info(f" ✗ {message}") + + # ------------------------------------------------------------------ + # Commandes + # ------------------------------------------------------------------ + def cmd( + self, + label, + command, + stdin=None, + secret_stdin=False, + check=True, + capture=False, + sudo=None, + timeout=None, + allow_fail_message=None, + ): + """Lance `command`. Rend (code de retour, sortie). + + `stdin` est le seul chemin par lequel un secret entre dans un + processus. `secret_stdin` ne change PAS l'exécution : il dit à + l'affichage et aux tests que ce contenu ne doit jamais être montré. + """ + full = command + if sudo is None: + sudo = self.use_sudo + if sudo: + full = f"sudo {command}" + self.ops.append( + { + "kind": "cmd", + "label": label, + "cmd": full, + "stdin": stdin, + "secret_stdin": secret_stdin, + } + ) + shown = full if not stdin else f"{full} « sur l'entrée standard »" + self.step(f"{label}\n {self.redactor(shown)}") + if self.dry_run: + return 0, "" + try: + proc = subprocess.run( + full, + shell=True, + input=stdin, + text=True, + timeout=timeout, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + ) + code, out = proc.returncode, proc.stdout or "" + except subprocess.TimeoutExpired: + code, out = 124, "" + self.fail(f"{label} : délai dépassé ({timeout} s)") + return code, out + if code != 0 and check: + self.fail(allow_fail_message or f"{label} (code {code})") + return code, out + + def read_root_file(self, path): + """Contenu d'un fichier que seul root peut lire, "" s'il n'existe + pas. Passe par `sudo cat` : /etc/ipsec.secrets est en 0600.""" + code, out = self.cmd( + f"lire {path}", + f"cat {shlex.quote(path)}", + check=False, + capture=True, + ) + return out if code == 0 else "" + + def propose(self, constat, command, sudo=True, question=None): + """Propose un correctif, l'applique si on l'accepte. + + Rend True seulement s'il a été appliqué ET a réussi. + + L'outil sait souvent quoi faire : renvoyer l'utilisateur taper la + commande lui-même, puis tout relancer, c'est lui faire porter un + travail qu'on a déjà identifié. On demande donc — on ne le fait pas + d'office : arrêter un service du système est une décision, pas un + détail d'implémentation. + + Refusé d'office à blanc, et quand l'entrée standard n'est pas un + terminal (cron, script, journal rejoué) : un outil qui modifie un + service parce que PERSONNE n'a répondu serait pire que le problème + qu'il résout. + """ + montrable = f"{'sudo ' if sudo else ''}{command}" + if self.dry_run: + self.info(f" (à blanc : proposerait « {montrable} »)") + return False + self.info(f" → Correctif proposé : {montrable}") + if not sys.stdin.isatty(): + self.warn( + "Pas de terminal pour demander : correctif NON appliqué." + ) + return False + if not self.confirm(question or "Appliquer maintenant ?"): + self.info(" Laissé en place.") + return False + code, _ = self.cmd( + f"appliquer le correctif : {constat}", + command, + sudo=sudo, + check=False, + ) + return code == 0 + + def confirm(self, question) -> bool: + """Pose `question` et rend vrai si la réponse est oui. + + La question est une ligne COMPLÈTE, terminée par une fin de ligne, + et non un prompt passé à `input`. Un lanceur qui relaie notre + sortie en la lisant ligne par ligne garde une ligne partielle dans + son tampon jusqu'à la fin de ligne suivante : la question reste + alors invisible jusqu'à ce que la réponse ait déjà été donnée, puis + ressort collée au texte qui la suit. C'est le cas du menu TODO, qui + lit par `readline` PARCE QUE le masquage des secrets travaille sur + une ligne entière — un secret à cheval sur deux morceaux passerait + au travers. La contrainte vient donc d'une garantie, elle ne se + contourne pas. + + Affichée même quand l'exécuteur est silencieux : on s'apprête à + BLOQUER dessus, et une question invisible est une attente sans + raison apparente. + """ + print(self.redactor(f" {question} [o/N]")) + return input().strip().lower() in ("o", "oui", "y", "yes") + + # ------------------------------------------------------------------ + # Fichiers + # ------------------------------------------------------------------ + def write(self, path, content, mode="0600", secret=False, label=None): + """Écrit `content` dans `path`, en root, de façon ATOMIQUE. + + Le contenu passe par l'entrée standard, jamais par la ligne de + commande. `umask` donne le bon mode dès la création, `chmod` le + rend déterministe même si le fichier existait, et `mv` publie le + résultat d'un coup — un fichier de configuration à moitié écrit + vaut souvent moins qu'un fichier absent. + """ + quoted = shlex.quote(path) + tmp = shlex.quote(f"{path}.erplibre-tmp") + umask = "077" if secret else "022" + script = ( + f"umask {umask}; cat > {tmp}" + f" && chmod {mode} {tmp}" + f" && mv -f {tmp} {quoted}" + ) + self.ops.append( + { + "kind": "write", + "path": path, + "content": content, + "mode": mode, + "secret": secret, + } + ) + self.step(label or f"écrire {path} ({mode})") + if self.dry_run: + body = "********" if secret else content + for line in body.rstrip("\n").splitlines(): + self.info(f" │ {line}") + return 0 + code, _ = self.cmd( + f"écrire {path}", + f"sh -c {shlex.quote(script)}", + stdin=content, + secret_stdin=secret, + check=True, + ) + return code + + def mkdir(self, path, mode="0700"): + return self.cmd( + f"créer {path} ({mode})", + f"install -d -m {mode} {shlex.quote(path)}", + )[0] + + def remove(self, path): + return self.cmd( + f"effacer {path}", f"rm -rf -- {shlex.quote(path)}", check=False + )[0] + + def backup_once(self, path): + """Copie `path` en `.erplibre.bak` s'il n'y en a pas encore. + + Une seule fois : la sauvegarde doit garder l'état ORIGINAL, pas + celui d'avant-hier. On touche à l'ipsec.conf de quelqu'un. + """ + backup = f"{path}.erplibre.bak" + source, target = shlex.quote(path), shlex.quote(backup) + script = ( + f"[ -f {source} ] && [ ! -f {target} ]" + f" && cp -p {source} {target} || true" + ) + return self.cmd( + f"sauvegarder {path} → {backup}", + f"sh -c {shlex.quote(script)}", + check=False, + )[0] + + def block(self, path, marker, body, mode="0644", secret=False): + """Pose (ou retire, si `body` est vide) un bloc marqué dans `path`. + + Rend True s'il a fallu écrire, False si le bloc était déjà en place. + L'appelant s'en sert pour ne recharger un démon que quand sa + configuration a réellement bougé. + + Le fichier est relu avant d'être réécrit : on ajoute une section à + la configuration de l'utilisateur, on ne la remplace pas. + """ + current = self.read_root_file(path) + if self.dry_run and not current: + current = f"# ({path} sera relu à l'exécution)\n" + new = replace_block(current, marker, body) + if new == current: + self.ok(f"{path} : bloc « {marker} » déjà à jour") + return False + self.backup_once(path) + self.write( + path, + new, + mode=mode, + secret=secret, + label=f"{'retirer' if not body else 'poser'} le bloc" + f" « {marker} » dans {path}", + ) + return True + + # ------------------------------------------------------------------ + # Logique Python (résolution, attente, routes) + # ------------------------------------------------------------------ + def call(self, label, function, dry_safe=False): + """Exécute une étape écrite en Python. + + `dry_safe` marque celles qui ne font que LIRE l'état de la machine + (résoudre un nom, lire une table de routage) : elles tournent même + à blanc, parce que sans elles le plan affiché serait creux. + """ + self.ops.append({"kind": "call", "label": label}) + self.step(label) + if self.dry_run and not dry_safe: + return None + return function() diff --git a/script/vpn/valid.py b/script/vpn/valid.py new file mode 100644 index 0000000..d08907d --- /dev/null +++ b/script/vpn/valid.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Validation des champs de profil, partagée par le format et les pilotes. + +Un module à part, et pour une raison précise : `profiles.py` valide ce qui +est commun, chaque pilote valide ses propres champs, et les deux ont besoin +des mêmes contrôles. Le mettre ici évite un import croisé entre le format et +les pilotes — et surtout, ces contrôles ne sont pas cosmétiques : chaque +valeur finit dans un fichier de configuration système et dans une ligne de +commande lancée par sudo. Un nom d'hôte avec un point-virgule doit être +refusé ICI, pas découvert par `sh`. + +Chaque fonction NORMALISE en place (`profile[key]` reçoit la valeur propre) +et lève `ProfileError` avec un message destiné à l'humain. +""" +from __future__ import annotations + +import ipaddress +import re + +# Le nom sert de nom de connexion, de répertoire et de nom de fichier : il +# reste dans un alphabet sans surprise. +NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$") +# Nom d'hôte ou adresse, avec un « utilisateur@ » facultatif — sshuttle vise +# une cible SSH, pas seulement une machine. Volontairement plus strict que la +# RFC : ce qui n'est ni lettre, ni chiffre, ni `.-_` est refusé. +SERVER_RE = re.compile( + r"^([A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]{0,252}$" +) +# Nom d'hôte seul (domaine de recherche DNS, alias) : pas d'« utilisateur@ ». +HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$") +# Clé WireGuard : 32 octets en base64, donc 43 caractères + « = ». +WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$") + + +class ProfileError(ValueError): + """Profil refusé. Le message est destiné à l'utilisateur.""" + + +def text(profile, key, label, required=True, pattern=None): + """Champ texte, sans espace de bord, refusé s'il sort du motif.""" + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : valeur obligatoire.") + profile[key] = "" + return "" + if "\n" in value or "\r" in value: + raise ProfileError(f"{label} : une seule ligne.") + if pattern and not pattern.match(value): + raise ProfileError(f"{label} : « {value} » refusé.") + profile[key] = value + return value + + +def path(profile, key, label, required=True): + """Chemin de fichier. L'existence n'est PAS exigée ici. + + Un profil peut être écrit sur une machine et joué sur une autre ; c'est + au montage de dire « ce fichier n'est pas là », avec le chemin sous les + yeux. Ce qui est refusé ici, c'est ce qui casserait un shell. + """ + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : chemin obligatoire.") + profile[key] = "" + return "" + if any(char in value for char in "\n\r\0"): + raise ProfileError(f"{label} : chemin illisible.") + profile[key] = value + return value + + +def integer(profile, key, label, low, high): + try: + value = int(profile.get(key)) + except (TypeError, ValueError): + raise ProfileError(f"{label} : nombre entier attendu.") + if not low <= value <= high: + raise ProfileError(f"{label} : hors bornes ({low}-{high}) : {value}.") + profile[key] = value + return value + + +def port(profile, key, label): + return integer(profile, key, label, 1, 65535) + + +def flag(profile, key): + profile[key] = bool(profile.get(key)) + return profile[key] + + +def ip_address(profile, key, label, required=False): + """Adresse IP nue (pas de préfixe).""" + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : adresse obligatoire.") + profile[key] = "" + return "" + try: + ipaddress.ip_address(value) + except ValueError: + raise ProfileError(f"{label} : « {value} » n'est pas une adresse IP.") + profile[key] = value + return value + + +def ip_interface(profile, key, label, required=True): + """Adresse AVEC préfixe (10.7.0.2/32) : c'est ce qu'une interface porte. + + Une adresse sans préfixe est acceptée et complétée en /32 — mais dire + « 10.7.0.2 » quand on veut dire « /24 » est une erreur silencieuse + coûteuse, alors le message le rappelle en cas de doute. + """ + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : adresse obligatoire.") + profile[key] = "" + return "" + try: + parsed = ipaddress.ip_interface(value) + except ValueError: + raise ProfileError( + f"{label} : « {value} » refusé. Attendu une adresse avec" + " préfixe, par exemple 10.7.0.2/32." + ) + profile[key] = str(parsed) + return profile[key] + + +def wg_key(profile, key, label, required=True): + """Clé publique WireGuard : 32 octets en base64. + + Vérifiée ici parce que `wg-quick` refuse la configuration ENTIÈRE sur + une clé mal formée, avec un message qui ne dit pas laquelle. + """ + value = str(profile.get(key) or "").strip() + if not value: + if required: + raise ProfileError(f"{label} : clé obligatoire.") + profile[key] = "" + return "" + if not WG_KEY_RE.match(value): + raise ProfileError( + f"{label} : « {value[:12]}… » n'a pas la forme d'une clé" + " WireGuard (32 octets en base64, 44 caractères finissant par" + " « = »)." + ) + profile[key] = value + return value + + +def cidr_list(routes) -> list[str]: + """Réseaux normalisés en CIDR. Une adresse seule devient un /32.""" + if routes in (None, ""): + return [] + if isinstance(routes, str): + routes = [r for r in re.split(r"[\s,]+", routes) if r] + if not isinstance(routes, list): + raise ProfileError("Les routes doivent être une liste de réseaux.") + clean = [] + for route in routes: + try: + network = ipaddress.ip_network(str(route).strip(), strict=False) + except ValueError as error: + raise ProfileError(f"Route refusée : « {route} » ({error}).") + text_form = str(network) + if text_form not in clean: + clean.append(text_form) + return clean diff --git a/script/vpn/vault.py b/script/vpn/vault.py new file mode 100644 index 0000000..4853e07 --- /dev/null +++ b/script/vpn/vault.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les secrets VPN, dans le coffre KeePassXC. + +Nommé `vault` et non `secrets` : `secrets` est un module de la bibliothèque +standard, et masquer un nom de la stdlib dans un paquet importé partout se +paie tôt ou tard. + +Une entrée par profil, dans le groupe « ERPLibre VPN », titrée +« ERPLibre VPN / » : + + username / password les identifiants PPP (MS-CHAPv2) + propriété « psk » la clé pré-partagée IPsec, PROTÉGÉE + +« Protégée » veut dire chiffrée en mémoire par KeePassXC et masquée dans son +interface — c'est le même traitement que le champ mot de passe, appliqué à un +champ personnalisé. + +Ce module ne fait QUE lire et écrire. Il ne choisit jamais de créer un coffre +tout seul : `ensure_vault` demande, et une réponse vide fait renoncer. Un +outil qui crée silencieusement un fichier de mots de passe dans un répertoire +qu'on n'a pas choisi est un outil qu'on n'ose plus lancer. +""" +from __future__ import annotations + +import getpass +import os +import stat + +from script.todo.todo_i18n import t + +try: + from pykeepass import PyKeePass, create_database +except ModuleNotFoundError: # pragma: no cover - dépend de l'installation + PyKeePass = None + create_database = None + +# Groupe où les entrées sont rangées, pour que le coffre reste lisible dans +# l'interface KeePassXC. Le TITRE reste unique globalement : les autres +# lecteurs du dépôt (kdbx_config) cherchent par titre, sans notion de groupe. +VAULT_GROUP = "ERPLibre VPN" + +# Champ secret par défaut : celui de tous les pilotes à clé pré-partagée. +FIELD_PSK = "psk" + +MASK = "********" + +# Le menu a déjà le coffre ouvert quand il lance `vpn.py` : il lui passe les +# secrets par l'ENVIRONNEMENT plutôt que de le faire redemander le mot de +# passe maître — deux fois par connexion, puisqu'un essai à blanc précède le +# vrai montage. +# +# Par l'environnement et non par un argument : /proc//environ n'est +# lisible que par le propriétaire du processus, /proc//cmdline par tout +# utilisateur de la machine. Et `sudo` remet l'environnement à zéro, donc ces +# variables n'atteignent aucune commande privilégiée : les secrets qui vont à +# root passent, eux, par l'entrée standard. +ENV_MARKER = "EL_VPN_SECRETS_PROVIDED" +ENV_PREFIX = "EL_VPN_SECRET_" + + +def secrets_to_env(values: dict) -> dict: + """Variables d'environnement portant `values`, marqueur compris.""" + env = {ENV_MARKER: "1"} + for key, value in values.items(): + env[f"{ENV_PREFIX}{key.upper()}"] = value or "" + return env + + +def secrets_from_env(fields) -> dict | None: + """Secrets déposés par le processus appelant, ou None s'il n'y en a pas. + + Le marqueur est explicite : sans lui, un champ vide serait indistinguable + d'un champ absent, et on rouvrirait le coffre pour rien. + """ + if os.environ.get(ENV_MARKER) != "1": + return None + return { + field: os.environ.get(f"{ENV_PREFIX}{field.upper()}", "") + for field in fields + } + + +# Dit quand on trouve le coffre plus ouvert qu'il ne devrait : ce n'est pas +# nous qui l'avons laissé ainsi, et ça mérite d'être su. +LOOSE_VAULT_TIGHTENED = ( + "Vault permissions tightened to 0600, it was readable by others: " +) + + +class VaultError(RuntimeError): + """Le coffre n'a pas pu être ouvert ou écrit. Message pour l'humain.""" + + +class VpnVault: + """Pont entre les profils VPN et le coffre .kdbx. + + Prend le `ConfigFile` et le `KdbxManager` déjà construits par le CLI + TODO : le mot de passe maître n'est demandé qu'une fois par session, + et il n'y a pas deux caches de coffre qui pourraient diverger. + """ + + def __init__(self, config_file, kdbx_manager): + self._config = config_file + self._manager = kdbx_manager + + # ------------------------------------------------------------------ + # Le fichier de coffre + # ------------------------------------------------------------------ + def vault_path(self) -> str: + """Chemin configuré du coffre, "" s'il n'y en a pas.""" + kdbx = self._config.get_config("kdbx") + if not isinstance(kdbx, dict): + return "" + return str(kdbx.get("path") or "").strip() + + def master_password_is_stored(self) -> bool: + """Vrai si un mot de passe maître dort dans la configuration. + + C'est légal — `KdbxManager` le lit — mais c'est un mot de passe + maître en clair sur le disque : le CLI doit pouvoir le SIGNALER. + """ + kdbx = self._config.get_config("kdbx") + return bool(isinstance(kdbx, dict) and kdbx.get("password")) + + def ensure_vault(self, ask=input, default_path=None) -> str: + """Chemin d'un coffre utilisable, "" si l'utilisateur renonce. + + Trois cas : déjà configuré et présent (rien à faire) ; configuré + mais absent (on propose de le créer) ; pas configuré (on demande + où, puis on crée si le fichier n'existe pas). + """ + if create_database is None: + raise VaultError( + "pykeepass n'est pas installé : lancer l'installation" + " ERPLibre, ou `pip install pykeepass` dans" + " .venv.erplibre." + ) + path = self.vault_path() + if path and os.path.exists(os.path.expanduser(path)): + return path + + if not path: + default_path = default_path or os.path.expanduser( + "~/.erplibre/secrets.kdbx" + ) + answer = ask(f"Chemin du coffre KeePassXC [{default_path}] : ") + path = (answer or "").strip() or default_path + + path = os.path.expanduser(path) + if not os.path.exists(path): + answer = ask(f"Créer le coffre « {path} » ? [o/N] : ") + if (answer or "").strip().lower() not in ("o", "oui", "y", "yes"): + return "" + self._create(path) + self._config.set_config_value(["kdbx", "path"], path) + return path + + def _create(self, path: str) -> None: + """Crée un coffre vide en 0600, mot de passe saisi deux fois.""" + parent = os.path.dirname(path) or "." + os.makedirs(parent, exist_ok=True) + first = getpass.getpass("Mot de passe maître du coffre : ") + if not first: + raise VaultError("Mot de passe vide : coffre non créé.") + if first != getpass.getpass("Confirmer : "): + raise VaultError("Les deux saisies diffèrent : coffre non créé.") + # Créé puis restreint : `create_database` ne prend pas de mode, et + # un coffre lisible par tout le monde le reste jusqu'au chmod. La + # fenêtre existe, elle est d'un tour de boucle ; l'alternative + # serait de créer le fichier vide en 0600 d'abord, ce que + # pykeepass refuse (il veut écrire un fichier neuf). + kdbx = create_database(path, password=first) + self.protect(path) + # La base rendue par `create_database` est déjà ouverte : la confier + # au gestionnaire évite une troisième saisie du mot de passe maître, + # juste après les deux de la création. + self._manager.adopt(kdbx) + + def protect(self, path=None) -> bool: + """Remet le coffre en 0600. Rend True s'il fallait le resserrer. + + À appeler après CHAQUE écriture, et pas seulement à la création : + `PyKeePass.save()` réécrit le fichier et lui redonne le mode du + umask — 0664 sur Ubuntu. Un chmod fait une fois à la création ne + survit donc pas au premier enregistrement, et un coffre de mots de + passe devient lisible par toute la machine sans que personne ne + touche à rien. + """ + path = os.path.expanduser(path or self.vault_path()) + if not path: + return False + try: + current = stat.S_IMODE(os.stat(path).st_mode) + except OSError: + return False + if not current & 0o077: + return False + os.chmod(path, 0o600) + return True + + def _open(self): + """Coffre ouvert, ou VaultError. Passe par KdbxManager pour ne + demander le mot de passe maître qu'une fois par session.""" + if not self.vault_path(): + # Court-circuit VOULU : sans chemin, `KdbxManager` ouvre un + # sélecteur de fichiers graphique — et sur un serveur sans + # tkinter, il journalise une erreur au lieu de dire ce qui + # manque. Ici on le dit. + raise VaultError( + "Aucun coffre KeePassXC configuré. Le créer depuis TODO ›" + " Execute › Déploiement › VPN › « Déposer les secrets »." + ) + if self.protect(): + print(f"! {t(LOOSE_VAULT_TIGHTENED)}{self.vault_path()}") + kdbx = self._manager.get_kdbx() + if kdbx is None: + raise VaultError( + "Coffre KeePassXC indisponible : chemin non configuré, ou" + " mot de passe refusé." + ) + return kdbx + + # ------------------------------------------------------------------ + # Lecture / écriture d'un profil + # ------------------------------------------------------------------ + def read(self, title: str, fields=(FIELD_PSK,)) -> dict: + """{"username", "password", } pour l'entrée `title`. + + Une entrée absente rend un dictionnaire de chaînes vides plutôt + qu'une exception : « pas encore de secret » est un état NORMAL, + que l'appelant affiche (`[5] Déposer les secrets`) au lieu de le + traiter comme une panne. + """ + empty = {"username": "", "password": ""} + empty.update({f: "" for f in fields}) + kdbx = self._open() + entry = kdbx.find_entries_by_title(title, first=True) + if entry is None: + return empty + values = { + "username": entry.username or "", + "password": entry.password or "", + } + for field in fields: + # `username` et `password` sont des champs NATIFS de KeePassXC : + # les chercher parmi les propriétés personnalisées les + # écraserait par du vide. Un pilote peut légitimement déclarer + # `password` dans ses `secret_fields`. + if field in ("username", "password"): + continue + values[field] = entry.get_custom_property(field) or "" + return values + + def write(self, title: str, values: dict) -> None: + """Crée ou met à jour l'entrée `title`. + + Seules les clés PRÉSENTES dans `values` sont touchées : le menu + laisse passer un champ pour le garder tel quel. Une chaîne vide, + elle, efface — c'est une décision, pas un oubli. + """ + kdbx = self._open() + entry = kdbx.find_entries_by_title(title, first=True) + if entry is None: + entry = kdbx.add_entry( + self._group(kdbx), + title, + values.get("username", ""), + values.get("password", ""), + ) + else: + if "username" in values: + entry.username = values["username"] + if "password" in values: + entry.password = values["password"] + for field, value in values.items(): + if field in ("username", "password"): + continue + entry.set_custom_property(field, value or "", protect=True) + kdbx.save() + # Sans ceci, l'enregistrement qu'on vient de faire aurait rendu le + # coffre lisible par toute la machine. + self.protect() + + def _group(self, kdbx): + group = kdbx.find_groups(name=VAULT_GROUP, first=True) + if group is None: + group = kdbx.add_group(kdbx.root_group, VAULT_GROUP) + return group + + def exists(self, title: str) -> bool: + return ( + self._open().find_entries_by_title(title, first=True) is not None + ) + + +def redact(text: str, values) -> str: + """`text` avec chaque secret remplacé par des astérisques. + + Les plus longs d'abord : masquer « ab » avant « abcdef » laisserait la + fin de « abcdef » en clair. Les secrets de moins de quatre caractères + sont masqués aussi — un tel secret n'existe pas en pratique, et + préférer le faux positif au secret imprimé est le bon arbitrage. + """ + if not text: + return text + if isinstance(values, dict): + values = values.values() + for secret in sorted({str(v) for v in values if v}, key=len, reverse=True): + text = text.replace(secret, MASK) + return text diff --git a/script/vpn/vpn.py b/script/vpn/vpn.py new file mode 100755 index 0000000..c3753cc --- /dev/null +++ b/script/vpn/vpn.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Monter, démonter et diagnostiquer un tunnel VPN. + + ./script/vpn/vpn.py list + ./script/vpn/vpn.py up --profile client-acme [--dry-run] + ./script/vpn/vpn.py down --profile client-acme + ./script/vpn/vpn.py status --profile client-acme + ./script/vpn/vpn.py diagnose --profile client-acme + ./script/vpn/vpn.py check [--driver l2tp_ipsec] + +Les profils (hôte, utilisateur, routes) viennent de la configuration ; les +secrets (PSK, mot de passe) du coffre KeePassXC. Voir `profiles.py` et +`vault.py`. + +À lancer en tant qu'UTILISATEUR, pas sous sudo : le coffre est dans le home +de l'utilisateur et son mot de passe maître est saisi par lui. Chaque étape +privilégiée appelle `sudo` séparément, et `--dry-run` les montre toutes sans +en exécuter aucune. +""" +from __future__ import annotations + +import argparse +import os +import sys + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +if new_path not in sys.path: + sys.path.append(new_path) + +from script.config import config_file +from script.todo.kdbx_manager import KdbxManager +from script.vpn import profiles +from script.vpn.drivers import DRIVERS, driver_names, get_driver +from script.vpn.drivers.base import INSTALL_SCRIPT +from script.vpn.runner import Runner +from script.vpn.vault import ( + VaultError, + VpnVault, + redact, + secrets_from_env, +) + +# Ce qu'on met à la place d'un secret qu'on n'a pas pu lire, en mode à blanc. +PLACEHOLDER = "" + + +def _vault(): + cfg = config_file.ConfigFile() + return VpnVault(cfg, KdbxManager(cfg)), cfg + + +def _load_secrets(profile, driver_cls, required=True): + """Secrets du profil, ou {} si on renonce. + + `required=False` sert le mode à blanc : montrer le plan ne justifie pas + d'exiger le mot de passe maître, et un plan avec des marqueurs à la + place des secrets reste un plan juste. + """ + fields = tuple(key for key, _, _ in driver_cls.secret_fields) + # Déjà fournis par le menu, qui tient le coffre ouvert ? Alors ne pas + # redemander le mot de passe maître. + deja = secrets_from_env(fields) + if deja is not None: + return deja + vault, _ = _vault() + title = profiles.secret_title(profile["name"]) + try: + values = vault.read(title, fields=fields) + except VaultError as err: + if required: + raise + print(f" ! {err}") + print(f" ! plan rendu avec « {PLACEHOLDER} » à la place des secrets") + return {field: PLACEHOLDER for field in fields} + if vault.master_password_is_stored(): + print( + " ! le mot de passe MAÎTRE du coffre est écrit dans la" + " configuration : le retirer et le saisir à la demande" + ) + return values + + +def _build(args, want_secrets=True, secrets_required=True): + """(profil, pilote, exécuteur) ou (None, None, None) après un message.""" + profile = profiles.load(args.profile) + if profile is None: + known = ", ".join(profiles.names()) or "aucun" + print(f"✗ Profil « {args.profile} » inconnu. Connus : {known}.") + return None, None, None + driver_cls = get_driver(profile["driver"]) + if driver_cls is None: + print( + f"✗ Le profil « {args.profile} » demande le pilote" + f" « {profile['driver']} », qui n'existe pas." + f" Connus : {', '.join(driver_names())}." + ) + return None, None, None + secrets = {} + if want_secrets: + try: + secrets = _load_secrets( + profile, driver_cls, required=secrets_required + ) + except VaultError as err: + print(f"✗ {err}") + return None, None, None + driver = driver_cls(profile, secrets) + values = driver.secret_values() + runner = Runner( + dry_run=getattr(args, "dry_run", False), + redactor=lambda text: redact(text, values), + ) + return profile, driver, runner + + +def _prime_sudo(runner): + """Demande le mot de passe sudo UNE fois, au début. + + Sans cela, l'invite surgit au milieu de la séquence — entre le « ipsec + up » et le « c » — là où une saisie lente fait expirer le tunnel. + """ + if runner.dry_run: + return + runner.cmd("autoriser sudo", "-v", sudo=True, check=False) + + +# ---------------------------------------------------------------------- +# Commandes +# ---------------------------------------------------------------------- +def cmd_list(args): + all_profiles = profiles.load_all() + if not all_profiles: + print( + "Aucun profil VPN. En créer un depuis TODO › Execute ›" + " Déploiement › VPN › « Ajouter / modifier un profil »." + ) + return 0 + for raw in all_profiles: + profile = profiles.with_defaults(raw) + mode = ( + "tout le trafic" + if profile["default_route"] + else ", ".join(profile["routes"]) or "aucune route" + ) + print( + f" {profile['name']:<20} {profile['driver']:<12}" + f" {profile['server']:<28} {mode}" + ) + return 0 + + +def cmd_up(args): + profile, driver, runner = _build( + args, secrets_required=not getattr(args, "dry_run", False) + ) + if driver is None: + return 1 + title = "Plan de montage (à blanc)" if runner.dry_run else "Montage" + print(f"\n{title} — {profile['name']} ({driver.label})\n") + _prime_sudo(runner) + ok = driver.up(runner) + print() + if ok and not runner.failures: + print( + "✓ Tunnel monté." + if not runner.dry_run + else "✓ Plan complet, rien n'a été exécuté." + ) + return 0 + print("✗ Montage incomplet :") + for failure in runner.failures: + print(f" · {failure}") + print( + " Démonter proprement avant de réessayer :" + f" ./script/vpn/vpn.py down --profile {profile['name']}" + ) + return 1 + + +def cmd_down(args): + profile, driver, runner = _build(args, want_secrets=False) + if driver is None: + return 1 + print(f"\nDémontage — {profile['name']}\n") + _prime_sudo(runner) + driver.down(runner) + return 0 + + +def cmd_status(args): + if not args.profile: + return cmd_list(args) + profile, driver, runner = _build(args, want_secrets=False) + if driver is None: + return 1 + runner.quiet = True + print(f"\nÉtat — {profile['name']} ({driver.label})\n") + verdicts = driver.status(runner) + _print_verdicts(verdicts) + return 0 if all(ok for _, ok, _ in verdicts if ok is not None) else 1 + + +def cmd_diagnose(args): + profile, driver, runner = _build(args, want_secrets=False) + if driver is None: + return 1 + runner.quiet = True + print(f"\nDiagnostic — {profile['name']} ({driver.label})\n") + verdicts = driver.status(runner) + _print_verdicts(verdicts) + print() + # Avant les journaux : quand le noyau est la cause, ils sont vides — + # le démon n'a pas vécu assez longtemps pour écrire — et faire lire + # soixante lignes de rien avant d'annoncer le remède n'aide personne. + if driver.needs_reboot(): + runner.quiet = False + driver.propose_reboot(runner) + runner.quiet = True + print() + for label, command in driver.log_commands(): + print(f"── {label} ──") + runner.quiet = False + runner.cmd(label, command, check=False) + runner.quiet = True + print() + failed = [label for label, ok, _ in verdicts if ok is False] + if failed: + print(f"✗ En défaut : {', '.join(failed)}") + return 1 + print("✓ Tous les étages répondent.") + return 0 + + +def cmd_check(args): + """Ce que la machine sait faire, avant tout profil.""" + names = [args.driver] if args.driver else driver_names() + code = 0 + for name in names: + driver_cls = get_driver(name) + if driver_cls is None: + print(f"✗ Pilote inconnu : {name}") + code = 1 + continue + driver = driver_cls({"name": "check"}) + missing = driver.missing_binaries() + broken = [d for _, ok, d in driver.check_kernel() if ok is False] + if missing: + code = 1 + _line("✗", driver_cls.label, f"absents : {', '.join(missing)}") + print(f" {INSTALL_SCRIPT} {name}") + elif broken: + # Les paquets sont là et le noyau ne suit pas : « prêt » serait + # faux, et l'installateur n'y changerait rien. + code = 1 + _line("✗", driver_cls.label, "; ".join(broken)) + else: + _line("✓", driver_cls.label, "prêt") + vault, _ = _vault() + path = vault.vault_path() + if not path: + _line("!", "coffre KeePassXC", "non configuré") + else: + exists = os.path.exists(os.path.expanduser(path)) + _line("✓" if exists else "✗", "coffre KeePassXC", path) + if not exists: + code = 1 + if vault.master_password_is_stored(): + _line( + "!", + "mot de passe maître", + "stocké en clair dans la configuration : le retirer", + ) + return code + + +def cmd_install(args): + """Une SEULE invocation, même pour plusieurs pilotes : le script fait + un `apt-get update` par appel, et cinq appels le referaient cinq + fois.""" + names = [args.driver] if args.driver else driver_names() + runner = Runner() + code, _ = runner.cmd( + f"installer les paquets de : {', '.join(names)}", + f"bash {INSTALL_SCRIPT} {' '.join(names)}", + check=True, + ) + return code + + +def _line(mark, label, detail): + """Une ligne de verdict, en colonnes. Un seul endroit décide de la + largeur : sinon les listes de `check` et de `status` cessent de + s'aligner entre elles.""" + print(f" {mark} {label:<34} {detail}") + + +def _print_verdicts(verdicts): + for label, ok, detail in verdicts: + _line("✓" if ok else ("?" if ok is None else "✗"), label, detail) + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="vpn.py", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("list", help="Lister les profils") + + for name, help_text, with_dry in ( + ("up", "Monter le tunnel", True), + ("down", "Démonter le tunnel", True), + ("status", "État du tunnel", False), + ("diagnose", "État détaillé + journaux", False), + ): + sp = sub.add_parser(name, help=help_text) + sp.add_argument( + "--profile", + required=name not in ("status",), + help="Nom du profil VPN", + ) + if with_dry: + sp.add_argument( + "--dry-run", + action="store_true", + help="Montrer le plan, secrets masqués, sans rien exécuter", + ) + + for name, help_text in ( + ("check", "Vérifier ce que la machine sait faire"), + ("install", "Installer les paquets client"), + ): + sp = sub.add_parser(name, help=help_text) + sp.add_argument( + "--driver", + choices=sorted(DRIVERS), + help="Se limiter à ce pilote", + ) + return parser + + +def main(argv=None): + args = build_parser().parse_args(argv) + handlers = { + "list": cmd_list, + "up": cmd_up, + "down": cmd_down, + "status": cmd_status, + "diagnose": cmd_diagnose, + "check": cmd_check, + "install": cmd_install, + } + return handlers[args.command](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_vpn_drivers.py b/test/test_vpn_drivers.py new file mode 100644 index 0000000..b8cb0b4 --- /dev/null +++ b/test/test_vpn_drivers.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les cinq pilotes VPN : contrat commun, et ce qui est propre à chacun. + +Le test qui compte le plus est `NoSecretLeaksAnywhere` : il rejoue le plan de +montage de CHAQUE pilote avec de faux secrets et vérifie qu'aucun n'atteint +une ligne de commande. `/proc//cmdline` est lisible par tout utilisateur +de la machine ; un secret en argument est un secret public. Ce test est +table-orientée exprès : un sixième pilote ajouté au registre y entre tout +seul, et échoue s'il se croit dispensé de la règle. + +Ni root, ni réseau, ni serveur en face : le `Runner` à blanc n'exécute rien, +il enregistre. Tous les serveurs de test sont 127.0.0.1 pour qu'aucun test ne +dépende d'une résolution de nom. +""" + +import base64 +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.vpn import profiles +from script.vpn.drivers import DRIVERS +from script.vpn.drivers.base import locate, which +from script.vpn.runner import Runner +from script.vpn.valid import ProfileError +from script.vpn.vault import redact + +# Des secrets reconnaissables, assez longs pour qu'aucun ne se retrouve par +# hasard dans un chemin ou une option. +SECRET = "S3cr3t-Qu3-P3rs0nn3-N3-D0it-V0ir" +WG_PRIVATE = base64.b64encode(bytes(range(32))).decode() +WG_PUBLIC = base64.b64encode(bytes(range(32, 64))).decode() +WG_PRESHARED = base64.b64encode(bytes(range(64, 96))).decode() + +# Un profil valide par pilote, et les secrets que ce pilote attend. +SAMPLES = { + "l2tp_ipsec": ( + { + "server": "127.0.0.1", + "ppp_user": "ACME\\user", + "routes": ["10.20.0.0/16"], + "probe": "10.20.0.1", + }, + {"psk": SECRET + "-psk", "password": SECRET + "-ppp"}, + ), + "wireguard": ( + { + "server": "127.0.0.1", + "wg_address": "10.7.0.2/32", + "wg_peer_key": WG_PUBLIC, + "routes": ["10.7.0.0/24"], + }, + {"wg_private_key": WG_PRIVATE, "wg_preshared_key": WG_PRESHARED}, + ), + "openvpn": ( + { + "server": "127.0.0.1", + "ovpn_config": "/tmp/acme/client.ovpn", + "ovpn_user": "user", + "routes": ["10.30.0.0/16"], + }, + {"password": SECRET + "-ovpn"}, + ), + "openconnect": ( + { + "server": "127.0.0.1", + "oc_user": "user", + "oc_protocol": "anyconnect", + "default_route": True, + }, + {"password": SECRET + "-oc"}, + ), + "sshuttle": ( + { + "server": "erplibre@127.0.0.1", + "routes": ["10.40.0.0/16"], + "probe": "10.40.0.1", + }, + {}, + ), +} + + +def build(driver_name, **overrides): + """(pilote instancié, exécuteur à blanc) pour `driver_name`.""" + fields, secrets = SAMPLES[driver_name] + profile = dict(fields, name=f"t-{driver_name}"[:31], driver=driver_name) + profile.update(overrides) + profile = profiles.validate(profile) + driver = DRIVERS[driver_name](profile, dict(secrets)) + runner = Runner( + dry_run=True, + quiet=True, + redactor=lambda text: redact(text, driver.secret_values()), + ) + return driver, runner + + +def commands(runner): + return [op["cmd"] for op in runner.ops if op["kind"] == "cmd"] + + +class DriverContract(unittest.TestCase): + """Ce que tout pilote doit déclarer pour que le menu et le CLI + fonctionnent sans le connaître.""" + + def test_every_driver_is_complete(self): + for name, cls in DRIVERS.items(): + with self.subTest(driver=name): + self.assertEqual(cls.name, name) + self.assertTrue(cls.label, "libellé vide") + self.assertLessEqual(len(cls.label), 34, "libellé trop long") + self.assertTrue(cls.hint, "aucun conseil de choix") + self.assertTrue(cls.binaries, "aucun binaire déclaré") + self.assertTrue(cls.server_label) + self.assertIsInstance(cls.proven, bool) + + def test_form_fields_are_well_formed(self): + for name, cls in DRIVERS.items(): + for field in cls.form_fields: + with self.subTest(driver=name, field=field): + key, label, kind, advanced = field + self.assertIn(kind, ("text", "int", "flag", "path")) + self.assertIn( + key, + cls.defaults, + "un champ demandé sans valeur par défaut", + ) + self.assertTrue(label) + self.assertIsInstance(advanced, bool) + + def test_secret_fields_are_well_formed(self): + for name, cls in DRIVERS.items(): + for key, label, required in cls.secret_fields: + with self.subTest(driver=name, secret=key): + self.assertTrue(label) + self.assertIsInstance(required, bool) + + def test_every_sample_profile_validates(self): + """Le jeu d'essai lui-même doit passer la validation : sinon les + tests suivants mesureraient un profil que personne ne pourrait + enregistrer.""" + for name in DRIVERS: + with self.subTest(driver=name): + driver, _ = build(name) + self.assertEqual(driver.profile["driver"], name) + + +class BinariesRootRunsAreNotOursToRun(unittest.TestCase): + """Un binaire lancé par root n'a pas à être exécutable par nous. + + `pppd` est installé en 4750 root:dip sur Debian et Ubuntu. Tout + utilisateur hors du groupe dip se voyait annoncer « pppd absent » sur + une machine où le paquet ppp était installé — et envoyé réinstaller ce + qui était déjà là. C'est l'EXISTENCE qui compte : xl2tpd, qui tourne en + root, l'exécute très bien. + """ + + def test_which_asks_about_us_and_locate_about_existence(self): + with tempfile.TemporaryDirectory() as directory: + faux = os.path.join(directory, "pppd") + open(faux, "w").close() + os.chmod(faux, 0o000) + with patch.dict(os.environ, {"PATH": directory}): + self.assertEqual(which("pppd"), "") + self.assertEqual(locate("pppd"), faux) + + def test_a_driver_sees_such_a_binary_as_present(self): + driver, _ = build("l2tp_ipsec") + with tempfile.TemporaryDirectory() as directory: + for binary in driver.binaries: + chemin = os.path.join(directory, binary) + open(chemin, "w").close() + os.chmod(chemin, 0o000) + with patch.dict(os.environ, {"PATH": directory}): + self.assertEqual(driver.missing_binaries(), []) + + def test_a_truly_absent_binary_is_still_reported(self): + driver, _ = build("l2tp_ipsec") + with tempfile.TemporaryDirectory() as directory: + with patch.dict(os.environ, {"PATH": directory}): + self.assertEqual( + sorted(driver.missing_binaries()), + sorted(driver.binaries), + ) + + +class NoSecretLeaksAnywhere(unittest.TestCase): + """Le test central : aucun secret dans une ligne de commande, pour + aucun pilote.""" + + def test_no_secret_in_any_command(self): + for name in DRIVERS: + driver, runner = build(name) + driver.up(runner) + with self.subTest(driver=name): + self.assertTrue(runner.ops, "plan vide") + for op in runner.ops: + if op["kind"] != "cmd": + continue + for secret in driver.secret_values(): + self.assertNotIn( + secret, + op["cmd"], + f"{name} : secret dans « {op['label']} »", + ) + + def test_a_secret_in_a_payload_is_always_marked(self): + """Un secret peut voyager par l'entrée standard ou dans un fichier + — mais l'opération doit être MARQUÉE, sinon l'affichage le + montrerait.""" + for name in DRIVERS: + driver, runner = build(name) + driver.up(runner) + for op in runner.ops: + payload = ( + op.get("stdin") + if op["kind"] == "cmd" + else op.get("content") + ) + if not payload: + continue + leaked = [s for s in driver.secret_values() if s in payload] + if leaked: + with self.subTest(driver=name, op=op.get("label")): + self.assertTrue( + op.get("secret_stdin") or op.get("secret"), + "secret non marqué", + ) + + def test_secret_files_are_owner_only_and_in_tmpfs(self): + for name in DRIVERS: + driver, runner = build(name) + driver.up(runner) + for op in runner.ops: + if op["kind"] == "write" and op["secret"]: + with self.subTest(driver=name, path=op["path"]): + self.assertEqual(op["mode"], "0600") + self.assertTrue(op["path"].startswith("/dev/shm/")) + + def test_down_erases_the_secret_directory(self): + """Sauf pour ceux qui n'écrivent aucun secret : il n'y a rien à + effacer, et prétendre le faire serait du théâtre.""" + writes_secrets = ("l2tp_ipsec", "wireguard", "openvpn") + for name in DRIVERS: + driver, runner = build(name) + driver.down(runner) + joined = " ".join(commands(runner)) + with self.subTest(driver=name): + if name in writes_secrets: + self.assertIn(f"rm -rf -- {driver.secret_dir}", joined) + + +class Wireguard(unittest.TestCase): + def test_config_holds_the_private_key_and_the_peer(self): + driver, _ = build("wireguard") + body = driver.config_body() + self.assertIn(f"PrivateKey = {WG_PRIVATE}", body) + self.assertIn(f"PublicKey = {WG_PUBLIC}", body) + self.assertIn(f"PresharedKey = {WG_PRESHARED}", body) + self.assertIn("Endpoint = 127.0.0.1:51820", body) + + def test_no_dns_line_in_the_configuration(self): + """`DNS =` fait appeler `resolvconf` par wg-quick, absent de + beaucoup d'installations systemd-resolved — et c'est la + configuration ENTIÈRE qui échoue alors.""" + driver, _ = build("wireguard", wg_dns="10.7.0.1") + self.assertNotIn("DNS =", driver.config_body()) + # …mais le DNS est bien appliqué, par resolvectl, après le montage. + driver, runner = build("wireguard", wg_dns="10.7.0.1") + driver.up(runner) + + def test_allowed_ips_carries_the_routing(self): + driver, _ = build("wireguard") + self.assertEqual(driver.allowed_ips, "10.7.0.0/24") + driver, _ = build("wireguard", default_route=True, routes=[]) + self.assertEqual(driver.allowed_ips, "0.0.0.0/0") + + def test_the_config_file_is_named_after_the_interface(self): + """`wg-quick` DÉDUIT le nom de l'interface du nom du fichier.""" + driver, _ = build("wireguard") + self.assertTrue( + driver.config_file.endswith(f"/{driver.iface}.conf"), + driver.config_file, + ) + self.assertLessEqual(len(driver.iface), 15) + + def test_no_manual_route_command(self): + """Les routes appartiennent à wg-quick, via AllowedIPs : en + ajouter ici entrerait en conflit avec les siennes.""" + driver, runner = build("wireguard") + driver.up(runner) + for command in commands(runner): + self.assertNotIn("ip route replace 10.7.0.0/24", command) + + def test_it_waits_for_a_handshake(self): + """L'interface monte même avec une clé fausse : sans cette attente, + « monté » ne voudrait dire que « l'interface existe ».""" + driver, runner = build("wireguard") + driver.up(runner) + self.assertTrue( + any("latest-handshakes" in c for c in commands(runner)), + "aucune attente de poignée de main", + ) + + def test_a_malformed_peer_key_is_refused(self): + """wg-quick refuse la configuration ENTIÈRE sur une clé mal formée, + avec un message qui ne dit pas laquelle. On le dit avant.""" + for bad in ("pas-une-cle", WG_PUBLIC[:-1], WG_PUBLIC + "x", ""): + with self.subTest(cle=bad): + with self.assertRaises(ProfileError): + build("wireguard", wg_peer_key=bad) + + def test_an_address_without_prefix_becomes_a_32(self): + driver, _ = build("wireguard", wg_address="10.7.0.9") + self.assertEqual(driver.profile["wg_address"], "10.7.0.9/32") + + +class Openvpn(unittest.TestCase): + def test_it_changes_directory_to_the_config(self): + """Un .ovpn référence ses fichiers voisins en relatif.""" + driver, _ = build("openvpn") + self.assertIn("--cd /tmp/acme", driver.command()) + + def test_config_comes_before_the_credentials(self): + """Ce qui suit `--config` l'emporte sur le contenu du fichier : un + `auth-user-pass` nu dedans ferait attendre une saisie qui ne + viendra jamais, le démon étant détaché.""" + command = build("openvpn")[0].command() + self.assertLess( + command.index("--config"), command.index("--auth-user-pass") + ) + + def test_credentials_are_two_lines_in_tmpfs(self): + driver, _ = build("openvpn") + self.assertEqual(driver.auth_body(), f"user\n{SECRET}-ovpn\n") + self.assertTrue(driver.auth_file.startswith("/dev/shm/")) + + def test_split_tunnel_asks_for_route_nopull(self): + self.assertIn("--route-nopull", build("openvpn")[0].command()) + + def test_full_tunnel_lets_the_server_push(self): + command = build("openvpn", default_route=True, routes=[])[0].command() + self.assertNotIn("--route-nopull", command) + + def test_no_credentials_no_auth_option(self): + """Un .ovpn qui s'authentifie par certificat ne doit pas se voir + imposer un fichier d'identifiants vide.""" + command = build("openvpn", ovpn_user="")[0].command() + self.assertNotIn("--auth-user-pass", command) + + def test_it_waits_for_the_initialisation_line(self): + driver, runner = build("openvpn") + driver.up(runner) + self.assertTrue( + any( + "Initialization Sequence Completed" in c + for c in commands(runner) + ) + ) + + def test_a_missing_config_path_is_refused(self): + with self.assertRaises(ProfileError): + build("openvpn", ovpn_config="") + + +class Openconnect(unittest.TestCase): + def test_the_password_never_touches_the_disk(self): + """Le seul pilote sans aucun fichier de secret.""" + driver, runner = build("openconnect") + driver.up(runner) + secret_writes = [ + op + for op in runner.ops + if op["kind"] == "write" and op.get("secret") + ] + self.assertEqual(secret_writes, []) + + def test_the_password_travels_on_marked_standard_input(self): + driver, runner = build("openconnect") + driver.up(runner) + launches = [ + op for op in runner.ops if op["kind"] == "cmd" and op.get("stdin") + ] + self.assertEqual(len(launches), 1) + self.assertTrue(launches[0]["secret_stdin"]) + self.assertIn(f"{SECRET}-oc", launches[0]["stdin"]) + self.assertIn("--passwd-on-stdin", launches[0]["cmd"]) + + def test_non_interactive_so_a_cert_prompt_cannot_eat_the_password(self): + self.assertIn("--non-inter", build("openconnect")[0].command()) + + def test_the_interface_is_named_not_discovered(self): + driver, _ = build("openconnect") + self.assertIn(f"--interface={driver.iface}", driver.command()) + self.assertLessEqual(len(driver.iface), 15) + + def test_an_unknown_protocol_is_refused(self): + with self.assertRaises(ProfileError): + build("openconnect", oc_protocol="carrier-pigeon") + + def test_routes_are_not_required(self): + """C'est le serveur qui les pousse : exiger une route déclarée + serait une fausse exigence.""" + driver, _ = build("openconnect", default_route=False, routes=[]) + self.assertEqual(driver.profile["routes"], []) + + +class OpenconnectSingleSignOn(unittest.TestCase): + """Le cas du « formulaire web » : le concentrateur délègue à un + fournisseur d'identité, et il n'y a aucun mot de passe à envoyer. + + Le client de Cisco exige alors un navigateur embarqué, donc un écran. + openconnect s'en passe : mesuré dans sa bibliothèque, il écoute sur le + port local 29786 et attend la redirection du navigateur, lequel peut + être celui de l'utilisateur, ailleurs, à travers un `ssh -L`. + """ + + def _sso(self, **overrides): + profile = dict( + SAMPLES["openconnect"][0], + name="t-sso", + driver="openconnect", + oc_sso=True, + oc_user="", + ) + profile.update(overrides) + return DRIVERS["openconnect"](profiles.validate(profile), {}) + + def test_a_profile_without_user_or_password_is_valid(self): + """En SSO, c'est le fournisseur d'identité qui décide de qui on est : + exiger un utilisateur refuserait un profil parfaitement valide.""" + driver = self._sso() + self.assertEqual(driver.profile["oc_user"], "") + self.assertEqual(driver.missing_secrets(), []) + + def test_the_command_asks_for_an_external_browser(self): + command = self._sso().command() + self.assertIn("--external-browser=echo", command) + self.assertNotIn("--passwd-on-stdin", command) + self.assertNotIn("--user=", command) + + def test_no_non_inter_in_sso(self): + """L'échange avec le navigateur EST l'interaction : l'interdire + ferait échouer la seule étape qui compte.""" + self.assertNotIn("--non-inter", self._sso().command()) + + def test_a_chosen_browser_is_honoured(self): + driver = self._sso(oc_external_browser="/usr/bin/xdg-open") + self.assertIn("--external-browser=/usr/bin/xdg-open", driver.command()) + + def test_it_explains_the_round_trip_before_waiting(self): + """openconnect attend en silence : sans explication, l'attente + ressemble à un blocage, et la redirection ne revient jamais si + personne n'a monté le tunnel ssh.""" + driver = self._sso() + runner = Runner(dry_run=True) + buffer = io.StringIO() + with redirect_stdout(buffer): + driver.up(runner) + printed = buffer.getvalue() + self.assertIn("29786", printed) + self.assertIn("ssh -L", printed) + + def test_sso_writes_no_secret_and_sends_nothing_on_stdin(self): + driver = self._sso() + runner = Runner(dry_run=True, quiet=True) + driver.up(runner) + for op in runner.ops: + self.assertFalse(op.get("secret")) + self.assertFalse(op.get("stdin")) + + def test_classic_mode_still_needs_a_password(self): + """Sans SSO et sans mot de passe, on le dit au lieu de lancer une + commande qui échouera. + + Le binaire est réputé présent : sans ce bouchon, le test mesure les + paquets de la machine qui l'exécute et c'est « openconnect absent » + qui remonte, sur un pilote qui a pourtant raison de le dire. + """ + profile = profiles.validate( + dict( + SAMPLES["openconnect"][0], + name="t-clas", + driver="openconnect", + ) + ) + driver = DRIVERS["openconnect"](profile, {}) + runner = Runner(dry_run=False, quiet=True) + runner.cmd = lambda *a, **k: (0, "") + runner.mkdir = lambda *a, **k: 0 + with patch( + "script.vpn.drivers.base.locate", return_value="/usr/bin/x" + ): + self.assertFalse(driver.up(runner)) + self.assertTrue( + [m for m in runner.failures if "coffre" in m], runner.failures + ) + + +class Sshuttle(unittest.TestCase): + def test_it_is_not_launched_under_sudo(self): + """sshuttle n'élève que la partie pare-feu. Sous sudo, la session + SSH serait ouverte par root — avec les clés de root.""" + driver, runner = build("sshuttle") + driver.up(runner) + launches = [c for c in commands(runner) if "sshuttle --remote" in c] + self.assertEqual(len(launches), 1) + self.assertFalse(launches[0].startswith("sudo "), launches[0]) + + def test_the_pidfile_lives_in_the_home(self): + """/run/erplibre-vpn appartient à root : sshuttle tourne sous + l'utilisateur et ne pourrait pas y écrire.""" + driver, _ = build("sshuttle") + self.assertTrue(driver.pid_file.startswith(os.path.expanduser("~"))) + + def test_it_has_no_secret_at_all(self): + driver, _ = build("sshuttle") + self.assertEqual(driver.secret_fields, ()) + self.assertEqual(driver.missing_secrets(), []) + + def test_subnets_come_from_the_routes(self): + driver, _ = build("sshuttle") + self.assertEqual(driver.subnets, ["10.40.0.0/16"]) + driver, _ = build("sshuttle", default_route=True, routes=[]) + self.assertEqual(driver.subnets, ["0.0.0.0/0"]) + + def test_status_judges_on_the_witness_only(self): + """Sans interface ni route à vérifier, une vérification + d'interface rendrait un « ✗ » qui ne veut rien dire.""" + driver, runner = build("sshuttle") + runner.quiet = True + labels = [label for label, _, _ in driver.status(runner)] + self.assertFalse([label for label in labels if "interface" in label]) + self.assertTrue([label for label in labels if "témoin" in label]) + + def test_an_ssh_target_with_a_user_is_accepted(self): + driver, _ = build("sshuttle") + self.assertEqual(driver.profile["server"], "erplibre@127.0.0.1") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_vpn_profiles.py b/test/test_vpn_profiles.py new file mode 100644 index 0000000..f12c208 --- /dev/null +++ b/test/test_vpn_profiles.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Profils VPN : validation et aller-retour sur disque. + +Ni root, ni réseau, ni serveur VPN. Les trois fichiers de configuration +fusionnés sont déplacés dans un répertoire temporaire : un test qui écrirait +dans `private/todo/todo_override_private.json` détruirait les profils de la +personne qui le lance. +""" + +import json +import os +import stat +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.vpn import profiles +from script.vpn.profiles import ProfileError + +VALID = { + "name": "acme", + "driver": "l2tp_ipsec", + "server": "vpn.acme.example", + "ppp_user": "ACME\\user", + "routes": ["10.20.0.0/16"], +} + + +class VpnProfileConfig(unittest.TestCase): + """Fusion et écriture, avec les trois fichiers dans un temporaire.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = os.path.join(self.tmp.name, "todo.json") + with open(base, "w") as fh: + json.dump({"vpn": []}, fh) + self.private = os.path.join(self.tmp.name, "private.json") + self.patches = [ + patch("script.config.config_file.CONFIG_FILE", base), + patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmp.name, "override.json"), + ), + patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + self.private, + ), + ] + for item in self.patches: + item.start() + + def tearDown(self): + for item in self.patches: + item.stop() + self.tmp.cleanup() + + def test_save_then_load(self): + profiles.save(VALID) + loaded = profiles.load("acme") + self.assertEqual(loaded["server"], "vpn.acme.example") + self.assertEqual(loaded["routes"], ["10.20.0.0/16"]) + # Les défauts sont appliqués à la lecture. + self.assertEqual(loaded["mtu"], 1280) + self.assertFalse(loaded["default_route"]) + + def test_private_file_is_owner_only(self): + """Le fichier des profils est en 0600. + + Il ne contient pas de secret, mais il nomme les serveurs et les + utilisateurs d'un client : c'est une carte, et une carte se garde.""" + profiles.save(VALID) + mode = stat.S_IMODE(os.stat(self.private).st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + + def test_save_twice_updates_in_place(self): + profiles.save(VALID) + profiles.save(dict(VALID, server="autre.example")) + self.assertEqual(len(profiles.load_all()), 1) + self.assertEqual(profiles.load("acme")["server"], "autre.example") + + def test_delete(self): + profiles.save(VALID) + self.assertTrue(profiles.delete("acme")) + self.assertIsNone(profiles.load("acme")) + self.assertFalse(profiles.delete("acme")) + + def test_shared_profile_is_not_deletable(self): + """Un profil venu du fichier partagé se lit mais ne s'efface pas + d'ici : `delete` doit rendre False, pas faire semblant.""" + with open(os.path.join(self.tmp.name, "todo.json"), "w") as fh: + json.dump({"vpn": [dict(VALID, name="partage")]}, fh) + self.assertIsNotNone(profiles.load("partage")) + self.assertFalse(profiles.delete("partage")) + self.assertIsNotNone(profiles.load("partage")) + + def test_shared_and_private_do_not_duplicate(self): + """Écrire un profil privé ne doit pas recopier ceux du partagé. + + La fusion ÉTEND les listes : réécrire la vue fusionnée ferait + apparaître le profil partagé deux fois à la lecture suivante.""" + with open(os.path.join(self.tmp.name, "todo.json"), "w") as fh: + json.dump({"vpn": [dict(VALID, name="partage")]}, fh) + profiles.save(dict(VALID, name="prive")) + noms = profiles.names() + self.assertEqual(sorted(noms), ["partage", "prive"], noms) + + +class VpnProfileValidation(unittest.TestCase): + def test_valid(self): + clean = profiles.validate(VALID) + self.assertEqual(clean["name"], "acme") + + def test_name_must_be_tame(self): + """Le nom devient un nom de connexion IPsec, de répertoire et de + fichier : ce qui n'est pas dans l'alphabet prévu est refusé.""" + for bad in ("Acme", "a b", "../evil", "a;rm -rf /", "", "é"): + with self.assertRaises(ProfileError, msg=bad): + profiles.validate(dict(VALID, name=bad)) + + def test_server_refuses_shell_metacharacters(self): + for bad in ("vpn.example;reboot", "vpn example", "$(id)", "a|b"): + with self.assertRaises(ProfileError, msg=bad): + profiles.validate(dict(VALID, server=bad)) + + def test_unknown_driver(self): + with self.assertRaises(ProfileError): + profiles.validate(dict(VALID, driver="carrier-pigeon")) + + def test_routes_normalised_to_cidr(self): + clean = profiles.validate( + dict(VALID, routes="10.0.0.0/8, 192.168.1.5") + ) + self.assertEqual(clean["routes"], ["10.0.0.0/8", "192.168.1.5/32"]) + + def test_bad_route(self): + with self.assertRaises(ProfileError): + profiles.validate(dict(VALID, routes=["10.0.0.0/99"])) + + def test_a_tunnel_without_destination_is_accepted_where_it_helps(self): + """Ni route déclarée, ni route par défaut : accepté pour L2TP. + + Un site ne remet souvent qu'une passerelle et des identifiants. + Refuser ce profil laissait sans issue : il joint l'hôte distant, et + l'adresse qu'on y obtient dit quel réseau ajouter. Le menu le dit, + et le montage le suggère.""" + clean = profiles.validate(dict(VALID, routes=[], default_route=False)) + self.assertEqual(clean["routes"], []) + self.assertFalse(clean["default_route"]) + + def test_it_stays_refused_where_the_technology_cannot_do_without(self): + """WireGuard sans AllowedIPs : wg-quick refuse la configuration + entière. sshuttle sans réseau : rien à détourner. Là, l'exigence + reste dure.""" + for driver, extra in ( + ( + "wireguard", + { + "wg_address": "10.7.0.2/32", + "wg_peer_key": ( + "SGVsbG9Xb3JsZEV4YW1wbGVLZXkxMjM0NTY3ODkwYWI=" + ), + }, + ), + ("sshuttle", {}), + ): + with self.subTest(driver=driver): + with self.assertRaises(ProfileError): + profiles.validate( + dict( + VALID, + driver=driver, + routes=[], + default_route=False, + **extra, + ) + ) + + def test_default_route_alone_is_enough(self): + clean = profiles.validate(dict(VALID, routes=[], default_route=True)) + self.assertTrue(clean["default_route"]) + + def test_mtu_bounds(self): + for bad in (10, 9000, "beaucoup"): + with self.assertRaises(ProfileError, msg=str(bad)): + profiles.validate(dict(VALID, mtu=bad)) + + def test_probe_must_be_an_address(self): + with self.assertRaises(ProfileError): + profiles.validate(dict(VALID, probe="serveur-interne")) + self.assertEqual( + profiles.validate(dict(VALID, probe="10.20.0.1"))["probe"], + "10.20.0.1", + ) + + def test_l2tp_needs_a_ppp_user(self): + """Exigence propre au pilote, pas au format de profil.""" + with self.assertRaises(ProfileError): + profiles.validate(dict(VALID, ppp_user="")) + + def test_secret_title_is_derived_from_the_name(self): + self.assertEqual(profiles.secret_title("acme"), "ERPLibre VPN / acme") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_vpn_render.py b/test/test_vpn_render.py new file mode 100644 index 0000000..3807b36 --- /dev/null +++ b/test/test_vpn_render.py @@ -0,0 +1,870 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce que le pilote L2TP/IPsec écrit, et ce qu'il n'écrit JAMAIS. + +Le test central de ce fichier est `test_no_secret_reaches_a_command_line` : +il rejoue tout le plan de montage à blanc et vérifie qu'aucun secret n'a +atterri dans une ligne de commande. `/proc//cmdline` est lisible par +tout utilisateur de la machine ; un secret en argument est un secret public +pendant toute la durée de la commande. + +Aucun root, aucun serveur en face : le `Runner` à blanc n'exécute rien, il +enregistre. Le serveur du profil est 127.0.0.1 pour qu'aucun test ne dépende +d'une résolution de nom. +""" + +import io +import os +import sys +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.vpn import profiles +from script.vpn.drivers import base +from script.vpn.drivers.l2tp_ipsec import ( + APPARMOR_PROFILE, + L2tpIpsecDriver, +) +from script.vpn.runner import Runner, replace_block +from script.vpn.vault import redact + +PSK = "cl3-Pr3-P4rt4g33!" +PPP_PASSWORD = "m0tD3P4ss3-PPP" +SECRETS = {"psk": PSK, "password": PPP_PASSWORD} + +PROFILE = profiles.validate( + { + "name": "acme", + "driver": "l2tp_ipsec", + "server": "127.0.0.1", + "ppp_user": "ACME\\user", + "routes": ["10.20.0.0/16"], + "probe": "10.20.0.1", + } +) + + +def _driver(**overrides): + profile = dict(PROFILE) + profile.update(overrides) + return L2tpIpsecDriver(profile, SECRETS) + + +def _dry_runner(driver): + return Runner( + dry_run=True, + quiet=True, + redactor=lambda text: redact(text, driver.secret_values()), + ) + + +class RenderedFiles(unittest.TestCase): + def test_ipsec_conn_is_transport_mode(self): + """Mode TRANSPORT, pas tunnel. En mode tunnel, la SA monte et + aucune interface PPP n'apparaît jamais.""" + body = _driver().ipsec_conn_body() + self.assertIn("type=transport", body) + self.assertNotIn("type=tunnel", body) + + def test_ipsec_conn_accepts_any_local_port(self): + """`leftprotoport=17/%any` : derrière du NAT, le port source est + réécrit et une politique clouée sur 1701 ne s'applique plus.""" + self.assertIn("leftprotoport=17/%any", _driver().ipsec_conn_body()) + + def test_ipsec_conn_names_the_server_and_the_connection(self): + body = _driver().ipsec_conn_body() + self.assertIn("conn erplibre-acme", body) + self.assertIn("right=127.0.0.1", body) + self.assertIn("rightprotoport=17/1701", body) + + def test_psk_is_written_in_hexadecimal(self): + """Le PSK part en hexadécimal : mêmes octets pour strongSwan, et + plus aucune question d'échappement de guillemets.""" + body = _driver().ipsec_secrets_body("127.0.0.1") + self.assertIn(f"PSK 0x{PSK.encode('utf-8').hex()}", body) + self.assertNotIn(PSK, body) + self.assertIn("%any 127.0.0.1 :", body) + + def test_ppp_options_escape_the_domain_backslash(self): + """`ACME\\user` est la forme courante sur un concentrateur L2TP. + Sans échappement, pppd envoie `ACMEuser` et le serveur refuse + sans dire pourquoi.""" + body = _driver().ppp_options_body() + self.assertIn(r'name "ACME\\user"', body) + + def test_ppp_refuses_no_method_and_requires_nothing(self): + """Aucun `refuse-*`, et `noauth`. + + Mesuré sur un vrai concentrateur : il demande « », et un + `refuse-pap` y répond « ConfNak » — le serveur coupe + alors sur « peer refused to authenticate », où le « peer » est NOUS. + `require-mschap-v2`, symétriquement, exigerait que le SERVEUR + s'authentifie auprès de nous : aucun sens pour un client. + + PAP est en clair sur la liaison PPP, qui voyage dans l'ESP : c'est + IPsec qui protège l'authentification, et ce pilote ne lance jamais + L2TP sans SA établie.""" + body = _driver().ppp_options_body() + self.assertIn("noauth", body) + for refuse in ( + "refuse-pap", + "refuse-eap", + "refuse-chap", + "refuse-mschap", + "require-mschap-v2", + "require-chap", + ): + self.assertNotIn(refuse, body) + + def test_an_interface_without_an_address_is_a_failure(self): + """pppd crée l'interface AVANT qu'IPCP ait négocié l'adresse. + + Lire tout de suite annonçait « ppp0 : sans adresse » sur un tunnel + sain, et faisait chercher les DNS du pair avant que pppd les ait + écrits. On attend donc l'adresse — et son absence au bout du délai + est un échec, pas un détail d'affichage. + + Le mode à blanc rend la main avant cette étape : on joue donc le + chemin réel, avec l'exécuteur bouchonné. La sonde du noyau est + bouchonnée elle aussi : sans cela le test mesurerait l'IPsec de la + machine qui l'exécute, et un conteneur sans XFRM le ferait échouer + sur un plan de montage pourtant correct. + """ + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + module = "script.vpn.drivers.l2tp_ipsec" + with patch( + f"{module}.netlink_family_available", return_value=True + ), patch.object( + runner, "cmd", return_value=(0, "established successfully") + ), patch.object( + runner, "write", return_value=0 + ), patch.object( + runner, "block", return_value=False + ), patch.object( + runner, "mkdir", return_value=0 + ), patch( + "script.vpn.drivers.base.locate", return_value="/usr/bin/x" + ), patch( + f"{module}.locate", return_value="" + ), patch( + f"{module}.resolve", return_value="203.0.113.9" + ), patch( + f"{module}.ppp_interfaces", return_value=set() + ), patch( + f"{module}.wait_for_new_interface", return_value="ppp0" + ), patch( + f"{module}.wait_for_interface_address", return_value=[] + ) as attente: + self.assertFalse(driver.up(runner)) + attente.assert_called_once() + self.assertTrue( + [motif for motif in runner.failures if "adresse" in motif], + runner.failures, + ) + + def test_xl2tpd_requires_nothing_of_the_peer(self): + """« require chap » et « require authentication » font passer + « require-chap » et « auth » à pppd, c'est-à-dire « que le serveur + me prouve qui il est ». Le serveur refuse, et la liaison tombe.""" + body = _driver().xl2tpd_conf_body() + self.assertIn("require authentication = no", body) + self.assertIn("require chap = no", body) + + def test_xl2tpd_comments_use_a_semicolon(self): + """L'analyseur de xl2tpd ne connaît pas « # » : un dièse en tête + fait refuser le fichier ENTIER — « data '#…' occurs with no + context », puis « Unable to load config file ».""" + first = _driver().xl2tpd_conf_body().splitlines()[0] + self.assertTrue(first.startswith(";"), first) + self.assertNotIn("#", _driver().xl2tpd_conf_body()) + + def test_the_peer_identity_is_accepted_as_presented(self): + """Une passerelle s'annonce par son IP quand `right` est un nom : + sans `rightid=%any`, strongSwan refuse — « IDir '203.0.113.5' does + not match to 'vpn.exemple.com' ».""" + self.assertIn("rightid=%any", _driver().ipsec_conn_body()) + + def test_split_tunnel_by_default(self): + """Pas de `defaultroute` sans demande explicite : capter tout le + trafic couperait la session SSH en cours.""" + self.assertNotIn("defaultroute", _driver().ppp_options_body()) + self.assertIn( + "defaultroute", + _driver(default_route=True).ppp_options_body(), + ) + + def test_mtu_and_user_come_from_the_profile(self): + body = _driver(mtu=1400).ppp_options_body() + self.assertIn("mtu 1400", body) + self.assertIn("mru 1400", body) + + def test_xl2tpd_points_at_the_tmpfs_options(self): + body = _driver().xl2tpd_conf_body() + self.assertIn("[lac erplibre-acme]", body) + self.assertIn("lns = 127.0.0.1", body) + self.assertIn( + "pppoptfile = /dev/shm/erplibre-vpn/acme/ppp.options", body + ) + + +class TheOrderOfTheMountingPlan(unittest.TestCase): + """Trois étapes dont l'ordre a été payé cher sur une vraie machine.""" + + def _plan(self, apparmor=False): + """Le plan de montage à blanc : une entrée par opération, commande + ou chemin de fichier. + + `apparmor` fait exister le profil de charon. Il n'y en a que sur + Debian et Ubuntu ; sans ce bouchon, le test de l'ordre des étapes + mesure la distribution qui l'exécute et ne trouve pas une étape que + le pilote a raison de ne pas produire ailleurs. + """ + driver = _driver() + runner = _dry_runner(driver) + existe = os.path.exists + + def presente(chemin): + if apparmor and chemin == APPARMOR_PROFILE: + return True + return existe(chemin) + + with patch("os.path.exists", side_effect=presente): + driver.up(runner) + return [op.get("cmd", "") + op.get("path", "") for op in runner.ops] + + def test_apparmor_is_allowed_before_charon_reads_the_secrets(self): + """AppArmor confine charon par CHEMIN et refuse /dev/shm. La règle + doit être posée ET le profil rechargé avant que charon lise les + secrets, sinon le refus arrive du noyau et ressort trois étages + plus loin en « no shared key found ».""" + plan = self._plan(apparmor=True) + regle = next( + i for i, c in enumerate(plan) if "apparmor_parser -r" in c + ) + # À blanc, charon est réputé déjà lancé : le plan recharge au lieu + # de démarrer, et « rereadsecrets » est le moment où charon lit nos + # secrets. C'est lui qui doit venir après la règle. + secrets = next(i for i, c in enumerate(plan) if "rereadsecrets" in c) + self.assertLess(regle, secrets) + + def test_it_waits_for_the_connection_before_bringing_it_up(self): + """`ipsec start` rend la main avant que le starter ait poussé les + connexions : un « up » immédiat échoue sur « no match », sur une + configuration parfaitement valide.""" + plan = self._plan() + attente = next(i for i, c in enumerate(plan) if "statusall" in c) + montee = next( + i for i, c in enumerate(plan) if "ipsec up erplibre-" in c + ) + self.assertLess(attente, montee) + + def test_a_busy_l2tp_port_stops_the_plan(self): + """Continuer produirait un tube de contrôle qui n'apparaît jamais, + deux étapes plus loin. On regarde le PORT et non le nom du service : + sur Ubuntu, xl2tpd est un script SysV enveloppé que + « disable --now » n'arrête pas toujours.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + occupe = ( + "UNCONN 0 0 0.0.0.0:1701 0.0.0.0:*" + ' users:(("xl2tpd",pid=1234,fd=5))' + ) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", + return_value="/usr/bin/ss", + ): + with patch.object(runner, "cmd", return_value=(0, occupe)): + self.assertFalse(driver._l2tp_port_is_free(runner)) + self.assertTrue(runner.failures) + # Le verdict NOMME ce qui tient le port. + self.assertIn("xl2tpd", runner.failures[0]) + + def test_a_free_l2tp_port_lets_the_plan_through(self): + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", + return_value="/usr/bin/ss", + ): + with patch.object(runner, "cmd", return_value=(0, "\n")): + self.assertTrue(driver._l2tp_port_is_free(runner)) + self.assertFalse(runner.failures) + + def test_without_ss_the_plan_is_not_blocked(self): + """Un faux blocage serait pire qu'un échec tardif : sans `ss`, on + laisse passer et le tube de contrôle tranchera.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + with patch("script.vpn.drivers.l2tp_ipsec.locate", return_value=""): + self.assertTrue(driver._l2tp_port_is_free(runner)) + + +class NotSawingOffTheBranchYouSitOn(unittest.TestCase): + """En mode « tout le trafic », le retour de la session SSH qui donne + l'ordre part dans le tunnel. On perd la machine, le menu, et le moyen de + démonter ce qu'on vient de monter.""" + + def _routes(self, ssh_connection=None): + driver = _driver(default_route=True, routes=[]) + runner = _dry_runner(driver) + env = {"SSH_CONNECTION": ssh_connection} if ssh_connection else {} + with patch.dict(os.environ, env, clear=not ssh_connection): + driver.up(runner) + return [ + op["cmd"] + for op in runner.ops + if op["kind"] == "cmd" and "ip route replace" in op["cmd"] + ] + + def test_the_ssh_client_keeps_a_direct_route(self): + routes = self._routes("192.0.2.50 54321 198.51.100.7 22") + self.assertTrue( + any("192.0.2.50/32" in c for c in routes), + routes, + ) + # Celle du serveur reste, évidemment. + self.assertTrue(any("127.0.0.1/32" in c for c in routes), routes) + + def test_nothing_extra_outside_an_ssh_session(self): + routes = self._routes(None) + self.assertTrue(any("127.0.0.1/32" in c for c in routes), routes) + self.assertEqual(len(routes), 1, routes) + + def test_a_bogus_ssh_connection_is_ignored(self): + """`SSH_CONNECTION` mal formée ne doit pas produire une route + absurde ni faire échouer le montage.""" + routes = self._routes("pas-une-adresse 1 2 3") + self.assertEqual(len(routes), 1, routes) + + def test_the_teardown_removes_every_survival_route(self): + """Elles sont plusieurs : l'état en garde une par ligne.""" + driver = _driver(default_route=True, routes=[]) + runner = _dry_runner(driver) + with patch.object( + driver, "read_state", return_value="1.2.3.4/32\n5.6.7.8/32" + ): + driver.down(runner) + joined = " ".join( + op.get("cmd", "") for op in runner.ops if op["kind"] == "cmd" + ) + self.assertIn("ip route del 1.2.3.4/32", joined) + self.assertIn("ip route del 5.6.7.8/32", joined) + + +class WhenTheToolKnowsTheFixItOffersIt(unittest.TestCase): + """L'outil sait souvent quoi faire. Renvoyer l'utilisateur taper la + commande puis tout relancer, c'est lui faire porter un travail déjà + identifié — mais le faire d'office serait arrêter un service du système + sans le demander. Donc : proposer, appliquer, revérifier.""" + + def setUp(self): + """La question posée s'affiche même sur un exécuteur silencieux — + c'est voulu, on s'apprête à bloquer dessus. Elle n'a rien à faire + dans la sortie du lanceur de tests pour autant.""" + silence = redirect_stdout(io.StringIO()) + silence.__enter__() + self.addCleanup(silence.__exit__, None, None, None) + + def _runner(self, sortie_port): + """Exécuteur bouchonné dont `ss` rend `sortie_port` — une réponse par + interrogation du port, dans l'ordre : avant le correctif, puis après. + + Seules les commandes `ss` consomment la liste : l'application du + correctif est une commande comme une autre, et si elle en prenait + une, le test mesurerait autre chose que ce qu'il croit. + """ + runner = Runner(dry_run=False, quiet=True) + reponses = list(sortie_port) + + def cmd(label, command, *a, **k): + if "ss -lunp" in command: + return 0, reponses.pop(0) if reponses else "" + return 0, "" + + runner.cmd = cmd + return runner + + TENU = ( + "UNCONN 0 0 0.0.0.0:1701 0.0.0.0:*" + ' users:(("xl2tpd",pid=12314,fd=3))' + ) + + def test_it_offers_to_stop_xl2tpd_then_carries_on(self): + driver = _driver() + # `ss` dit « tenu », puis « libre » après le correctif. + runner = self._runner([self.TENU, ""]) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ): + self.assertTrue(driver._l2tp_port_is_free(runner)) + self.assertFalse(runner.failures) + + def test_a_refused_fix_leaves_the_failure_standing(self): + driver = _driver() + runner = self._runner([self.TENU]) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="n" + ): + self.assertFalse(driver._l2tp_port_is_free(runner)) + self.assertTrue(runner.failures) + + def test_a_fix_that_does_not_free_the_port_still_fails(self): + """Accepté, appliqué, et le port reste tenu : on ne prétend pas que + c'est réglé.""" + driver = _driver() + runner = self._runner([self.TENU, self.TENU]) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ): + self.assertFalse(driver._l2tp_port_is_free(runner)) + self.assertTrue(runner.failures) + + def test_a_leftover_of_the_same_profile_offers_our_own_down(self): + """Un montage précédent du MÊME profil n'a rien à faire décider : + le remède est notre propre « down », et on le propose.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + args = ( + "12314 xl2tpd -c /dev/shm/erplibre-vpn/acme/xl2tpd.conf" + " -C /run/erplibre-vpn/acme.control" + ) + appels = {"ss": 0} + + def cmd(label, command, *a, **k): + if "ss -lunp" in command: + appels["ss"] += 1 + # Tenu au premier regard, libre après le « down ». + return 0, self.TENU if appels["ss"] == 1 else "" + if "ps -o pid=" in command: + return 0, args + return 0, "" + + runner.cmd = cmd + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ): + self.assertTrue(driver._l2tp_port_is_free(runner)) + self.assertFalse(runner.failures) + + def test_a_sibling_tunnel_is_named_not_killed(self): + """Le port peut être tenu par un de NOS tunnels, sur un autre + profil. Le tuer couperait le sien : on le nomme et on s'arrête.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + args = ( + "12314 xl2tpd -c" + " /dev/shm/erplibre-vpn/autre-client/xl2tpd.conf" + " -C /run/erplibre-vpn/autre-client.control" + ) + + def cmd(label, command, *a, **k): + if "ss -lunp" in command: + return 0, self.TENU + if "ps -o pid=" in command: + return 0, args + return 0, "" + + runner.cmd = cmd + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("builtins.input") as demande: + self.assertFalse(driver._l2tp_port_is_free(runner)) + demande.assert_not_called() + self.assertTrue( + [m for m in runner.failures if "autre-client" in m], + runner.failures, + ) + + def test_another_daemon_is_named_not_stopped(self): + """Arrêter à l'aveugle un service qu'on ne connaît pas serait pire + que le blocage : on le nomme, et on s'arrête là.""" + driver = _driver() + autre = ( + "UNCONN 0 0 0.0.0.0:1701 0.0.0.0:*" + ' users:(("un-autre-truc",pid=999,fd=3))' + ) + runner = self._runner([autre]) + with patch( + "script.vpn.drivers.l2tp_ipsec.locate", return_value="/usr/bin/ss" + ), patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ) as demande: + self.assertFalse(driver._l2tp_port_is_free(runner)) + demande.assert_not_called() + self.assertTrue(runner.failures) + + def test_the_question_is_a_whole_line_not_an_input_prompt(self): + """Un lanceur qui relaie notre sortie en la lisant ligne par ligne + garde une ligne partielle dans son tampon. Une question passée en + prompt d'`input` reste donc invisible jusqu'à ce que la réponse ait + déjà été donnée, puis ressort collée au texte suivant — et on + répond à une question qu'on n'a pas lue.""" + runner = Runner(dry_run=False, quiet=True) + runner.cmd = lambda *a, **k: (0, "") + sortie = io.StringIO() + with patch("sys.stdin.isatty", return_value=True), patch( + "builtins.input", return_value="o" + ) as demande, redirect_stdout(sortie): + runner.propose("essai", "systemctl stop x", question="Arrêter ?") + # Rien ne doit être confié au prompt d'`input` : c'est lui qui ne + # porte pas de fin de ligne. + demande.assert_called_once_with() + self.assertIn("Arrêter ? [o/N]\n", sortie.getvalue()) + + def test_without_a_terminal_nothing_is_applied(self): + """Un outil qui arrête un service parce que PERSONNE n'a répondu + serait pire que le problème qu'il résout.""" + runner = Runner(dry_run=False, quiet=True) + applique = [] + runner.cmd = lambda *a, **k: (applique.append(a) or (0, "")) + with patch("sys.stdin.isatty", return_value=False): + self.assertFalse( + runner.propose("essai", "systemctl stop quelque-chose") + ) + self.assertEqual(applique, []) + + def test_dry_run_only_announces_the_fix(self): + runner = Runner(dry_run=True, quiet=True) + with patch("builtins.input") as demande: + self.assertFalse(runner.propose("essai", "systemctl stop x")) + demande.assert_not_called() + + +class TheInstallerShipsWhatTheNegotiationNeeds(unittest.TestCase): + def test_debian_gets_the_plugin_that_provides_3des(self): + """Sans le greffon openssl, charon ANNONCE 3DES, le concentrateur le + choisit — c'est souvent le seul qu'il connaisse — et la négociation + meurt sur « ENCRYPTION_ALGORITHM 3DES_CBC not supported! ».""" + with open("script/install/install_vpn.sh") as fh: + script = fh.read() + ligne = [ + line + for line in script.splitlines() + if "l2tp_ipsec:debian" in line or "strongswan-starter" in line + ] + self.assertTrue( + any("libstrongswan-standard-plugins" in line for line in ligne), + ligne, + ) + + +class SecretsStayOffTheCommandLine(unittest.TestCase): + def test_no_secret_reaches_a_command_line(self): + driver = _driver() + runner = _dry_runner(driver) + driver.up(runner) + self.assertTrue(runner.ops, "le plan est vide") + for op in runner.ops: + if op["kind"] != "cmd": + continue + for secret in (PSK, PPP_PASSWORD): + self.assertNotIn( + secret, + op["cmd"], + f"secret dans une commande : {op['label']}", + ) + + def test_secrets_travel_only_on_marked_standard_input(self): + """Un secret peut passer par l'entrée standard — mais alors + l'opération DOIT être marquée, sinon l'affichage le montrerait.""" + driver = _driver() + runner = _dry_runner(driver) + driver.up(runner) + for op in runner.ops: + content = ( + op.get("stdin") if op["kind"] == "cmd" else op.get("content") + ) + if not content: + continue + leaks = [s for s in (PSK, PPP_PASSWORD) if s in content] + if leaks: + marked = op.get("secret_stdin") or op.get("secret") + self.assertTrue( + marked, + f"secret non marqué dans {op.get('label') or op.get('path')}", + ) + + def test_the_files_that_hold_secrets_are_owner_only(self): + driver = _driver() + runner = _dry_runner(driver) + driver.up(runner) + writes = [op for op in runner.ops if op["kind"] == "write"] + secret_writes = [op for op in writes if op["secret"]] + self.assertEqual(len(secret_writes), 2, [w["path"] for w in writes]) + for op in secret_writes: + self.assertEqual(op["mode"], "0600", op["path"]) + self.assertTrue( + op["path"].startswith("/dev/shm/"), + f"{op['path']} n'est pas dans un tmpfs", + ) + + def test_dry_run_output_shows_no_secret(self): + """Ce que « Afficher la configuration rendue » imprime doit être + montrable à l'écran de quelqu'un d'autre.""" + driver = _driver() + runner = Runner( + dry_run=True, + redactor=lambda text: redact(text, driver.secret_values()), + ) + buffer = io.StringIO() + with redirect_stdout(buffer): + driver.up(runner) + printed = buffer.getvalue() + self.assertTrue(printed.strip()) + for secret in (PSK, PPP_PASSWORD): + self.assertNotIn(secret, printed) + self.assertIn("********", printed) + + def test_redact_masks_the_longest_first(self): + """Masquer « ab » avant « abcdef » laisserait « cdef » en clair.""" + masked = redact("abcdef et ab", {"a": "ab", "b": "abcdef"}) + self.assertNotIn("abcdef", masked) + self.assertEqual(masked, "******** et ********") + + +class DownOrder(unittest.TestCase): + def test_secrets_include_is_removed_before_the_file(self): + """L'ordre compte : un `include` qui pointe vers un fichier + disparu fait échouer TOUT rechargement de charon, y compris celui + d'une autre connexion.""" + driver = _driver() + runner = _dry_runner(driver) + driver.down(runner) + commands = [ + op.get("cmd", "") + op.get("path", "") for op in runner.ops + ] + read_secrets = next( + i for i, c in enumerate(commands) if "cat /etc/ipsec.secrets" in c + ) + remove_dir = next( + i + for i, c in enumerate(commands) + if "rm -rf -- /dev/shm/erplibre-vpn/acme" in c + ) + self.assertLess(read_secrets, remove_dir) + + def test_down_erases_the_secret_directory(self): + driver = _driver() + runner = _dry_runner(driver) + driver.down(runner) + joined = " ".join(op.get("cmd", "") for op in runner.ops) + self.assertIn("rm -rf -- /dev/shm/erplibre-vpn/acme", joined) + + +class MarkedBlocks(unittest.TestCase): + """`replace_block` décide de ce qu'on écrit dans /etc/ipsec.conf. + Elle est pure : elle se juge sans /etc.""" + + def test_appends_when_absent(self): + result = replace_block("config setup\n", "acme", "conn acme\n x=1") + self.assertIn("config setup", result) + self.assertIn(">>> erplibre-vpn acme", result) + self.assertIn("conn acme", result) + self.assertIn("<<< erplibre-vpn acme", result) + + def test_replaces_in_place_and_keeps_the_rest(self): + first = replace_block("avant\n", "acme", "un") + second = replace_block(first, "acme", "deux") + self.assertIn("avant", second) + self.assertIn("deux", second) + self.assertNotIn("un\n", second) + self.assertEqual(second.count(">>> erplibre-vpn acme"), 1) + + def test_removes_when_body_is_empty(self): + with_block = replace_block("avant\nautre\n", "acme", "un") + without = replace_block(with_block, "acme", "") + self.assertNotIn("erplibre-vpn acme", without) + self.assertIn("avant", without) + self.assertIn("autre", without) + + def test_two_profiles_coexist(self): + text = replace_block("", "acme", "un") + text = replace_block(text, "beta", "deux") + self.assertIn("erplibre-vpn acme", text) + self.assertIn("erplibre-vpn beta", text) + text = replace_block(text, "acme", "") + self.assertNotIn("erplibre-vpn acme", text) + self.assertIn("erplibre-vpn beta", text) + + +class WhatTheKernelGivesAndWhatOnlyARebootGivesBack(unittest.TestCase): + """Un module inaccessible se manifeste trois étages plus haut : charon + démarre, abandonne à l'initialisation, et l'attente de la connexion + accuse un bloc de configuration parfaitement formé. La vérification la + plus basse est donc celle qui doit parler la première.""" + + PRESENT = ("XFRM d'essai", lambda: True) + ABSENT = ("XFRM d'essai", lambda: False) + + def setUp(self): + """La question posée s'affiche même sur un exécuteur silencieux — + c'est voulu, on s'apprête à bloquer dessus. Elle n'a rien à faire + dans la sortie du lanceur de tests pour autant.""" + silence = redirect_stdout(io.StringIO()) + silence.__enter__() + self.addCleanup(silence.__exit__, None, None, None) + + def _kernel(self, driver, features, stale): + driver.kernel_features = features + return patch( + "script.vpn.drivers.base.stale_kernel", return_value=stale + ) + + def test_netlink_route_answers_on_any_linux(self): + """La sonde n'exige aucun droit : elle ouvre et referme.""" + self.assertTrue(base.netlink_family_available(0)) + + def test_a_refused_family_is_absent_not_an_exception(self): + with patch("socket.socket", side_effect=OSError(93, "nope")): + self.assertFalse(base.netlink_family_available(base.NETLINK_XFRM)) + + def test_a_kernel_without_any_module_tree_is_not_stale(self): + """Un noyau compilé sans modules n'a rien à redémarrer : le + déclarer périmé enverrait redémarrer pour rien.""" + with patch("os.path.isdir", return_value=False), patch( + "os.listdir", return_value=[] + ): + self.assertEqual(base.stale_kernel(), "") + + def test_the_running_tree_gone_while_another_stands_is_stale(self): + with patch("os.path.isdir", return_value=False), patch( + "os.listdir", return_value=["1.2.3-neuf"] + ), patch("platform.release", return_value="1.2.2-vieux"): + self.assertEqual(base.stale_kernel(), "1.2.2-vieux") + + def test_a_present_tree_is_never_stale(self): + with patch("os.path.isdir", return_value=True): + self.assertEqual(base.stale_kernel(), "") + + def test_missing_and_stale_names_the_reboot(self): + driver = _driver() + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"): + (label, ok, detail) = driver.check_kernel()[0] + self.assertTrue(driver.needs_reboot()) + self.assertEqual((label, ok), ("noyau", False)) + self.assertIn("1.2.2-vieux", detail) + self.assertIn("redémarrer", detail) + + def test_missing_on_a_current_kernel_offers_no_reboot(self): + """Redémarrer ne fait pas apparaître ce que le noyau n'a pas.""" + driver = _driver() + with self._kernel(driver, (self.ABSENT,), ""): + (_, ok, detail) = driver.check_kernel()[0] + self.assertFalse(driver.needs_reboot()) + self.assertFalse(ok) + self.assertNotIn("redémarrer", detail) + + def test_a_stale_tree_alone_is_a_warning_not_a_failure(self): + """La capacité répond : un tunnel déjà monté fonctionne, et un + « ✗ » ferait mentir le diagnostic.""" + driver = _driver() + with self._kernel(driver, (self.PRESENT,), "1.2.2-vieux"): + (_, ok, detail) = driver.check_kernel()[0] + self.assertFalse(driver.needs_reboot()) + self.assertIs(ok, True) + self.assertIn("1.2.2-vieux", detail) + + def test_a_healthy_kernel_says_so_once(self): + driver = _driver() + with self._kernel(driver, (self.PRESENT,), ""): + checks = driver.check_kernel() + self.assertEqual(len(checks), 1) + self.assertIs(checks[0][1], True) + + def test_a_driver_that_asks_nothing_of_the_kernel_stays_silent(self): + driver = _driver() + with self._kernel(driver, (), ""): + self.assertEqual(driver.check_kernel(), []) + + def test_the_reboot_is_offered_and_applied_when_accepted(self): + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + lancees = [] + runner.cmd = lambda label, command, **k: ( + lancees.append(command) or (0, "") + ) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "sys.stdin.isatty", return_value=True + ), patch("builtins.input", return_value="o"): + self.assertTrue(driver.propose_reboot(runner)) + self.assertEqual(lancees, ["systemctl reboot"]) + + def test_nothing_reboots_without_a_terminal_to_answer(self): + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + lancees = [] + runner.cmd = lambda label, command, **k: ( + lancees.append(command) or (0, "") + ) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "sys.stdin.isatty", return_value=False + ): + self.assertFalse(driver.propose_reboot(runner)) + self.assertEqual(lancees, []) + + def test_nothing_reboots_on_a_dry_run(self): + driver = _driver() + runner = _dry_runner(driver) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "builtins.input" + ) as demande: + self.assertFalse(driver.propose_reboot(runner)) + demande.assert_not_called() + + def test_an_accepted_reboot_still_stops_the_mount(self): + """La machine met quelques secondes à s'arrêter. Monter un tunnel + dans l'intervalle serait le monter sur le noyau qu'on quitte.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + runner.cmd = lambda label, command, **k: (0, "") + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "sys.stdin.isatty", return_value=True + ), patch("builtins.input", return_value="o"): + self.assertFalse(driver.ensure_ready(runner)) + self.assertTrue(runner.failures) + + def test_a_faulty_kernel_stops_the_mount_before_it_writes(self): + """Rien ne doit atterrir dans /etc quand l'étage du dessous est à + terre : les blocs posés là survivent au redémarrage et la + configuration de quelqu'un a été touchée pour rien.""" + driver = _driver() + runner = Runner(dry_run=False, quiet=True) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"), patch( + "sys.stdin.isatty", return_value=False + ): + self.assertFalse(driver.up(runner)) + touches = [ + op + for op in runner.ops + if "/etc" in op.get("cmd", "") + op.get("path", "") + ] + self.assertEqual(touches, []) + self.assertTrue(runner.failures) + + def test_the_kernel_is_judged_before_the_packages(self): + """Du plus bas au plus haut : la première ligne fausse doit être la + CAUSE, pas une conséquence.""" + driver = _driver() + runner = Runner(dry_run=True, quiet=True) + with self._kernel(driver, (self.ABSENT,), "1.2.2-vieux"): + labels = [label for label, _, _ in driver.standard_status(runner)] + self.assertEqual(labels[0], "noyau") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_vpn_vault.py b/test/test_vpn_vault.py new file mode 100644 index 0000000..159b6ee --- /dev/null +++ b/test/test_vpn_vault.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le coffre KeePassXC : aller-retour d'un PSK, et permissions. + +Un vrai fichier .kdbx est créé dans un répertoire temporaire — pykeepass fait +tout hors ligne, donc ces tests ne demandent ni root, ni réseau, ni coffre de +l'utilisateur. Ils sont ignorés (et le disent) si pykeepass n'est pas +installé, plutôt que de passer en silence. +""" + +import io +import json +import os +import stat +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.config.config_file import ConfigFile +from script.todo.kdbx_manager import KdbxManager +from script.vpn.vault import ( + VaultError, + VpnVault, + secrets_from_env, + secrets_to_env, +) + +try: + from pykeepass import create_database +except ModuleNotFoundError: + create_database = None + +MASTER = "coffre-de-test" +PSK = "cl3-Pr3-P4rt4g33!" +PPP_PASSWORD = "m0tD3P4ss3-PPP" +TITLE = "ERPLibre VPN / acme" + + +@unittest.skipUnless(create_database, "pykeepass n'est pas installé") +class VpnVaultRoundTrip(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.kdbx_path = os.path.join(self.tmp.name, "secrets.kdbx") + create_database(self.kdbx_path, password=MASTER) + # 0600 d'emblée : sinon chaque test verrait le coffre resserré à + # l'ouverture et l'annoncerait, ce qui noierait la sortie de la + # suite sous un avertissement qui n'est le sujet que d'un test. + os.chmod(self.kdbx_path, 0o600) + + # Le mot de passe maître est mis dans la configuration UNIQUEMENT + # ici : c'est ce qui permet au test de tourner sans saisie. Le CLI, + # lui, signale cette situation à l'utilisateur. + self.base = os.path.join(self.tmp.name, "todo.json") + with open(self.base, "w") as fh: + json.dump( + {"kdbx": {"path": self.kdbx_path, "password": MASTER}}, fh + ) + self.private = os.path.join(self.tmp.name, "private.json") + self.patches = [ + patch("script.config.config_file.CONFIG_FILE", self.base), + patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmp.name, "override.json"), + ), + patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + self.private, + ), + ] + for item in self.patches: + item.start() + config = ConfigFile() + self.vault = VpnVault(config, KdbxManager(config)) + + def tearDown(self): + for item in self.patches: + item.stop() + self.tmp.cleanup() + + def test_write_then_read(self): + self.vault.write( + TITLE, + {"username": "ACME\\user", "password": "ppp", "psk": PSK}, + ) + values = self.vault.read(TITLE, fields=("psk", "password")) + self.assertEqual(values["psk"], PSK) + self.assertEqual(values["password"], "ppp") + self.assertEqual(values["username"], "ACME\\user") + + def test_password_is_the_native_field_not_a_custom_one(self): + """`password` est un champ NATIF de KeePassXC. Le chercher parmi les + propriétés personnalisées le rendrait vide, alors qu'un pilote a le + droit de le déclarer dans ses `secret_fields`.""" + self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) + entry = self.vault._open().find_entries_by_title(TITLE, first=True) + self.assertEqual(entry.password, "ppp") + self.assertIsNone(entry.get_custom_property("password")) + + def test_psk_is_a_protected_property(self): + """Protégée = chiffrée en mémoire et masquée dans KeePassXC, comme + le champ mot de passe.""" + self.vault.write(TITLE, {"psk": PSK}) + entry = self.vault._open().find_entries_by_title(TITLE, first=True) + self.assertTrue(entry.is_custom_property_protected("psk")) + + def test_entry_lands_in_its_own_group(self): + self.vault.write(TITLE, {"psk": PSK}) + entry = self.vault._open().find_entries_by_title(TITLE, first=True) + self.assertEqual(entry.group.name, "ERPLibre VPN") + + def test_update_keeps_the_untouched_fields(self): + """Une réponse vide dans le menu ne doit pas effacer l'autre + secret : `write` ne touche que les clés qu'on lui donne.""" + self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) + self.vault.write(TITLE, {"password": "nouveau"}) + values = self.vault.read(TITLE, fields=("psk", "password")) + self.assertEqual(values["password"], "nouveau") + self.assertEqual(values["psk"], PSK) + + def test_missing_entry_reads_as_empty(self): + """« Pas encore de secret » est un état normal, pas une panne.""" + values = self.vault.read("ERPLibre VPN / inconnu", fields=("psk",)) + self.assertEqual(values, {"username": "", "password": "", "psk": ""}) + self.assertFalse(self.vault.exists("ERPLibre VPN / inconnu")) + + def test_the_vault_stays_owner_only_after_a_write(self): + """LE test de non-régression. + + `PyKeePass.save()` réécrit le fichier et lui redonne le mode du + umask — 0664 sur Ubuntu. Un chmod fait une fois à la création ne + survivait pas au premier enregistrement : le coffre devenait + lisible par toute la machine sans que personne ne touche à rien. + """ + os.chmod(self.kdbx_path, 0o600) + self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) + mode = stat.S_IMODE(os.stat(self.kdbx_path).st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + + def test_a_loose_vault_is_tightened_and_it_says_so(self): + """Trouvé desserré, il ne l'est pas resté — et ce n'est pas nous qui + l'avions laissé ainsi, donc on le dit.""" + os.chmod(self.kdbx_path, 0o664) + buffer = io.StringIO() + with redirect_stdout(buffer): + self.vault.read(TITLE, fields=("psk",)) + mode = stat.S_IMODE(os.stat(self.kdbx_path).st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + self.assertIn("0600", buffer.getvalue()) + + def test_protect_leaves_an_already_tight_vault_alone(self): + os.chmod(self.kdbx_path, 0o600) + self.assertFalse(self.vault.protect()) + + def test_stored_master_password_is_reported(self): + self.assertTrue(self.vault.master_password_is_stored()) + + +@unittest.skipUnless(create_database, "pykeepass n'est pas installé") +class VpnVaultBootstrap(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = os.path.join(self.tmp.name, "todo.json") + with open(self.base, "w") as fh: + json.dump({"kdbx": {"path": "", "password": ""}}, fh) + self.private = os.path.join(self.tmp.name, "private.json") + self.patches = [ + patch("script.config.config_file.CONFIG_FILE", self.base), + patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmp.name, "override.json"), + ), + patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + self.private, + ), + ] + for item in self.patches: + item.start() + config = ConfigFile() + self.vault = VpnVault(config, KdbxManager(config)) + self.target = os.path.join(self.tmp.name, "nouveau.kdbx") + + def tearDown(self): + for item in self.patches: + item.stop() + self.tmp.cleanup() + + def _answers(self, *values): + answers = iter(values) + return lambda _prompt: next(answers) + + def test_creates_the_vault_and_remembers_where(self): + with patch("getpass.getpass", return_value=MASTER): + path = self.vault.ensure_vault(ask=self._answers(self.target, "o")) + self.assertEqual(path, self.target) + self.assertTrue(os.path.exists(self.target)) + # Le chemin est retenu dans le fichier PRIVÉ, le seul gitignored. + with open(self.private) as fh: + self.assertEqual(json.load(fh)["kdbx"]["path"], self.target) + + def test_new_vault_is_owner_only(self): + with patch("getpass.getpass", return_value=MASTER): + self.vault.ensure_vault(ask=self._answers(self.target, "o")) + mode = stat.S_IMODE(os.stat(self.target).st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + + def test_refusing_creates_nothing(self): + path = self.vault.ensure_vault(ask=self._answers(self.target, "n")) + self.assertEqual(path, "") + self.assertFalse(os.path.exists(self.target)) + + def test_mismatched_confirmation_creates_nothing(self): + with patch("getpass.getpass", side_effect=[MASTER, "autre"]): + with self.assertRaises(VaultError): + self.vault.ensure_vault(ask=self._answers(self.target, "o")) + self.assertFalse(os.path.exists(self.target)) + + def test_reading_without_a_configured_vault_says_so(self): + """Sans chemin, `KdbxManager` ouvrirait un sélecteur graphique et, + sur un serveur sans tkinter, journaliserait une erreur au lieu de + dire ce qui manque.""" + with self.assertRaises(VaultError) as caught: + self.vault.read(TITLE, fields=("psk",)) + self.assertIn("coffre", str(caught.exception).lower()) + + +class SecretsHandedOverByEnvironment(unittest.TestCase): + """Le menu tient déjà le coffre ouvert quand il lance `vpn.py`. + + Sans ce passage, le mot de passe maître était redemandé DEUX fois par + connexion — un essai à blanc précède le montage. Par l'environnement et + non par un argument : /proc//environ n'est lisible que par le + propriétaire, /proc//cmdline par tout le monde. + """ + + def test_round_trip(self): + env = secrets_to_env({"psk": PSK, "password": PPP_PASSWORD}) + with patch.dict(os.environ, env, clear=False): + got = secrets_from_env(("psk", "password")) + self.assertEqual(got, {"psk": PSK, "password": PPP_PASSWORD}) + + def test_without_the_marker_nothing_is_claimed(self): + """Un champ vide serait indistinguable d'un champ absent : le + marqueur tranche, et on rouvre le coffre plutôt que de deviner.""" + env = secrets_to_env({"psk": PSK}) + del env["EL_VPN_SECRETS_PROVIDED"] + with patch.dict(os.environ, env, clear=False): + self.assertIsNone(secrets_from_env(("psk",))) + + def test_an_empty_secret_survives_the_trip(self): + env = secrets_to_env({"psk": PSK, "wg_preshared_key": ""}) + with patch.dict(os.environ, env, clear=False): + got = secrets_from_env(("psk", "wg_preshared_key")) + self.assertEqual(got["wg_preshared_key"], "") + self.assertEqual(got["psk"], PSK) + + def test_no_secret_in_a_variable_name(self): + """Les noms de variables partent dans l'environnement d'un + sous-processus : ils ne doivent porter que la CLÉ, jamais la + valeur.""" + env = secrets_to_env({"psk": PSK}) + for name in env: + self.assertNotIn(PSK, name) + + +@unittest.skipUnless(create_database, "pykeepass n'est pas installé") +class VaultToTunnel(unittest.TestCase): + """La couture complète : coffre → profil → plan de montage. + + Les autres tests injectent les secrets à la main. Celui-ci les fait + VRAIMENT sortir d'un .kdbx, comme le CLI, et vérifie qu'ils n'ont pas + fui en chemin. C'est le seul qui juge l'ensemble. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.kdbx_path = os.path.join(self.tmp.name, "secrets.kdbx") + create_database(self.kdbx_path, password=MASTER) + # 0600 d'emblée : sinon chaque test verrait le coffre resserré à + # l'ouverture et l'annoncerait, ce qui noierait la sortie de la + # suite sous un avertissement qui n'est le sujet que d'un test. + os.chmod(self.kdbx_path, 0o600) + self.base = os.path.join(self.tmp.name, "todo.json") + with open(self.base, "w") as fh: + json.dump( + { + "kdbx": {"path": self.kdbx_path, "password": MASTER}, + "vpn": [], + }, + fh, + ) + self.patches = [ + patch("script.config.config_file.CONFIG_FILE", self.base), + patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmp.name, "override.json"), + ), + patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmp.name, "private.json"), + ), + ] + for item in self.patches: + item.start() + + def tearDown(self): + for item in self.patches: + item.stop() + self.tmp.cleanup() + + def test_secret_goes_from_the_vault_to_the_plan_without_leaking(self): + from script.vpn import profiles + from script.vpn.drivers.l2tp_ipsec import L2tpIpsecDriver + from script.vpn.runner import Runner + from script.vpn.vault import redact + + config = ConfigFile() + vault = VpnVault(config, KdbxManager(config)) + profile = profiles.save( + { + "name": "acme", + "driver": "l2tp_ipsec", + "server": "127.0.0.1", + "ppp_user": "ACME\\user", + "routes": ["10.20.0.0/16"], + } + ) + vault.write( + profiles.secret_title("acme"), + { + "username": profile["ppp_user"], + "password": PPP_PASSWORD, + "psk": PSK, + }, + ) + + secrets = vault.read( + profiles.secret_title("acme"), fields=("psk", "password") + ) + self.assertEqual(secrets["psk"], PSK) + self.assertEqual(secrets["password"], PPP_PASSWORD) + + driver = L2tpIpsecDriver(profiles.load("acme"), secrets) + runner = Runner( + dry_run=True, + quiet=True, + redactor=lambda text: redact(text, driver.secret_values()), + ) + self.assertTrue(driver.up(runner)) + + # Le PSK atteint le fichier de secrets, en hexadécimal, et rien + # d'autre. + secret_writes = [ + op for op in runner.ops if op["kind"] == "write" and op["secret"] + ] + hex_psk = PSK.encode("utf-8").hex() + self.assertTrue( + any(hex_psk in op["content"] for op in secret_writes), + "le PSK n'a pas atteint le fichier de secrets", + ) + for op in runner.ops: + if op["kind"] == "cmd": + self.assertNotIn(PSK, op["cmd"]) + self.assertNotIn(PPP_PASSWORD, op["cmd"]) + # Le PSK en clair n'apparaît dans AUCUN contenu écrit : c'est sa + # forme hexadécimale qui voyage. + for op in secret_writes: + self.assertNotIn(PSK, op["content"]) + + +if __name__ == "__main__": + unittest.main()