Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il refuse de monter. Cinq technologies libres, un pilote chacune, derrière un `vpn.py` qui monte, démonte et diagnostique. Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un disque persistant. Le diagnostic part du noyau et remonte, pour que la première ligne fausse soit la cause et non une conséquence. Vérifié : 138 tests, dont le rendu de chaque fichier généré. --- EN --- The repository had no way to raise a VPN tunnel, nor to say why one refuses to come up. Five free technologies, one driver each, behind a `vpn.py` that raises, tears down and diagnoses. What is not secret — host, user, routes, MTU — lives in readable JSON configuration; pre-shared keys and passwords live in a KeePassXC vault. A profile can be shown and shared without handing over the means to raise the tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk. Diagnosis starts at the kernel and climbs, so the first false line is the cause and not a consequence. Checked: 138 tests, including the rendering of every generated file. Assisted-by: Claude Opus 5
898 lines
35 KiB
Python
898 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""L2TP sur IPsec, clé pré-partagée : strongSwan + xl2tpd + pppd.
|
|
|
|
Trois étages, et il faut les trois pour avoir une interface :
|
|
|
|
1. IPsec en mode TRANSPORT protège l'UDP 1701 entre nous et le serveur.
|
|
Mode transport, pas tunnel : c'est L2TP qui encapsule, IPsec ne fait
|
|
que chiffrer. Un `type=tunnel` ici ne monte jamais.
|
|
2. L2TP (xl2tpd) ouvre une session dans ce canal protégé.
|
|
3. PPP s'authentifie (MS-CHAPv2) et crée l'interface ppp*.
|
|
|
|
Trois pièges connus, réglés ici et pas ailleurs :
|
|
|
|
· `charon { install_routes = no }` — sinon charon pose lui-même une
|
|
route pour la SA, qui détourne le trafic L2TP et le tunnel n'aboutit
|
|
jamais. C'est LE symptôme classique « la SA est établie, ppp0
|
|
n'apparaît pas ».
|
|
· Route de survie vers le serveur. En mode « tout le trafic », la route
|
|
par défaut part dans ppp0 — y compris les paquets ESP à destination du
|
|
serveur, qui se retrouvent à passer par le tunnel qu'ils portent. Une
|
|
route /32 vers le serveur via la passerelle d'origine évite ce
|
|
serpent qui se mord la queue.
|
|
· systemd-resolved ignore /etc/ppp/resolv.conf. `usepeerdns` remplit ce
|
|
fichier, personne ne le lit, et « le VPN marche mais aucun nom ne
|
|
résout ». D'où l'appel `resolvectl` explicite.
|
|
|
|
Où vivent les fichiers, et pourquoi :
|
|
|
|
/dev/shm/erplibre-vpn/<profil>/ 0700 root — LES SECRETS. tmpfs : rien
|
|
n'est écrit sur un disque persistant, et
|
|
un redémarrage efface tout.
|
|
/run/erplibre-vpn/<profil>.* 0755 — l'état non secret (interface
|
|
retenue, pid, route ajoutée). Lisible
|
|
sans sudo : `status` en a besoin.
|
|
/etc/ipsec.conf, /etc/ipsec.secrets un bloc marqué, retiré au « down ».
|
|
/etc/strongswan.d/erplibre-vpn.conf le réglage install_routes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
import sys
|
|
|
|
from script.vpn import valid
|
|
from script.vpn.drivers.base import (
|
|
NETLINK_XFRM,
|
|
SECRET_DIR,
|
|
STATE_DIR,
|
|
VpnDriver,
|
|
interface_addresses,
|
|
locate,
|
|
netlink_family_available,
|
|
ppp_interfaces,
|
|
pppd_dns,
|
|
resolve,
|
|
wait_for_interface_address,
|
|
wait_for_new_interface,
|
|
which,
|
|
)
|
|
|
|
IPSEC_CONF = "/etc/ipsec.conf"
|
|
IPSEC_SECRETS = "/etc/ipsec.secrets"
|
|
STRONGSWAN_DROPIN = "/etc/strongswan.d/erplibre-vpn.conf"
|
|
# AppArmor confine charon par CHEMIN sur Debian et Ubuntu. Le profil se
|
|
# termine par `#include <local/…>` : c'est le point d'extension prévu, et le
|
|
# fichier local existe déjà, vide.
|
|
APPARMOR_PROFILE = "/etc/apparmor.d/usr.lib.ipsec.charon"
|
|
APPARMOR_LOCAL = "/etc/apparmor.d/local/usr.lib.ipsec.charon"
|
|
|
|
# Le drop-in est PARTAGÉ par tous les profils : c'est un réglage de charon,
|
|
# pas d'une connexion. Il vaut pour toute SA de la machine, et c'est assumé —
|
|
# sur un poste client, aucune SA ne veut que charon pose ses routes.
|
|
DROPIN_BODY = """# Généré par ERPLibre (script/vpn) — ne pas éditer.
|
|
#
|
|
# charon poserait sinon une route pour chaque SA. En L2TP/IPsec, cette route
|
|
# détourne le trafic UDP 1701 et le tunnel ne monte jamais : la SA s'établit,
|
|
# ppp0 n'apparaît pas. xl2tpd et pppd posent les routes dont on a besoin.
|
|
charon {
|
|
install_routes = no
|
|
}
|
|
"""
|
|
|
|
|
|
# Ce que charon dit, et ce que ça veut dire. Ces cinq messages sont ceux
|
|
# qu'on rencontre en montant un tunnel L2TP/IPsec, et aucun ne se comprend
|
|
# seul : le premier a coûté une heure de recherche du côté du PSK, alors que
|
|
# le PSK était juste et le refus venait d'AppArmor.
|
|
IPSEC_HINTS = (
|
|
(
|
|
"no shared key found",
|
|
"charon n'a pas trouvé le PSK. Le fichier est là, mais un refus"
|
|
" AppArmor sur /dev/shm l'empêche de le LIRE :"
|
|
" journalctl -k | grep DENIED.",
|
|
),
|
|
(
|
|
"not supported!",
|
|
"charon a retenu un algorithme qu'il ne sait pas exécuter — le"
|
|
" greffon manque. Sur Debian et Ubuntu, 3DES vient du greffon"
|
|
" openssl : paquet libstrongswan-standard-plugins.",
|
|
),
|
|
(
|
|
"does not match to",
|
|
"l'identité annoncée par la passerelle diffère de celle attendue."
|
|
" `rightid=%any` l'accepte : le bloc de /etc/ipsec.conf est-il à"
|
|
" jour ? Un « down » puis un « up » le réécrit.",
|
|
),
|
|
(
|
|
"AUTHENTICATION_FAILED",
|
|
"la passerelle a refusé la clé pré-partagée.",
|
|
),
|
|
(
|
|
"NO_PROPOSAL_CHOSEN",
|
|
"la passerelle refuse toutes nos propositions de chiffrement.",
|
|
),
|
|
)
|
|
|
|
|
|
class L2tpIpsecDriver(VpnDriver):
|
|
name = "l2tp_ipsec"
|
|
label = "L2TP/IPsec PSK"
|
|
binaries = ("ipsec", "xl2tpd", "pppd", "ip")
|
|
# L'IPsec du noyau est la première condition de tout : sans la famille
|
|
# netlink XFRM, charon s'arrête à l'initialisation sur « kernel-ipsec »
|
|
# manquant, et l'échec se lit ensuite comme une connexion jamais
|
|
# chargée. La sonde ne rend « absent » que si le noyau refuse la
|
|
# famille, ce qu'aucun droit ni aucun réglage ne provoque.
|
|
kernel_features = (
|
|
(
|
|
"XFRM (IPsec du noyau)",
|
|
lambda: netlink_family_available(NETLINK_XFRM),
|
|
),
|
|
)
|
|
secret_fields = (
|
|
("psk", "IPsec pre-shared key (PSK)", True),
|
|
("password", "PPP password", True),
|
|
)
|
|
iface_kind = "ppp"
|
|
# Faux : un profil sans route reste utilisable — il joint l'hôte
|
|
# distant, et l'adresse qu'on y obtient dit quel réseau ajouter. Le
|
|
# site ne donne souvent qu'une passerelle et des identifiants.
|
|
needs_routes = False
|
|
hint = "When the far side imposes it: a router, a firewall, Windows RRAS"
|
|
proven = True
|
|
user_field = "ppp_user"
|
|
defaults = {
|
|
"ppp_user": "",
|
|
"use_peer_dns": True,
|
|
"dns_search": "",
|
|
# Le port L2TP LOCAL. 1701 est la valeur attendue ; le déplacer est
|
|
# le remède quand un xl2tpd du système tient déjà le port.
|
|
"l2tp_local_port": 1701,
|
|
}
|
|
form_fields = (
|
|
(
|
|
"ppp_user",
|
|
"PPP user (the one the server authenticates)",
|
|
"text",
|
|
False,
|
|
),
|
|
("l2tp_local_port", "Local L2TP port", "int", True),
|
|
("use_peer_dns", "Use the DNS pushed by the peer?", "flag", True),
|
|
("dns_search", "DNS search domain (optional)", "text", True),
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Chemins et noms dérivés du profil
|
|
# ------------------------------------------------------------------
|
|
@property
|
|
def conn(self):
|
|
"""Nom de la connexion IPsec ET du LAC xl2tpd. Préfixé pour ne
|
|
jamais entrer en collision avec une connexion de l'utilisateur."""
|
|
return f"erplibre-{self.name_tag}"
|
|
|
|
@property
|
|
def secrets_file(self):
|
|
return f"{self.secret_dir}/ipsec.secrets"
|
|
|
|
@property
|
|
def xl2tpd_conf(self):
|
|
return f"{self.secret_dir}/xl2tpd.conf"
|
|
|
|
@property
|
|
def ppp_options(self):
|
|
return f"{self.secret_dir}/ppp.options"
|
|
|
|
@property
|
|
def control_file(self):
|
|
return f"{STATE_DIR}/{self.name_tag}.control"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Validation
|
|
# ------------------------------------------------------------------
|
|
@classmethod
|
|
def validate_profile(cls, profile):
|
|
valid.text(
|
|
profile,
|
|
"ppp_user",
|
|
"Utilisateur PPP (c'est lui que le serveur authentifie)",
|
|
)
|
|
valid.port(profile, "l2tp_local_port", "Port L2TP local")
|
|
valid.flag(profile, "use_peer_dns")
|
|
valid.text(
|
|
profile,
|
|
"dns_search",
|
|
"Domaine de recherche DNS",
|
|
required=False,
|
|
pattern=valid.HOST_RE,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Rendu des fichiers
|
|
# ------------------------------------------------------------------
|
|
def ipsec_conn_body(self):
|
|
"""Le bloc `conn` pour /etc/ipsec.conf.
|
|
|
|
`leftprotoport=17/%any` et non `17/1701` : derrière du NAT, le port
|
|
source local est réécrit, et une politique clouée sur 1701 ne
|
|
s'applique alors plus aux paquets qui sortent. `%any` couvre les
|
|
deux cas — dont 1701.
|
|
|
|
Les propositions incluent 3DES et modp1024 : c'est vieux, et c'est
|
|
exactement ce que servent les concentrateurs L2TP qu'on rencontre.
|
|
Les listes sont ordonnées, le meilleur d'abord.
|
|
"""
|
|
p = self.profile
|
|
return "\n".join(
|
|
[
|
|
f"conn {self.conn}",
|
|
" keyexchange=ikev1",
|
|
" authby=secret",
|
|
" type=transport",
|
|
" left=%defaultroute",
|
|
" leftprotoport=17/%any",
|
|
f" right={p['server']}",
|
|
# La passerelle s'annonce comme elle veut : par son IP, par
|
|
# un FQDN, parfois par autre chose. Sans `rightid=%any`,
|
|
# strongSwan déduit l'identité attendue de `right` et refuse
|
|
# tout ce qui en diffère — « IDir '203.0.113.5' does not
|
|
# match to 'vpn.exemple.com' », sur une configuration par
|
|
# ailleurs juste. En PSK, c'est la CLÉ qui protège, pas
|
|
# l'identité annoncée par le pair.
|
|
" rightid=%any",
|
|
" rightprotoport=17/1701",
|
|
" ike=aes256-sha1-modp1024,aes128-sha1-modp1024,"
|
|
"3des-sha1-modp1024!",
|
|
" esp=aes256-sha1,aes128-sha1,3des-sha1!",
|
|
" dpdaction=clear",
|
|
" dpddelay=30s",
|
|
" auto=add",
|
|
]
|
|
)
|
|
|
|
def ipsec_secrets_body(self, server_ip):
|
|
"""Le PSK, en HEXADÉCIMAL.
|
|
|
|
strongSwan accepte `PSK "texte"` ou `PSK 0x<hex>`, et les deux
|
|
donnent les mêmes octets. L'hexadécimal évite toute question
|
|
d'échappement : un PSK contenant `"` ou `\\` casse la forme citée,
|
|
et un PSK est justement ce qu'on ne veut pas voir se faire tronquer
|
|
en silence.
|
|
"""
|
|
psk = self.secrets.get("psk", "")
|
|
as_hex = psk.encode("utf-8").hex()
|
|
return "\n".join(
|
|
[
|
|
"# Généré par ERPLibre (script/vpn). tmpfs : jamais sur",
|
|
"# disque, effacé au « down » et à l'extinction.",
|
|
f"%any {server_ip} : PSK 0x{as_hex}",
|
|
]
|
|
)
|
|
|
|
def xl2tpd_conf_body(self):
|
|
p = self.profile
|
|
# « ; » et non « # » : l'analyseur de xl2tpd ne connaît que le
|
|
# point-virgule, et refuse le fichier ENTIER sur un « # » en tête —
|
|
# « data '#…' occurs with no context », suivi de « Unable to load
|
|
# config file ». Un commentaire mal marqué cassait tout.
|
|
return "\n".join(
|
|
[
|
|
"; Généré par ERPLibre (script/vpn).",
|
|
"[global]",
|
|
f"port = {p['l2tp_local_port']}",
|
|
"access control = no",
|
|
"",
|
|
f"[lac {self.conn}]",
|
|
f"lns = {p['server']}",
|
|
# Rien à EXIGER du pair : « require chap » et « require
|
|
# authentication » font passer « require-chap » et « auth »
|
|
# à pppd, c'est-à-dire « que le serveur s'authentifie
|
|
# auprès de moi ». Le serveur refuse, à juste titre, et pppd
|
|
# coupe : « LCP terminated by peer (peer refused to
|
|
# authenticate) ». La politique d'authentification est celle
|
|
# de ppp.options, et elle ne parle que de NOUS.
|
|
"require chap = no",
|
|
"refuse pap = no",
|
|
"require authentication = no",
|
|
"ppp debug = no",
|
|
f"pppoptfile = {self.ppp_options}",
|
|
"length bit = yes",
|
|
"redial = no",
|
|
]
|
|
)
|
|
|
|
def ppp_options_body(self):
|
|
"""Les options pppd, mot de passe compris.
|
|
|
|
C'est le seul secret que la technologie oblige à poser dans un
|
|
fichier : pppd ne lit un mot de passe ni d'une variable
|
|
d'environnement, ni d'un argument. Le fichier est donc en 0600
|
|
dans un tmpfs 0700 appartenant à root, et il est effacé au « down ».
|
|
Résiduel assumé : tant que le tunnel est monté, root peut le lire.
|
|
"""
|
|
p = self.profile
|
|
lines = [
|
|
"# Généré par ERPLibre (script/vpn). tmpfs, 0600, effacé au down.",
|
|
"ipcp-accept-local",
|
|
"ipcp-accept-remote",
|
|
# AUCUN `refuse-*`. La méthode est celle que le concentrateur
|
|
# demande, et mesuré sur un vrai : « rcvd [LCP ConfReq …
|
|
# <auth pap> …] », auquel un `refuse-pap` répond
|
|
# « ConfNak <auth chap MD5> » — le serveur coupe alors la
|
|
# liaison sur « peer refused to authenticate », et le « peer »
|
|
# de ce message, c'est NOUS.
|
|
#
|
|
# PAP envoie le mot de passe en clair SUR LA LIAISON PPP, qui
|
|
# voyage dans la session L2TP, elle-même dans l'ESP. C'est le
|
|
# dispositif même de L2TP/IPsec : c'est IPsec qui protège
|
|
# l'authentification PPP. Ce pilote ne lance jamais L2TP sans SA
|
|
# IPsec établie — il abandonne avant —, donc le mot de passe ne
|
|
# sort jamais en clair du poste.
|
|
"noccp",
|
|
# Ne rien exiger du pair. Un client n'authentifie pas son
|
|
# concentrateur en PPP : c'est IPsec qui l'a fait, avant.
|
|
"noauth",
|
|
"noipdefault",
|
|
f"mtu {p['mtu']}",
|
|
f"mru {p['mtu']}",
|
|
"connect-delay 5000",
|
|
"lcp-echo-interval 30",
|
|
"lcp-echo-failure 4",
|
|
f"name {_pppd_quote(p['ppp_user'])}",
|
|
f"password {_pppd_quote(self.secrets.get('password', ''))}",
|
|
]
|
|
if p.get("use_peer_dns"):
|
|
lines.append("usepeerdns")
|
|
if p.get("default_route"):
|
|
# `replacedefaultroute` remet l'ancienne route en descendant :
|
|
# sans lui, une déconnexion brutale laisse la machine sans
|
|
# route par défaut du tout.
|
|
lines.append("defaultroute")
|
|
lines.append("replacedefaultroute")
|
|
return "\n".join(lines)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Montée
|
|
# ------------------------------------------------------------------
|
|
def up(self, runner):
|
|
p = self.profile
|
|
if not self.ensure_ready(runner):
|
|
return False
|
|
|
|
server_ip = runner.call(
|
|
f"résoudre {p['server']}",
|
|
lambda: resolve(p["server"]),
|
|
dry_safe=True,
|
|
)
|
|
if not server_ip:
|
|
runner.fail(
|
|
f"« {p['server']} » ne résout pas : sans son adresse, ni le"
|
|
" PSK ni la route de survie ne peuvent être posés."
|
|
)
|
|
return False
|
|
runner.ok(f"{p['server']} → {server_ip}")
|
|
|
|
before = (
|
|
runner.call(
|
|
"relever les interfaces PPP existantes",
|
|
ppp_interfaces,
|
|
dry_safe=True,
|
|
)
|
|
or set()
|
|
)
|
|
|
|
if not self._l2tp_port_is_free(runner):
|
|
return False
|
|
|
|
self.prepare_dirs(runner)
|
|
runner.write(
|
|
self.secrets_file,
|
|
self.ipsec_secrets_body(server_ip) + "\n",
|
|
mode="0600",
|
|
secret=True,
|
|
)
|
|
runner.write(
|
|
self.xl2tpd_conf, self.xl2tpd_conf_body() + "\n", mode="0600"
|
|
)
|
|
runner.write(
|
|
self.ppp_options,
|
|
self.ppp_options_body() + "\n",
|
|
mode="0600",
|
|
secret=True,
|
|
)
|
|
runner.write(STRONGSWAN_DROPIN, DROPIN_BODY, mode="0644")
|
|
self._allow_apparmor_to_read_secrets(runner)
|
|
runner.block(IPSEC_CONF, self.name_tag, self.ipsec_conn_body(), "0644")
|
|
runner.block(
|
|
IPSEC_SECRETS,
|
|
self.name_tag,
|
|
f"include {self.secrets_file}",
|
|
"0600",
|
|
)
|
|
|
|
self._reload_charon(runner)
|
|
if not self._wait_for_conn(runner):
|
|
return False
|
|
|
|
if p.get("default_route"):
|
|
self.add_host_route(runner, server_ip)
|
|
self.protect_the_ssh_session(runner)
|
|
|
|
# Capturé, et non affiché en direct : cette sortie EST le
|
|
# diagnostic. Une négociation IKE tient en une vingtaine de lignes,
|
|
# et c'est la dernière qui dit pourquoi ça a échoué.
|
|
code, out = runner.cmd(
|
|
f"monter la SA IPsec ({self.conn})",
|
|
f"ipsec up {shlex.quote(self.conn)}",
|
|
timeout=120,
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
if runner.dry_run:
|
|
pass
|
|
elif code != 0 or "established successfully" not in out:
|
|
for line in out.strip().splitlines()[-6:]:
|
|
runner.info(f" │ {line}")
|
|
runner.fail("La SA IPsec n'est pas montée.")
|
|
for motif, explication in IPSEC_HINTS:
|
|
if motif in out:
|
|
runner.info(f" → {explication}")
|
|
if not any(motif in out for motif, _ in IPSEC_HINTS):
|
|
runner.info(
|
|
" → Causes usuelles restantes : UDP 500/4500"
|
|
" filtré, ou passerelle injoignable. Voir"
|
|
" « diagnostic »."
|
|
)
|
|
return False
|
|
else:
|
|
runner.ok("SA IPsec établie.")
|
|
|
|
runner.cmd(
|
|
"lancer xl2tpd (instance dédiée à ce profil)",
|
|
"xl2tpd -c {} -C {} -p {}".format(
|
|
shlex.quote(self.xl2tpd_conf),
|
|
shlex.quote(self.control_file),
|
|
shlex.quote(self.pid_file),
|
|
),
|
|
)
|
|
if not self._wait_for_control(runner):
|
|
return False
|
|
runner.cmd(
|
|
"demander la session L2TP",
|
|
"sh -c {}".format(
|
|
shlex.quote(
|
|
f'echo "c {self.conn}" > {shlex.quote(self.control_file)}'
|
|
)
|
|
),
|
|
timeout=30,
|
|
)
|
|
|
|
iface = runner.call(
|
|
"attendre l'interface PPP",
|
|
lambda: wait_for_new_interface(before, "ppp"),
|
|
)
|
|
if runner.dry_run:
|
|
runner.info(" (à blanc : l'interface serait nommée ici)")
|
|
return True
|
|
if not iface:
|
|
runner.fail(
|
|
"Aucune interface PPP n'est apparue. La SA IPsec est"
|
|
" montée : le refus vient de L2TP ou de PPP"
|
|
" (identifiants, MS-CHAPv2). Voir « diagnostic »."
|
|
)
|
|
return False
|
|
# L'interface EXISTE dès que pppd la crée, bien avant qu'IPCP ait
|
|
# négocié l'adresse. La lire tout de suite annonçait « ppp0 : sans
|
|
# adresse » sur un tunnel qui allait très bien — et faisait chercher
|
|
# les DNS du pair avant que pppd les ait écrits.
|
|
addresses = runner.call(
|
|
f"attendre l'adresse de {iface}",
|
|
lambda: wait_for_interface_address(iface),
|
|
)
|
|
if not addresses:
|
|
runner.fail(
|
|
f"{iface} est apparue sans obtenir d'adresse : IPCP n'a pas"
|
|
" abouti. L'authentification PPP a-t-elle réussi ? Le"
|
|
" journal de pppd le dit — voir « diagnostic »."
|
|
)
|
|
return False
|
|
runner.ok(f"interface {iface} : {', '.join(addresses)}")
|
|
self.write_state(runner, "iface", iface)
|
|
self.add_routes(runner, iface)
|
|
self.suggest_routes(runner, iface)
|
|
if p.get("use_peer_dns"):
|
|
servers = runner.call(
|
|
"lire les DNS poussés par le pair", pppd_dns, dry_safe=True
|
|
)
|
|
if servers:
|
|
self.set_resolved_dns(
|
|
runner, iface, servers, p.get("dns_search", "")
|
|
)
|
|
else:
|
|
runner.warn("Le pair n'a poussé aucun DNS.")
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Descente
|
|
# ------------------------------------------------------------------
|
|
def down(self, runner):
|
|
"""Défait tout, dans l'ordre inverse, sans s'arrêter au premier
|
|
échec : une descente doit nettoyer ce qu'elle PEUT nettoyer, même
|
|
si un étage est déjà tombé de lui-même."""
|
|
runner.cmd(
|
|
"fermer la session L2TP",
|
|
"sh -c {}".format(
|
|
shlex.quote(
|
|
f"[ -p {shlex.quote(self.control_file)} ] &&"
|
|
f' echo "d {self.conn}" >'
|
|
f" {shlex.quote(self.control_file)} || true"
|
|
)
|
|
),
|
|
check=False,
|
|
timeout=20,
|
|
)
|
|
self.kill_pidfile(runner, "arrêter xl2tpd")
|
|
runner.cmd(
|
|
f"descendre la SA IPsec ({self.conn})",
|
|
f"ipsec down {shlex.quote(self.conn)}",
|
|
check=False,
|
|
)
|
|
self.del_host_route(runner)
|
|
# Les blocs AVANT les fichiers : un `include` qui pointe vers un
|
|
# fichier disparu fait échouer tout rechargement de charon, y
|
|
# compris ceux d'une autre connexion.
|
|
runner.block(IPSEC_SECRETS, self.name_tag, "", "0600")
|
|
runner.block(IPSEC_CONF, self.name_tag, "", "0644")
|
|
runner.remove(self.secret_dir)
|
|
self.clear_state(runner, "iface", "hostroute", "pid", "control")
|
|
self._reload_charon(runner, check=False)
|
|
runner.ok("Tunnel démonté, secrets effacés.")
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# État
|
|
# ------------------------------------------------------------------
|
|
def status(self, runner):
|
|
# `statusall` et non `status` : `status` ne montre que les SA, et son
|
|
# « no match » est la réponse NORMALE d'un tunnel démonté. Il ne dit
|
|
# rien de la connexion elle-même — confondre les deux envoyait
|
|
# chercher une configuration absente alors qu'elle était chargée.
|
|
code, out = runner.cmd(
|
|
"état de charon",
|
|
f"ipsec statusall {shlex.quote(self.conn)}",
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
if code != 0:
|
|
unreachable = "charon injoignable (arrêté ? sudo refusé ?)"
|
|
return self.standard_status(
|
|
runner,
|
|
extra=[
|
|
("connexion chargée", None, unreachable),
|
|
("SA IPsec", None, unreachable),
|
|
],
|
|
)
|
|
loaded = f"{self.conn}:" in out
|
|
established = "ESTABLISHED" in out
|
|
extra = [
|
|
(
|
|
"connexion chargée",
|
|
loaded,
|
|
self.conn if loaded else "absente de charon",
|
|
),
|
|
(
|
|
"SA IPsec",
|
|
established,
|
|
"établie" if established else "aucune SA (tunnel démonté)",
|
|
),
|
|
]
|
|
return self.standard_status(runner, extra=extra)
|
|
|
|
def log_commands(self):
|
|
"""L'unité strongSwan n'a pas le même nom partout : on essaie les
|
|
deux plutôt que de deviner la distribution."""
|
|
return [
|
|
(
|
|
"journal strongSwan / xl2tpd",
|
|
"journalctl -n 40 --no-pager -u strongswan-starter"
|
|
" -u strongswan -u xl2tpd",
|
|
),
|
|
("journal pppd", "journalctl -n 20 --no-pager -t pppd"),
|
|
# Le seul endroit où un refus AppArmor apparaît. Sans cette
|
|
# ligne, un « no shared key found » reste inexplicable.
|
|
(
|
|
"refus AppArmor (noyau)",
|
|
'sh -c "journalctl -k --no-pager -n 200'
|
|
" | grep -i 'apparmor=\\\"DENIED\\\"' | tail -5"
|
|
" || echo 'aucun refus AppArmor récent'\"",
|
|
),
|
|
]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Détails propres à L2TP/IPsec
|
|
# ------------------------------------------------------------------
|
|
def _wait_for_control(self, runner):
|
|
"""Attend que xl2tpd crée son tube de contrôle.
|
|
|
|
xl2tpd se détache immédiatement et crée le FIFO ensuite. Écrire
|
|
dedans sans attendre échoue sur « No such file or directory », une
|
|
erreur qui ne dit rien du vrai problème — lequel est presque
|
|
toujours un xl2tpd qui n'a pas pu s'attacher au port.
|
|
"""
|
|
control = shlex.quote(self.control_file)
|
|
script = (
|
|
f"for i in $(seq 1 40); do [ -p {control} ] && exit 0;"
|
|
" sleep 0.25; done; exit 1"
|
|
)
|
|
code, _ = runner.cmd(
|
|
"attendre le tube de contrôle de xl2tpd",
|
|
f"sh -c {shlex.quote(script)}",
|
|
check=False,
|
|
timeout=25,
|
|
)
|
|
if code == 0 or runner.dry_run:
|
|
return True
|
|
runner.fail(
|
|
"xl2tpd n'a pas créé son tube de contrôle : il n'a pas"
|
|
" démarré. Cause la plus fréquente, UDP"
|
|
f" {self.profile['l2tp_local_port']} déjà pris — voir"
|
|
" « diagnostic »."
|
|
)
|
|
return False
|
|
|
|
def _l2tp_port_is_free(self, runner):
|
|
"""Faux si le port L2TP local est encore tenu après remèdes.
|
|
|
|
La question n'est PAS « le service xl2tpd est-il actif ? » mais « le
|
|
port est-il libre ? » : un xl2tpd orphelin tient UDP 1701 alors que
|
|
`systemctl is-active xl2tpd` répond « inactive », et le service en
|
|
relance un second par-dessus. Le nom du service ne dit rien ; le
|
|
port, tout.
|
|
|
|
On classe donc pid par pid, parce que les détenteurs peuvent être de
|
|
natures différentes en même temps :
|
|
|
|
· un reste d'un montage précédent de CE profil → on propose notre
|
|
propre « down » ;
|
|
· un xl2tpd qui n'est pas à nous (service, orphelin) → on propose de
|
|
l'arrêter et de le terminer ;
|
|
· le tunnel d'un AUTRE de nos profils → on le nomme et on s'arrête :
|
|
couper le sien est une décision qui revient à son propriétaire ;
|
|
· autre chose → on le nomme, on n'y touche pas.
|
|
|
|
Sans `ss`, on ne bloque pas : l'absence du tube de contrôle de
|
|
xl2tpd le dira deux étapes plus loin, et un faux blocage serait pire
|
|
qu'un échec tardif.
|
|
"""
|
|
port = self.profile["l2tp_local_port"]
|
|
if not locate("ss"):
|
|
return True
|
|
report = runner.warn if runner.dry_run else runner.fail
|
|
|
|
holders, tenu = self._port_holders(runner, port)
|
|
if not tenu:
|
|
return True
|
|
|
|
voisin = self._sibling_profile(holders)
|
|
if voisin:
|
|
report(f"UDP {port} est tenu par notre tunnel « {voisin} ».")
|
|
runner.info(
|
|
" → Le démonter d'abord :"
|
|
f" ./script/vpn/vpn.py down --profile {voisin}"
|
|
" (deux profils L2TP ne partagent pas le même port, et"
|
|
" couper le sien est votre décision, pas la nôtre)."
|
|
)
|
|
return runner.dry_run
|
|
|
|
# Remède 1 : un reste de CE profil.
|
|
if self._mine(holders):
|
|
runner.info(
|
|
f" Un montage précédent de « {self.name_tag} » n'a pas"
|
|
" été démonté."
|
|
)
|
|
if runner.propose(
|
|
f"reste du montage précédent de {self.name_tag}",
|
|
f"{sys.executable} -u ./script/vpn/vpn.py down --profile"
|
|
f" {shlex.quote(self.name_tag)}",
|
|
sudo=False,
|
|
question="Démonter ce reste et réessayer ?",
|
|
):
|
|
holders, tenu = self._port_holders(runner, port)
|
|
if not tenu:
|
|
runner.ok(f"Reste démonté, UDP {port} libre.")
|
|
return True
|
|
|
|
# Remède 2 : des xl2tpd qui ne sont pas à nous.
|
|
etrangers = self._foreign_xl2tpd(holders)
|
|
if etrangers:
|
|
if runner.propose(
|
|
f"UDP {port} tenu par xl2tpd",
|
|
"sh -c {}".format(
|
|
shlex.quote(
|
|
"systemctl stop xl2tpd 2>/dev/null;"
|
|
f" kill {' '.join(etrangers)} 2>/dev/null; true"
|
|
)
|
|
),
|
|
question=(
|
|
"Arrêter le service xl2tpd et terminer les processus"
|
|
f" restants ({', '.join(etrangers)}), puis réessayer ?"
|
|
),
|
|
):
|
|
holders, tenu = self._port_holders(runner, port)
|
|
if not tenu:
|
|
runner.ok(f"UDP {port} libéré.")
|
|
runner.info(
|
|
" (le service reviendra au prochain démarrage :"
|
|
" sudo systemctl disable xl2tpd)"
|
|
)
|
|
return True
|
|
|
|
report(f"UDP {port} est encore tenu : {tenu.splitlines()[0][:110]}")
|
|
inconnus = [
|
|
args for _pid, args in holders if "xl2tpd" not in args and args
|
|
]
|
|
if inconnus:
|
|
runner.info(
|
|
f" → « {inconnus[0][:60]} » n'est pas à nous :"
|
|
" l'arrêter demande votre décision, ou changer"
|
|
" « l2tp_local_port » dans le profil."
|
|
)
|
|
return runner.dry_run
|
|
|
|
def _who_holds_the_port(self, runner, port):
|
|
"""Ligne(s) de `ss` décrivant qui tient UDP `port`, "" si personne."""
|
|
script = f"ss -lunp 2>/dev/null | grep ':{port} ' || true"
|
|
_, out = runner.cmd(
|
|
f"UDP {port} est-il déjà tenu ?",
|
|
f"sh -c {shlex.quote(script)}",
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
return out.strip()
|
|
|
|
def _port_holders(self, runner, port):
|
|
"""([(pid, ligne de commande)], sortie brute de `ss`).
|
|
|
|
La ligne de commande est ce qui distingue nos instances des autres :
|
|
les nôtres portent leur configuration dans SECRET_DIR/<profil>/.
|
|
"""
|
|
tenu = self._who_holds_the_port(runner, port)
|
|
pids = re.findall(r"pid=(\d+)", tenu)
|
|
if not pids:
|
|
return [], tenu
|
|
_, out = runner.cmd(
|
|
"à qui appartiennent ces processus ?",
|
|
f"ps -o pid=,args= -p {' '.join(pids)}",
|
|
sudo=False,
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
vu = {}
|
|
for line in (out or "").splitlines():
|
|
morceaux = line.strip().split(None, 1)
|
|
if len(morceaux) == 2:
|
|
vu[morceaux[0]] = morceaux[1]
|
|
# `ss` nomme déjà le processus : c'est le repli quand `ps` ne dit
|
|
# rien (hidepid, processus disparu entre les deux appels). Sans ce
|
|
# repli on perdrait la classification, donc le remède, sur une
|
|
# information qu'on avait pourtant déjà.
|
|
noms = dict(
|
|
(pid, nom)
|
|
for nom, pid in re.findall(r'\("([^"]+)",pid=(\d+)', tenu)
|
|
)
|
|
return [(pid, vu.get(pid) or noms.get(pid, "")) for pid in pids], tenu
|
|
|
|
def _mine(self, holders):
|
|
"""Pids qui sont des instances de CE profil."""
|
|
marque = f"{SECRET_DIR}/{self.name_tag}/"
|
|
return [pid for pid, args in holders if marque in args]
|
|
|
|
def _sibling_profile(self, holders):
|
|
"""Nom d'un AUTRE de nos profils tenant le port, "" sinon."""
|
|
for _pid, args in holders:
|
|
trouve = re.search(
|
|
rf"{re.escape(SECRET_DIR)}/([^/\s]+)/", args or ""
|
|
)
|
|
if trouve and trouve.group(1) != self.name_tag:
|
|
return trouve.group(1)
|
|
return ""
|
|
|
|
def _foreign_xl2tpd(self, holders):
|
|
"""Pids xl2tpd qui ne sont pas des instances à nous."""
|
|
return [
|
|
pid
|
|
for pid, args in holders
|
|
if "xl2tpd" in args and SECRET_DIR not in args
|
|
]
|
|
|
|
def _allow_apparmor_to_read_secrets(self, runner):
|
|
"""Autorise charon à lire nos secrets en tmpfs.
|
|
|
|
AppArmor confine charon par CHEMIN, et `/dev/shm` ne figure pas dans
|
|
son profil. Sans cette règle, charon lit /etc/ipsec.secrets, suit
|
|
notre `include`, et se fait refuser le fichier par le noyau — pour
|
|
échouer trois étages plus loin sur « no shared key found », alors
|
|
que le PSK est là et bien formé. Seul `journalctl -k` le dit, en
|
|
clair : `apparmor="DENIED" … denied_mask="r"`.
|
|
|
|
Le fichier `local/` est le point d'extension prévu par Debian et
|
|
Ubuntu ; il ne contient aucun secret, seulement un chemin. Il n'est
|
|
PAS retiré au démontage : c'est une permission de chemin, valable
|
|
pour tous les profils, et inoffensive quand le répertoire est vide.
|
|
"""
|
|
if not os.path.exists(APPARMOR_PROFILE):
|
|
# Ni Debian ni Ubuntu : pas de profil charon à étendre.
|
|
return
|
|
changed = runner.block(
|
|
APPARMOR_LOCAL,
|
|
"secrets",
|
|
f"{SECRET_DIR}/** r,",
|
|
"0644",
|
|
)
|
|
if not changed:
|
|
return
|
|
# Le rechargement s'applique aussi au charon DÉJÀ lancé : sans lui,
|
|
# la règle n'entrerait en vigueur qu'au prochain démarrage.
|
|
runner.cmd(
|
|
"recharger le profil AppArmor de charon",
|
|
f"apparmor_parser -r {shlex.quote(APPARMOR_PROFILE)}",
|
|
check=False,
|
|
)
|
|
|
|
def _wait_for_conn(self, runner):
|
|
"""Attend que la connexion soit chargée dans charon.
|
|
|
|
`ipsec start` rend la main tout de suite : charon met une fraction
|
|
de seconde à démarrer, et le starter lui pousse les connexions
|
|
ENSUITE. Un `ipsec up` lancé dans l'instant échoue sur « no match »
|
|
— la connexion étant parfaitement valide, c'est l'erreur la plus
|
|
trompeuse de toute la séquence.
|
|
"""
|
|
conn = shlex.quote(self.conn)
|
|
script = (
|
|
f"for i in $(seq 1 40); do ipsec statusall {conn} 2>/dev/null"
|
|
f" | grep -q {shlex.quote(self.conn + ':')} && exit 0;"
|
|
" sleep 0.25; done; exit 1"
|
|
)
|
|
code, _ = runner.cmd(
|
|
"attendre que charon ait chargé la connexion",
|
|
f"sh -c {shlex.quote(script)}",
|
|
check=False,
|
|
timeout=20,
|
|
)
|
|
if code == 0 or runner.dry_run:
|
|
return True
|
|
runner.fail(
|
|
f"charon n'a pas chargé « {self.conn} » en 10 s. Le bloc de"
|
|
" /etc/ipsec.conf est-il bien formé ? Voir le journal de"
|
|
" strongSwan."
|
|
)
|
|
return False
|
|
|
|
def _reload_charon(self, runner, check=True):
|
|
code, _ = runner.cmd(
|
|
"charon tourne-t-il ?",
|
|
"ipsec status",
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
if code != 0:
|
|
runner.cmd("démarrer charon", "ipsec start", check=check)
|
|
return
|
|
runner.cmd("recharger ipsec.conf", "ipsec reload", check=check)
|
|
runner.cmd("relire les secrets", "ipsec rereadsecrets", check=check)
|
|
|
|
|
|
def _pppd_quote(value: str) -> str:
|
|
"""`value` cité pour un fichier d'options pppd.
|
|
|
|
pppd lit des chaînes entre guillemets doubles et y traite `\\` comme
|
|
échappement. Un utilisateur de la forme `DOMAINE\\prenom` est courant sur
|
|
les concentrateurs L2TP : sans cet échappement, pppd envoie
|
|
`DOMAINEprenom` et le serveur refuse sans dire pourquoi.
|
|
"""
|
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
return f'"{escaped}"'
|