Arun Shah

Automating Server Hardening with Ansible: A

Practical Guide

Automating Server Hardening with Ansible: A Practical Guide

Server hardening is a critical security practice involving configuring an operating system and its applications to minimize the attack surface and reduce vulnerabilities. Manually hardening servers is time-consuming, error-prone, and difficult to scale consistently across an infrastructure. This is where automation tools like Ansible shine.

Ansible allows you to define your desired server state declaratively using simple YAML playbooks. By automating hardening tasks, you ensure:

This guide explores practical examples of automating common server hardening tasks using Ansible.

Ansible Fundamentals Refresher

Before diving in, let’s recall some key Ansible concepts:

Example Playbook Structure (Conceptual)

A typical playbook might look like this:

---
- name: Harden Ubuntu Servers # Name of the play
  hosts: webservers # Target hosts or group from your inventory
  become: yes # Execute tasks with root privileges (sudo)
  vars: # Define variables used in the playbook
    ssh_port: 22
    allowed_users: "admin user1"

  tasks: # List of tasks to execute sequentially
    - name: Task 1 - Configure SSH
      # ... module calls ...
      notify: Restart SSH Service # Notify handler on change

    - name: Task 2 - Configure Firewall
      # ... module calls ...

    # ... other tasks ...

  handlers: # List of handlers triggered by 'notify'
    - name: Restart SSH Service
      ansible.builtin.service:
        name: sshd # Service name might vary (ssh vs sshd)
        state: restarted

Now let’s look at specific hardening tasks.

1. Configuring Secure SSH Access

SSH is often the primary management interface, making its security critical.

Goal: Disable password authentication, disable root login, allow only specific users/groups, use a non-standard port (optional), and ensure key-based authentication is enabled.

# tasks/main.yml (within an ssh_hardening role, or directly in playbook tasks)
---
- name: Ensure SSH configuration directory exists
  ansible.builtin.file:
    path: /etc/ssh/sshd_config.d
    state: directory
    mode: '0755'

# Use drop-in config files for easier management
- name: Harden SSH - Disable Password Authentication
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config.d/60-hardening.conf
    create: yes # Create the file if it doesn't exist
    mode: '0644'
    regexp: '^PasswordAuthentication'
    line: 'PasswordAuthentication no'
  notify: Validate and Restart SSH # Use a handler

- name: Harden SSH - Disable Root Login
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config.d/60-hardening.conf
    regexp: '^PermitRootLogin'
    line: 'PermitRootLogin no'
  notify: Validate and Restart SSH

- name: Harden SSH - Enable Public Key Authentication
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config.d/60-hardening.conf
    regexp: '^PubkeyAuthentication'
    line: 'PubkeyAuthentication yes'
  notify: Validate and Restart SSH

# Example: Allow specific users (use vars for flexibility)
- name: Harden SSH - Allow Specific Users
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config.d/60-hardening.conf
    regexp: '^AllowUsers'
    line: "AllowUsers {{ allowed_users }}" # Use variable defined in vars section
  notify: Validate and Restart SSH

# Example: Change default port (ensure firewall allows the new port!)
# - name: Harden SSH - Change Port
#   ansible.builtin.lineinfile:
#     path: /etc/ssh/sshd_config.d/60-hardening.conf
#     regexp: '^#?Port'
#     line: "Port {{ ssh_port }}" # Use variable
#   notify: Validate and Restart SSH

# handlers/main.yml
---
- name: Validate and Restart SSH
  block: # Group tasks for the handler
    - name: Validate sshd_config syntax
      ansible.builtin.command: "sshd -t" # Check config validity
      changed_when: false # This command doesn't change state
      check_mode: no # Always run validation, even in check mode

    - name: Restart sshd service
      ansible.builtin.service:
        name: sshd # Or 'ssh' depending on the OS
        state: restarted
  listen: "Validate and Restart SSH" # Name must match notify directive

Why these settings?

2. Enforcing Firewall Rules (using UFW)

Control network traffic to allow only necessary services. ufw (Uncomplicated Firewall) is a common frontend for iptables on Ubuntu/Debian.

Goal: Enable the firewall, set default deny policies, and allow specific ports (e.g., SSH, HTTP, HTTPS).

# tasks/main.yml (within a firewall role, or directly in playbook tasks)
---
- name: Ensure UFW is installed
  ansible.builtin.apt:
    name: ufw
    state: present

