erplibre/script/vpn/drivers/l2tp_ipsec.py

900 lines
35 KiB
Python
Raw Normal View History

#!/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}"'