erplibre/script/todo/vpn_menu.py
Mathieu Benoit 9250d4d401 [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 06:26:34 -04:00

677 lines
27 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le menu VPN : profils, secrets, montée, diagnostic.
La frontière avec `script/vpn/` est nette : ici on DEMANDE (quel profil,
quelle adresse, quel PSK) et on affiche ; là-bas on décide et on exécute. Ce
fichier ne connaît ni ipsec.conf, ni xl2tpd, ni aucun chemin système.
Deux chemins d'exécution, pour une raison :
· les profils et les secrets sont manipulés EN PROCESSUS, par les modules
`script.vpn.profiles` et `script.vpn.vault` — le mot de passe maître du
coffre est déjà en mémoire ici, le redemander à un sous-processus serait
une saisie de plus à chaque geste ;
· le montage, la descente et le diagnostic passent par `script/vpn/vpn.py`
en sous-processus — ils durent, ils parlent, et ils appellent sudo. La
sortie en direct est ce qui rend un « ipsec up » suivable.
"""
import getpass
import os
import click
from script.todo.todo_i18n import t
from script.vpn import anyconnect_xml, presets, profiles
from script.vpn.drivers import DRIVERS, get_driver
from script.vpn.vault import VaultError, VpnVault, secrets_to_env
# `-u` : sans lui, la sortie du script est mise en tampon par blocs dès
# qu'elle est redirigée, et une montée de tunnel de trente secondes
# n'afficherait rien avant la fin.
VPN_CLI = "./.venv.erplibre/bin/python -u ./script/vpn/vpn.py"
# Nommé pour que la clé i18n tienne sur une ligne lisible — c'est la même
# chaîne que celle du CLI, et elle est longue parce qu'elle doit dire quoi
# faire, pas seulement que quelque chose ne va pas.
# Ce qu'on dit à qui n'a reçu du site qu'une passerelle et des
# identifiants : le profil est utilisable, et le premier montage dira
# lui-même quel réseau ajouter.
NO_ROUTE_NOTE = (
"No network routed yet: this tunnel will only reach the remote host."
" Connect once — the address you get tells you which network to add."
)
# La légende de l'étoile posée sur les technologies non éprouvées. Elle dit
# ce qui manque — la confrontation au terrain — et non que le code serait
# douteux : les tests unitaires, eux, sont là.
UNPROVEN_NOTE = "never mounted against a real server: only unit tests cover it"
# Où poser un préréglage quand il n'y en a aucun. Dit les DEUX répertoires,
# parce qu'ils ne servent pas au même usage : l'un est suivi par git et ne
# doit rien porter d'identifiant, l'autre est ignoré et existe pour ça.
PRESET_LOCATION_NOTE = (
"Drop a .json file in conf/vpn_presets/ (shared, nothing identifying)"
" or in private/vpn/presets/ (git-ignored, where a site preset goes)."
)
# Ce qu'on dit quand le nom tapé désigne un profil déjà là. Dit LEQUEL des
# deux gagne, champ par champ : sans cela, on ne sait pas si rejouer un
# préréglage remet la passerelle à jour ou efface les routes ajoutées.
PRESET_REPLAYED_NOTE = (
"This profile already exists: the preset refreshes what it declares,"
" everything personal is kept."
)
# Où le client de Cisco dépose les profils qu'un site distribue. Le dire
# évite d'avoir à le chercher, et c'est le seul endroit où il se trouve
# quand le client graphique a déjà servi sur la machine.
ANYCONNECT_LOCATION_NOTE = (
"An AnyConnect profile usually sits in"
" /opt/cisco/secureclient/vpn/profile/ (or .../anyconnect/profile/)."
)
# Ce que le fichier ne dit PAS, et qu'il reste donc à régler. Le profil
# AnyConnect ne déclare pas la méthode d'authentification : c'est le
# concentrateur qui l'annonce à la connexion.
ANYCONNECT_NEXT_STEP = (
"Next step: create a profile from one of them. The .xml carries no"
" username and does not say whether the service authenticates by"
" password or by web form."
)
MASTER_PASSWORD_WARNING = (
"The vault MASTER password is stored in the configuration in clear"
" text. Remove it and type it on demand."
)
# Les technologies se choisissent par LETTRE. Le menu qui précède numérote
# ses entrées ; une seconde liste numérotée juste après invite à retaper un
# numéro de menu — et c'est exactement ce qui s'est produit. La lettre dit
# « autre question ».
DRIVER_LETTERS = "abcdefghijklmnopqrstuvwxyz"
def match_driver(answer, names):
"""Le pilote désigné par `answer`.
Rend le nom du pilote, "" si rien ne correspond, ou la LISTE des
candidats quand c'est ambigu — le dire vaut mieux qu'en choisir un.
Trois formes, dans cet ordre : la lettre affichée ; le rang, parce que
quelqu'un tapera un chiffre et qu'il a raison de le faire vu le menu qui
précède ; et un début de libellé, parce que devant « L2TP/IPsec PSK » on
tape « L ». « open » désigne deux pilotes : celui-là est refusé en le
nommant.
"""
answer = (answer or "").strip().lower()
if not answer:
return ""
if len(answer) == 1 and answer in DRIVER_LETTERS:
index = DRIVER_LETTERS.index(answer)
if index < len(names):
return names[index]
if answer.isdigit():
index = int(answer) - 1
return names[index] if 0 <= index < len(names) else ""
matches = [
name
for name in names
if DRIVERS[name].label.lower().startswith(answer)
or name.startswith(answer)
]
if len(matches) == 1:
return matches[0]
return matches or ""
class VpnMenuMixin:
# ------------------------------------------------------------------
# Menu
# ------------------------------------------------------------------
def prompt_execute_vpn(self):
print(f"🔐 {t('VPN tunnels: connect, profiles, vault secrets')}")
choices = [
{"section": t("Connection")},
{"prompt_description": t("VPN - Connect a profile")},
{"prompt_description": t("VPN - Disconnect a profile")},
{"prompt_description": t("VPN - Status and diagnosis")},
{"section": t("Profiles & secrets")},
{
"prompt_description": t(
"VPN - Create a profile from a site preset"
)
},
{
"prompt_description": t(
"VPN - Import an AnyConnect profile (.xml)"
)
},
{"prompt_description": t("VPN - Add or edit a profile")},
{"prompt_description": t("VPN - Store secrets in the vault")},
{
"prompt_description": t(
"VPN - Show the rendered configuration (dry-run)"
)
},
{"prompt_description": t("VPN - Delete a profile")},
{"section": t("Host")},
{"prompt_description": t("VPN - Install the client packages")},
{"prompt_description": t("VPN - What can this machine do?")},
]
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
elif status == "1":
self._vpn_connect()
elif status == "2":
self._vpn_disconnect()
elif status == "3":
self._vpn_diagnose()
elif status == "4":
self._vpn_from_preset()
elif status == "5":
self._vpn_import_anyconnect()
elif status == "6":
self._vpn_edit_profile()
elif status == "7":
self._vpn_store_secrets()
elif status == "8":
self._vpn_show_config()
elif status == "9":
self._vpn_delete_profile()
elif status == "10":
self._vpn_install()
elif status == "11":
self._vpn_check()
else:
print(t("Command not found !"))
# ------------------------------------------------------------------
# Actions déléguées au CLI
# ------------------------------------------------------------------
def _vpn_cli(self, arguments, secrets_env=None):
self.execute.exec_command_live(
f"{VPN_CLI} {arguments}",
source_erplibre=False,
new_env=secrets_env or None,
)
def _vpn_secrets_env(self, name):
"""Secrets du profil, prêts pour l'environnement du sous-processus.
Le coffre est déjà ouvert ici : le faire rouvrir par `vpn.py` ferait
retaper le mot de passe maître deux fois par connexion, puisqu'un
essai à blanc précède le montage. Rend {} quand le coffre n'est pas
joignable — `vpn.py` demandera alors lui-même, et le dira.
"""
profile = profiles.load(name)
driver_cls = get_driver(profile["driver"]) if profile else None
if driver_cls is None or not driver_cls.secret_fields:
return {}
fields = tuple(key for key, _, _ in driver_cls.secret_fields)
vault = VpnVault(self.config_file, self.kdbx_manager)
if not vault.vault_path():
return {}
try:
values = vault.read(profiles.secret_title(name), fields=fields)
except VaultError as error:
print(f"! {error}")
return {}
return secrets_to_env({key: values.get(key, "") for key in fields})
def _vpn_connect(self):
name = self._vpn_select_profile()
if not name:
return
# Lu UNE fois pour les deux exécutions qui suivent.
secrets_env = self._vpn_secrets_env(name)
# Le plan d'abord, l'exécution ensuite : monter un tunnel réécrit
# /etc/ipsec.conf et la table de routage. Le voir avant coûte une
# touche et évite de découvrir une faute de frappe dans un journal.
self._vpn_cli(f"up --profile {name} --dry-run", secrets_env)
if not self._is_yes(input(f"\n{t('Run this plan? (y/N): ')}")):
return
self._vpn_cli(f"up --profile {name}", secrets_env)
def _vpn_disconnect(self):
name = self._vpn_select_profile()
if name:
self._vpn_cli(f"down --profile {name}")
def _vpn_diagnose(self):
name = self._vpn_select_profile()
if name:
self._vpn_cli(f"diagnose --profile {name}")
def _vpn_show_config(self):
name = self._vpn_select_profile()
if name:
self._vpn_cli(
f"up --profile {name} --dry-run",
self._vpn_secrets_env(name),
)
def _vpn_check(self):
self._vpn_cli("check")
def _vpn_install(self):
driver_cls = self._vpn_pick_driver(None)
if driver_cls is None:
return
print(f"\n{t('The installation requires sudo.')}")
self._vpn_cli(f"install --driver {driver_cls.name}")
# ------------------------------------------------------------------
# Profils
# ------------------------------------------------------------------
def _vpn_select_profile(self):
"""Nom du profil choisi, "" si l'utilisateur renonce."""
all_profiles = [profiles.with_defaults(p) for p in profiles.load_all()]
if not all_profiles:
print(t("No VPN profile yet: create one first."))
return ""
for index, profile in enumerate(all_profiles, start=1):
target = (
t("all traffic")
if profile["default_route"]
else ", ".join(profile["routes"])
)
print(
f"[{index}] {profile['name']:<20}"
f" {profile['server']:<26} {target}"
)
answer = input(f"{t('Profile number (0 to go back)')} : ").strip()
if not answer.isdigit() or not 1 <= int(answer) <= len(all_profiles):
if answer not in ("0", ""):
print(t("Unknown choice."))
return ""
return all_profiles[int(answer) - 1]["name"]
def _vpn_from_preset(self):
"""Crée un profil à partir d'un préréglage de site.
Le préréglage porte ce que l'établissement publie et qui est le même
pour tout le monde ; il ne reste à taper que l'identifiant. Le
formulaire est celui de `_vpn_edit_profile`, amorcé : dupliquer les
questions ici ferait vivre deux formulaires qui divergeraient au
prochain champ ajouté à un pilote.
"""
found, errors = presets.load_all()
for error in errors:
print(f"! {t('Unreadable preset: ')}{error}")
if not found:
print(t("No site preset available."))
print(f" {t(PRESET_LOCATION_NOTE)}")
return
for index, preset in enumerate(found, start=1):
print(
f"[{index}] {presets.label(preset):<34}"
f" {preset.get('server', ''):<28}"
f" {t(preset.get('hint', '') or '')}"
)
answer = input(f"{t('Preset number (0 to go back)')} : ").strip()
if not answer.isdigit() or not 1 <= int(answer) <= len(found):
if answer not in ("0", ""):
print(t("Unknown choice."))
return
preset = found[int(answer) - 1]
print(f"\n{t('An empty answer keeps the preset value.')}")
name = input(
f"{t('Profile name (lowercase, digits, - or _)')} : "
).strip()
if not name:
return
existing = profiles.load(name)
seed = presets.apply(preset, name)
if existing:
print(f"! {t(PRESET_REPLAYED_NOTE)}")
# Le PRÉRÉGLAGE gagne sur les champs qu'il déclare : rejouer un
# préréglage sur un profil existant sert à le remettre à jour
# après un déménagement de passerelle ou un groupe renommé, et
# garder l'ancienne valeur ne ferait rien de ce qu'on demande.
#
# Le reste vient du profil, parce que c'est ce qui est PERSONNEL
# et qu'aucun préréglage ne porte : l'identifiant, les routes
# ajoutées à la main, le certificat épinglé, l'adresse témoin.
#
# `k in seed` borne la reprise aux champs que le pilote du
# préréglage connaît : sur un profil qui change de technologie,
# recopier tout ferait suivre une clé WireGuard dans un profil
# OpenConnect, où rien ne la lirait jamais.
declared = set(preset) - set(presets.META_KEYS)
seed.update(
{
key: value
for key, value in existing.items()
if key in seed and key not in declared
}
)
self._vpn_edit_profile(seed=seed)
def _vpn_import_anyconnect(self):
"""Transforme un profil AnyConnect (`.xml`) en préréglages.
Le fichier qu'un site distribue porte déjà le nom d'hôte et le
groupe de connexion, et c'est ce dernier qui décide quel service du
concentrateur on joint. Le retaper à la main est l'occasion de se
tromper sur le seul champ qui compte.
Écrit dans `private/vpn/presets/`, jamais dans `conf/` : le fichier
nomme un établissement.
"""
print(t(ANYCONNECT_LOCATION_NOTE))
path = input(f"{t('Path to the .xml profile')} : ").strip()
if not path:
return
try:
found = anyconnect_xml.parse_file(os.path.expanduser(path))
except anyconnect_xml.ProfileXmlError as error:
print(f"\n✗ {error}")
return
print()
for preset in found:
print(f" {presets.label(preset)}")
print(f" {t('Gateway')} : {preset['server']}")
print(
f" {t('Connection group')} :"
f" {preset['oc_usergroup'] or t('none')}"
)
stem = os.path.splitext(os.path.basename(path))[0]
stem = presets.slug_stem(stem)
written = presets.save(found, stem)
print(f"\n✓ {t('Presets written: ')}{written}")
print(f" {t(ANYCONNECT_NEXT_STEP)}")
def _vpn_edit_profile(self, seed=None):
"""Crée ou modifie un profil, quelle que soit la technologie.
Les questions viennent du PILOTE (`form_fields`) : ce menu ne sait
pas qu'un profil L2TP a un utilisateur PPP ni qu'un profil WireGuard
a une clé de pair. Ajouter une technologie n'ajoute donc pas une
ligne ici.
Une réponse vide garde la valeur actuelle : modifier une seule route
ne doit pas obliger à ressaisir tout le reste.
`seed` amorce le formulaire avec un profil déjà rempli — un
préréglage de site. Il porte alors le nom ET la technologie, donc les
deux questions correspondantes ne sont pas posées : le préréglage y a
déjà répondu, et redemander « quelle technologie ? » invite à
contredire le seul champ qu'on ne doit pas changer.
"""
if seed is not None:
current = dict(seed)
name = current["name"]
driver_cls = get_driver(current.get("driver"))
if driver_cls is None:
print(f"✗ {t('Unknown driver: ')}{current.get('driver')}")
return
else:
name = input(
f"{t('Profile name (lowercase, digits, - or _)')} : "
).strip()
if not name:
return
current = profiles.load(name) or {"name": name}
driver_cls = self._vpn_pick_driver(current.get("driver"))
if driver_cls is None:
return
# Les défauts DU PILOTE CHOISI, pour que chaque question ait un
# défaut sensé même sur un profil qui change de technologie.
draft = profiles.with_defaults(dict(current, driver=driver_cls.name))
draft["server"] = self._vpn_ask(
t(driver_cls.server_label), draft.get("server", "")
)
# L'identité d'abord, le routage ensuite : c'est l'ordre du document
# que le site remet — passerelle, utilisateur, mot de passe, clé —
# et le routage est une question à part, à laquelle ce document ne
# répond souvent pas.
self._vpn_ask_fields(draft, driver_cls, advanced=False)
draft["routes"] = self._vpn_ask(
t("Networks to reach, comma-separated"),
", ".join(draft.get("routes", [])),
)
draft["default_route"] = self._vpn_ask_flag(
t("Send ALL traffic through the tunnel?"),
draft.get("default_route", False),
)
draft["probe"] = self._vpn_ask(
t("Witness address reachable only through the tunnel (optional)"),
draft.get("probe", ""),
)
if self._is_yes(input(f"{t('Advanced settings? (y/N)')} : ")):
if driver_cls.uses_mtu:
draft["mtu"] = self._vpn_ask(
t("MTU"), str(draft.get("mtu", 1280))
)
self._vpn_ask_fields(draft, driver_cls, advanced=True)
try:
saved = profiles.save(draft)
except profiles.ProfileError as error:
print(f"\n✗ {t('Profile refused: ')}{error}")
return
print(f"\n✓ {t('Profile saved: ')}{saved['name']}")
if not saved["routes"] and not saved["default_route"]:
print(f" {t(NO_ROUTE_NOTE)}")
if driver_cls.secret_fields:
print(f" {t('Next step: store its secrets in the vault.')}")
else:
print(
f" {t('No secret to store: this one authenticates over SSH.')}"
)
def _vpn_ask_fields(self, draft, driver_cls, advanced):
"""Déroule les champs déclarés par le pilote."""
for key, label, kind, is_advanced in driver_cls.form_fields:
if bool(is_advanced) != advanced:
continue
if kind == "flag":
draft[key] = self._vpn_ask_flag(
t(label), draft.get(key, False)
)
else:
draft[key] = self._vpn_ask(
t(label), str(draft.get(key, "") or "")
)
@staticmethod
def _vpn_pick_driver(current):
"""La technologie, par lettre, avec un conseil par ligne.
C'est la seule décision du formulaire où l'utilisateur a besoin
d'aide : le reste se déduit de ce que le site lui a donné.
Une étoile marque les technologies qu'aucun serveur réel n'a
encore validées, et une légende dit ce qu'elle signifie : la liste
montre autrement cinq choix d'apparence égale.
`[0] Retour` est là comme dans tous les menus de ce CLI : sans lui,
on est coincé dans le formulaire dès qu'on a tapé un nom de profil.
"""
names = list(DRIVERS)
if len(names) == 1:
return DRIVERS[names[0]]
default = current if current in DRIVERS else names[0]
print(f"\n{t('Which technology?')}")
unproven = False
for letter, name in zip(DRIVER_LETTERS, names):
driver_cls = DRIVERS[name]
mark = " ←" if name == default else ""
# L'étoile occupe une colonne à elle : sans cela, les lignes
# marquées décaleraient leur conseil et la liste se lirait mal.
star = " " if driver_cls.proven else "*"
unproven = unproven or not driver_cls.proven
print(
f"[{letter}] {driver_cls.label:<16}{star}"
f" {t(driver_cls.hint)}{mark}"
)
if unproven:
print(f" * {t(UNPROVEN_NOTE)}")
print(f"[0] {t('Back')}")
default_letter = DRIVER_LETTERS[names.index(default)]
answer = input(
f"{t('Choice')} [{default_letter} = {DRIVERS[default].label}] : "
).strip()
if not answer:
return DRIVERS[default]
if answer == "0":
return None
chosen = match_driver(answer, names)
if isinstance(chosen, list):
labels = ", ".join(DRIVERS[name].label for name in chosen)
print(f"{t('Several technologies match: ')}{labels}")
return None
if not chosen:
print(t("Unknown choice."))
return None
return DRIVERS[chosen]
def _vpn_delete_profile(self):
name = self._vpn_select_profile()
if not name:
return
if not self._is_yes(
input(f"{t('Delete profile')} « {name} » ? (y/N) : ")
):
return
if profiles.delete(name):
print(f"✓ {t('Profile deleted.')}")
print(f" {t('Its vault entry is kept: delete it in KeePassXC.')}")
else:
message = t(
"Not deletable here: this profile comes from a shared"
" configuration file."
)
print(f"✗ {message}")
# ------------------------------------------------------------------
# Secrets
# ------------------------------------------------------------------
def _vpn_store_secrets(self):
name = self._vpn_select_profile()
if not name:
return
profile = profiles.load(name)
driver_cls = get_driver(profile["driver"])
if driver_cls is None:
print(f"✗ {t('Unknown driver: ')}{profile['driver']}")
return
if not driver_cls.secret_fields:
print(
f"{t('No secret to store: this one authenticates over SSH.')}"
)
return
vault = VpnVault(self.config_file, self.kdbx_manager)
try:
path = vault.ensure_vault(ask=input)
except VaultError as error:
print(f"✗ {error}")
return
if not path:
print(t("No vault: nothing stored."))
return
if vault.master_password_is_stored():
print(f"\n! {t(MASTER_PASSWORD_WARNING)}")
title = profiles.secret_title(name)
fields = tuple(key for key, _, _ in driver_cls.secret_fields)
# Lu AVANT les invites, pour deux raisons : le mot de passe maître
# est alors demandé avant qu'on tape des secrets, et non après ; et
# chaque invite peut dire s'il y a déjà quelque chose derrière.
# « Une réponse vide garde la valeur en place » est un piège quand
# il n'y a rien en place.
try:
existing = vault.read(title, fields=fields)
except VaultError as error:
print(f"✗ {error}")
return
print(f"\n{t('Vault entry')} : {title}")
print(f"{t('An empty answer keeps the stored value.')}")
# Les contraintes du pilote AVANT la première invite : une borne de
# longueur annoncée après coup coûte une deuxième saisie.
for note in driver_cls(profile).secret_notes():
print(f"! {note}")
print()
values = {}
if driver_cls.user_field:
# Recopié pour que le coffre reste LISIBLE dans KeePassXC ; le
# profil reste la source de vérité de l'identifiant.
values["username"] = profile.get(driver_cls.user_field, "")
for key, label, _required in driver_cls.secret_fields:
state = t("already set") if existing.get(key) else t("empty")
secret = self._vpn_ask_secret(f"{t(label)} [{state}]")
if secret is None:
return
if secret:
values[key] = secret
try:
vault.write(title, values)
except VaultError as error:
print(f"✗ {error}")
return
print(f"\n✓ {t('Secrets stored in the vault.')}")
# Ce qui reste vide et qui est OBLIGATOIRE : le dire ici, pas au
# premier montage raté.
absents = [
t(label)
for key, label, required in driver_cls.secret_fields
if required and not (values.get(key) or existing.get(key))
]
if absents:
print(
f"✗ {t('Still missing, the tunnel will not come up: ')}"
f"{', '.join(absents)}"
)
@staticmethod
def _vpn_ask_secret(label):
"""Un secret, saisi deux fois, jamais affiché.
Deux fois parce qu'une faute de frappe dans un PSK ne se voit pas :
elle ressort en « no matching proposal » côté IKE, trois étages plus
loin, et fait chercher au mauvais endroit pendant une heure.
Rend "" pour « garder la valeur en place », None pour renoncer.
"""
first = getpass.getpass(f"{label} : ")
if not first:
return ""
if first != getpass.getpass(f"{t('Confirm')} : "):
print(f"✗ {t('The two entries differ, nothing stored.')}")
return None
return first
# ------------------------------------------------------------------
@staticmethod
def _vpn_ask_flag(label, current):
"""Question oui/non dont le défaut est la valeur ACTUELLE.
Une réponse vide garde ce qui est en place : rééditer un profil pour
changer une route ne doit pas remettre le mode de routage à zéro.
"""
answer = input(f"{label} [{'O/n' if current else 'o/N'}] : ")
answer = answer.strip().lower()
if not answer:
return bool(current)
return answer in ("y", "yes", "o", "oui")
@staticmethod
def _vpn_ask(label, default):
"""Question à réponse par défaut. Vide = on garde `default`."""
shown = f" [{default}]" if default else ""
answer = input(f"{label}{shown} : ").strip()
return answer or default