Add guest playbooks: ssh hardening, reboot check, auto updates, service check, guest agent, admin user
Claude-Session: https://claude.ai/code/session_01JEczyvRjfakkiDNkbZA7PR
This commit is contained in:
@@ -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 }}
|
||||||
|
======================================================
|
||||||
@@ -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"
|
||||||
@@ -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']
|
||||||
@@ -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) ~ ')' }}
|
||||||
|
======================================================
|
||||||
@@ -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))
|
||||||
@@ -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 <vmid> --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 <vmid> --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' }}
|
||||||
Reference in New Issue
Block a user