erplibre/script/todo/qemu_privilege.py
Mathieu Benoit 12cef44991 [FIX] qemu menu : sortir le venv du PATH des outils système
Le « bin » du venv est en tête du PATH de chaque commande lancée par le
menu, et il contient un python3. Un outil système écrit en Python et
amorcé par « env python3 » s'y amorce donc, dans un interpréteur où les
modules de la distribution n'existent pas : l'import échoue sur un module
que la machine possède pourtant. Sous sudo le piège était invisible, sudo
réinitialisant le PATH ; le retirer là où il ne servait plus l'a mis au
jour.

Vérifié : 6 tests, rougis par trois mutations. La ligne d'amorçage des
outils visés n'a pas été inspectée : le mécanisme est démontré, pas qu'il
soit la cause sur un hôte donné.

--- EN ---

The venv's « bin » leads the PATH of every command the menu launches, and
it holds a python3. A system tool written in Python and started through
« env python3 » therefore boots on that interpreter, where the
distribution's modules do not exist: the import fails on a module the
machine does have. Under sudo the trap was invisible, sudo resetting the
PATH; removing it where it was no longer needed brought it out.

Checked: 6 tests, turned red by three mutations. The shebang of the tools
concerned was not inspected: the mechanism is demonstrated, not that it
is the cause on any given host.

Assisted-by: Claude Opus 5
2026-09-03 05:00:55 -04:00

167 lines
6 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Faut-il « sudo » pour parler à libvirt ? Une seule réponse pour tout TODO.
Appartenir au groupe libvirt suffit à joindre qemu:///system : préfixer alors
les commandes de « sudo » ne donne aucun droit de plus et réclame un mot de
passe pour rien. La question se tranche en ESSAYANT, jamais en lisant
/etc/group : les groupes d'un processus sont figés à l'ouverture de session,
donc un utilisateur fraîchement ajouté y figure sans que le shell courant en
dispose. L'essai dit ce que le shell peut FAIRE, la table dit ce qui a été
DÉCLARÉ — et c'est l'écart entre les deux qui explique « je suis pourtant
dans le groupe ».
"""
import grp
import os
import shutil
import subprocess
# Réponse du sondage, gardée pour la session : chaque commande du menu la
# demande, et lancer un virsh par entrée de menu se verrait.
_CACHE: bool | None = None
PROBE = ["virsh", "--connect", "qemu:///system", "list", "--name"]
def reset_cache() -> None:
"""Oublie le sondage. À appeler après un changement de droits."""
global _CACHE
_CACHE = None
def libvirt_reachable(force: bool = False) -> bool:
"""qemu:///system répond-il SANS sudo ?
Rend faux quand virsh est absent : il n'y a alors rien à joindre, et le
dire évite de conclure « il faut sudo » sur une machine sans libvirt.
"""
global _CACHE
if _CACHE is not None and not force:
return _CACHE
if shutil.which("virsh") is None:
_CACHE = False
return _CACHE
try:
probe = subprocess.run(
PROBE, capture_output=True, text=True, timeout=15
)
_CACHE = probe.returncode == 0
except (OSError, subprocess.SubprocessError):
_CACHE = False
return _CACHE
def needs_sudo() -> bool:
"""Faut-il préfixer les commandes libvirt de « sudo » ?
Root n'en a jamais besoin. Sans virsh, il n'y a rien à préfixer : rendre
faux laisse la commande échouer sur « command not found » plutôt que sur
une invite de mot de passe qui ne mène nulle part.
"""
if os.geteuid() == 0:
return False
if shutil.which("virsh") is None:
return False
return not libvirt_reachable()
def sudo_prefix() -> str:
"""« sudo » et son espace, ou la chaîne vide. À coller devant virsh."""
return "sudo " if needs_sudo() else ""
# L'URI ne se laisse JAMAIS implicite. Pour un utilisateur non root, libvirt
# choisit « qemu:///session », un hyperviseur séparé où AUCUNE des VM du
# système n'existe : « virsh list --all » y rend une liste vide, sans erreur
# et sans avertissement. Appartenir au groupe libvirt donne le DROIT d'accéder
# à qemu:///system, mais ne change pas l'URI par défaut. Tant que les
# commandes passaient par sudo, l'URI de root masquait l'omission.
LIBVIRT_URI = "qemu:///system"
def virsh_argv(*args: str) -> list:
"""Argv d'un virsh local : sudo si besoin, URI toujours."""
prefixe = ["sudo"] if needs_sudo() else []
return prefixe + ["virsh", "--connect", LIBVIRT_URI, *args]
def virsh_cmd(args: str = "") -> str:
"""Même chose, en chaîne pour un shell. « args » est déjà échappé."""
base = f"{sudo_prefix()}virsh --connect {LIBVIRT_URI}"
return f"{base} {args}" if args else base
def system_path(path: str = "") -> str:
"""PATH débarrassé des répertoires de venv du projet.
TODO tourne DANS son venv, dont le « bin » est en TÊTE du PATH : chaque
commande lancée par le menu le voit en premier, et ce répertoire contient
un « python3 ». Un outil système écrit en Python et amorcé par
« #!/usr/bin/env python3 » y trouve donc l'interpréteur du venv, où les
modules fournis par la distribution — PyGObject, entre autres — n'existent
pas, et l'import échoue sur un module que le système possède pourtant.
Sous sudo le piège ne se voyait pas : sudo réinitialise le PATH par son
« secure_path ». Le retirer là où il n'était pas nécessaire l'a mis au
jour, et l'assainissement doit donc être explicite.
"""
path = path or os.environ.get("PATH", "")
venv = os.environ.get("VIRTUAL_ENV", "")
gardees = []
for entree in path.split(os.pathsep):
if not entree:
continue
# Un répertoire du venv courant, ou de n'importe quel « .venv* » du
# dépôt : les deux mènent au même interpréteur de trop.
if venv and os.path.normpath(entree).startswith(
os.path.normpath(venv) + os.sep
):
continue
if any(
part.startswith(".venv")
for part in os.path.normpath(entree).split(os.sep)
):
continue
gardees.append(entree)
return os.pathsep.join(gardees)
def system_env(env: dict | None = None) -> dict:
"""Environnement où un outil système trouve l'interpréteur système."""
base = dict(env or os.environ)
base["PATH"] = system_path(base.get("PATH", ""))
return base
def group_state(user: str = "") -> tuple[bool, bool]:
"""(déclaré, actif) pour le groupe libvirt.
« déclaré » : le nom figure dans le groupe, d'après la base système.
« actif » : le processus courant PORTE le groupe. Les deux diffèrent tant
que la session n'a pas été rouverte, et c'est le cas qui déroute le plus.
Les deux valent faux quand le groupe n'existe pas — libvirt pas encore
installé.
"""
user = user or _current_user()
try:
entry = grp.getgrnam("libvirt")
except KeyError:
return False, False
declared = user in entry.gr_mem
try:
declared = declared or grp.getgrgid(os.getgid()).gr_name == "libvirt"
except (KeyError, OSError):
pass
return declared, entry.gr_gid in os.getgroups()
def _current_user() -> str:
"""Le nom de l'utilisateur courant, sans lever si l'environnement ment."""
try:
import getpass
return getpass.getuser()
except Exception:
return os.environ.get("USER") or ""