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 usingsudo
.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 mainsshd_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
(ordist
): Applies security patches and updates.safe
is generally less disruptive thandist
. 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 injail.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.
# 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
- Ansible Documentation: https://docs.ansible.com/
- CIS Benchmarks: https://www.cisecurity.org/cis-benchmarks/
- Ansible
community.general.ufw
Module: https://docs.ansible.com/ansible/latest/collections/community/general/ufw_module.html - Ansible
ansible.posix.sysctl
Module: https://docs.ansible.com/ansible/latest/collections/ansible/posix/sysctl_module.html - Fail2ban: https://www.fail2ban.org/
Comments