- name: Set default firewall policies (deny incoming, allow outgoing)
  community.general.ufw:
    default: deny
    direction: incoming

- name: Set default firewall policy (allow outgoing)
  community.general.ufw:
    default: allow
    direction: outgoing

- name: Allow SSH port (using variable)
  community.general.ufw:
    rule: allow
    port: "{{ ssh_port | default(22) }}" # Use variable, default to 22
    proto: tcp
    comment: "Allow SSH access"

- name: Allow HTTP traffic
  community.general.ufw:
    rule: allow
    port: "80"
    proto: tcp
    comment: "Allow HTTP"

- name: Allow HTTPS traffic
  community.general.ufw:
    rule: allow
    port: "443"
    proto: tcp
    comment: "Allow HTTPS"

# Add rules for other necessary ports (e.g., databases, monitoring agents)

- name: Enable and start UFW service
  community.general.ufw:
    state: enabled # Ensures firewall is active and enabled on boot

Why these settings?

3. Applying System Updates & Patching

Regularly applying security patches is fundamental. Ansible can automate this process.

Goal: Ensure all packages are updated to the latest versions and remove unused dependencies.

# tasks/main.yml (within an patching role, or directly in playbook tasks)
---
- name: Update apt package cache
  ansible.builtin.apt:
    update_cache: yes
    cache_valid_time: 3600 # Update cache if older than 1 hour
  changed_when: false # Don't report change for cache update

- name: Upgrade all packages to the latest version
  ansible.builtin.apt:
    # upgrade: dist # Performs 'apt-get dist-upgrade'
    upgrade: safe # Performs 'apt-get upgrade', safer default
    autoclean: yes # Clean up downloaded package files
  register: apt_upgrade_result
  # Consider adding checks or handlers for required reboots

- name: Autoremove unused packages installed as dependencies
  ansible.builtin.apt:
    autoremove: yes
    purge: yes # Also remove configuration files

Why these settings?

4. Disabling Unnecessary Services

Every running service potentially increases the attack surface.

Goal: Stop and disable services that are not required for the server’s function.

# tasks/main.yml
---
# Define unnecessary services in variables for flexibility
# Example: vars/main.yml
# unnecessary_services:
#   - avahi-daemon # Zeroconf networking
#   - cups         # Printing service
#   - nfs-common   # NFS client utilities (if not needed)

- name: Stop and disable unnecessary services
  ansible.builtin.service:
    name: "{{ item }}"
    state: stopped # Ensure service is stopped
    enabled: no # Ensure service doesn't start on boot
  loop: "{{ unnecessary_services | default([]) }}" # Loop through the list
  ignore_errors: yes # Continue if a service doesn't exist

Why these settings?

5. Kernel Parameter Hardening (Sysctl)

Tune kernel parameters for improved security and network behavior.

Goal: Apply recommended security-related sysctl settings.

# tasks/main.yml
---
# Define sysctl settings in variables
# Example: vars/main.yml
# secure_sysctl_settings:
#   # Network Hardening
#   - { name: net.ipv4.ip_forward, value: '0' } # Disable IP forwarding unless it's a router
#   - { name: net.ipv4.conf.all.accept_redirects, value: '0' }
#   - { name: net.ipv4.conf.default.accept_redirects, value: '0' }
#   - { name: net.ipv4.conf.all.secure_redirects, value: '0' }
#   - { name: net.ipv4.conf.default.secure_redirects, value: '0' }
#   - { name: net.ipv4.conf.all.send_redirects, value: '0' }
#   - { name: net.ipv4.conf.default.send_redirects, value: '0' }
#   - { name: net.ipv4.tcp_syncookies, value: '1' } # Help protect against SYN flood attacks
#   # Filesystem Hardening
#   - { name: fs.protected_hardlinks, value: '1' }
#   - { name: fs.protected_symlinks, value: '1' }

- name: Apply secure sysctl settings
  ansible.posix.sysctl: # Use fully qualified collection name (FQCN)
    name: "{{ item.name }}"
    value: "{{ item.value }}"
    sysctl_file: /etc/sysctl.d/90-hardening.conf # Use a dedicated config file
    state: present
    reload: yes # Apply settings immediately
  loop: "{{ secure_sysctl_settings | default([]) }}"

Why these settings?

6. Installing Host-Based Intrusion Detection (HIDS)

Monitor system activity for signs of compromise. fail2ban is useful for blocking brute-force attempts, while tools like aide or tripwire monitor file integrity.

