erplibre/script/todo/qemu_privilege.py
Mathieu Benoit feb6cd7b12 [FIX] qemu menu : passer par le groupe libvirt plutôt que par sudo
Appartenir au groupe libvirt suffit à joindre qemu:///system : le sudo
écrit en dur n'y ajoutait aucun droit et réclamait un mot de passe à
chaque entrée de menu. La question se tranche en ESSAYANT, jamais en
lisant /etc/group : les groupes d'un processus sont figés à l'ouverture
de session, donc la table dit le déclaré, l'essai le faisable. C'est la
distinction que porte aussi l'avertissement d'avant-installation. Un
hyperviseur distant garde sudo, ses droits ne se sondant pas d'ici.

Vérifié : 10 tests, rougis par deux mutations — lire /etc/group, et
conclure « pas de sudo » sur un sondage mort.

--- EN ---

Membership of the libvirt group is enough to reach qemu:///system: the
hardcoded sudo added no right there and asked for a password at every
menu entry. The question is settled by TRYING, never by reading
/etc/group: a process's groups are frozen at session start, so the table
states what is declared, the attempt what is doable. The pre-install
warning carries that same distinction. A remote hypervisor keeps sudo,
its rights not being probeable from here.

Checked: 10 tests, turned red by two mutations — reading /etc/group, and
concluding « no sudo » from a dead probe.

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

104 lines
3.4 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 ""
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 ""