[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 00:26:01 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""Le privilège se sonde, il ne se suppose pas.
|
|
|
|
|
|
|
|
|
|
Appartenir au groupe libvirt suffit à joindre qemu:///system. Préfixer alors
|
|
|
|
|
chaque commande de « sudo » ne donne aucun droit de plus et réclame un mot de
|
|
|
|
|
passe à chaque entrée de menu.
|
|
|
|
|
|
|
|
|
|
Ce que ces tests gardent :
|
|
|
|
|
|
|
|
|
|
- la question se tranche en ESSAYANT ; /etc/group ne dit que ce qui est
|
|
|
|
|
DÉCLARÉ, et les groupes d'un processus sont figés à l'ouverture de session ;
|
|
|
|
|
- root ne demande jamais sudo, et une machine sans virsh non plus — une invite
|
|
|
|
|
de mot de passe pour une commande introuvable ne mène nulle part ;
|
|
|
|
|
- l'avertissement d'avant-installation se tait quand l'accès est déjà là.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import io
|
[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 01:36:39 -04:00
|
|
|
import os
|
[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 00:26:01 -04:00
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
from contextlib import redirect_stdout
|
2026-09-03 00:57:56 -04:00
|
|
|
from pathlib import Path
|
[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 00:26:01 -04:00
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
sys.argv = ["todo.py"]
|
|
|
|
|
from script.todo import qemu_privilege as qp # noqa: E402
|
|
|
|
|
from script.todo.todo import TODO # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Sondage(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
qp.reset_cache()
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
qp.reset_cache()
|
|
|
|
|
|
|
|
|
|
def _sonde(self, rc):
|
|
|
|
|
class Res:
|
|
|
|
|
returncode = rc
|
|
|
|
|
stdout = ""
|
|
|
|
|
stderr = ""
|
|
|
|
|
|
|
|
|
|
return mock.patch.object(qp.subprocess, "run", return_value=Res())
|
|
|
|
|
|
|
|
|
|
def test_reachable_means_no_sudo(self):
|
|
|
|
|
with mock.patch.object(
|
|
|
|
|
qp.shutil, "which", return_value="/usr/bin/virsh"
|
|
|
|
|
), mock.patch.object(qp.os, "geteuid", return_value=1000), self._sonde(
|
|
|
|
|
0
|
|
|
|
|
):
|
|
|
|
|
self.assertFalse(qp.needs_sudo())
|
|
|
|
|
self.assertEqual(qp.sudo_prefix(), "")
|
|
|
|
|
|
|
|
|
|
def test_unreachable_means_sudo(self):
|
|
|
|
|
with mock.patch.object(
|
|
|
|
|
qp.shutil, "which", return_value="/usr/bin/virsh"
|
|
|
|
|
), mock.patch.object(qp.os, "geteuid", return_value=1000), self._sonde(
|
|
|
|
|
1
|
|
|
|
|
):
|
|
|
|
|
self.assertTrue(qp.needs_sudo())
|
|
|
|
|
self.assertEqual(qp.sudo_prefix(), "sudo ")
|
|
|
|
|
|
|
|
|
|
def test_root_never_needs_sudo(self):
|
|
|
|
|
with mock.patch.object(
|
|
|
|
|
qp.shutil, "which", return_value="/usr/bin/virsh"
|
|
|
|
|
), mock.patch.object(qp.os, "geteuid", return_value=0), self._sonde(1):
|
|
|
|
|
self.assertFalse(qp.needs_sudo())
|
|
|
|
|
|
|
|
|
|
def test_without_virsh_no_password_prompt(self):
|
|
|
|
|
"""Demander un mot de passe pour lancer une commande introuvable ne
|
|
|
|
|
mène nulle part : l'échec doit être « command not found »."""
|
|
|
|
|
with mock.patch.object(
|
|
|
|
|
qp.shutil, "which", return_value=None
|
|
|
|
|
), mock.patch.object(qp.os, "geteuid", return_value=1000):
|
|
|
|
|
self.assertFalse(qp.needs_sudo())
|
|
|
|
|
|
|
|
|
|
def test_the_probe_runs_once(self):
|
|
|
|
|
"""Chaque entrée de menu la demande : un virsh par commande se
|
|
|
|
|
verrait."""
|
|
|
|
|
with mock.patch.object(
|
|
|
|
|
qp.shutil, "which", return_value="/usr/bin/virsh"
|
|
|
|
|
), mock.patch.object(qp.os, "geteuid", return_value=1000):
|
|
|
|
|
with self._sonde(0) as run:
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
qp.needs_sudo()
|
|
|
|
|
self.assertEqual(run.call_count, 1)
|
|
|
|
|
|
|
|
|
|
def test_a_dead_probe_falls_back_on_sudo(self):
|
|
|
|
|
"""Un virsh qui n'arrive pas au bout ne prouve pas l'accès : mieux
|
|
|
|
|
vaut une invite de mot de passe qu'une commande refusée."""
|
|
|
|
|
with mock.patch.object(
|
|
|
|
|
qp.shutil, "which", return_value="/usr/bin/virsh"
|
|
|
|
|
), mock.patch.object(
|
|
|
|
|
qp.os, "geteuid", return_value=1000
|
|
|
|
|
), mock.patch.object(
|
|
|
|
|
qp.subprocess, "run", side_effect=OSError("boom")
|
|
|
|
|
):
|
|
|
|
|
self.assertTrue(qp.needs_sudo())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AvertissementAvantInstallation(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
qp.reset_cache()
|
|
|
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
qp.reset_cache()
|
|
|
|
|
|
|
|
|
|
def _rendu(self, joignable, declare, actif):
|
|
|
|
|
with mock.patch.object(
|
|
|
|
|
qp, "libvirt_reachable", return_value=joignable
|
|
|
|
|
), mock.patch.object(qp, "group_state", return_value=(declare, actif)):
|
|
|
|
|
buf = io.StringIO()
|
|
|
|
|
with redirect_stdout(buf):
|
|
|
|
|
self.todo._qemu_warn_libvirt_access()
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
def test_it_stays_quiet_when_access_is_there(self):
|
|
|
|
|
self.assertEqual("", self._rendu(True, True, True))
|
|
|
|
|
|
|
|
|
|
def test_it_names_usermod_when_the_group_is_missing(self):
|
|
|
|
|
rendu = self._rendu(False, False, False)
|
|
|
|
|
self.assertIn("usermod -aG libvirt", rendu)
|
|
|
|
|
|
|
|
|
|
def test_a_declared_but_inactive_group_asks_for_a_new_session(self):
|
|
|
|
|
"""Refaire un usermod déjà fait ne changerait rien : ce qui manque est
|
|
|
|
|
une session, pas une ligne dans /etc/group."""
|
|
|
|
|
rendu = self._rendu(False, True, False)
|
|
|
|
|
self.assertIn("newgrp libvirt", rendu)
|
|
|
|
|
self.assertNotIn("usermod", rendu)
|
|
|
|
|
|
|
|
|
|
def test_an_active_group_says_nothing_even_if_virsh_fails(self):
|
|
|
|
|
"""Le groupe est porté par le processus : la cause est ailleurs
|
|
|
|
|
(démon arrêté, socket absente) et l'accuser tromperait."""
|
|
|
|
|
self.assertEqual("", self._rendu(False, True, True))
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 00:57:56 -04:00
|
|
|
class LUriEstToujoursExplicite(unittest.TestCase):
|
|
|
|
|
"""Sans « --connect », un virsh non root vise qemu:///session.
|
|
|
|
|
|
|
|
|
|
Cet hyperviseur-là est SÉPARÉ : aucune VM du système n'y existe, et
|
|
|
|
|
« list --all » y rend une liste vide, sans erreur ni avertissement. Tant
|
|
|
|
|
que les commandes passaient par sudo, l'URI de root masquait l'omission ;
|
|
|
|
|
la retirer l'a mise au jour.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_the_builder_always_names_the_uri(self):
|
|
|
|
|
for besoin in (True, False):
|
|
|
|
|
with mock.patch.object(qp, "needs_sudo", return_value=besoin):
|
|
|
|
|
argv = qp.virsh_argv("list", "--all")
|
|
|
|
|
self.assertIn("--connect", argv)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
qp.LIBVIRT_URI, argv[argv.index("--connect") + 1]
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(f"--connect {qp.LIBVIRT_URI}", qp.virsh_cmd("x"))
|
|
|
|
|
|
|
|
|
|
def test_sudo_only_when_the_probe_asks_for_it(self):
|
|
|
|
|
with mock.patch.object(qp, "needs_sudo", return_value=False):
|
|
|
|
|
self.assertNotIn("sudo", qp.virsh_argv("list"))
|
|
|
|
|
with mock.patch.object(qp, "needs_sudo", return_value=True):
|
|
|
|
|
self.assertEqual("sudo", qp.virsh_argv("list")[0])
|
|
|
|
|
|
|
|
|
|
def test_no_menu_call_builds_virsh_by_hand(self):
|
|
|
|
|
"""Un virsh écrit à la main échapperait au constructeur, donc à
|
|
|
|
|
l'URI : c'est exactement ce qui vidait la liste des VM."""
|
|
|
|
|
for chemin in (
|
|
|
|
|
"script/todo/qemu_manage.py",
|
|
|
|
|
"script/todo/qemu_install_monitor.py",
|
|
|
|
|
):
|
|
|
|
|
source = Path(chemin).read_text(encoding="utf-8")
|
|
|
|
|
for num, ligne in enumerate(source.splitlines(), 1):
|
|
|
|
|
if '"virsh"' not in ligne and "}virsh " not in ligne:
|
|
|
|
|
continue
|
|
|
|
|
voisin = "\n".join(
|
|
|
|
|
source.splitlines()[max(0, num - 4) : num + 4]
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"--connect",
|
|
|
|
|
voisin,
|
|
|
|
|
f"{chemin}:{num} appelle virsh sans URI",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
[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 01:36:39 -04:00
|
|
|
class LePathDesOutilsSysteme(unittest.TestCase):
|
|
|
|
|
"""TODO tourne dans son venv, dont le « bin » est en tête du PATH.
|
|
|
|
|
|
|
|
|
|
Ce répertoire contient un « python3 ». Un outil de la distribution amorcé
|
|
|
|
|
par « #!/usr/bin/env python3 » y trouve donc l'interpréteur du venv, où
|
|
|
|
|
les modules du système n'existent pas — l'import échoue sur un module que
|
|
|
|
|
la machine possède pourtant. Sous sudo le piège était invisible : sudo
|
|
|
|
|
réinitialise le PATH.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_the_project_venvs_are_dropped(self):
|
|
|
|
|
chemin = os.pathsep.join(
|
|
|
|
|
[
|
|
|
|
|
"/home/x/git/erplibre/.venv.erplibre/bin",
|
|
|
|
|
"/home/x/git/erplibre/.venv.odoo18.0_python3.12.10/bin",
|
|
|
|
|
"/usr/local/bin",
|
|
|
|
|
"/usr/bin",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
|
|
|
|
|
propre = qp.system_path(chemin)
|
|
|
|
|
self.assertNotIn(".venv", propre)
|
|
|
|
|
# L'ordre du reste ne bouge pas : il décide quel outil gagne.
|
|
|
|
|
self.assertEqual(["/usr/local/bin", "/usr/bin"], propre.split(":"))
|
|
|
|
|
|
|
|
|
|
def test_the_active_venv_is_dropped_even_without_the_name(self):
|
|
|
|
|
"""Un venv hors du dépôt ne porte pas « .venv » : VIRTUAL_ENV le
|
|
|
|
|
désigne, et c'est ce nom-là qui tranche."""
|
|
|
|
|
with mock.patch.dict(
|
|
|
|
|
qp.os.environ, {"VIRTUAL_ENV": "/opt/env"}, clear=False
|
|
|
|
|
):
|
|
|
|
|
propre = qp.system_path("/opt/env/bin:/usr/bin")
|
|
|
|
|
self.assertEqual("/usr/bin", propre)
|
|
|
|
|
|
|
|
|
|
def test_a_path_without_any_venv_is_untouched(self):
|
|
|
|
|
with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"/usr/local/bin:/usr/bin",
|
|
|
|
|
qp.system_path("/usr/local/bin:/usr/bin"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_empty_entries_do_not_become_the_current_directory(self):
|
|
|
|
|
"""Une entrée vide dans PATH signifie « le répertoire courant » : la
|
|
|
|
|
recopier ferait chercher un outil système là où on se trouve."""
|
|
|
|
|
with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
|
|
|
|
|
self.assertEqual("/usr/bin", qp.system_path("/usr/bin::"))
|
|
|
|
|
|
|
|
|
|
def test_the_env_carries_the_cleaned_path(self):
|
|
|
|
|
with mock.patch.dict(
|
|
|
|
|
qp.os.environ,
|
|
|
|
|
{"VIRTUAL_ENV": "/opt/env", "PATH": "/opt/env/bin:/usr/bin"},
|
|
|
|
|
clear=False,
|
|
|
|
|
):
|
|
|
|
|
env = qp.system_env()
|
|
|
|
|
self.assertEqual("/usr/bin", env["PATH"])
|
|
|
|
|
|
|
|
|
|
def test_the_hardware_plan_uses_it(self):
|
|
|
|
|
"""virt-xml est un script Python du système : sans PATH assaini, il
|
|
|
|
|
s'amorce sur l'interpréteur du venv."""
|
|
|
|
|
source = Path("script/todo/qemu_manage.py").read_text(encoding="utf-8")
|
|
|
|
|
debut = source.index("def _qemu_adjust_hardware")
|
|
|
|
|
corps = source[debut : source.index("\n def ", debut + 10)]
|
|
|
|
|
self.assertIn("system_path()", corps)
|
|
|
|
|
|
|
|
|
|
|
[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 00:26:01 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|