Automating Server Hardening with Ansible: A Practical Guide

AnsibleSecurityAutomationHardeningDevOpsInfrastructure as Code

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:

  • Consistency: Every server configured with the same playbook receives identical hardening settings.
  • Repeatability: Easily apply the same hardening baseline to new servers or re-apply it to existing ones.
  • Auditability: Playbooks serve as version-controlled documentation of your security configuration.
  • Efficiency: Automate tasks that would take hours manually, freeing up time for other priorities.
  • Compliance: Consistently meet security benchmarks (like CIS Benchmarks) across your fleet.

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:

  • Playbook: A YAML file defining a set of tasks to be executed on target hosts.
  • Inventory: A list of hosts (servers) that Ansible manages.
  • Module: A unit of code Ansible executes (e.g., apt for package management, service for managing services, lineinfile for modifying files). Modules are designed to be idempotent (running them multiple times has the same effect as running them once).
  • Task: An action within a playbook, typically involving executing a module with specific parameters.
  • Handler: A special task triggered by a notify directive on another task, usually used to restart services only if a configuration change occurred.
  • Role: A way to organize related tasks, variables, files, and handlers into a reusable structure, promoting modularity. While we’ll show tasks directly here, organizing these into roles (e.g., an ssh_hardening role) is best practice for larger setups.

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?

  • PasswordAuthentication no: Prevents brute-force password guessing attacks. Requires SSH keys.
  • PermitRootLogin no: Reduces risk by forcing logins as non-privileged users first, then using sudo.
  • PubkeyAuthentication yes: Explicitly enables more secure key-based authentication.
  • AllowUsers / AllowGroups: Restricts access to only authorized personnel (Principle of Least Privilege).
  • Handlers (notify, listen): Ensure the SSH service is restarted only if a configuration file actually changed, making the playbook idempotent and efficient. Validating the config (sshd -t) before restarting prevents locking yourself out due to syntax errors.
  • Drop-in Config Files (sshd_config.d/): Modifying separate files in /etc/ssh/sshd_config.d/ is often preferred over editing the main sshd_config directly. It makes managing configurations from different sources (e.g., base OS, hardening role, specific app needs) cleaner and less prone to conflicts.

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?

  • Default Deny: A core security principle – block everything by default and only explicitly allow necessary traffic.
  • Specific Port Rules: Reduces the attack surface by only exposing required services.
  • state: enabled: Ensures the firewall is active and persists across reboots.
  • Using Variables (ssh_port): Makes the playbook adaptable to different environments.

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?

  • update_cache: Ensures package lists are current before upgrading.
  • upgrade: safe (or dist): Applies security patches and updates. safe is generally less disruptive than dist. Choose based on your patching policy.
  • autoremove/purge: Reduces clutter and potential attack surface by removing orphaned packages and their configs.
  • Scheduling: This playbook should be run regularly (e.g., weekly or monthly) via scheduling tools like cron or Ansible Tower/AWX.

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?

  • state: stopped, enabled: no: Ensures services are not running now and won’t start on reboot.
  • loop / with_items: Efficiently applies the same action to multiple services.
  • ignore_errors: yes: Prevents the playbook from failing if a listed service isn’t installed on a particular host.

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?

  • Disabling IP forwarding and ICMP redirects reduces the server’s susceptibility to certain network attacks.
  • Enabling SYN cookies helps mitigate SYN flood DoS attacks.
  • Filesystem protections (protected_hardlinks, protected_symlinks) prevent certain types of race conditions and permission bypasses.
  • reload: yes: Ensures settings are applied without requiring a reboot.
  • Dedicated Config File: Keeps hardening settings separate from OS defaults.

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?

  • fail2ban monitors log files (like /var/log/auth.log) for patterns indicating failed login attempts and uses firewall rules (iptables) to temporarily ban offending IP addresses.
  • Using jail.local overrides defaults in jail.conf without modifying the original package file.

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.

```yaml
# 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?

  • MAC systems confine processes, limiting the damage an exploited application can cause even if running as root (though avoiding root is still paramount). enforcing mode actively blocks policy violations.

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?

  • Centralized logs provide a single point for analysis, correlation of events across multiple servers, and long-term retention for compliance and forensics.

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