[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-03 23:42:49 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""L'exécuteur : tout ce qui touche vraiment la machine passe par ici.
|
|
|
|
|
|
|
|
|
|
Un pilote VPN ne lance jamais rien lui-même. Il DEMANDE — « écris ce
|
|
|
|
|
fichier », « lance cette commande » — et cet objet exécute, ou se contente
|
|
|
|
|
d'afficher quand on l'a lancé à blanc. Trois choses en découlent :
|
|
|
|
|
|
|
|
|
|
1. `--dry-run` n'est pas une branche parallèle dans chaque pilote : c'est un
|
|
|
|
|
drapeau ici. Ce qui s'affiche est exactement ce qui s'exécuterait.
|
|
|
|
|
2. Chaque opération est ENREGISTRÉE dans `ops`. Les tests unitaires peuvent
|
|
|
|
|
donc vérifier, sans root et sans serveur en face, qu'aucun secret n'a
|
|
|
|
|
atterri dans une ligne de commande.
|
|
|
|
|
3. La règle « les secrets ne passent que par l'entrée standard » est tenue en
|
|
|
|
|
UN endroit, pas dans cinq pilotes.
|
|
|
|
|
|
|
|
|
|
Pourquoi l'entrée standard : `/proc/<pid>/cmdline` est lisible par tout
|
|
|
|
|
utilisateur de la machine, `/proc/<pid>/environ` par le seul propriétaire du
|
|
|
|
|
processus. Un mot de passe en argument est visible de tous pendant toute la
|
|
|
|
|
durée de la commande.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import shlex
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
|
[ADD] vpn openconnect : groupe d'URL, SSO délégué, mot de passe borné
Deux mécanismes désignent un service sur un concentrateur : un chemin
d'URL, une valeur de menu déroulant. Les confondre rend le formulaire d'un
AUTRE service — identifiants justes refusés, rien ne désignant le groupe.
Et certains ne comparent que les N premiers caractères du mot de passe,
que le profil déclare désormais sans jamais rien tronquer.
Une passerelle qui exige un navigateur intégré arrête openconnect sur
« No SSO handler », les distributions le bâtissant sans webview. Un greffon
fait l'étape web et rend un cookie ; le pilote monte lui-même, et le profil
garde son interface, son état et son diagnostic.
Vérifié : 249 tests unitaires, un tunnel monté contre une passerelle SAML.
--- EN ---
Two mechanisms designate a service on one concentrator: a URL path, and a
dropdown value. Confusing them hands over ANOTHER service's login form —
correct credentials refused, with nothing pointing at the group. And some
compare only the first N characters of the password, which the profile now
declares without ever truncating anything.
A gateway demanding an embedded browser stops openconnect on "No SSO
handler", distributions building it without a webview. A helper does the
web step and returns a cookie; the driver mounts the tunnel itself, so the
profile keeps its interface, its state and its diagnosis.
Checked: 249 unit tests, and one tunnel mounted against a SAML gateway.
Assisted-by: Claude Opus 5
2026-09-04 05:52:09 -04:00
|
|
|
# Le même masque que le coffre : deux masques différents dans la même
|
|
|
|
|
# sortie feraient croire à deux natures de secret.
|
|
|
|
|
from script.vpn.vault import MASK
|
|
|
|
|
|
[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-03 23:42:49 -04:00
|
|
|
# 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] = []
|
|
|
|
|
|
[ADD] vpn openconnect : groupe d'URL, SSO délégué, mot de passe borné
Deux mécanismes désignent un service sur un concentrateur : un chemin
d'URL, une valeur de menu déroulant. Les confondre rend le formulaire d'un
AUTRE service — identifiants justes refusés, rien ne désignant le groupe.
Et certains ne comparent que les N premiers caractères du mot de passe,
que le profil déclare désormais sans jamais rien tronquer.
Une passerelle qui exige un navigateur intégré arrête openconnect sur
« No SSO handler », les distributions le bâtissant sans webview. Un greffon
fait l'étape web et rend un cookie ; le pilote monte lui-même, et le profil
garde son interface, son état et son diagnostic.
Vérifié : 249 tests unitaires, un tunnel monté contre une passerelle SAML.
--- EN ---
Two mechanisms designate a service on one concentrator: a URL path, and a
dropdown value. Confusing them hands over ANOTHER service's login form —
correct credentials refused, with nothing pointing at the group. And some
compare only the first N characters of the password, which the profile now
declares without ever truncating anything.
A gateway demanding an embedded browser stops openconnect on "No SSO
handler", distributions building it without a webview. A helper does the
web step and returns a cookie; the driver mounts the tunnel itself, so the
profile keeps its interface, its state and its diagnosis.
Checked: 249 unit tests, and one tunnel mounted against a SAML gateway.
Assisted-by: Claude Opus 5
2026-09-04 05:52:09 -04:00
|
|
|
def add_secret(self, value):
|
|
|
|
|
"""Masque `value` dans tout ce qui s'affichera DÉSORMAIS.
|
|
|
|
|
|
|
|
|
|
Le masquage est monté une fois pour toutes au démarrage, à partir de
|
|
|
|
|
ce que le coffre a rendu. Mais un secret peut NAÎTRE en cours de
|
|
|
|
|
route : un jeton de session obtenu par une authentification web
|
|
|
|
|
n'existe pas avant qu'elle aboutisse, et il ne doit pas moins être
|
|
|
|
|
masqué que le mot de passe qui l'a produit.
|
|
|
|
|
|
|
|
|
|
Sous huit caractères, on ne masque pas : une valeur courte se
|
|
|
|
|
retrouve par hasard dans un chemin ou un nom d'interface, et on
|
|
|
|
|
masquerait du texte utile en croyant protéger un secret.
|
|
|
|
|
"""
|
|
|
|
|
value = str(value or "")
|
|
|
|
|
if len(value) < 8:
|
|
|
|
|
return
|
|
|
|
|
previous = self.redactor
|
|
|
|
|
self.redactor = lambda text: previous(text).replace(value, MASK)
|
|
|
|
|
|
[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-03 23:42:49 -04:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# 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é.
|
[ADD] vpn openconnect : groupe d'URL, SSO délégué, mot de passe borné
Deux mécanismes désignent un service sur un concentrateur : un chemin
d'URL, une valeur de menu déroulant. Les confondre rend le formulaire d'un
AUTRE service — identifiants justes refusés, rien ne désignant le groupe.
Et certains ne comparent que les N premiers caractères du mot de passe,
que le profil déclare désormais sans jamais rien tronquer.
Une passerelle qui exige un navigateur intégré arrête openconnect sur
« No SSO handler », les distributions le bâtissant sans webview. Un greffon
fait l'étape web et rend un cookie ; le pilote monte lui-même, et le profil
garde son interface, son état et son diagnostic.
Vérifié : 249 tests unitaires, un tunnel monté contre une passerelle SAML.
--- EN ---
Two mechanisms designate a service on one concentrator: a URL path, and a
dropdown value. Confusing them hands over ANOTHER service's login form —
correct credentials refused, with nothing pointing at the group. And some
compare only the first N characters of the password, which the profile now
declares without ever truncating anything.
A gateway demanding an embedded browser stops openconnect on "No SSO
handler", distributions building it without a webview. A helper does the
web step and returns a cookie; the driver mounts the tunnel itself, so the
profile keeps its interface, its state and its diagnosis.
Checked: 249 unit tests, and one tunnel mounted against a SAML gateway.
Assisted-by: Claude Opus 5
2026-09-04 05:52:09 -04:00
|
|
|
|
|
|
|
|
`capture` vaut True (sortie et erreurs lues, donc invisibles),
|
|
|
|
|
False (tout à l'écran, rien de lu), ou « stdout » — la sortie est
|
|
|
|
|
lue, les erreurs restent à l'écran. Ce troisième cas existe pour
|
|
|
|
|
une commande qui RETOURNE un secret sur sa sortie tout en parlant
|
|
|
|
|
sur ses erreurs : capturer les deux ferait attendre l'utilisateur
|
|
|
|
|
en silence devant une authentification qui réclame son geste.
|
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
|
|
|
|
|
|
|
|
Faute de `stdin`, l'entrée est /dev/null et JAMAIS le terminal
|
|
|
|
|
hérité. Une commande qui reçoit un terminal sur son entrée peut
|
|
|
|
|
appeler `tcsetattr` ; hors du groupe de processus d'avant-plan,
|
|
|
|
|
elle reçoit alors SIGTTOU et s'ARRÊTE — état T, que ni SIGINT ni
|
|
|
|
|
SIGTERM ne lèvent, et qui garde les verrous déjà pris. Le cas se
|
|
|
|
|
produit quand la sortie est capturée : sudo alloue un
|
|
|
|
|
pseudo-terminal pour l'entrée pendant que la sortie part dans un
|
|
|
|
|
tuyau, et le groupe d'avant-plan de ce terminal n'est pas celui de
|
|
|
|
|
la commande. Rien ici n'a besoin de lire l'humain : les questions
|
|
|
|
|
passent par `confirm`, dans CE processus, et un secret arrive par
|
|
|
|
|
`stdin`. Une invite de mot de passe sudo n'en souffre pas — sudo
|
|
|
|
|
ouvre /dev/tty, pas son entrée standard.
|
[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-03 23:42:49 -04:00
|
|
|
"""
|
|
|
|
|
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,
|
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
|
|
|
# Rien à fournir : /dev/null, et jamais le terminal hérité
|
|
|
|
|
# (voir la docstring). Quand `input` porte un contenu,
|
|
|
|
|
# subprocess branche lui-même le tuyau et `stdin` doit
|
|
|
|
|
# rester None — les deux ensemble sont refusés.
|
|
|
|
|
stdin=subprocess.DEVNULL if stdin is None else None,
|
[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-03 23:42:49 -04:00
|
|
|
text=True,
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
stdout=subprocess.PIPE if capture else None,
|
[ADD] vpn openconnect : groupe d'URL, SSO délégué, mot de passe borné
Deux mécanismes désignent un service sur un concentrateur : un chemin
d'URL, une valeur de menu déroulant. Les confondre rend le formulaire d'un
AUTRE service — identifiants justes refusés, rien ne désignant le groupe.
Et certains ne comparent que les N premiers caractères du mot de passe,
que le profil déclare désormais sans jamais rien tronquer.
Une passerelle qui exige un navigateur intégré arrête openconnect sur
« No SSO handler », les distributions le bâtissant sans webview. Un greffon
fait l'étape web et rend un cookie ; le pilote monte lui-même, et le profil
garde son interface, son état et son diagnostic.
Vérifié : 249 tests unitaires, un tunnel monté contre une passerelle SAML.
--- EN ---
Two mechanisms designate a service on one concentrator: a URL path, and a
dropdown value. Confusing them hands over ANOTHER service's login form —
correct credentials refused, with nothing pointing at the group. And some
compare only the first N characters of the password, which the profile now
declares without ever truncating anything.
A gateway demanding an embedded browser stops openconnect on "No SSO
handler", distributions building it without a webview. A helper does the
web step and returns a cookie; the driver mounts the tunnel itself, so the
profile keeps its interface, its state and its diagnosis.
Checked: 249 unit tests, and one tunnel mounted against a SAML gateway.
Assisted-by: Claude Opus 5
2026-09-04 05:52:09 -04:00
|
|
|
stderr=(subprocess.STDOUT if capture is True else None),
|
[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-03 23:42:49 -04:00
|
|
|
)
|
|
|
|
|
code, out = proc.returncode, proc.stdout or ""
|
|
|
|
|
except subprocess.TimeoutExpired:
|
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
|
|
|
# Le délai rend la main à l'appelant ; il ne garantit pas que
|
|
|
|
|
# la commande soit morte. `shell=True` met un shell entre nous
|
|
|
|
|
# et le vrai travail, et subprocess ne tue que ce shell — un
|
|
|
|
|
# `sudo apt-get` lancé par lui devient orphelin et continue,
|
|
|
|
|
# verrous compris. D'où le code 124 et un échec ANNONCÉ plutôt
|
|
|
|
|
# qu'un silence : la suite se juge sur un état inconnu.
|
[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-03 23:42:49 -04:00
|
|
|
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()
|