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

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

--- EN ---

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

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

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

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}"'