[ADD] qemu réseau : voir et recréer le sous-réseau des VM
Une VM prend son adresse dans le /24 de son réseau et en sort par le .1, porté par le pont : déplacer ce /24 sous une VM allumée lui laisse un bail qui ne mène nulle part, et abattre le réseau détache son tap, que libvirt n'y remet pas. network_qemu.py tient les trois gestes dans l'ordre — arrêter les VM attachées, redéfinir le réseau sur le préfixe voulu, rallumer celles qu'il a arrêtées, elles seules. Une VM sourde au shutdown annule la redéfinition plutôt que d'y perdre son pont ; --force-off coupe le courant. Un préfixe qui recouvre ce que l'hôte route déjà est refusé. Vérifié : 13 tests neufs, la numérotation du menu, une passe à blanc. --- EN --- A VM takes its address in its network's /24 and leaves through the .1, carried by the bridge: moving that /24 under a live VM leaves it a lease leading nowhere, and tearing the network down detaches its tap, which libvirt does not put back. network_qemu.py holds the three steps in order — shut down the attached VMs, redefine the network on the wanted prefix, start again the ones it stopped, those only. A VM deaf to the shutdown cancels the redefinition rather than losing its bridge; --force-off cuts the power. A prefix overlapping what the host already routes is refused. Checked: 13 new tests, the menu numbering, one dry run. Assisted-by: Claude Opus 5
This commit is contained in:
parent
82c651f1df
commit
e7d630b330
13 changed files with 1041 additions and 7 deletions
|
|
@ -41,6 +41,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
## Ajouté
|
||||
<!-- [en] -->
|
||||
|
||||
- A **Network** section in the QEMU/KVM menu, and the script behind it, `script/qemu/network_qemu.py`. `--status` reads what the libvirt network serves, which bridge carries it, which VMs are attached, what their leases say, and what the host routes elsewhere — nothing is modified. `--recreate` puts the subnet back under those VMs in three ordered steps, and the order IS the feature: the attached VMs are shut down FIRST, since redefining a network under a live VM leaves it with a lease that leads nowhere and a tap no longer on any bridge; then the network is redefined on the wanted prefix — 192.168.122 by default, libvirt's own, the one that ~/.ssh/config entries and old notes assume — and restarted; then exactly the VMs it stopped are started again, a VM already off staying off. A VM that ignores the shutdown CANCELS the redefinition rather than losing its bridge, and `--force-off` is what cuts its power. A target prefix that overlaps what the host already routes is refused: that is how a machine loses its own gateway to a bridge. A network already serving the wanted prefix is not redefined, and the stop-and-start alone still repairs the taps a torn-down network detached
|
||||
- A VPN tool, five free technologies at the menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — reachable from **TODO › Execute › Network › VPN** and from the Deployment section. What is not secret (host, user, routes, MTU) lives in readable JSON and the pre-shared keys and passwords in a KeePassXC vault, so a profile can be shown, compared and shared without handing over the means to raise the tunnel. Secrets are written to tmpfs at 0700 and never to a persistent disk; `--dry-run` shows every privileged step without running one, and the tool runs as yourself, each step calling sudo on its own. Asking for all traffic through the tunnel no longer cuts the SSH session that gave the order — the operator's address, read from `SSH_CONNECTION`, gets a survival route of its own and every one of them is withdrawn on teardown. Only L2TP/IPsec has been raised against a real concentrator; the four others are starred at the picker as covered by unit tests alone
|
||||
- `diagnose` names the failing stage lowest first, so the first false line is the cause and not a consequence: what the kernel exposes · packages · the technology's own check · interface and addresses · each declared route · a witness address that answers only through the tunnel · the journals. The kernel stage catches what no configuration can fix: upgrading the kernel package replaces `/lib/modules/<version>` and the running kernel can load no further module, so IPsec turns unavailable on a kernel that supports it, charon aborts at initialisation, and the symptom surfaces three stages higher as a connection never loaded. The only remedy is a reboot, and it is OFFERED, never done: not on a dry run, not without a terminal to answer, and only when the capability is missing AND the modules are gone
|
||||
- 3D acceleration for QEMU VMs, ticked at creation and settable afterwards, even on a VM with NO virtual screen — `auto` never grants one there, abstaining rather than adding a video device nobody asked for, while an off-screen render or an emulator inside the VM wants exactly that. A render node can exist while EGL refuses to start on it: QEMU then rejects the domain and the VM stays unusable until someone undoes the setting, so creation falls back to software rendering and an existing VM is offered the removal. Inside the guest the render node is `root:render` at 0660 and the account was not in it, so every GL application fell back to software rendering although VIRGL negotiation had succeeded, with nothing to say so; `render` and `video` are now declared BEFORE use, an unknown group name making cloud-init create no account at all — no password, no SSH key, a VM that boots unreachable
|
||||
|
|
@ -142,6 +143,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
|
||||
<!-- [fr] -->
|
||||
|
||||
- Une section **Réseau** au menu QEMU/KVM, et le script qui la porte, `script/qemu/network_qemu.py`. `--status` lit ce que sert le réseau libvirt, quel pont le porte, quelles VM y sont attachées, ce que disent leurs baux et ce que l'hôte route par ailleurs — rien n'est modifié. `--recreate` remet le sous-réseau sous ces VM en trois gestes ordonnés, et l'ordre EST la fonctionnalité : les VM attachées sont arrêtées D'ABORD, redéfinir un réseau sous une VM vivante lui laissant un bail qui ne mène nulle part et un tap qui n'est plus sur aucun pont ; puis le réseau est redéfini sur le préfixe voulu — 192.168.122 par défaut, celui de libvirt, celui que supposent les entrées ~/.ssh/config et les notes prises avant — et redémarré ; puis exactement les VM qu'il a arrêtées sont rallumées, une VM déjà éteinte le restant. Une VM qui n'obéit pas au shutdown ANNULE la redéfinition plutôt que d'y perdre son pont, et « --force-off » est ce qui lui coupe le courant. Un préfixe visé qui recouvre ce que l'hôte route déjà est refusé : c'est ainsi qu'une machine perd sa propre passerelle au profit d'un pont. Un réseau qui sert déjà le préfixe voulu n'est pas redéfini, et le seul cycle d'arrêt-relance répare encore les taps qu'un réseau abattu a détachés
|
||||
- Un outil VPN, cinq technologies libres au menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — accessible depuis **TODO › Execute › Réseau › VPN** et depuis la section Déploiement. Ce qui n'est pas secret (hôte, utilisateur, routes, MTU) vit dans une configuration JSON lisible et les clés pré-partagées comme les mots de passe dans un coffre KeePassXC : un profil peut donc être montré, comparé et partagé sans donner de quoi monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700 et jamais sur un disque persistant ; `--dry-run` montre chaque geste privilégié sans en exécuter un, et l'outil tourne sous votre identité, chaque étape appelant sudo d'elle-même. Demander tout le trafic par le tunnel ne coupe plus la session SSH qui vient d'en donner l'ordre — l'adresse de l'opérateur, lue dans `SSH_CONNECTION`, reçoit sa propre route de survie, et toutes sont retirées au démontage. Seul L2TP/IPsec a monté un tunnel contre un concentrateur réel ; les quatre autres portent au choix une étoile disant que seuls des tests unitaires les couvrent
|
||||
- `diagnose` nomme l'étage fautif du plus bas au plus haut, pour que la première ligne fausse soit la cause et non une conséquence : ce que le noyau expose · les paquets · la vérification propre à la technologie · interface et adresses · chaque route déclarée · une adresse témoin qui ne répond qu'à travers le tunnel · les journaux. L'étage du noyau attrape ce qu'aucune configuration ne rattrape : mettre à jour le paquet du noyau remplace `/lib/modules/<version>` et le noyau qui tourne ne peut plus charger aucun module, si bien que l'IPsec devient indisponible sur un noyau qui le prend en charge, que charon abandonne à l'initialisation et que le symptôme ressort trois étages plus haut en connexion jamais chargée. Le seul remède est un redémarrage, et il est PROPOSÉ, jamais fait : ni à blanc, ni sans terminal pour répondre, et seulement quand la capacité manque ET que les modules ont disparu
|
||||
- L'accélération 3D des VM QEMU, cochée à la création et réglable ensuite, même sur une VM SANS écran virtuel — « auto » ne l'accorde jamais là, s'abstenant plutôt que de poser un périphérique vidéo que personne n'a demandé, alors qu'un rendu hors écran ou un émulateur tournant dedans veut exactement cela. Un nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le domaine et la VM reste inutilisable jusqu'à ce que quelqu'un défasse le réglage, d'où un repli sur le rendu logiciel à la création et le retrait proposé sur une VM existante. Dans l'invité, le nœud de rendu appartient à « root:render » en 0660 et le compte n'y était pas : toute application GL retombait sur le rendu logiciel alors que la négociation VIRGL avait réussi, sans que rien ne le signale ; « render » et « video » sont désormais déclarés AVANT usage, un nom de groupe inconnu faisant que cloud-init ne crée aucun compte — ni mot de passe, ni clé SSH, une VM qui démarre injoignable
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
|
||||
## Ajouté
|
||||
|
||||
- Une section **Réseau** au menu QEMU/KVM, et le script qui la porte, `script/qemu/network_qemu.py`. `--status` lit ce que sert le réseau libvirt, quel pont le porte, quelles VM y sont attachées, ce que disent leurs baux et ce que l'hôte route par ailleurs — rien n'est modifié. `--recreate` remet le sous-réseau sous ces VM en trois gestes ordonnés, et l'ordre EST la fonctionnalité : les VM attachées sont arrêtées D'ABORD, redéfinir un réseau sous une VM vivante lui laissant un bail qui ne mène nulle part et un tap qui n'est plus sur aucun pont ; puis le réseau est redéfini sur le préfixe voulu — 192.168.122 par défaut, celui de libvirt, celui que supposent les entrées ~/.ssh/config et les notes prises avant — et redémarré ; puis exactement les VM qu'il a arrêtées sont rallumées, une VM déjà éteinte le restant. Une VM qui n'obéit pas au shutdown ANNULE la redéfinition plutôt que d'y perdre son pont, et « --force-off » est ce qui lui coupe le courant. Un préfixe visé qui recouvre ce que l'hôte route déjà est refusé : c'est ainsi qu'une machine perd sa propre passerelle au profit d'un pont. Un réseau qui sert déjà le préfixe voulu n'est pas redéfini, et le seul cycle d'arrêt-relance répare encore les taps qu'un réseau abattu a détachés
|
||||
- Un outil VPN, cinq technologies libres au menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — accessible depuis **TODO › Execute › Réseau › VPN** et depuis la section Déploiement. Ce qui n'est pas secret (hôte, utilisateur, routes, MTU) vit dans une configuration JSON lisible et les clés pré-partagées comme les mots de passe dans un coffre KeePassXC : un profil peut donc être montré, comparé et partagé sans donner de quoi monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700 et jamais sur un disque persistant ; `--dry-run` montre chaque geste privilégié sans en exécuter un, et l'outil tourne sous votre identité, chaque étape appelant sudo d'elle-même. Demander tout le trafic par le tunnel ne coupe plus la session SSH qui vient d'en donner l'ordre — l'adresse de l'opérateur, lue dans `SSH_CONNECTION`, reçoit sa propre route de survie, et toutes sont retirées au démontage. Seul L2TP/IPsec a monté un tunnel contre un concentrateur réel ; les quatre autres portent au choix une étoile disant que seuls des tests unitaires les couvrent
|
||||
- `diagnose` nomme l'étage fautif du plus bas au plus haut, pour que la première ligne fausse soit la cause et non une conséquence : ce que le noyau expose · les paquets · la vérification propre à la technologie · interface et adresses · chaque route déclarée · une adresse témoin qui ne répond qu'à travers le tunnel · les journaux. L'étage du noyau attrape ce qu'aucune configuration ne rattrape : mettre à jour le paquet du noyau remplace `/lib/modules/<version>` et le noyau qui tourne ne peut plus charger aucun module, si bien que l'IPsec devient indisponible sur un noyau qui le prend en charge, que charon abandonne à l'initialisation et que le symptôme ressort trois étages plus haut en connexion jamais chargée. Le seul remède est un redémarrage, et il est PROPOSÉ, jamais fait : ni à blanc, ni sans terminal pour répondre, et seulement quand la capacité manque ET que les modules ont disparu
|
||||
- L'accélération 3D des VM QEMU, cochée à la création et réglable ensuite, même sur une VM SANS écran virtuel — « auto » ne l'accorde jamais là, s'abstenant plutôt que de poser un périphérique vidéo que personne n'a demandé, alors qu'un rendu hors écran ou un émulateur tournant dedans veut exactement cela. Un nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le domaine et la VM reste inutilisable jusqu'à ce que quelqu'un défasse le réglage, d'où un repli sur le rendu logiciel à la création et le retrait proposé sur une VM existante. Dans l'invité, le nœud de rendu appartient à « root:render » en 0660 et le compte n'y était pas : toute application GL retombait sur le rendu logiciel alors que la négociation VIRGL avait réussi, sans que rien ne le signale ; « render » et « video » sont désormais déclarés AVANT usage, un nom de groupe inconnu faisant que cloud-init ne crée aucun compte — ni mot de passe, ni clé SSH, une VM qui démarre injoignable
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Recreating the virtual environment, use installation guide from tool `make`.
|
|||
|
||||
## Added
|
||||
|
||||
- A **Network** section in the QEMU/KVM menu, and the script behind it, `script/qemu/network_qemu.py`. `--status` reads what the libvirt network serves, which bridge carries it, which VMs are attached, what their leases say, and what the host routes elsewhere — nothing is modified. `--recreate` puts the subnet back under those VMs in three ordered steps, and the order IS the feature: the attached VMs are shut down FIRST, since redefining a network under a live VM leaves it with a lease that leads nowhere and a tap no longer on any bridge; then the network is redefined on the wanted prefix — 192.168.122 by default, libvirt's own, the one that ~/.ssh/config entries and old notes assume — and restarted; then exactly the VMs it stopped are started again, a VM already off staying off. A VM that ignores the shutdown CANCELS the redefinition rather than losing its bridge, and `--force-off` is what cuts its power. A target prefix that overlaps what the host already routes is refused: that is how a machine loses its own gateway to a bridge. A network already serving the wanted prefix is not redefined, and the stop-and-start alone still repairs the taps a torn-down network detached
|
||||
- A VPN tool, five free technologies at the menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — reachable from **TODO › Execute › Network › VPN** and from the Deployment section. What is not secret (host, user, routes, MTU) lives in readable JSON and the pre-shared keys and passwords in a KeePassXC vault, so a profile can be shown, compared and shared without handing over the means to raise the tunnel. Secrets are written to tmpfs at 0700 and never to a persistent disk; `--dry-run` shows every privileged step without running one, and the tool runs as yourself, each step calling sudo on its own. Asking for all traffic through the tunnel no longer cuts the SSH session that gave the order — the operator's address, read from `SSH_CONNECTION`, gets a survival route of its own and every one of them is withdrawn on teardown. Only L2TP/IPsec has been raised against a real concentrator; the four others are starred at the picker as covered by unit tests alone
|
||||
- `diagnose` names the failing stage lowest first, so the first false line is the cause and not a consequence: what the kernel exposes · packages · the technology's own check · interface and addresses · each declared route · a witness address that answers only through the tunnel · the journals. The kernel stage catches what no configuration can fix: upgrading the kernel package replaces `/lib/modules/<version>` and the running kernel can load no further module, so IPsec turns unavailable on a kernel that supports it, charon aborts at initialisation, and the symptom surfaces three stages higher as a connection never loaded. The only remedy is a reboot, and it is OFFERED, never done: not on a dry run, not without a terminal to answer, and only when the capability is missing AND the modules are gone
|
||||
- 3D acceleration for QEMU VMs, ticked at creation and settable afterwards, even on a VM with NO virtual screen — `auto` never grants one there, abstaining rather than adding a video device nobody asked for, while an off-screen render or an emulator inside the VM wants exactly that. A render node can exist while EGL refuses to start on it: QEMU then rejects the domain and the VM stays unusable until someone undoes the setting, so creation falls back to software rendering and an existing VM is offered the removal. Inside the guest the render node is `root:render` at 0660 and the account was not in it, so every GL application fell back to software rendering although VIRGL negotiation had succeeded, with nothing to say so; `render` and `video` are now declared BEFORE use, an unknown group name making cloud-init create no account at all — no password, no SSH key, a VM that boots unreachable
|
||||
|
|
|
|||
|
|
@ -685,6 +685,34 @@ sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
|
|||
definition. To fully recreate a VM with the same name, `destroy` + `undefine`
|
||||
it first, or redeploy with `--force`.
|
||||
|
||||
## The VMs' subnet
|
||||
|
||||
Every VM deployed here lives in the libvirt network `default`, which serves a
|
||||
/24 — `192.168.122.0/24` out of the box. The VMs take an address in it by DHCP
|
||||
and leave through its `.1`, carried by the bridge. Move that /24 under a
|
||||
running VM and it keeps a lease that leads nowhere; tear the network down and
|
||||
its tap is no longer on any bridge, which libvirt does not undo by itself.
|
||||
|
||||
`network_qemu.py` reads that state, and puts the subnet back under the VMs:
|
||||
|
||||
```bash
|
||||
# What the network serves, its bridge, its VMs, their leases — reads only
|
||||
./script/qemu/network_qemu.py --status
|
||||
|
||||
# Put the subnet back: stop the attached VMs, redefine, start them again
|
||||
./script/qemu/network_qemu.py --recreate
|
||||
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
|
||||
./script/qemu/network_qemu.py --recreate --force-off # VMs that ignore ACPI
|
||||
```
|
||||
|
||||
The default prefix is libvirt's own, `192.168.122`: it is what the `.ssh/config`
|
||||
entries and the notes written before assume. A prefix that overlaps what the
|
||||
host already routes is refused — a bridge taking the host's gateway address is
|
||||
how a machine loses its own network. A VM that ignores the shutdown cancels the
|
||||
redefinition rather than losing its bridge under it.
|
||||
|
||||
Both are also at **TODO › Execute › Deploy › QEMU/KVM**, section **Network**.
|
||||
|
||||
## SSH access from another machine (ProxyJump)
|
||||
|
||||
With the default NAT network the VM is reachable **only from the KVM host**.
|
||||
|
|
@ -697,6 +725,36 @@ host as a jump host (it already reaches the VM). Get the VM IP with
|
|||
définition. Pour recréer proprement une VM du même nom, faites `destroy` +
|
||||
`undefine` d'abord, ou redéployez avec `--force`.
|
||||
|
||||
## Le sous-réseau des VM
|
||||
|
||||
Toute VM déployée ici vit dans le réseau libvirt « default », qui sert un /24 —
|
||||
`192.168.122.0/24` à l'installation. Les VM y prennent une adresse par DHCP et
|
||||
en sortent par le `.1`, porté par le pont. Déplacer ce /24 sous une VM allumée
|
||||
lui laisse un bail qui ne mène nulle part ; abattre le réseau détache son tap
|
||||
du pont, et libvirt ne l'y remet pas de lui-même.
|
||||
|
||||
`network_qemu.py` lit cet état, et remet le sous-réseau sous les VM :
|
||||
|
||||
```bash
|
||||
# Ce que sert le réseau, son pont, ses VM, leurs baux — ne modifie rien
|
||||
./script/qemu/network_qemu.py --status
|
||||
|
||||
# Remettre le sous-réseau : arrêter les VM attachées, redéfinir, relancer
|
||||
./script/qemu/network_qemu.py --recreate
|
||||
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
|
||||
./script/qemu/network_qemu.py --recreate --force-off # VM sourdes à l'ACPI
|
||||
```
|
||||
|
||||
Le préfixe par défaut est celui de libvirt, `192.168.122` : c'est celui que
|
||||
supposent les entrées de `.ssh/config` et les notes prises avant. Un préfixe
|
||||
qui recouvre ce que l'hôte route déjà est refusé — un pont qui prend l'adresse
|
||||
de la passerelle de l'hôte, c'est ainsi qu'une machine perd son réseau. Une VM
|
||||
qui n'obéit pas au shutdown annule la redéfinition plutôt que d'y perdre son
|
||||
pont.
|
||||
|
||||
Les deux sont aussi au menu **TODO › Execute › Deploy › QEMU/KVM**, section
|
||||
**Réseau**.
|
||||
|
||||
## Accès SSH depuis une autre machine (ProxyJump)
|
||||
|
||||
Avec le réseau NAT par défaut, la VM n'est joignable que **depuis l'hôte
|
||||
|
|
|
|||
|
|
@ -371,6 +371,36 @@ sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
|
|||
définition. Pour recréer proprement une VM du même nom, faites `destroy` +
|
||||
`undefine` d'abord, ou redéployez avec `--force`.
|
||||
|
||||
## Le sous-réseau des VM
|
||||
|
||||
Toute VM déployée ici vit dans le réseau libvirt « default », qui sert un /24 —
|
||||
`192.168.122.0/24` à l'installation. Les VM y prennent une adresse par DHCP et
|
||||
en sortent par le `.1`, porté par le pont. Déplacer ce /24 sous une VM allumée
|
||||
lui laisse un bail qui ne mène nulle part ; abattre le réseau détache son tap
|
||||
du pont, et libvirt ne l'y remet pas de lui-même.
|
||||
|
||||
`network_qemu.py` lit cet état, et remet le sous-réseau sous les VM :
|
||||
|
||||
```bash
|
||||
# Ce que sert le réseau, son pont, ses VM, leurs baux — ne modifie rien
|
||||
./script/qemu/network_qemu.py --status
|
||||
|
||||
# Remettre le sous-réseau : arrêter les VM attachées, redéfinir, relancer
|
||||
./script/qemu/network_qemu.py --recreate
|
||||
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
|
||||
./script/qemu/network_qemu.py --recreate --force-off # VM sourdes à l'ACPI
|
||||
```
|
||||
|
||||
Le préfixe par défaut est celui de libvirt, `192.168.122` : c'est celui que
|
||||
supposent les entrées de `.ssh/config` et les notes prises avant. Un préfixe
|
||||
qui recouvre ce que l'hôte route déjà est refusé — un pont qui prend l'adresse
|
||||
de la passerelle de l'hôte, c'est ainsi qu'une machine perd son réseau. Une VM
|
||||
qui n'obéit pas au shutdown annule la redéfinition plutôt que d'y perdre son
|
||||
pont.
|
||||
|
||||
Les deux sont aussi au menu **TODO › Execute › Deploy › QEMU/KVM**, section
|
||||
**Réseau**.
|
||||
|
||||
## Accès SSH depuis une autre machine (ProxyJump)
|
||||
|
||||
Avec le réseau NAT par défaut, la VM n'est joignable que **depuis l'hôte
|
||||
|
|
|
|||
|
|
@ -330,6 +330,34 @@ sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
|
|||
definition. To fully recreate a VM with the same name, `destroy` + `undefine`
|
||||
it first, or redeploy with `--force`.
|
||||
|
||||
## The VMs' subnet
|
||||
|
||||
Every VM deployed here lives in the libvirt network `default`, which serves a
|
||||
/24 — `192.168.122.0/24` out of the box. The VMs take an address in it by DHCP
|
||||
and leave through its `.1`, carried by the bridge. Move that /24 under a
|
||||
running VM and it keeps a lease that leads nowhere; tear the network down and
|
||||
its tap is no longer on any bridge, which libvirt does not undo by itself.
|
||||
|
||||
`network_qemu.py` reads that state, and puts the subnet back under the VMs:
|
||||
|
||||
```bash
|
||||
# What the network serves, its bridge, its VMs, their leases — reads only
|
||||
./script/qemu/network_qemu.py --status
|
||||
|
||||
# Put the subnet back: stop the attached VMs, redefine, start them again
|
||||
./script/qemu/network_qemu.py --recreate
|
||||
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
|
||||
./script/qemu/network_qemu.py --recreate --force-off # VMs that ignore ACPI
|
||||
```
|
||||
|
||||
The default prefix is libvirt's own, `192.168.122`: it is what the `.ssh/config`
|
||||
entries and the notes written before assume. A prefix that overlaps what the
|
||||
host already routes is refused — a bridge taking the host's gateway address is
|
||||
how a machine loses its own network. A VM that ignores the shutdown cancels the
|
||||
redefinition rather than losing its bridge under it.
|
||||
|
||||
Both are also at **TODO › Execute › Deploy › QEMU/KVM**, section **Network**.
|
||||
|
||||
## SSH access from another machine (ProxyJump)
|
||||
|
||||
With the default NAT network the VM is reachable **only from the KVM host**.
|
||||
|
|
|
|||
480
script/qemu/network_qemu.py
Executable file
480
script/qemu/network_qemu.py
Executable file
|
|
@ -0,0 +1,480 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Le sous-réseau d'un réseau libvirt, et sa remise en place sous les VM.
|
||||
|
||||
Un réseau libvirt sert un /24 : ses VM y prennent une adresse par DHCP et le
|
||||
quittent par le .1, porté par son pont. Changer ce /24 sous des VM allumées
|
||||
les laisse avec un bail qui ne mène plus nulle part — leur passerelle n'existe
|
||||
plus, et leur tap n'est même plus sur un pont si le réseau a été abattu.
|
||||
|
||||
D'où l'ordre que ce script tient, et qui est tout ce qu'il fait :
|
||||
|
||||
0. ARRÊTER les VM attachées au réseau — proprement, et en les attendant ;
|
||||
1. REDÉFINIR le réseau sur le préfixe voulu, puis le redémarrer ;
|
||||
2. REDÉMARRER les VM qu'il a arrêtées, elles seules.
|
||||
|
||||
Le préfixe par défaut est celui de libvirt (192.168.122) : c'est celui que la
|
||||
documentation, les entrées ~/.ssh/config et les notes prises avant supposent.
|
||||
|
||||
Les fonctions qui savent lire libvirt vivent dans deploy_qemu.py, chargé ici
|
||||
comme module ; ce fichier n'apporte que l'ordre des gestes.
|
||||
|
||||
./script/qemu/network_qemu.py --status
|
||||
./script/qemu/network_qemu.py --recreate
|
||||
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Le préfixe d'origine du « default » de libvirt. Le remettre est le cas
|
||||
# courant : c'est de lui que partent les baux, les entrées SSH écrites à la
|
||||
# main et les README.
|
||||
PREFIXE_LIBVIRT = "192.168.122"
|
||||
|
||||
# Ce qu'on laisse à une VM pour s'éteindre d'elle-même. Un arrêt propre passe
|
||||
# par l'invité : il démonte ses systèmes de fichiers, là où une coupure les
|
||||
# laisse à rejouer au démarrage suivant.
|
||||
DELAI_ARRET = 120
|
||||
|
||||
|
||||
def deploy_qemu():
|
||||
"""deploy_qemu.py chargé comme module, comme le fait le menu TODO.
|
||||
|
||||
Chargé par CHEMIN et non par « import » : script/qemu n'est pas un paquet,
|
||||
et ce script doit rester lançable depuis n'importe quel répertoire.
|
||||
"""
|
||||
path = Path(__file__).resolve().parent / "deploy_qemu.py"
|
||||
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
DQ = deploy_qemu()
|
||||
|
||||
|
||||
def domaines(use_sudo: bool) -> list[str]:
|
||||
"""Tous les domaines définis sur l'hôte, allumés ou non."""
|
||||
return DQ.virsh_out(["list", "--all", "--name"], use_sudo).split()
|
||||
|
||||
|
||||
def domaine_actif(nom: str, use_sudo: bool) -> bool:
|
||||
"""La VM tourne-t-elle ? Lu en anglais, virsh traduisant « running »."""
|
||||
etat = DQ.virsh_out(["domstate", nom], use_sudo).strip()
|
||||
return etat.startswith("running") or etat.startswith("paused")
|
||||
|
||||
|
||||
def domaines_du_reseau(reseau: str, use_sudo: bool) -> list[str]:
|
||||
"""Les VM dont une interface nomme ce réseau.
|
||||
|
||||
Lu dans la définition PERSISTANTE (« --inactive ») : la vue vivante d'une
|
||||
VM allumée décore l'interface de ce que libvirt lui a alloué, mais c'est
|
||||
la définition qui dit à quel réseau la VM revient en démarrant.
|
||||
"""
|
||||
attaches = []
|
||||
motif = re.compile(rf"<source network='{re.escape(reseau)}'")
|
||||
for nom in domaines(use_sudo):
|
||||
if motif.search(
|
||||
DQ.virsh_out(["dumpxml", "--inactive", nom], use_sudo)
|
||||
):
|
||||
attaches.append(nom)
|
||||
return attaches
|
||||
|
||||
|
||||
def baux(reseau: str, use_sudo: bool) -> list[str]:
|
||||
"""Les lignes de « net-dhcp-leases », en-tête compris, ou []."""
|
||||
out = DQ.virsh_out(["net-dhcp-leases", reseau], use_sudo)
|
||||
return [ligne for ligne in out.splitlines() if ligne.strip()]
|
||||
|
||||
|
||||
def prefixe_de(cidr: str) -> str:
|
||||
"""« 192.168.122.0/24 » -> « 192.168.122 ». '' si rien n'est lisible."""
|
||||
if not cidr:
|
||||
return ""
|
||||
return cidr.split("/")[0].rsplit(".", 1)[0]
|
||||
|
||||
|
||||
def etat(reseau: str, use_sudo: bool) -> None:
|
||||
"""Ce que sert le réseau, qui y vit, et ce que l'hôte route par ailleurs.
|
||||
|
||||
Écrit AVANT toute modification, et lisible seul : c'est le rapport qu'on
|
||||
relit pour décider s'il y a quelque chose à recréer.
|
||||
"""
|
||||
actif, autostart = DQ.network_state(reseau, use_sudo)
|
||||
cidr = DQ.network_cidr(reseau, use_sudo)
|
||||
pont = DQ.network_bridge(reseau, use_sudo)
|
||||
print(f"\n== Réseau libvirt « {reseau} » ==")
|
||||
print(f" état : {'actif' if actif else 'INACTIF'}")
|
||||
print(f" autostart : {'oui' if autostart else 'non'}")
|
||||
print(f" sous-réseau : {cidr or 'illisible'}")
|
||||
print(f" pont : {pont or 'inconnu'}")
|
||||
|
||||
collision = DQ.network_collision(
|
||||
cidr, DQ.host_networks(exclure_ponts=[pont])
|
||||
)
|
||||
if collision:
|
||||
print(
|
||||
f" ⚠ collision : l'hôte route déjà {collision} par une AUTRE"
|
||||
" interface. Le pont y prendrait l'adresse de la passerelle."
|
||||
)
|
||||
else:
|
||||
print(" collision : aucune")
|
||||
|
||||
print("\n Ce que l'hôte route (hors ce pont) :")
|
||||
for reseau_hote in sorted(
|
||||
{str(r) for r in DQ.host_networks(exclure_ponts=[pont])}
|
||||
):
|
||||
print(f" {reseau_hote}")
|
||||
|
||||
attaches = domaines_du_reseau(reseau, use_sudo)
|
||||
print(f"\n VM attachées à « {reseau} » : {len(attaches)}")
|
||||
for nom in attaches:
|
||||
vivante = "allumée" if domaine_actif(nom, use_sudo) else "éteinte"
|
||||
print(f" {nom} ({vivante})")
|
||||
|
||||
lignes = baux(reseau, use_sudo)
|
||||
if lignes:
|
||||
print("\n Baux DHCP :")
|
||||
for ligne in lignes:
|
||||
print(f" {ligne}")
|
||||
|
||||
|
||||
def attendre_extinction(noms: list[str], delai: int, use_sudo: bool) -> list:
|
||||
"""Attend que ces VM s'éteignent. Rend celles qui tournent encore.
|
||||
|
||||
Une seule attente pour tout le monde, et non un délai par VM : elles
|
||||
s'éteignent en parallèle, les compter l'une après l'autre multiplierait
|
||||
l'attente par leur nombre.
|
||||
"""
|
||||
fin = time.monotonic() + delai
|
||||
restantes = list(noms)
|
||||
while restantes and time.monotonic() < fin:
|
||||
restantes = [n for n in restantes if domaine_actif(n, use_sudo)]
|
||||
if not restantes:
|
||||
break
|
||||
time.sleep(2)
|
||||
return restantes
|
||||
|
||||
|
||||
def arreter(noms: list[str], runner, delai: int, forcer: bool) -> list[str]:
|
||||
"""Étape 0 : éteint les VM attachées. Rend celles qui n'ont pas obéi.
|
||||
|
||||
L'arrêt est demandé à l'invité (« shutdown ») ; « destroy » coupe le
|
||||
courant, et ne part qu'à la demande explicite de l'appelant.
|
||||
"""
|
||||
if not noms:
|
||||
print(" Aucune VM attachée à arrêter.")
|
||||
return []
|
||||
for nom in noms:
|
||||
runner.run(
|
||||
["virsh", "-c", DQ.LIBVIRT_URI, "shutdown", nom],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
if runner.dry_run:
|
||||
return []
|
||||
print(f" Attente de l'extinction ({delai} s au plus)…")
|
||||
restantes = attendre_extinction(noms, delai, runner.use_sudo)
|
||||
if restantes and forcer:
|
||||
print(f" Arrêt forcé : {', '.join(restantes)}")
|
||||
for nom in restantes:
|
||||
runner.run(
|
||||
["virsh", "-c", DQ.LIBVIRT_URI, "destroy", nom],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
restantes = attendre_extinction(restantes, 30, runner.use_sudo)
|
||||
return restantes
|
||||
|
||||
|
||||
def redefinir(reseau: str, prefixe: str, runner) -> bool:
|
||||
"""Étape 1 : pose le réseau sur `prefixe`, puis le démarre. Vrai si fait.
|
||||
|
||||
La redéfinition garde le XML tel quel, son seul sous-réseau réécrit :
|
||||
l'UUID, le nom du pont et son adresse MAC restent, si bien que les
|
||||
domaines qui nomment ce réseau le retrouvent sans être redéfinis eux aussi.
|
||||
"""
|
||||
xml = DQ.virsh_out(["net-dumpxml", reseau], runner.use_sudo)
|
||||
if not xml:
|
||||
print(f" ⚠ réseau « {reseau} » illisible : rien n'est changé.")
|
||||
return False
|
||||
cidr = DQ.cidr_from_network_xml(xml)
|
||||
ancien = prefixe_de(cidr)
|
||||
if not ancien:
|
||||
print(
|
||||
f" ⚠ sous-réseau de « {reseau} » illisible : rien n'est changé."
|
||||
)
|
||||
return False
|
||||
|
||||
pont = DQ.bridge_from_network_xml(xml)
|
||||
vise = f"{prefixe}.0/24"
|
||||
# Le pont du réseau examiné est écarté : il porte le sous-réseau ACTUEL, et
|
||||
# le compter ferait passer un réseau démarré pour sa propre collision.
|
||||
collision = DQ.network_collision(
|
||||
vise, DQ.host_networks(exclure_ponts=[pont])
|
||||
)
|
||||
if collision:
|
||||
print(
|
||||
f" ⚠ {vise} recouvre {collision}, que cette machine route déjà."
|
||||
"\n Le pont y prendrait l'adresse de la passerelle de l'hôte,"
|
||||
" qui perdrait son accès au réseau. Rien n'est changé."
|
||||
)
|
||||
return False
|
||||
for autre in DQ.libvirt_networks_cidrs(reseau, runner.use_sudo):
|
||||
if autre.overlaps(ipaddress.ip_network(vise)):
|
||||
print(
|
||||
f" ⚠ {vise} est déjà servi par un autre réseau libvirt"
|
||||
f" ({autre}). Rien n'est changé."
|
||||
)
|
||||
return False
|
||||
|
||||
actif, _autostart = DQ.network_state(reseau, runner.use_sudo)
|
||||
if ancien == prefixe:
|
||||
print(f" Le réseau sert déjà {vise} : aucune redéfinition.")
|
||||
else:
|
||||
print(f" {cidr} → {vise}")
|
||||
if actif:
|
||||
runner.run(
|
||||
["virsh", "-c", DQ.LIBVIRT_URI, "net-destroy", reseau],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
actif = False
|
||||
DQ.define_network_xml(
|
||||
DQ.moved_network_xml(xml, ancien, prefixe), runner
|
||||
)
|
||||
if not actif:
|
||||
runner.run(
|
||||
["virsh", "-c", DQ.LIBVIRT_URI, "net-start", reseau],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
runner.run(
|
||||
["virsh", "-c", DQ.LIBVIRT_URI, "net-autostart", reseau],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def redemarrer(noms: list[str], runner) -> None:
|
||||
"""Étape 2 : rallume les VM que l'étape 0 a éteintes, elles seules.
|
||||
|
||||
Une VM déjà éteinte AVANT l'opération le reste : ce script recrée un
|
||||
sous-réseau, il ne décide pas de ce qui doit tourner sur l'hôte.
|
||||
"""
|
||||
for nom in noms:
|
||||
runner.run(
|
||||
["virsh", "-c", DQ.LIBVIRT_URI, "start", nom],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def recreer(args, runner) -> int:
|
||||
"""Les trois étapes, dans l'ordre. Rend le code de sortie du programme."""
|
||||
reseau, prefixe = args.network, args.prefix
|
||||
cidr = DQ.network_cidr(reseau, runner.use_sudo)
|
||||
attaches = domaines_du_reseau(reseau, runner.use_sudo)
|
||||
allumees = [n for n in attaches if domaine_actif(n, runner.use_sudo)]
|
||||
|
||||
print(f"\n== Recréer le sous-réseau de « {reseau} » ==")
|
||||
print(f" {cidr or 'sous-réseau illisible'} → {prefixe}.0/24")
|
||||
print(f" VM attachées : {len(attaches)}, dont allumées : {len(allumees)}")
|
||||
for nom in allumees:
|
||||
print(f" {nom}")
|
||||
print(
|
||||
"\n Les VM allumées seront ARRÊTÉES, le réseau redéfini, puis"
|
||||
"\n ces mêmes VM redémarrées. Une VM à adresse fixe dans l'ancien"
|
||||
"\n sous-réseau devra être corrigée dans l'invité."
|
||||
)
|
||||
if not args.assume_yes and not args.dry_run:
|
||||
if not demander("\n Continuer ? (o/N) : "):
|
||||
print(" Annulé.")
|
||||
return 1
|
||||
|
||||
print("\n-- 0/2 Arrêt des VM attachées --")
|
||||
restantes = arreter(allumees, runner, args.timeout, args.force_off)
|
||||
if restantes:
|
||||
print(
|
||||
f" ⚠ encore allumées : {', '.join(restantes)}."
|
||||
"\n Le réseau n'est PAS redéfini : le faire sous une VM vivante"
|
||||
"\n la laisserait sans passerelle et sans pont."
|
||||
"\n Éteignez-les, ou relancez avec --force-off."
|
||||
)
|
||||
return 1
|
||||
|
||||
print("\n-- 1/2 Redéfinition du réseau --")
|
||||
if not redefinir(reseau, prefixe, runner):
|
||||
print(
|
||||
"\n Le réseau n'a pas été redéfini. Les VM arrêtées sont"
|
||||
" redémarrées telles quelles."
|
||||
)
|
||||
redemarrer(allumees, runner)
|
||||
return 1
|
||||
|
||||
print("\n-- 2/2 Redémarrage des VM --")
|
||||
redemarrer(allumees, runner)
|
||||
|
||||
if not runner.dry_run:
|
||||
print("\nTerminé. Le nouvel état :")
|
||||
etat(reseau, runner.use_sudo)
|
||||
print(
|
||||
"\n Une VM absente des baux n'a pas encore renouvelé le sien :"
|
||||
"\n laissez-lui le temps de démarrer, puis relancez --status."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def demander(question: str) -> bool:
|
||||
"""Question fermée posée sur le terminal. Vaut NON par défaut.
|
||||
|
||||
Lue sur /dev/tty quand il existe : le menu TODO branche l'entrée standard
|
||||
du script sur autre chose que le clavier, et une question sans réponse
|
||||
possible vaudrait acceptation silencieuse.
|
||||
"""
|
||||
try:
|
||||
with open("/dev/tty", "r+") as tty:
|
||||
tty.write(question)
|
||||
tty.flush()
|
||||
reponse = tty.readline()
|
||||
except OSError:
|
||||
try:
|
||||
reponse = input(question)
|
||||
except EOFError:
|
||||
return False
|
||||
return reponse.strip().lower() in ("o", "oui", "y", "yes")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
description=(
|
||||
"État et remise en place du sous-réseau d'un réseau libvirt."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Exemples :\n"
|
||||
" %(prog)s --status\n"
|
||||
" %(prog)s --recreate\n"
|
||||
" %(prog)s --recreate --prefix 192.168.140 --assume-yes\n"
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--network",
|
||||
default="default",
|
||||
help="Réseau libvirt visé (défaut : default).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="Affiche l'état du réseau, ses VM et ses baux, puis quitte.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--recreate",
|
||||
action="store_true",
|
||||
help="Arrête les VM attachées, redéfinit le réseau sur --prefix, "
|
||||
"puis redémarre ces VM.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--prefix",
|
||||
default=PREFIXE_LIBVIRT,
|
||||
help=f"Préfixe /24 visé (défaut : {PREFIXE_LIBVIRT}, celui de "
|
||||
"libvirt).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=DELAI_ARRET,
|
||||
help=f"Secondes laissées aux VM pour s'éteindre (défaut : "
|
||||
f"{DELAI_ARRET}).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--force-off",
|
||||
action="store_true",
|
||||
help="Coupe le courant des VM qui n'obéissent pas au shutdown.",
|
||||
)
|
||||
p.add_argument(
|
||||
"-y",
|
||||
"--assume-yes",
|
||||
action="store_true",
|
||||
help="Ne pose pas la question de confirmation.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Affiche les commandes sans rien exécuter.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--no-sudo",
|
||||
action="store_true",
|
||||
help="N'ajoute jamais sudo (utile dans le groupe libvirt).",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def valider_prefixe(prefixe: str) -> str:
|
||||
"""Refuse un préfixe qui n'est pas trois octets. Rend le préfixe."""
|
||||
if not re.fullmatch(r"\d{1,3}\.\d{1,3}\.\d{1,3}", prefixe):
|
||||
sys.exit(
|
||||
f"Préfixe {prefixe!r} invalide : il en faut trois octets, "
|
||||
"ex. 192.168.122."
|
||||
)
|
||||
try:
|
||||
ipaddress.ip_network(f"{prefixe}.0/24")
|
||||
except ValueError as err:
|
||||
sys.exit(f"Préfixe {prefixe!r} invalide : {err}")
|
||||
return prefixe
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Sortie ligne par ligne même dans un tube (menu TODO) : sinon les étapes
|
||||
# restent bufferisées et l'opération paraît figée.
|
||||
try:
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
args = build_parser().parse_args()
|
||||
if not shutil.which("virsh"):
|
||||
sys.exit(
|
||||
"virsh est absent : préparez l'hôte par\n"
|
||||
" ./script/qemu/deploy_qemu.py --setup-host"
|
||||
)
|
||||
runner = DQ.Runner(
|
||||
use_sudo=not args.no_sudo and os.geteuid() != 0,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
if runner.use_sudo:
|
||||
# Dit avant que sudo ne réclame le mot de passe : la première lecture
|
||||
# de l'état passe par lui, et un mot de passe tapé sans savoir ce
|
||||
# qu'il autorise est donné à l'aveugle.
|
||||
print(
|
||||
"🔑 sudo va demander votre mot de passe : cet hôte n'ouvre"
|
||||
" qemu:///system\n qu'à root (appartenir au groupe libvirt"
|
||||
" l'éviterait)."
|
||||
)
|
||||
if args.recreate:
|
||||
valider_prefixe(args.prefix)
|
||||
sys.exit(recreer(args, runner))
|
||||
etat(args.network, runner.use_sudo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
# Un Ctrl-C au milieu d'une attente d'extinction n'est pas une panne :
|
||||
# la trace Python noierait l'état où l'opération s'est arrêtée.
|
||||
print("\nInterrompu.")
|
||||
sys.exit(130)
|
||||
|
|
@ -326,8 +326,9 @@ class QemuMenuMixin:
|
|||
)
|
||||
},
|
||||
# Douze entrées sous un seul titre ne se lisent plus : on cherche
|
||||
# à la ligne près. Trois intentions les séparent — vivre avec ses
|
||||
# VM, y entrer, réparer quand ça va mal.
|
||||
# à la ligne près. Quatre intentions les séparent — vivre avec ses
|
||||
# VM, y entrer, régler le réseau qui les porte, réparer quand ça
|
||||
# va mal.
|
||||
{"section": t("Manage")},
|
||||
{"prompt_description": t("List VMs (virsh list --all)")},
|
||||
{"prompt_description": t("Show a VM IP address")},
|
||||
|
|
@ -350,6 +351,13 @@ class QemuMenuMixin:
|
|||
"Android emulator (start, tunnel, scrcpy)"
|
||||
)
|
||||
},
|
||||
{"section": t("VM network")},
|
||||
{"prompt_description": t("Show the libvirt network state")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Recreate the VM subnet (stop, redefine, restart)"
|
||||
)
|
||||
},
|
||||
{"section": t("Troubleshoot")},
|
||||
{"prompt_description": t("Clean up QEMU (orphan files)")},
|
||||
{
|
||||
|
|
@ -402,16 +410,20 @@ class QemuMenuMixin:
|
|||
elif status == "12":
|
||||
self._qemu_emulator_menu()
|
||||
elif status == "13":
|
||||
self._qemu_cleanup()
|
||||
self._qemu_network_status()
|
||||
elif status == "14":
|
||||
self._qemu_recover_files()
|
||||
self._qemu_network_recreate()
|
||||
elif status == "15":
|
||||
self._qemu_test_vm()
|
||||
self._qemu_cleanup()
|
||||
elif status == "16":
|
||||
self._qemu_diagnostics()
|
||||
self._qemu_recover_files()
|
||||
elif status == "17":
|
||||
self._qemu_stats()
|
||||
self._qemu_test_vm()
|
||||
elif status == "18":
|
||||
self._qemu_diagnostics()
|
||||
elif status == "19":
|
||||
self._qemu_stats()
|
||||
elif status == "20":
|
||||
self._qemu_list_images()
|
||||
else:
|
||||
cmd_no_found = True
|
||||
|
|
|
|||
89
script/todo/qemu_network.py
Normal file
89
script/todo/qemu_network.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Menu QEMU/KVM : le sous-réseau où vivent les VM.
|
||||
|
||||
Deux entrées pour une seule question — quel /24 le réseau libvirt sert-il, et
|
||||
que faire quand ce n'est plus celui que les VM attendent. Voir l'état ne
|
||||
modifie rien ; le recréer arrête les VM attachées, redéfinit le réseau, puis
|
||||
redémarre ces VM.
|
||||
|
||||
Le travail est fait par script/qemu/network_qemu.py, lancé comme un programme
|
||||
et non importé : c'est lui qui porte l'ordre des trois gestes, et il reste
|
||||
utilisable seul sur un hôte où le menu ne tourne pas — celui, justement, qui
|
||||
vient de perdre son réseau.
|
||||
|
||||
Mixin de la classe TODO : ses méthodes vivent sur la même instance que celles
|
||||
des autres fichiers, elles s'appellent donc par « self. » sans rien importer.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from script.todo.qemu_privilege import needs_sudo
|
||||
from script.todo.todo_i18n import t
|
||||
|
||||
# Le préfixe d'origine du « default » de libvirt, proposé par défaut. Il fait
|
||||
# autorité dans network_qemu.py ; répété ici pour que l'invite ait une valeur à
|
||||
# afficher sans charger le script.
|
||||
PREFIXE_LIBVIRT = "192.168.122"
|
||||
|
||||
|
||||
class QemuNetworkMixin:
|
||||
"""Menu QEMU/KVM : le sous-réseau où vivent les VM."""
|
||||
|
||||
def _qemu_network_script_path(self):
|
||||
"""Chemin absolu vers script/qemu/network_qemu.py."""
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..",
|
||||
"qemu",
|
||||
"network_qemu.py",
|
||||
)
|
||||
return os.path.realpath(path)
|
||||
|
||||
def _qemu_network_cmd(self, *args):
|
||||
"""La commande du script, sudo laissé à qui en a besoin.
|
||||
|
||||
« --no-sudo » quand le groupe libvirt suffit : préfixer sudo ne donne
|
||||
alors aucun droit de plus et réclame un mot de passe pour rien.
|
||||
"""
|
||||
parts = [self._qemu_network_script_path(), *args]
|
||||
if not needs_sudo():
|
||||
parts.append("--no-sudo")
|
||||
return " ".join(parts)
|
||||
|
||||
def _qemu_network_status(self):
|
||||
"""Affiche le sous-réseau servi, les VM attachées et leurs baux."""
|
||||
cmd = self._qemu_network_cmd("--status")
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||
|
||||
def _qemu_network_recreate(self):
|
||||
"""Recrée le sous-réseau : arrête les VM, redéfinit, redémarre.
|
||||
|
||||
L'état est montré AVANT la question : le préfixe à viser se lit sur
|
||||
celui que le réseau sert aujourd'hui, et personne ne devrait avoir à
|
||||
deviner ce qu'il va changer.
|
||||
"""
|
||||
self._qemu_network_status()
|
||||
print(f"\n{t('Recreating the subnet does three things:')}")
|
||||
print(f" 0. {t('shut down the VMs attached to the network')}")
|
||||
print(f" 1. {t('redefine the network on the wanted prefix')}")
|
||||
print(f" 2. {t('start those same VMs again')}")
|
||||
prefixe = input(
|
||||
f"\n{t('Target prefix')} [{PREFIXE_LIBVIRT}] : "
|
||||
).strip()
|
||||
prefixe = prefixe or PREFIXE_LIBVIRT
|
||||
reseau = input(f"{t('libvirt network')} [default] : ").strip()
|
||||
reseau = reseau or "default"
|
||||
forcer = self._is_yes(
|
||||
input(t("Power off VMs that ignore the shutdown? (y/N): "))
|
||||
)
|
||||
args = ["--recreate", "--network", reseau, "--prefix", prefixe]
|
||||
if forcer:
|
||||
args.append("--force-off")
|
||||
cmd = self._qemu_network_cmd(*args)
|
||||
print(f"\n{t('Will execute:')} {cmd}")
|
||||
# La confirmation est posée par le script lui-même : il la pose sur
|
||||
# /dev/tty, donc elle fonctionne aussi quand il est lancé hors du menu.
|
||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||
|
|
@ -36,6 +36,7 @@ from script.todo.qemu_deploy import QemuDeployMixin
|
|||
from script.todo.qemu_install import QemuInstallMixin
|
||||
from script.todo.qemu_manage import QemuManageMixin
|
||||
from script.todo.qemu_menu import QemuMenuMixin
|
||||
from script.todo.qemu_network import QemuNetworkMixin
|
||||
from script.todo.qemu_recover import QemuRecoverMixin
|
||||
from script.todo.vpn_menu import VpnMenuMixin
|
||||
from script.todo.kdbx_manager import KdbxManager
|
||||
|
|
@ -98,6 +99,7 @@ class TODO(
|
|||
QemuDeployMixin,
|
||||
QemuInstallMixin,
|
||||
QemuManageMixin,
|
||||
QemuNetworkMixin,
|
||||
QemuRecoverMixin,
|
||||
QemuAccessMixin,
|
||||
ProxmoxMenuMixin,
|
||||
|
|
|
|||
|
|
@ -5422,6 +5422,47 @@ TRANSLATIONS = {
|
|||
"fr": "🔌 Accès aux VM",
|
||||
"en": "🔌 VM access",
|
||||
},
|
||||
# Menu QEMU — section « Réseau »
|
||||
"VM network": {
|
||||
"fr": "🌐 Réseau",
|
||||
"en": "🌐 Network",
|
||||
},
|
||||
"Show the libvirt network state": {
|
||||
"fr": "🌐 Voir l'état du réseau libvirt (sous-réseau, VM, baux)",
|
||||
"en": "🌐 Show the libvirt network state (subnet, VMs, leases)",
|
||||
},
|
||||
"Recreate the VM subnet (stop, redefine, restart)": {
|
||||
"fr": "♻️ Recréer le sous-réseau des VM (arrêter, redéfinir, relancer)",
|
||||
"en": "♻️ Recreate the VM subnet (stop, redefine, restart)",
|
||||
},
|
||||
"Recreating the subnet does three things:": {
|
||||
"fr": "Recréer le sous-réseau fait trois choses :",
|
||||
"en": "Recreating the subnet does three things:",
|
||||
},
|
||||
"shut down the VMs attached to the network": {
|
||||
"fr": "arrêter les VM attachées au réseau",
|
||||
"en": "shut down the VMs attached to the network",
|
||||
},
|
||||
"redefine the network on the wanted prefix": {
|
||||
"fr": "redéfinir le réseau sur le préfixe voulu",
|
||||
"en": "redefine the network on the wanted prefix",
|
||||
},
|
||||
"start those same VMs again": {
|
||||
"fr": "redémarrer ces mêmes VM",
|
||||
"en": "start those same VMs again",
|
||||
},
|
||||
"Target prefix": {
|
||||
"fr": "Préfixe visé",
|
||||
"en": "Target prefix",
|
||||
},
|
||||
"libvirt network": {
|
||||
"fr": "Réseau libvirt",
|
||||
"en": "libvirt network",
|
||||
},
|
||||
"Power off VMs that ignore the shutdown? (y/N): ": {
|
||||
"fr": "Couper le courant des VM qui n'obéissent pas ? (o/N) : ",
|
||||
"en": "Power off VMs that ignore the shutdown? (y/N): ",
|
||||
},
|
||||
"Troubleshoot": {
|
||||
"fr": "🩺 Dépanner",
|
||||
"en": "🩺 Troubleshoot",
|
||||
|
|
|
|||
288
test/test_qemu_network_recreate.py
Normal file
288
test/test_qemu_network_recreate.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Recréer le sous-réseau d'un réseau libvirt sous les VM qui y vivent.
|
||||
|
||||
Une VM prend son adresse par DHCP dans le /24 de son réseau et en sort par le
|
||||
.1, porté par le pont. Changer ce /24 sous une VM allumée la laisse avec un
|
||||
bail qui ne mène nulle part ; abattre le réseau détache en plus son tap du
|
||||
pont, et rien ne l'y rebranche tant qu'elle n'a pas redémarré.
|
||||
|
||||
D'où l'ordre que ces tests gardent :
|
||||
|
||||
- les VM attachées sont ARRÊTÉES avant que le réseau ne soit touché, et ce
|
||||
sont exactement celles-là qui redémarrent ensuite ;
|
||||
- une VM qui n'obéit pas au shutdown ANNULE la redéfinition : la faire sous
|
||||
elle lui retirerait pont et passerelle ;
|
||||
- un préfixe visé qui recouvre ce que l'hôte route déjà est REFUSÉ — c'est la
|
||||
panne d'origine, l'hôte y perdant sa propre passerelle au profit du pont ;
|
||||
- un réseau qui sert déjà le préfixe visé n'est PAS redéfini : relancer
|
||||
l'opération ne doit rien casser.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _network_qemu():
|
||||
"""network_qemu.py chargé comme module, comme le fait le menu TODO."""
|
||||
path = RACINE / "script/qemu/network_qemu.py"
|
||||
spec = importlib.util.spec_from_file_location("network_qemu", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
NQ = _network_qemu()
|
||||
|
||||
XML_DEFAUT = """<network>
|
||||
<name>default</name>
|
||||
<uuid>2cf1310d-8812-456e-b761-9867efe4af8d</uuid>
|
||||
<forward mode='nat'/>
|
||||
<bridge name='virbr0' stp='on' delay='0'/>
|
||||
<mac address='52:54:00:6b:6d:2f'/>
|
||||
<ip address='192.168.131.1' netmask='255.255.255.0'>
|
||||
<dhcp>
|
||||
<range start='192.168.131.2' end='192.168.131.254'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>
|
||||
"""
|
||||
|
||||
# Une définition de domaine, réduite à ce que le script y cherche.
|
||||
XML_VM = """<domain type='kvm'>
|
||||
<name>{nom}</name>
|
||||
<devices>
|
||||
<interface type='network'>
|
||||
<source network='{reseau}'/>
|
||||
</interface>
|
||||
</devices>
|
||||
</domain>
|
||||
"""
|
||||
|
||||
|
||||
def reseaux(*cidrs):
|
||||
import ipaddress
|
||||
|
||||
return [ipaddress.ip_network(c, strict=False) for c in cidrs]
|
||||
|
||||
|
||||
class Args:
|
||||
"""Les options de la ligne de commande, réduites à ce que recreer lit."""
|
||||
|
||||
def __init__(self, **kw):
|
||||
self.network = kw.get("network", "default")
|
||||
self.prefix = kw.get("prefix", "192.168.122")
|
||||
self.timeout = kw.get("timeout", 1)
|
||||
self.force_off = kw.get("force_off", False)
|
||||
self.assume_yes = kw.get("assume_yes", True)
|
||||
self.dry_run = kw.get("dry_run", False)
|
||||
|
||||
|
||||
class Parc:
|
||||
"""Un hôte bouchonné : ses VM, leur état, et ce que virsh en dit.
|
||||
|
||||
Les états sont MUTABLES : « shutdown » éteint la VM dans ce parc, si bien
|
||||
que l'attente d'extinction du script s'y termine comme sur une vraie
|
||||
machine, sans dormir.
|
||||
"""
|
||||
|
||||
def __init__(self, vms, reseau="default"):
|
||||
# vms : {nom: "running" | "shut off"}
|
||||
self.etats = dict(vms)
|
||||
self.reseau = reseau
|
||||
self.sourdes = set()
|
||||
self.lancees = []
|
||||
|
||||
def virsh_out(self, args, use_sudo, timeout=20):
|
||||
verbe = args[0]
|
||||
if verbe == "list":
|
||||
return " ".join(self.etats)
|
||||
if verbe == "domstate":
|
||||
return self.etats[args[1]]
|
||||
if verbe == "dumpxml":
|
||||
return XML_VM.format(nom=args[-1], reseau=self.reseau)
|
||||
if verbe == "net-dumpxml":
|
||||
return XML_DEFAUT
|
||||
if verbe == "net-dhcp-leases":
|
||||
return ""
|
||||
return ""
|
||||
|
||||
def run(self, cmd, **kwargs):
|
||||
propre = [
|
||||
x for x in cmd if x not in ("virsh", "-c", NQ.DQ.LIBVIRT_URI)
|
||||
]
|
||||
self.lancees.append(propre)
|
||||
verbe, cible = propre[0], propre[-1]
|
||||
if verbe == "shutdown" and cible not in self.sourdes:
|
||||
self.etats[cible] = "shut off"
|
||||
elif verbe == "destroy":
|
||||
self.etats[cible] = "shut off"
|
||||
elif verbe == "start":
|
||||
self.etats[cible] = "running"
|
||||
|
||||
def verbes(self):
|
||||
return [c[0] for c in self.lancees]
|
||||
|
||||
|
||||
def lancer(
|
||||
parc, args=None, hote=("192.168.2.0/24",), etat_reseau=(True, True)
|
||||
):
|
||||
"""Lance recreer() sur un parc bouchonné. Rend (code, sortie)."""
|
||||
runner = mock.MagicMock()
|
||||
runner.dry_run = False
|
||||
runner.use_sudo = False
|
||||
runner.run.side_effect = parc.run
|
||||
with mock.patch.object(
|
||||
NQ.DQ, "virsh_out", side_effect=parc.virsh_out
|
||||
), mock.patch.object(
|
||||
NQ.DQ, "host_networks", return_value=reseaux(*hote)
|
||||
), mock.patch.object(
|
||||
NQ.DQ, "libvirt_networks_cidrs", return_value=[]
|
||||
), mock.patch.object(
|
||||
NQ.DQ, "network_state", return_value=etat_reseau
|
||||
):
|
||||
with redirect_stdout(io.StringIO()) as sortie:
|
||||
code = NQ.recreer(args or Args(), runner)
|
||||
return code, sortie.getvalue()
|
||||
|
||||
|
||||
class LOrdreDesTroisGestes(unittest.TestCase):
|
||||
def test_the_vms_stop_before_the_network_moves_and_start_after(self):
|
||||
"""L'ordre EST la fonctionnalité : redéfinir sous une VM allumée lui
|
||||
retire son pont, la redémarrer avant la redéfinition lui redonne un
|
||||
bail dans l'ancien /24."""
|
||||
parc = Parc({"vm-a": "running", "vm-b": "running"})
|
||||
code, texte = lancer(parc)
|
||||
self.assertEqual(0, code)
|
||||
verbes = parc.verbes()
|
||||
self.assertEqual(
|
||||
["shutdown", "shutdown", "net-destroy", "net-define"], verbes[:4]
|
||||
)
|
||||
self.assertLess(verbes.index("net-define"), verbes.index("start"))
|
||||
self.assertEqual(["net-start", "net-autostart"], verbes[4:6])
|
||||
self.assertEqual(["start", "start"], verbes[6:])
|
||||
|
||||
def test_only_the_vms_it_stopped_are_started_again(self):
|
||||
"""Une VM éteinte AVANT l'opération le reste : ce script recrée un
|
||||
sous-réseau, il ne décide pas de ce qui tourne sur l'hôte."""
|
||||
parc = Parc({"vm-a": "running", "vm-b": "shut off"})
|
||||
lancer(parc)
|
||||
demarrees = [c[-1] for c in parc.lancees if c[0] == "start"]
|
||||
self.assertEqual(["vm-a"], demarrees)
|
||||
|
||||
def test_only_the_vms_of_that_network_are_touched(self):
|
||||
"""Une VM sur un autre réseau n'a rien à faire dans l'opération."""
|
||||
parc = Parc({"vm-a": "running"}, reseau="autre-reseau")
|
||||
lancer(parc)
|
||||
self.assertNotIn("shutdown", parc.verbes())
|
||||
self.assertNotIn("start", parc.verbes())
|
||||
|
||||
def test_a_network_without_vm_is_still_recreated(self):
|
||||
parc = Parc({})
|
||||
code, _ = lancer(parc)
|
||||
self.assertEqual(0, code)
|
||||
self.assertIn("net-define", parc.verbes())
|
||||
|
||||
|
||||
class LaVmQuiNObeitPas(unittest.TestCase):
|
||||
def test_a_vm_that_stays_up_cancels_the_redefinition(self):
|
||||
"""Rien n'est touché : la redéfinition sous une VM vivante lui
|
||||
retirerait son pont ET sa passerelle, et elle n'a même plus de quoi
|
||||
le dire."""
|
||||
parc = Parc({"vm-a": "running"})
|
||||
parc.sourdes.add("vm-a")
|
||||
code, texte = lancer(parc)
|
||||
self.assertEqual(1, code)
|
||||
self.assertNotIn("net-define", parc.verbes())
|
||||
self.assertNotIn("net-destroy", parc.verbes())
|
||||
self.assertIn("--force-off", texte)
|
||||
|
||||
def test_force_off_cuts_the_power_and_the_work_goes_on(self):
|
||||
parc = Parc({"vm-a": "running"})
|
||||
parc.sourdes.add("vm-a")
|
||||
code, _ = lancer(parc, Args(force_off=True))
|
||||
self.assertEqual(0, code)
|
||||
self.assertIn("destroy", parc.verbes())
|
||||
self.assertLess(
|
||||
parc.verbes().index("destroy"), parc.verbes().index("net-define")
|
||||
)
|
||||
|
||||
|
||||
class LePrefixeVise(unittest.TestCase):
|
||||
def test_a_prefix_the_host_already_routes_is_refused(self):
|
||||
"""La panne d'origine, dans l'autre sens : poser le réseau sur le /24
|
||||
de l'hôte donne au pont l'adresse de la passerelle, et la machine
|
||||
perd son accès au réseau au démarrage suivant."""
|
||||
parc = Parc({"vm-a": "running"})
|
||||
code, texte = lancer(
|
||||
parc, Args(prefix="192.168.2"), hote=("192.168.2.0/24",)
|
||||
)
|
||||
self.assertEqual(1, code)
|
||||
self.assertNotIn("net-define", parc.verbes())
|
||||
self.assertIn("passerelle", texte)
|
||||
|
||||
def test_the_stopped_vms_start_again_even_when_nothing_moved(self):
|
||||
"""Un refus ne doit pas laisser le parc éteint : les VM arrêtées pour
|
||||
l'opération sont rendues à l'état où on les a prises."""
|
||||
parc = Parc({"vm-a": "running"})
|
||||
lancer(parc, Args(prefix="192.168.2"), hote=("192.168.2.0/24",))
|
||||
self.assertEqual("running", parc.etats["vm-a"])
|
||||
|
||||
def test_a_prefix_already_served_is_not_redefined(self):
|
||||
"""Idempotence : relancer l'opération sur un réseau déjà bon ne le
|
||||
redéfinit pas, et n'abat donc rien sous les VM.
|
||||
|
||||
Le cycle d'extinction et de rallumage, lui, a lieu : c'est ce qui
|
||||
REBRANCHE le tap d'une VM sur le pont. Un réseau abattu laisse ses VM
|
||||
détachées, et libvirt ne les y remet pas de lui-même — sur un parc
|
||||
déjà déplacé, c'est la seule chose qui reste à faire."""
|
||||
parc = Parc({"vm-a": "running"})
|
||||
code, texte = lancer(parc, Args(prefix="192.168.131"))
|
||||
self.assertEqual(0, code)
|
||||
self.assertNotIn("net-define", parc.verbes())
|
||||
self.assertNotIn("net-destroy", parc.verbes())
|
||||
self.assertEqual(["shutdown", "net-autostart", "start"], parc.verbes())
|
||||
|
||||
def test_an_invalid_prefix_stops_the_program(self):
|
||||
"""Trois octets, et un /24 que ipaddress accepte. Un préfixe pris de
|
||||
travers réécrirait le XML au hasard."""
|
||||
for mauvais in ("192.168", "192.168.1.1", "192.168.999", "abc"):
|
||||
with self.assertRaises(SystemExit):
|
||||
NQ.valider_prefixe(mauvais)
|
||||
self.assertEqual("192.168.122", NQ.valider_prefixe("192.168.122"))
|
||||
|
||||
|
||||
class LaLecture(unittest.TestCase):
|
||||
def test_the_prefix_comes_from_the_subnet(self):
|
||||
self.assertEqual("192.168.122", NQ.prefixe_de("192.168.122.0/24"))
|
||||
self.assertEqual("", NQ.prefixe_de(""))
|
||||
|
||||
def test_the_attached_vms_are_read_from_the_persistent_definition(self):
|
||||
"""« --inactive » : la vue vivante d'une VM allumée est décorée de ce
|
||||
que libvirt lui a alloué au démarrage, mais c'est la définition qui
|
||||
dit à quel réseau elle revient."""
|
||||
parc = Parc({"vm-a": "running"})
|
||||
with mock.patch.object(NQ.DQ, "virsh_out", side_effect=parc.virsh_out):
|
||||
self.assertEqual(["vm-a"], NQ.domaines_du_reseau("default", False))
|
||||
self.assertEqual([], NQ.domaines_du_reseau("autre", False))
|
||||
|
||||
def test_a_paused_vm_counts_as_alive(self):
|
||||
"""Une VM en pause tient toujours son tap : la redéfinir sous elle la
|
||||
casse comme une VM qui tourne."""
|
||||
parc = Parc({"vm-a": "paused"})
|
||||
with mock.patch.object(NQ.DQ, "virsh_out", side_effect=parc.virsh_out):
|
||||
self.assertTrue(NQ.domaine_actif("vm-a", False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -383,6 +383,8 @@ class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
|
|||
"List available images": "_qemu_list_images",
|
||||
"Recover files from a VM disk (libguestfs)": "_qemu_recover_files",
|
||||
"Diagnostics (report to share)": "_qemu_diagnostics",
|
||||
"Show the libvirt network state": "_qemu_network_status",
|
||||
"Recreate the VM subnet": "_qemu_network_recreate",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue