From ce7e2bc20578ae0dd1c2fc770a1845694049ec20 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Sun, 6 Sep 2026 20:27:50 -0400 Subject: [PATCH] Add guest playbooks: ssh hardening, reboot check, auto updates, service check, guest agent, admin user Claude-Session: https://claude.ai/code/session_01JEczyvRjfakkiDNkbZA7PR --- playbooks/auto_updates.yml | 144 ++++++++++++++++++++++++ playbooks/reboot_check.yml | 146 ++++++++++++++++++++++++ playbooks/service_check.yml | 82 ++++++++++++++ playbooks/ssh_harden.yml | 209 +++++++++++++++++++++++++++++++++++ playbooks/user_setup.yml | 104 +++++++++++++++++ playbooks/vm_guest_agent.yml | 39 +++++++ 6 files changed, 724 insertions(+) create mode 100644 playbooks/auto_updates.yml create mode 100644 playbooks/reboot_check.yml create mode 100644 playbooks/service_check.yml create mode 100644 playbooks/ssh_harden.yml create mode 100644 playbooks/user_setup.yml create mode 100644 playbooks/vm_guest_agent.yml diff --git a/playbooks/auto_updates.yml b/playbooks/auto_updates.yml new file mode 100644 index 0000000..669a29e --- /dev/null +++ b/playbooks/auto_updates.yml @@ -0,0 +1,144 @@ +--- +# Enables automatic security updates: unattended-upgrades on Ubuntu and +# dnf-automatic on Rocky Linux. Neither reboots automatically; use +# reboot_check.yml for that. + +- name: Check Host Availability + hosts: ubuntu:rocky + gather_facts: false + ignore_unreachable: true + + tasks: + - name: Ping hosts to check availability + ansible.builtin.ping: + register: ping_result + ignore_errors: true + ignore_unreachable: true + + - name: Add reachable hosts to group + ansible.builtin.group_by: + key: reachable_hosts + when: ping_result is succeeded + + - name: Report offline hosts + ansible.builtin.debug: + msg: "{{ inventory_hostname }} - OFFLINE" + when: ping_result is not succeeded + delegate_to: localhost + +- name: Auto Updates - Ubuntu + hosts: ubuntu:&reachable_hosts + become: true + gather_facts: false + + tasks: + - name: Install unattended-upgrades + ansible.builtin.apt: + name: unattended-upgrades + state: present + update_cache: true + cache_valid_time: 3600 + + - name: Enable periodic package list refresh and upgrades + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + content: | + // Managed by Ansible (playbooks/auto_updates.yml) + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + APT::Periodic::AutocleanInterval "7"; + owner: root + group: root + mode: '0644' + + - name: Configure unattended-upgrades behaviour + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/52unattended-upgrades-local + content: | + // Managed by Ansible (playbooks/auto_updates.yml) + // Allowed origins (security / ESM) come from the stock 50unattended-upgrades. + Unattended-Upgrade::Remove-Unused-Dependencies "true"; + Unattended-Upgrade::Automatic-Reboot "false"; + owner: root + group: root + mode: '0644' + + - name: Enable apt timers + ansible.builtin.systemd: + name: "{{ item }}" + enabled: true + state: started + loop: + - apt-daily.timer + - apt-daily-upgrade.timer + + - name: Dry-run unattended-upgrade + ansible.builtin.command: unattended-upgrade --dry-run -v + register: uu_dry_run + changed_when: false + failed_when: false + + - name: List apt timers # noqa: command-instead-of-module (read-only listing) + ansible.builtin.command: systemctl list-timers apt-daily* --no-pager --all + register: apt_timers + changed_when: false + + - name: Print report + ansible.builtin.debug: + msg: | + ====================================================== + HOST: {{ inventory_hostname }} ({{ ansible_host }}) + ====================================================== + TIMERS: + {{ apt_timers.stdout }} + ------------------------------------------------------ + DRY RUN (last lines): + {{ (uu_dry_run.stdout_lines + uu_dry_run.stderr_lines)[-6:] | join('\n') }} + ====================================================== + +- name: Auto Updates - Rocky Linux + hosts: rocky:&reachable_hosts + become: true + gather_facts: false + + tasks: + - name: Install dnf-automatic + ansible.builtin.dnf: + name: dnf-automatic + state: present + + - name: Configure dnf-automatic for security updates + community.general.ini_file: + path: /etc/dnf/automatic.conf + section: "{{ item.section }}" + option: "{{ item.option }}" + value: "{{ item.value }}" + mode: '0644' + loop: + - { section: commands, option: upgrade_type, value: security } + - { section: commands, option: download_updates, value: "yes" } + - { section: commands, option: apply_updates, value: "yes" } + - { section: emitters, option: emit_via, value: stdio } + loop_control: + label: "{{ item.section }}/{{ item.option }} = {{ item.value }}" + + - name: Enable dnf-automatic timer + ansible.builtin.systemd: + name: dnf-automatic.timer + enabled: true + state: started + + - name: List dnf-automatic timers # noqa: command-instead-of-module (read-only listing) + ansible.builtin.command: systemctl list-timers dnf-automatic* --no-pager --all + register: dnf_timers + changed_when: false + + - name: Print report + ansible.builtin.debug: + msg: | + ====================================================== + HOST: {{ inventory_hostname }} ({{ ansible_host }}) + ====================================================== + TIMERS: + {{ dnf_timers.stdout }} + ====================================================== diff --git a/playbooks/reboot_check.yml b/playbooks/reboot_check.yml new file mode 100644 index 0000000..3052a8c --- /dev/null +++ b/playbooks/reboot_check.yml @@ -0,0 +1,146 @@ +--- +# Reports which Ubuntu/Rocky guests need a reboot and, only when asked, +# reboots them. LXC containers restart via Proxmox when they reboot themselves. +# +# Extra vars (optional): +# do_reboot - default false (report only) +# reboot_exclude - hosts never rebooted by this playbook (default: [docker], +# which most likely runs Semaphore itself) + +- name: Check Host Availability + hosts: ubuntu:rocky + gather_facts: false + ignore_unreachable: true + + tasks: + - name: Ping hosts to check availability + ansible.builtin.ping: + register: ping_result + ignore_errors: true + ignore_unreachable: true + + - name: Add reachable hosts to group + ansible.builtin.group_by: + key: reachable_hosts + when: ping_result is succeeded + + - name: Report offline hosts + ansible.builtin.debug: + msg: "{{ inventory_hostname }} - OFFLINE" + when: ping_result is not succeeded + delegate_to: localhost + +- name: Reboot Check - Ubuntu + hosts: ubuntu:&reachable_hosts + become: true + gather_facts: true + + tasks: + - name: Check for the reboot-required flag + ansible.builtin.stat: + path: /var/run/reboot-required + register: reboot_flag + + - name: Read the packages that requested a reboot + ansible.builtin.slurp: + src: /var/run/reboot-required.pkgs + register: reboot_pkgs + when: reboot_flag.stat.exists + failed_when: false + + - name: Record reboot status + ansible.builtin.set_fact: + reboot_needed: "{{ reboot_flag.stat.exists }}" + reboot_reason: "{{ (reboot_pkgs.content | default('') | b64decode | trim | replace('\n', ', ')) if reboot_flag.stat.exists else '' }}" + +- name: Reboot Check - Rocky Linux + hosts: rocky:&reachable_hosts + become: true + gather_facts: true + + tasks: + - name: Ensure needs-restarting is available + ansible.builtin.dnf: + name: yum-utils + state: present + + # needs-restarting -r compares package install times against the kernel boot + # time, which inside an LXC container is the host's boot time, so the flag + # would never clear. Containers use the process-based check instead. + - name: Check whether a reboot is required (VM) + ansible.builtin.command: needs-restarting -r + register: needs_reboot + failed_when: needs_reboot.rc not in [0, 1] + changed_when: false + when: ansible_virtualization_type != "lxc" + + - name: Check for processes using updated libraries (LXC) + ansible.builtin.command: needs-restarting + register: stale_procs + failed_when: stale_procs.rc not in [0, 1] + changed_when: false + when: ansible_virtualization_type == "lxc" + + - name: Record reboot status + ansible.builtin.set_fact: + reboot_needed: >- + {{ (needs_reboot.rc == 1) if ansible_virtualization_type != 'lxc' + else ((stale_procs.stdout | trim | length) > 0) }} + reboot_reason: >- + {{ (needs_reboot.stdout | default('') | trim | replace('\n', '; ')) if ansible_virtualization_type != 'lxc' + else ((stale_procs.stdout_lines | default([]) | length | string) ~ ' process(es) using updated libraries') }} + +- name: Reboot Check - Report + hosts: reachable_hosts + gather_facts: false + + tasks: + - name: Print reboot status + ansible.builtin.debug: + msg: >- + {{ inventory_hostname }} ({{ ansible_virtualization_type | default('unknown') }}) - + REBOOT NEEDED: {{ 'YES' if reboot_needed | default(false) | bool else 'no' }} + {{ ('- ' ~ reboot_reason) if (reboot_needed | default(false) | bool and reboot_reason | default('') | length > 0) else '' }} + +- name: Reboot Check - Reboot hosts that need it + hosts: reachable_hosts + become: true + gather_facts: false + + vars: + do_reboot: false + reboot_exclude: + - docker + + tasks: + - name: Report hosts skipped because they are excluded + ansible.builtin.debug: + msg: "SKIPPED: {{ inventory_hostname }} needs a reboot but is listed in reboot_exclude" + when: + - do_reboot | bool + - reboot_needed | default(false) | bool + - inventory_hostname in reboot_exclude + + # Inside a container the default boot_id belongs to the host and never + # changes, so the reboot module would wait forever. PID 1's start time does + # change when the container restarts. + - name: Reboot LXC container + ansible.builtin.reboot: + boot_time_command: "awk '{print $22}' /proc/1/stat" + reboot_timeout: 300 + msg: "Reboot initiated by Ansible (reboot_check.yml)" + when: + - do_reboot | bool + - reboot_needed | default(false) | bool + - inventory_hostname not in reboot_exclude + - ansible_virtualization_type == "lxc" + + - name: Reboot VM + ansible.builtin.reboot: + reboot_timeout: 300 + msg: "Reboot initiated by Ansible (reboot_check.yml)" + when: + - do_reboot | bool + - reboot_needed | default(false) | bool + - inventory_hostname not in reboot_exclude + - ansible_virtualization_type != "lxc" diff --git a/playbooks/service_check.yml b/playbooks/service_check.yml new file mode 100644 index 0000000..6d17533 --- /dev/null +++ b/playbooks/service_check.yml @@ -0,0 +1,82 @@ +--- +# Verifies the critical services listed per host in the inventory +# (critical_services) and optionally starts any that are stopped. +# +# Extra vars (optional): +# restart_stopped - default false; when true, start services found stopped + +- name: Check Host Availability + hosts: ubuntu:rocky + gather_facts: false + ignore_unreachable: true + + tasks: + - name: Ping hosts to check availability + ansible.builtin.ping: + register: ping_result + ignore_errors: true + ignore_unreachable: true + + - name: Add reachable hosts to group + ansible.builtin.group_by: + key: reachable_hosts + when: ping_result is succeeded + + - name: Report offline hosts + ansible.builtin.debug: + msg: "{{ inventory_hostname }} - OFFLINE" + when: ping_result is not succeeded + delegate_to: localhost + +- name: Service Check + hosts: reachable_hosts + become: true + gather_facts: false + + vars: + restart_stopped: false + + tasks: + - name: Gather service facts + ansible.builtin.service_facts: + + - name: Evaluate critical services + ansible.builtin.set_fact: + service_states: >- + {{ service_states | default([]) + + [{'name': item, 'state': (ansible_facts.services[item ~ '.service'].state | default('not-found'))}] }} + loop: "{{ critical_services | default([]) }}" + + - name: Print report + ansible.builtin.debug: + msg: | + ====================================================== + HOST: {{ inventory_hostname }} ({{ ansible_host }}) + ====================================================== + {% if service_states | default([]) | length == 0 %} + no critical services defined in inventory + {% else %} + {% for svc in service_states %} + {{ '%-24s' | format(svc.name) }} {{ 'RUNNING' if svc.state == 'running' else ('NOT FOUND' if svc.state == 'not-found' else svc.state | upper) }} + {% endfor %} + {% endif %} + ====================================================== + + - name: Warn on services that are not running + ansible.builtin.debug: + msg: "WARNING: {{ item.name }} is {{ 'not installed' if item.state == 'not-found' else item.state }} on {{ inventory_hostname }}" + loop: "{{ service_states | default([]) }}" + loop_control: + label: "{{ item.name }}" + when: item.state != 'running' + + - name: Start stopped services + ansible.builtin.service: + name: "{{ item.name }}" + state: started + loop: "{{ service_states | default([]) }}" + loop_control: + label: "{{ item.name }}" + when: + - restart_stopped | bool + - item.state not in ['running', 'not-found'] diff --git a/playbooks/ssh_harden.yml b/playbooks/ssh_harden.yml new file mode 100644 index 0000000..5912447 --- /dev/null +++ b/playbooks/ssh_harden.yml @@ -0,0 +1,209 @@ +--- +# Hardens sshd on every reachable Ubuntu/Rocky guest and turns on a fail2ban +# sshd jail. Settings go into /etc/ssh/sshd_config.d/00-ansible.conf, which +# sorts first and therefore wins over anything in sshd_config or the +# cloud-init drop-in (sshd keeps the first value it sees for each option). +# +# Lockout guard: password authentication is only disabled on hosts where +# root already has at least one authorized key. +# +# Extra vars (optional): +# ssh_disable_password_auth - default true +# ssh_max_auth_tries - default 4 + +- name: Check Host Availability + hosts: ubuntu:rocky + gather_facts: false + ignore_unreachable: true + + tasks: + - name: Ping hosts to check availability + ansible.builtin.ping: + register: ping_result + ignore_errors: true + ignore_unreachable: true + + - name: Add reachable hosts to group + ansible.builtin.group_by: + key: reachable_hosts + when: ping_result is succeeded + + - name: Report offline hosts + ansible.builtin.debug: + msg: "{{ inventory_hostname }} - OFFLINE" + when: ping_result is not succeeded + delegate_to: localhost + +- name: SSH Hardening - Install fail2ban on Ubuntu + hosts: ubuntu:&reachable_hosts + become: true + gather_facts: false + + tasks: + - name: Install fail2ban and nftables via apt + ansible.builtin.apt: + name: + - fail2ban + - nftables + state: present + update_cache: true + cache_valid_time: 3600 + +- name: SSH Hardening - Install fail2ban on Rocky Linux + hosts: rocky:&reachable_hosts + become: true + gather_facts: false + + tasks: + - name: Ensure EPEL repo is enabled + ansible.builtin.dnf: + name: epel-release + state: present + + - name: Install fail2ban and nftables via dnf + ansible.builtin.dnf: + name: + - fail2ban + - nftables + state: present + +- name: SSH Hardening - Configure sshd and fail2ban + hosts: reachable_hosts + become: true + gather_facts: false + + vars: + ssh_disable_password_auth: true + ssh_max_auth_tries: 4 + sshd_dropin_path: /etc/ssh/sshd_config.d/00-ansible.conf + + tasks: + - name: Count public keys in root's authorized_keys + ansible.builtin.command: grep -cE '^(ssh-|ecdsa-|sk-)' /root/.ssh/authorized_keys + register: key_count + failed_when: false + changed_when: false + + - name: Decide whether password authentication can be disabled + ansible.builtin.set_fact: + has_keys: "{{ key_count.rc == 0 and (key_count.stdout | int) > 0 }}" + + - name: Warn when root has no authorized keys + ansible.builtin.debug: + msg: "WARNING: no authorized keys for root on {{ inventory_hostname }} - password authentication stays enabled" + when: not has_keys + + - name: Ensure sshd_config.d directory exists + ansible.builtin.file: + path: /etc/ssh/sshd_config.d + state: directory + owner: root + group: root + mode: '0755' + + - name: Ensure sshd_config includes the drop-in directory + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^Include\s+/etc/ssh/sshd_config\.d/\*\.conf' + line: 'Include /etc/ssh/sshd_config.d/*.conf' + insertbefore: BOF + backup: true + register: sshd_include + + - name: Write sshd hardening drop-in + ansible.builtin.copy: + dest: "{{ sshd_dropin_path }}" + content: | + # Managed by Ansible (playbooks/ssh_harden.yml) - do not edit by hand + PermitRootLogin prohibit-password + PubkeyAuthentication yes + PasswordAuthentication {{ 'no' if (has_keys and ssh_disable_password_auth | bool) else 'yes' }} + KbdInteractiveAuthentication no + MaxAuthTries {{ ssh_max_auth_tries }} + X11Forwarding no + owner: root + group: root + mode: '0600' + validate: /usr/sbin/sshd -t -f %s + register: sshd_dropin + + - name: Record whether sshd needs a restart + ansible.builtin.set_fact: + sshd_changed: "{{ sshd_include is changed or sshd_dropin is changed }}" + + - name: Configure fail2ban sshd jail + ansible.builtin.copy: + dest: /etc/fail2ban/jail.d/sshd.local + content: | + # Managed by Ansible (playbooks/ssh_harden.yml) + [sshd] + enabled = true + backend = systemd + banaction = nftables-multiport + maxretry = 5 + findtime = 10m + bantime = 1h + owner: root + group: root + mode: '0644' + register: f2b_jail + + - name: Ensure fail2ban is enabled and running + ansible.builtin.systemd: + name: fail2ban + enabled: true + state: "{{ 'restarted' if f2b_jail is changed else 'started' }}" + +- name: SSH Hardening - Restart SSH on Ubuntu + hosts: ubuntu:&reachable_hosts + become: true + gather_facts: false + + tasks: + - name: Restart ssh service + ansible.builtin.service: + name: ssh + state: restarted + when: sshd_changed | bool + +- name: SSH Hardening - Restart SSH on Rocky Linux + hosts: rocky:&reachable_hosts + become: true + gather_facts: false + + tasks: + - name: Restart sshd service + ansible.builtin.service: + name: sshd + state: restarted + when: sshd_changed | bool + +- name: SSH Hardening - Verify access + hosts: reachable_hosts + become: true + gather_facts: false + + tasks: + - name: Drop the cached SSH connection so the next task performs a fresh login + ansible.builtin.meta: reset_connection + + - name: Confirm key-based login still works + ansible.builtin.ping: + + - name: Read fail2ban sshd jail status + ansible.builtin.command: fail2ban-client status sshd + register: f2b_status + changed_when: false + failed_when: false + + - name: Summary + ansible.builtin.debug: + msg: | + ====================================================== + HOST: {{ inventory_hostname }} ({{ ansible_host }}) + ====================================================== + PASSWORD AUTH: {{ 'disabled' if (has_keys and ssh_disable_password_auth | default(true) | bool) else 'ENABLED' }} + ROOT KEYS: {{ key_count.stdout if key_count.rc == 0 else 0 }} + SSHD: {{ 'restarted' if sshd_changed | bool else 'unchanged' }} + FAIL2BAN: {{ 'sshd jail active' if f2b_status.rc == 0 else 'WARNING: jail not reporting (' ~ (f2b_status.stderr | default('') | trim) ~ ')' }} + ====================================================== diff --git a/playbooks/user_setup.yml b/playbooks/user_setup.yml new file mode 100644 index 0000000..156015f --- /dev/null +++ b/playbooks/user_setup.yml @@ -0,0 +1,104 @@ +--- +# Creates a non-root admin user with sudo and the homelab SSH keys on every +# reachable Ubuntu/Rocky guest. First step toward running Ansible as a +# non-root user. +# +# Extra vars (optional): +# admin_user - default harry +# admin_sudo_nopasswd - default true (required for unattended become) +# admin_ssh_keys - list of public keys (defaults to the ansible-key and +# the personal key already used by add_root_key.yml) + +- name: Check Host Availability + hosts: ubuntu:rocky + gather_facts: false + ignore_unreachable: true + + tasks: + - name: Ping hosts to check availability + ansible.builtin.ping: + register: ping_result + ignore_errors: true + ignore_unreachable: true + + - name: Add reachable hosts to group + ansible.builtin.group_by: + key: reachable_hosts + when: ping_result is succeeded + + - name: Report offline hosts + ansible.builtin.debug: + msg: "{{ inventory_hostname }} - OFFLINE" + when: ping_result is not succeeded + delegate_to: localhost + +- name: User Setup - Create admin user on Ubuntu + hosts: ubuntu:&reachable_hosts + become: true + gather_facts: false + + vars: + admin_user: harry + + tasks: + - name: Create admin user in the sudo group - {{ admin_user }} + ansible.builtin.user: + name: "{{ admin_user }}" + shell: /bin/bash + groups: sudo + append: true + create_home: true + +- name: User Setup - Create admin user on Rocky Linux + hosts: rocky:&reachable_hosts + become: true + gather_facts: false + + vars: + admin_user: harry + + tasks: + - name: Create admin user in the wheel group - {{ admin_user }} + ansible.builtin.user: + name: "{{ admin_user }}" + shell: /bin/bash + groups: wheel + append: true + create_home: true + +- name: User Setup - Authorized keys and sudo + hosts: reachable_hosts + become: true + gather_facts: false + + vars: + admin_user: harry + admin_sudo_nopasswd: true + admin_ssh_keys: + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINegitHaQG8i+s412h3wTWf/KvpeX1CEpYFva9MhHR+7 ansible-key" + - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDbFKxd6W+gHcNrugkxMwvixGmk3LxtsMEnSdaFp4L78WZ4VhdZrsnSJQ/rViT+vuc6K5SeugtggfQ90L25UyyMv2BhLZaoMS2dpoVJEZon/Y2Hemm11vdEApAK/u0kPTTM5JB0/TZfpoP9sr4pmRIUruRXBhLDvtUV9tJvKSzbhChtB+vBg+MpywG9VvDP6eCVpFaP43RcAFtJR7irJmexd5m4CVhIFaWNeTbbe8YI08i2gDinYl95ygu9i/TorQKLWme2v9ejxQFfJTWGaXHnQ18W0X94Ns4a4OccGcvJ8JmvYsaAPRAOSveq1Fpdw1VXHSKigTmRl6pHWd9/rq6K6Ojctbjo1kHiNHdLKLZbLDeM0OQlzNl74snsImmZddY59wnOKyHtfY0ZI7sQ+7A/0cSaMY6h7lt2xK0WmayuoT8UtKZlItZHCkOA5bV29arI/HTz+yR/LW8SihPPFpVv5aDiOUs0PYd9j0+CtULBJeYpMQJpkD93g06vgEJjhUOBZ6JmilxVDihDARU0x0xiRCQJcq4Nhk/x3GzPe2kkrTI+nZFaQn1K8hcBdSyA12/xAG8Fh1SMCxLwJdCNuL3gQoYd6XPzKqn6BIqAgiyq5DbHhO6mehOye4Rro6i/H1UkZe862QH/R5uH+8Z2/FYRpiCPzQyLwrqx/eVfT7JMFQ== harwinder@harwinder.net" # yamllint disable-line rule:line-length + + tasks: + - name: Add authorized keys for {{ admin_user }} + ansible.posix.authorized_key: + user: "{{ admin_user }}" + key: "{{ item }}" + state: present + loop: "{{ admin_ssh_keys }}" + loop_control: + label: "{{ item.split()[-1] }}" + + - name: Grant sudo to {{ admin_user }} + ansible.builtin.copy: + dest: "/etc/sudoers.d/90-{{ admin_user }}" + content: "{{ admin_user }} ALL=(ALL) {{ 'NOPASSWD:' if admin_sudo_nopasswd | bool else '' }}ALL\n" + owner: root + group: root + mode: '0440' + validate: /usr/sbin/visudo -cf %s + + - name: Summary + ansible.builtin.debug: + msg: >- + {{ inventory_hostname }} - user {{ admin_user }} ready + (sudo {{ 'NOPASSWD' if admin_sudo_nopasswd | bool else 'with password' }}, {{ admin_ssh_keys | length }} key(s)) diff --git a/playbooks/vm_guest_agent.yml b/playbooks/vm_guest_agent.yml new file mode 100644 index 0000000..b0fcb81 --- /dev/null +++ b/playbooks/vm_guest_agent.yml @@ -0,0 +1,39 @@ +--- +# Installs and enables the QEMU guest agent on VMs (group "vms") so Proxmox +# can query IPs, quiesce filesystems for snapshots and shut the VM down cleanly. +# The VM also needs the agent enabled on the Proxmox side: +# qm set --agent enabled=1 (then power-cycle the VM once) + +- name: VM Guest Agent - Install and enable qemu-guest-agent + hosts: vms + become: true + gather_facts: true + + tasks: + - name: Install qemu-guest-agent + ansible.builtin.package: + name: qemu-guest-agent + state: present + + - name: Check whether the guest agent virtio port is present + ansible.builtin.stat: + path: /dev/virtio-ports/org.qemu.guest_agent.0 + register: agent_port + + - name: Enable and start qemu-guest-agent + ansible.builtin.systemd: + name: qemu-guest-agent + enabled: true + state: started + when: agent_port.stat.exists + + - name: Warn when the agent device is missing + ansible.builtin.debug: + msg: "WARNING: {{ inventory_hostname }} has no guest agent device - run 'qm set --agent enabled=1' on the Proxmox host and power-cycle the VM" + when: not agent_port.stat.exists + + - name: Summary + ansible.builtin.debug: + msg: >- + {{ inventory_hostname }} ({{ ansible_virtualization_type | default('unknown') }}) - + qemu-guest-agent {{ 'RUNNING' if agent_port.stat.exists else 'NOT AVAILABLE' }}