5 ways to harden a new system with Ansible
This article discusses some common hardening tasks and how they can be accomplished in a repeatable way with Ansible. I provide a sample Ansible playbook at the end of the article that you can run against your systems on first boot to harden them. You can extend this playbook to include additional hardening tasks as you discover them.
Introduction
Every sysadmin has a checklist of security-related tasks that they execute against each new system. This checklist is a great habit, as it ensures that new servers in your environment meet a set of minimum security requirements. However, doing this work manually is also slow and error-prone. It's easy to encounter configuration inconsistencies due to the manual series of steps, and there's no way to address configuration drift without manually re-running your checklist.
Implementing your security workflow in Ansible is a great way to automate some "low hanging fruit" in your environment. This article discusses some of the basic steps that I take to harden a new system, and it shows you how to implement them using Ansible. The automations in this article aren't earth-shattering; they're probably little things that you already do to secure your systems. However, automating these tasks ensures that your infrastructure is configured in a consistent, repeatable way across your environment.
Before I get started, I'll quickly show you my environment so that you can follow along. I'm using a simple directory structure with a single Ansible playbook (main.yml
) and one host in my inventory:
$ tree
.
├── files
│ └── etc
│ ├── issue
│ ├── motd
│ ├── ssh
│ │ └── sshd_config
│ └── sudoers.d
│ └── admin
├── inventory.ini
└── main.yml
4 directories, 6 files
$ cat inventory.ini
nyc1-webserver-1.example.com
Patch software
The first thing that I like to do on a newly-imaged system is ensure that its software is fully patched. An incredible amount of attack surface can be eliminated by merely staying vigilant about patching. Ansible makes this easy. The task below fully patches all of your packages, and can easily be run regularly using cron
(or Ansible Tower, in a larger environment):
- name: Perform full patching
package:
name: '*'
state: latest
Secure remote access
Once my host is patched, I quickly secure remote access via SSH. First, I create a local user with sudo
permissions so that I can disable remote login by the root user. The tasks below are just an example, and you will probably want to customize them to meet your needs:
- name: Add admin group
group:
name: admin
state: present
- name: Add local user
user:
name: admin
group: admin
shell: /bin/bash
home: /home/admin
create_home: yes
state: present
- name: Add SSH public key for user
authorized_key:
user: admin
key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
state: present
- name: Add sudoer rule for local user
copy:
dest: /etc/sudoers.d/admin
src: etc/sudoers.d/admin
owner: root
group: root
mode: 0440
validate: /usr/sbin/visudo -csf %s
These tasks add a local "admin" user and group, add an SSH public key for the user, and add a sudo
rule for the admin user that permits passwordless sudo
. The SSH key will be the same public key for the user that is locally executing the Ansible playbook, as shown by the file lookup call.
There are many ways to customize sshd
to meet your unique security goals, but my fellow sudoer Nate Lager's post is a great start. Specifically, his post discusses configuration directives (such as disabling password authentication) in the sshd
configuration file. Ansible can be used to create a known-good configuration for all of the servers in your environment. This goes a long way toward enforcing a consistent security posture for one of your most critical services, especially if you remain vigilant about regularly executing Ansible across your hosts.
Ansible's copy
module is used to lay down this configuration file on remote systems:
- name: Add hardened SSH config
copy:
dest: /etc/ssh/sshd_config
src: etc/ssh/sshd_config
owner: root
group: root
mode: 0600
notify: Reload SSH
The SSH configuration file that I use is below. It's mostly a default file with some additional tuning, such as disabling password authentication and barring root login. You will likely develop your own configuration file best practices to meet your organization's needs, such as only permitting a specific set of approved ciphers.
$ cat files/etc/ssh/sshd_config
HostKey /etc/ssh/ssh_host_rsa_key
HostKey /etc/ssh/ssh_host_ecdsa_key
HostKey /etc/ssh/ssh_host_ed25519_key
SyslogFacility AUTHPRIV
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no
ChallengeResponseAuthentication no
GSSAPIAuthentication yes
GSSAPICleanupCredentials no
UsePAM yes
X11Forwarding no
Banner /etc/issue.net
AcceptEnv LANG LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES
AcceptEnv LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT
AcceptEnv LC_IDENTIFICATION LC_ALL LANGUAGE
AcceptEnv XMODIFIERS
Subsystem sftp /usr/libexec/openssh/sftp-server
PermitRootLogin no
PermitRootLogin no
Finally, notice that I use a handler to trigger a refresh of the sshd
service. That handler is found in the handlers section of the playbook. For more info about handlers and what they do, see the docs.
handlers:
- name: Reload SSH
service:
name: sshd
state: reloaded
Once you have SSH locked down from a configuration perspective, it's time to restrict SSH to only permitted IP addresses. If you're using the default firewalld
, this is easily done by moving the SSH service to the internal zone and establishing a list of allowed networks. Ansible makes this simple with the firewalld
module. Here is an example:
- name: Add SSH port to internal zone
firewalld:
zone: internal
service: ssh
state: enabled
immediate: yes
permanent: yes
- name: Add permitted networks to internal zone
firewalld:
zone: internal
source: "{{ item }}"
state: enabled
immediate: yes
permanent: yes
with_items: "{{ allowed_ssh_networks }}"
- name: Drop ssh from the public zone
firewalld:
zone: public
service: ssh
state: disabled
immediate: yes
permanent: yes
This task uses a with_items loop with the allowed_ssh_networks variable. That variable is defined in the vars section of the playbook:
vars:
allowed_ssh_networks:
- 192.168.122.0/24
- 10.10.10.0/24
In this case, the module restricts access to the internal zone to the 10.10.10.0/24 and 192.168.122.0/24 networks. The immediate and permanent parameters tell the module to apply the rules immediately and add them to firewalld's
permanent rules to persist on reboot. You can confirm the configuration by looking at the generated rules. For more information about firewalld
and its configuration, check out Enable Sysadmin's firewalld post.
[root@nyc1-webserver-1 ~]# firewall-cmd --list-all --zone=public
public (active)
target: default
icmp-block-inversion: no
interfaces: eth0
sources:
services: dhcpv6-client
ports:
protocols:
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
[root@nyc1-webserver-1 ~]# firewall-cmd --list-all --zone=internal
internal (active)
target: default
icmp-block-inversion: no
interfaces:
sources: 192.168.122.0/24 10.10.10.0/24
services: dhcpv6-client mdns samba-client ssh
ports:
protocols:
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
Disable unused software and services
With access to SSH locked down, I turn my attention to removing unused software and disabling unnecessary services. Ansible's package
and service
modules are up to the challenge, as seen below:
- name: Remove undesirable packages
package:
name: "{{ unnecessary_software }}"
state: absent
- name: Stop and disable unnecessary services
service:
name: "{{ item }}"
state: stopped
enabled: no
with_items: "{{ unnecessary_services }}"
ignore_errors: yes
There are two things to notice about these tasks. First, I again use variables (and loops, for the service task) to keep my playbook short and reusable. These variables have been added to the vars section of the playbook:
vars:
allowed_ssh_networks:
- 192.168.122.0/24
- 10.10.10.0/24
unnecessary_services:
- postfix
- telnet
unnecessary_software:
- tcpdump
- nmap-ncat
- wpa_supplicant
Second, I set ignore_errors
to yes for the service task. This prevents the playbook run from failing if a service doesn't exist on the target machine (which is acceptable). For example, many servers probably don't have the telnet
service. But if they do, I want to make sure it's disabled. By ignoring errors, I can successfully run this playbook against a host even if it doesn't have the telnet
service installed. If you're concerned with this approach, you could write more complex conditionals to only try disabling a service if it already exists on the remote system.
Security policy improvements
You've now performed several very concrete tasks that improve your newly-provisioned system's security posture. The last thing that I like to do on my systems isn't a technical security improvement—it's a process and legal-oriented control. I always ensure that my systems have a login banner and message of the day to alert users about my acceptable use policy. This ensures that there is no doubt that access to a system is restricted: it's printed right in the login banner and MOTD.
Installing these files is a perfect activity for Ansible's file
module since they rarely change across all of my servers. For more discussion about when to use the file
or copy
module, check out my recent article.
- name: Set a message of the day
copy:
dest: /etc/motd
src: etc/motd
owner: root
group: root
mode: 0644
- name: Set a login banner
copy:
dest: "{{ item }}"
src: etc/issue
owner: root
group: root
mode: 0644
with_items:
- /etc/issue
- /etc/issue.net
The contents of the actual files are a simple, terse explanation of authorized access and acceptable use. You will want to work with your company's legal team to ensure that the messaging is right for your organization.
$ cat files/etc/issue
Use of this system is restricted to authorized users only, and all use is subjected to an acceptable use policy.
IF YOU ARE NOT AUTHORIZED TO USE THIS SYSTEM, DISCONNECT NOW.
$ cat files/etc/motd
THIS SYSTEM IS FOR AUTHORIZED USE ONLY
By using this system, you agree to be bound by all policies found at https://wiki.example.com/acceptable_use_policy.html. Improper use of this system is subject to civil and legal penalties.
All activities are logged and monitored.
Wrap up
This article shows you how to bring together several server-hardening tasks into a single Ansible playbook to run against new systems (and continue running against existing systems) to improve your security posture. By leveraging some of the tips that you learned and combining them with your own security runbook, you can use Ansible to ensure quick, easy, and repeatable security configurations across your environment. This is a great way for the Ansible novice to begin automating a common workflow. It's also a perfect way for the experienced Ansible user to leverage their favorite tool to improve their organization's security posture.
The full playbook used in this article is included below:
---
- hosts: all
vars:
allowed_ssh_networks:
- 192.168.122.0/24
- 10.10.10.0/24
unnecessary_services:
- postfix
- telnet
unnecessary_software:
- tcpdump
- nmap-ncat
- wpa_supplicant
tasks:
- name: Perform full patching
package:
name: '*'
state: latest
- name: Add admin group
group:
name: admin
state: present
- name: Add local user
user:
name: admin
group: admin
shell: /bin/bash
home: /home/admin
create_home: yes
state: present
- name: Add SSH public key for user
authorized_key:
user: admin
key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
state: present
- name: Add sudoer rule for local user
copy:
dest: /etc/sudoers.d/admin
src: etc/sudoers.d/admin
owner: root
group: root
mode: 0440
validate: /usr/sbin/visudo -csf %s
- name: Add hardened SSH config
copy:
dest: /etc/ssh/sshd_config
src: etc/ssh/sshd_config
owner: root
group: root
mode: 0600
notify: Reload SSH
- name: Add SSH port to internal zone
firewalld:
zone: internal
service: ssh
state: enabled
immediate: yes
permanent: yes
- name: Add permitted networks to internal zone
firewalld:
zone: internal
source: "{{ item }}"
state: enabled
immediate: yes
permanent: yes
with_items: "{{ allowed_ssh_networks }}"
- name: Drop ssh from the public zone
firewalld:
zone: public
service: ssh
state: disabled
immediate: yes
permanent: yes
- name: Remove undesirable packages
package:
name: "{{ unnecessary_software }}"
state: absent
- name: Stop and disable unnecessary services
service:
name: "{{ item }}"
state: stopped
enabled: no
with_items: "{{ unnecessary_services }}"
ignore_errors: yes
- name: Set a message of the day
copy:
dest: /etc/motd
src: etc/motd
owner: root
group: root
mode: 0644
- name: Set a login banner
copy:
dest: "{{ item }}"
src: etc/issue
owner: root
group: root
mode: 0644
with_items:
- /etc/issue
- /etc/issue.net
handlers:
- name: Reload SSH
service:
name: sshd
state: reloaded
[ Need more on Ansible? Take a free technical overview course from Red Hat. Ansible Essentials: Simplicity in Automation Technical Overview. ]





Anthony Critelli
Anthony Critelli is a Linux systems engineer with interests in automation, containerization, tracing, and performance. He started his professional career as a network engineer and eventually made the switch to the Linux systems side of IT. He holds a B.S. and an M.S. More about me