PoC BunkerWeb 1.6.17 : docker-compose sur Proxmox, déployé par Ansible

Stack BunkerWeb (scheduler, UI, MariaDB, Redis), sites de test (nginx,
Juice Shop, go-httpbin), filtrage DOCKER-USER, script de tests, README
et schéma d'architecture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-27 11:11:07 +01:00
commit 099584c95c
42 files changed
+4260

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
---
apps_dir: "{{ poc_base_dir }}/apps"
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=PoC BunkerWeb - filtrage des ports applis (DOCKER-USER)
After=docker.service
Requires=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/bwpoc-firewall.sh
[Install]
WantedBy=multi-user.target docker.service
+6
View File
@@ -0,0 +1,6 @@
---
- name: Recharger le filtrage applis
ansible.builtin.systemd_service:
name: bwpoc-firewall.service
state: restarted
daemon_reload: true
+68
View File
@@ -0,0 +1,68 @@
---
- name: Créer l'arborescence des applis
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ apps_dir }}"
- "{{ apps_dir }}/www"
- name: Déposer la vitrine statique
ansible.builtin.template:
src: "www/{{ item }}.j2"
dest: "{{ apps_dir }}/www/{{ item }}"
mode: "0644"
loop:
- index.html
- contact.html
- name: Créer le réseau partagé bw-services (mode single)
ansible.builtin.shell: docker network inspect bw-services >/dev/null 2>&1 || { docker network create bw-services && echo created; }
register: apps_net
changed_when: "'created' in apps_net.stdout"
when: poc_mode == 'single'
- name: Générer le compose des applis
ansible.builtin.template:
src: compose.yml.j2
dest: "{{ apps_dir }}/compose.yml"
mode: "0644"
validate: docker compose -f %s config -q
- name: Démarrer les applis
community.docker.docker_compose_v2:
project_src: "{{ apps_dir }}"
project_name: apps
pull: missing
remove_orphans: true
state: present
- name: Filtrage des ports publiés (mode split)
when: poc_mode == 'split'
block:
- name: Installer iptables
ansible.builtin.apt:
name: iptables
state: present
- name: Déposer le script de filtrage
ansible.builtin.template:
src: bwpoc-firewall.sh.j2
dest: /usr/local/sbin/bwpoc-firewall.sh
mode: "0755"
notify: Recharger le filtrage applis
- name: Déposer l'unité systemd
ansible.builtin.copy:
src: bwpoc-firewall.service
dest: /etc/systemd/system/bwpoc-firewall.service
mode: "0644"
notify: Recharger le filtrage applis
- name: Activer le filtrage au démarrage
ansible.builtin.systemd_service:
name: bwpoc-firewall.service
enabled: true
state: started
daemon_reload: true
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# {{ ansible_managed }}
# N'autorise que le WAF (et les sources listées) à joindre les ports publiés
# des applis. Les règles vont dans DOCKER-USER, consultée par Docker avant
# ses propres règles ; --ctorigdstport filtre sur le port publié (avant DNAT).
set -eu
IFACE="{{ ansible_default_ipv4.interface }}"
PORTS="{{ apps_ports.values() | min }}:{{ apps_ports.values() | max }}"
iptables -N BWPOC 2>/dev/null || iptables -F BWPOC
{% for src in [waf_ip] + apps_direct_access_sources %}
iptables -A BWPOC -s {{ src }} -j RETURN
{% endfor %}
iptables -A BWPOC -j DROP
RULE="-i $IFACE -p tcp -m conntrack --ctstate NEW --ctorigdstport $PORTS -j BWPOC"
# shellcheck disable=SC2086
iptables -C DOCKER-USER $RULE 2>/dev/null || iptables -I DOCKER-USER $RULE
+56
View File
@@ -0,0 +1,56 @@
# {{ ansible_managed }}
# Sites de test du PoC BunkerWeb, mode {{ poc_mode }}
{% if poc_mode == 'split' %}
# Ports publiés sur {{ ansible_host }}, filtrés par la chaîne iptables BWPOC :
# seul le WAF ({{ waf_ip }}) peut les joindre.
{% else %}
# Aucun port publié : BunkerWeb joint les conteneurs par le réseau bw-services.
{% endif %}
services:
www:
image: {{ nginx_image }}
container_name: poc-www
restart: unless-stopped
read_only: true
tmpfs:
- /var/cache/nginx
- /run
- /tmp
volumes:
- ./www:/usr/share/nginx/html:ro
{% if poc_mode == 'split' %}
ports:
- "{{ ansible_host }}:{{ apps_ports.www }}:80"
{% endif %}
networks: [apps]
shop:
image: {{ juiceshop_image }}
container_name: poc-shop
restart: unless-stopped
{% if poc_mode == 'split' %}
ports:
- "{{ ansible_host }}:{{ apps_ports.shop }}:3000"
{% endif %}
networks: [apps]
api:
image: {{ httpbin_image }}
container_name: poc-api
restart: unless-stopped
read_only: true
{% if poc_mode == 'split' %}
ports:
- "{{ ansible_host }}:{{ apps_ports.api }}:8080"
{% endif %}
networks: [apps]
networks:
apps:
{% if poc_mode == 'single' %}
name: bw-services
external: true
{% else %}
name: poc-apps
{% endif %}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PoC BunkerWeb - contact</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
label, input, textarea, button { display: block; width: 100%; margin-bottom: .8rem; }
</style>
</head>
<body>
<h1>Contact</h1>
<p>Formulaire factice envoyé en GET : la vitrine est statique, c'est BunkerWeb qui
inspecte les paramètres. Essayez un message contenant
<code>&lt;script&gt;alert(1)&lt;/script&gt;</code> ou <code>' OR 1=1 --</code>.</p>
<form method="get" action="contact.html">
<label for="nom">Nom</label>
<input id="nom" name="nom">
<label for="message">Message</label>
<textarea id="message" name="message" rows="5"></textarea>
<button type="submit">Envoyer</button>
</form>
<p><a href="index.html">Retour</a></p>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PoC BunkerWeb - vitrine</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; color: #222; }
h1 { color: #0b7a75; }
code { background: #f2f2f2; padding: .1rem .3rem; border-radius: 3px; }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #ddd; padding: .4rem .6rem; text-align: left; }
</style>
</head>
<body>
<h1>PoC BunkerWeb</h1>
<p>Vous voyez cette page : vous avez passé le <strong>challenge antibot JavaScript</strong>
de BunkerWeb. Un <code>curl</code> sur cette même URL reste bloqué sur <code>/challenge</code>.</p>
<h2>Sites du PoC</h2>
<table>
<tr><th>Site</th><th>Application</th><th>Fonctionnalités mises en avant</th></tr>
<tr><td><a href="https://www.{{ poc_domain }}/">www</a></td><td>nginx statique</td><td>antibot, en-têtes de sécurité, HTTPS forcé</td></tr>
<tr><td><a href="https://shop.{{ poc_domain }}/">shop</a></td><td>OWASP Juice Shop</td><td>ModSecurity + OWASP CRS 4, websockets, User-Agents malveillants</td></tr>
<tr><td><a href="https://api.{{ poc_domain }}/get">api</a></td><td>go-httpbin</td><td>limitation de débit, méthodes, taille max, bad behavior, règle ModSecurity maison</td></tr>
<tr><td><a href="https://bwui.{{ poc_domain }}/">bwui</a></td><td>UI BunkerWeb</td><td>rapports, bannissements, réglages</td></tr>
</table>
<h2>Essais rapides dans le navigateur</h2>
<ul>
<li><a href="contact.html">Formulaire de contact</a> : saisissez <code>&lt;script&gt;alert(1)&lt;/script&gt;</code> et envoyez.</li>
<li><a href="/?file=../../../../etc/passwd">Traversée de répertoire</a> : réponse 403 attendue.</li>
</ul>
<p><small>Servi par {{ nginx_image }} derrière BunkerWeb {{ bunkerweb_version }}.</small></p>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
---
bw_dir: "{{ poc_base_dir }}/bunkerweb"
+64
View File
@@ -0,0 +1,64 @@
---
- name: Créer le répertoire de la stack BunkerWeb
ansible.builtin.file:
path: "{{ bw_dir }}"
state: directory
owner: root
group: root
mode: "0750"
- name: Créer le réseau partagé bw-services
ansible.builtin.shell: docker network inspect bw-services >/dev/null 2>&1 || { docker network create bw-services && echo created; }
register: bw_net
changed_when: "'created' in bw_net.stdout"
- name: Générer le compose BunkerWeb
ansible.builtin.template:
src: compose.yml.j2
dest: "{{ bw_dir }}/compose.yml"
owner: root
group: root
mode: "0600" # contient les secrets
validate: docker compose -f %s config -q
- name: Démarrer la stack BunkerWeb
community.docker.docker_compose_v2:
project_src: "{{ bw_dir }}"
project_name: bunkerweb
pull: missing
remove_orphans: true
state: present
register: bw_compose
- name: Attendre que BunkerWeb réponde sur 443
ansible.builtin.wait_for:
host: "{{ ansible_host }}"
port: 443
timeout: 300
- name: Attendre que le site api réponde au travers du WAF
ansible.builtin.uri:
url: "https://{{ ansible_host }}/get"
headers:
Host: "api.{{ poc_domain }}"
validate_certs: false
status_code: 200
register: bw_probe
until: bw_probe.status == 200
retries: 30
delay: 10
- name: Écrire le récapitulatif des accès côté contrôleur (credentials/ACCES.txt)
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../credentials/ACCES.txt"
mode: "0600"
content: |
UI BunkerWeb : https://bwui.{{ poc_domain }}/ (WAF {{ ansible_host }})
Identifiant : {{ bw_ui_admin_user }}
Mot de passe : {{ bw_ui_admin_password }}
delegate_to: localhost
become: false
- name: Rappel des accès
ansible.builtin.debug:
msg: "UI BunkerWeb : https://bwui.{{ poc_domain }}/ , identifiants dans credentials/ACCES.txt"
+133
View File
@@ -0,0 +1,133 @@
# {{ ansible_managed }}
# Stack BunkerWeb {{ bunkerweb_version }} : WAF + scheduler + UI + MariaDB + Redis
# Mode {{ poc_mode }}{% if poc_mode == 'split' %}, applis sur {{ apps_ip }}{% endif %}
{% macro upstream(name, site) -%}
{%- if site.upstream is match('^https?://') -%}
{{ site.upstream }}
{%- elif poc_mode == 'single' -%}
http://poc-{{ site.upstream }}:{{ apps_internal_ports[site.upstream] }}
{%- else -%}
http://{{ apps_ip }}:{{ apps_ports[site.upstream] }}
{%- endif -%}
{%- endmacro %}
{% macro val(v) %}{{ (v | string | replace('$', '$$')) | to_json }}{% endmacro %}
x-db-env: &db-env
DATABASE_URI: {{ val('mariadb+pymysql://bunkerweb:' ~ bw_db_password ~ '@bw-db:3306/db') }}
x-api-env: &api-env
API_WHITELIST_IP: "127.0.0.0/8 10.20.30.0/24"
services:
bunkerweb:
image: bunkerity/bunkerweb:{{ bunkerweb_version }}
container_name: bunkerweb
restart: unless-stopped
ports:
- "80:8080/tcp"
- "443:8443/tcp"
- "443:8443/udp" # HTTP/3 (QUIC)
labels:
- "bunkerweb.INSTANCE=yes"
environment:
<<: [*db-env, *api-env]
networks:
- bw-universe
- bw-services
bw-scheduler:
image: bunkerity/bunkerweb-scheduler:{{ bunkerweb_version }}
container_name: bw-scheduler
restart: unless-stopped
depends_on:
- bunkerweb
- bw-db
- bw-redis
volumes:
- bw-storage:/data
environment:
<<: [*db-env, *api-env]
BUNKERWEB_INSTANCES: "bunkerweb"
SERVER_NAME: {{ val(bw_sites.keys() | map('regex_replace', '$', '.' ~ poc_domain) | join(' ')) }}
# --- Réglages globaux ---
{% for k, v in bw_global_settings.items() %}
{{ k }}: {{ val(v) }}
{% endfor %}
{% for name, site in bw_sites.items() %}
{% set fqdn = name ~ '.' ~ poc_domain %}
# --- {{ fqdn }} ---
{{ fqdn }}_USE_REVERSE_PROXY: "yes"
{{ fqdn }}_REVERSE_PROXY_URL: "/"
{{ fqdn }}_REVERSE_PROXY_HOST: {{ val(upstream(name, site)) }}
{{ fqdn }}_SELF_SIGNED_SSL_SUBJ: {{ val('/CN=' ~ fqdn ~ '/') }}
{% for k, v in (site.settings | default({})).items() %}
{{ fqdn }}_{{ k }}: {{ val(v) }}
{% endfor %}
{% for k, v in (site.custom_confs | default({})).items() %}
{{ fqdn }}_CUSTOM_CONF_{{ k }}: {{ val(v) }}
{% endfor %}
{% endfor %}
networks:
- bw-universe
- bw-db
bw-ui:
image: bunkerity/bunkerweb-ui:{{ bunkerweb_version }}
container_name: bw-ui
restart: unless-stopped
depends_on:
- bw-db
volumes:
- bw-ui-data:/data # secrets Flask, clés TOTP et Biscuit
environment:
<<: *db-env
ADMIN_USERNAME: {{ val(bw_ui_admin_user) }}
ADMIN_PASSWORD: {{ val(bw_ui_admin_password) }}
TZ: "Europe/Paris"
networks:
- bw-universe
- bw-db
bw-db:
image: {{ mariadb_image }}
container_name: bw-db
restart: unless-stopped
command: --max-allowed-packet=67108864
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
MYSQL_DATABASE: "db"
MYSQL_USER: "bunkerweb"
MYSQL_PASSWORD: {{ val(bw_db_password) }}
volumes:
- bw-data:/var/lib/mysql
networks:
- bw-db
bw-redis:
image: {{ redis_image }}
container_name: bw-redis
restart: unless-stopped
command: ["redis-server", "--requirepass", {{ val(bw_redis_password) }}, "--save", "60", "1"]
volumes:
- bw-redis:/data
networks:
- bw-universe
volumes:
bw-data:
bw-storage:
bw-ui-data:
bw-redis:
networks:
bw-universe:
name: bw-universe
ipam:
driver: default
config:
- subnet: 10.20.30.0/24 # seul réseau autorisé à parler à l'API BunkerWeb
bw-services:
name: bw-services # réseau des applis (mode single), créé par Ansible
external: true
bw-db:
name: bw-db
+44
View File
@@ -0,0 +1,44 @@
---
- name: Installer les outils de test
ansible.builtin.apt:
name:
- curl
- jq
- sqlmap
- nmap
- apache2-utils # ab
- dnsutils
state: present
update_cache: true
cache_valid_time: 3600
- name: Créer le répertoire des tests
ansible.builtin.file:
path: "{{ poc_base_dir }}/tests"
state: directory
mode: "0755"
- name: Déposer le script de tests
ansible.builtin.copy:
src: "{{ playbook_dir }}/../tests/bwpoc-tests.sh"
dest: "{{ poc_base_dir }}/tests/bwpoc-tests.sh"
mode: "0755"
- name: Déposer la configuration des tests
ansible.builtin.copy:
dest: "{{ poc_base_dir }}/tests/bwpoc-tests.env"
mode: "0644"
content: |
# Généré par Ansible
WAF_IP={{ waf_ip }}
DOMAIN={{ poc_domain }}
APPS_IP={{ apps_ip }}
PORT_WWW={{ apps_ports.www }}
PORT_SHOP={{ apps_ports.shop }}
PORT_API={{ apps_ports.api }}
- name: Lien pratique dans le PATH
ansible.builtin.file:
src: "{{ poc_base_dir }}/tests/bwpoc-tests.sh"
dest: /usr/local/bin/bwpoc-tests
state: link
+54
View File
@@ -0,0 +1,54 @@
---
- name: Attendre la fin de cloud-init
ansible.builtin.command: cloud-init status --wait
changed_when: false
failed_when: false
- name: Installer les paquets de base
ansible.builtin.apt:
name:
- qemu-guest-agent
- ca-certificates
- curl
- gnupg
- jq
- python3-yaml
- python3-debian # requis par deb822_repository
- chrony
state: present
update_cache: true
cache_valid_time: 3600
- name: Activer l'agent QEMU
ansible.builtin.systemd_service:
name: qemu-guest-agent
state: started
enabled: true
failed_when: false # inactif hors Proxmox
- name: Fuseau horaire Europe/Paris
ansible.builtin.file:
src: /usr/share/zoneinfo/Europe/Paris
dest: /etc/localtime
state: link
force: true
- name: Résolution locale des sites du PoC (vers le WAF)
ansible.builtin.blockinfile:
path: /etc/hosts
marker: "# {mark} PoC BunkerWeb"
block: |
{{ waf_ip }} www.{{ poc_domain }} shop.{{ poc_domain }} api.{{ poc_domain }} bwui.{{ poc_domain }}
- name: Vérifier la présence du modèle hosts de cloud-init
ansible.builtin.stat:
path: /etc/cloud/templates/hosts.debian.tmpl
register: ci_hosts_tmpl
- name: Persister la résolution si cloud-init gère /etc/hosts
ansible.builtin.blockinfile:
path: /etc/cloud/templates/hosts.debian.tmpl
marker: "# {mark} PoC BunkerWeb"
block: |
{{ waf_ip }} www.{{ poc_domain }} shop.{{ poc_domain }} api.{{ poc_domain }} bwui.{{ poc_domain }}
when: ci_hosts_tmpl.stat.exists
+5
View File
@@ -0,0 +1,5 @@
---
- name: Redémarrer Docker
ansible.builtin.systemd_service:
name: docker
state: restarted
+51
View File
@@ -0,0 +1,51 @@
---
# Installation de Docker CE depuis le dépôt officiel (Debian 13 trixie)
- name: Retirer les paquets Docker de la distribution
ansible.builtin.apt:
name: [docker.io, docker-compose, docker-doc, podman-docker, containerd, runc]
state: absent
- name: Déclarer le dépôt Docker
ansible.builtin.deb822_repository:
name: docker
types: [deb]
uris: https://download.docker.com/linux/debian
suites: ["{{ ansible_distribution_release }}"]
components: [stable]
architectures: [amd64]
signed_by: https://download.docker.com/linux/debian/gpg
state: present
- name: Installer Docker CE et le plugin compose
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
- name: Limiter la taille des journaux des conteneurs
ansible.builtin.copy:
dest: /etc/docker/daemon.json
mode: "0644"
content: |
{
"log-driver": "json-file",
"log-opts": { "max-size": "20m", "max-file": "3" }
}
notify: Redémarrer Docker
- name: Démarrer Docker
ansible.builtin.systemd_service:
name: docker
state: started
enabled: true
- name: Ajouter l'utilisateur au groupe docker
ansible.builtin.user:
name: "{{ poc_vm_user }}"
groups: docker
append: true
+45
View File
@@ -0,0 +1,45 @@
---
# Modèle Debian 13 cloud-init, créé une seule fois sur le nœud Proxmox.
- name: Le modèle existe-t-il déjà ?
ansible.builtin.command: qm status {{ pve_template_vmid }}
register: tpl_status
changed_when: false
failed_when: false
- name: Créer le modèle
when: tpl_status.rc != 0
block:
- name: Télécharger l'image Debian genericcloud
ansible.builtin.get_url:
url: "{{ pve_image_url }}"
dest: "{{ pve_image_path }}"
mode: "0644"
- name: Créer la VM modèle
ansible.builtin.command: >-
qm create {{ pve_template_vmid }}
--name {{ pve_template_name }}
--ostype l26 --cpu host --cores 2 --memory 2048
--machine q35
--scsihw virtio-scsi-single
--net0 virtio,bridge={{ pve_bridge }}{{ (',tag=' ~ pve_vlan_tag) if pve_vlan_tag | string | length > 0 else '' }}
--serial0 socket --vga serial0
--agent enabled=1
changed_when: true
- name: Importer le disque système
ansible.builtin.command: >-
qm set {{ pve_template_vmid }}
--scsi0 {{ pve_storage }}:0,import-from={{ pve_image_path }},discard=on,iothread=1,ssd=1
changed_when: true
- name: Ajouter le lecteur cloud-init et l'ordre de démarrage
ansible.builtin.command: >-
qm set {{ pve_template_vmid }}
--ide2 {{ pve_storage }}:cloudinit
--boot order=scsi0
changed_when: true
- name: Convertir en modèle
ansible.builtin.command: qm template {{ pve_template_vmid }}
changed_when: true
+13
View File
@@ -0,0 +1,13 @@
---
# Clone et configure les VM du PoC à partir du modèle (exécuté sur le nœud Proxmox).
- name: Déposer la clé SSH publique pour cloud-init
ansible.builtin.copy:
content: "{{ lookup('ansible.builtin.file', poc_ssh_pubkey_file) }}\n"
dest: /root/bwpoc_authorized_keys
mode: "0600"
- name: Créer chaque VM
ansible.builtin.include_tasks: vm.yml
loop: "{{ groups['poc_vms'] | unique }}"
loop_control:
loop_var: vm_name
+56
View File
@@ -0,0 +1,56 @@
---
- name: "{{ vm_name }} : lecture des paramètres"
ansible.builtin.set_fact:
vm: "{{ hostvars[vm_name] }}"
- name: "{{ vm_name }} : la VM existe-t-elle ?"
ansible.builtin.command: qm status {{ vm.vm_id }}
register: vm_status
changed_when: false
failed_when: false
- name: "{{ vm_name }} : création"
when: vm_status.rc != 0
block:
- name: "{{ vm_name }} : l'IP {{ vm.ansible_host }} doit être libre"
ansible.builtin.command: ping -c 2 -W 1 {{ vm.ansible_host }}
register: ip_ping
changed_when: false
failed_when: ip_ping.rc == 0
- name: "{{ vm_name }} : clonage du modèle"
ansible.builtin.command: >-
qm clone {{ pve_template_vmid }} {{ vm.vm_id }}
--name {{ vm_name }} --full 1 --storage {{ pve_storage }}
changed_when: true
- name: "{{ vm_name }} : ressources et cloud-init"
ansible.builtin.command: >-
qm set {{ vm.vm_id }}
--cores {{ vm.vm_cores }} --memory {{ vm.vm_memory }}
--onboot 1 --tags bwpoc
--ciuser {{ poc_vm_user }}
--sshkeys /root/bwpoc_authorized_keys
--ipconfig0 ip={{ vm.ansible_host }}/{{ poc_net_prefix }},gw={{ poc_net_gateway }}
--nameserver "{{ poc_net_dns | join(' ') }}"
--searchdomain {{ poc_net_searchdomain }}
--ciupgrade 0
changed_when: true
- name: "{{ vm_name }} : agrandissement du disque à {{ vm.vm_disk_gb }} Go"
ansible.builtin.command: qm resize {{ vm.vm_id }} scsi0 {{ vm.vm_disk_gb }}G
changed_when: true
- name: "{{ vm_name }} : démarrage"
ansible.builtin.command: qm start {{ vm.vm_id }}
register: vm_start
changed_when: vm_start.rc == 0
failed_when: vm_start.rc != 0 and 'already running' not in vm_start.stderr
- name: "{{ vm_name }} : attente du SSH"
ansible.builtin.wait_for:
host: "{{ vm.ansible_host }}"
port: 22
timeout: 300
delegate_to: localhost
become: false