Goal: Install and configure fail2ban to block SSH brute-force attempts.

# tasks/main.yml
---
- name: Install fail2ban package
  ansible.builtin.apt:
    name: fail2ban
    state: present

# Use template for local configuration overrides
- name: Configure fail2ban jail overrides (jail.local)
  ansible.builtin.template:
    src: templates/fail2ban.jail.local.j2 # Your custom template
    dest: /etc/fail2ban/jail.local
    owner: root
    group: root
    mode: '0644'
  notify: Restart fail2ban # Use handler

# handlers/main.yml
---
- name: Restart fail2ban
  ansible.builtin.service:
    name: fail2ban
    state: restarted
  listen: "Restart fail2ban"

templates/fail2ban.jail.local.j2 Example:

[DEFAULT]
# Whitelist your own IPs/networks
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24
bantime = 1h # Ban for 1 hour
findtime = 10m # Look back 10 minutes for failures
maxretry = 5 # Ban after 5 failures

[sshd]
enabled = true
port = {{ ssh_port | default(22) }} # Use SSH port variable
# Add other jails as needed (e.g., for web server logs)

Why these settings?

7. Configuring Mandatory Access Control (MAC)

SELinux or AppArmor provide an additional layer of security by enforcing fine-grained permissions beyond standard Linux discretionary access controls (DAC).

Goal: Ensure SELinux (on RHEL/CentOS derivatives) or AppArmor (on Ubuntu/Debian) is installed and in enforcing mode.

# tasks/main.yml (Example for SELinux on RHEL-based systems)
---
- name: Ensure SELinux is in enforcing mode
  ansible.posix.selinux:
    policy: targeted # Common default policy
    state: enforcing
  when: ansible_os_family == "RedHat" # Only run on RedHat family OS

# tasks/main.yml (Example for AppArmor on Debian-based systems)
---
- name: Ensure AppArmor is installed and enabled
  ansible.builtin.apt:
    name: apparmor
    state: present
  when: ansible_os_family == "Debian"

- name: Ensure AppArmor service is started and enabled
  ansible.builtin.service:
    name: apparmor
    state: started
    enabled: yes
  when: ansible_os_family == "Debian"

Why these settings?

8. Centralizing Log Management

Forwarding logs to a central server (like an ELK stack, Splunk, Graylog, or Loki) is crucial for security monitoring, analysis, and incident response.

Goal: Configure rsyslog (a common system logger) to forward logs to a central server.

# tasks/main.yml
---
# Ensure rsyslog is installed (usually default)
- name: Ensure rsyslog is installed
  ansible.builtin.package: # Use generic 'package' for broader compatibility
    name: rsyslog
    state: present

# Configure log forwarding using a template
- name: Configure rsyslog forwarding
  ansible.builtin.template:
    src: templates/forward-logs.conf.j2 # Template defining forwarding rule
    dest: /etc/rsyslog.d/60-forwarding.conf # Use drop-in config directory
    owner: root
    group: root
    mode: '0644'
  notify: Restart rsyslog # Use handler

# handlers/main.yml
---
- name: Restart rsyslog
  ansible.builtin.service:
    name: rsyslog
    state: restarted
  listen: "Restart rsyslog"

templates/forward-logs.conf.j2 Example:

# Forward all logs to a central server via TCP
*.* @@{{ central_log_server }}:{{ central_log_port | default(514) }}

(Define central_log_server and optionally central_log_port in your Ansible variables).

Why these settings?

Conclusion: Consistency Through Automation

Automating server hardening with Ansible transforms a tedious, error-prone task into a consistent, repeatable, and auditable process. This playbook provides a foundation; tailor it to your specific environment and compliance requirements (e.g., CIS Benchmarks). Consider organizing these tasks into dedicated Ansible Roles for better reusability. Regularly review and update your hardening playbooks as new threats emerge and best practices evolve. By embracing automation, you build a more secure and resilient infrastructure foundation.

References

  1. Ansible Documentation: https://docs.ansible.com/
  2. CIS Benchmarks: https://www.cisecurity.org/cis-benchmarks/
  3. Ansible community.general.ufw Module: https://docs.ansible.com/ansible/latest/collections/community/general/ufw_module.html
  4. Ansible ansible.posix.sysctl Module: https://docs.ansible.com/ansible/latest/collections/ansible/posix/sysctl_module.html
  5. Fail2ban: https://www.fail2ban.org/

Comments