Nodes

Tutorial: A More Secure Ansible Playbook, Part 1

Posted by Joel Hans on Aug 31, 2017

A note about tutorials: We encourage our users to try out tutorials, but they aren't fully supported by our team—we can't always provide support when things go wrong. Be sure to check which OS and version it has been tested with before you proceed. If you run into issues, post a comment and we'll try to help out.

If you want a fully managed experience, with dedicated support for any application you might want to run, contact us for more information.

In our last Ansible tutorial, we covered the basics in Ansible’s method of configuration management, which can help you get new servers set up faster and with more reliability. The Ansible playbook that we created there was rather basic, so I thought it was about time to build a more complex playbook that supports more security out of the box while not sacrificing in being able to access the server in the usual ways.

Here’s the goals for this Ansible playbook:

  • Set up a non-root user with sudo access.
  • Upgrade all installed packages.
  • Install a few basic packages to make initial management easier, like nano. These can be easily customized according to your needs.
  • Copy your SSH key to the VPS to enable password-less logins.
  • Harden SSH with some basic security measures, such as disabling root and password-based logins.
  • Install iptables if needed, and set up some basic restrictions to improve security.
  • Install fail2ban to help prevent brute force attacks.

The final two steps will be outlined next week in the second part of this Ansible tutorial.

This playbook isn’t meant to be comprehensive when it comes to convenience or security—once you provision the server using this playbook, you should probably research some additional steps you can take, such as using Lynis to audit your security.

The goal here isn’t just to have you copy the code here and recreate your own playbook—instead, we’ll walk through the various components step-by-step so that you can use this playbook as a foundation for your own customizations.

Prerequisites

  1. A newly-provisioned or rebuilt server running any of our OS options—CentOS, Debian, or Ubuntu.
  2. Ansible installed on your local machine—see these instructions for more details
  3. An Ansible hosts file set up with the IP(s) of your server(s)—see Step 2 of our previous tutorial

Step 1. Setting up the playbook structure

Ansible playbooks can be structured in a number of different ways, but the developers do have their recommendations. This Anisble script is still relatively simple compared to what’s possible with the system, so our structure is going to be far simpler as well.

Here’s the general structure we’re following:

provision.yml

roles
  common/
    tasks/
      main.yml
  ssh
    tasks/
      main.yml
  packages
    tasks/
      main.yml
  iptables
    tasks/
      main.yml

If you want, you can go ahead and create the directories now, just to give you a better sense as to how the playbook separates its logic into different areas.

Step 2. Creating provision.yml

The provision.yml file is the core of our playbook—it’s where we define which servers we’re going to be working with, a few global variables, and tell Ansible where to look for its tasks.

---
- name: Provision a new server with hardened SSH and basic iptables.

  # Specify the hosts you want to target
  hosts: <b>HOST</b>

  # Specify the user you want to connect to the server.
  # With a new installation, you will connect with `root`. If you want to
  # re-run this playbook at a later date, you should change `remote_user` to
  # the user you specified under `vars/username` below and uncomment the
  # `become: true` line. You should then run the playbook using the
  # `--ask-become-pass` flag, like so:
  # `ansible-playbook -k provision.yml --ask-become-pass`.
  remote_user: root
  # become: true

  vars:
    username: <b>USER</b>
    # Before first using the playbook, run the below command to create a hashed
    # password that Ansible will assign to your new user.
    # python -c 'import crypt; print crypt.crypt("<b>password</b>", "$1$<b>SALT</b>$")'
    password: <b>PASSWORD GOES HERE</b>
    public_key: ~/.ssh/id_rsa.pub

  roles:
    - user
    - packages
    - ssh
    - iptables

There are a number of variables that you will need to change according to your needs.

Step 3. Creating user/tasks/main.yml

Our first major step is setting up the right environment for a new non-root user, and then creating that user. Here’s the first component:

- name: Ensure wheel group is present
  group:
    name: wheel
    state: present

This Ansible task is a simple one: it checks to see if the wheel group exists on your server. If it doesn’t for some reason—it should on all our OS options—the playbook will fail, and then you can fix it with the groupadd command.

The next step is a critical one, so let’s take a look:

- name: Ensure wheel group has sudo privileges
  lineinfile:
    dest: /etc/sudoers
    state: present
    regexp: "^%wheel"
    line: "%wheel ALL=(ALL:ALL) ALL"
    validate: "/usr/sbin/visudo -cf %s"

This is an example of using regex to replace one line within a file with a different string of text. We’re looking inside of the /etc/sudoers file, and requesting a line that begins (^) with %wheel. When that line is found, we replace the entire line with %wheel ALL=(ALL:ALL) ALL, which allows users in the wheel group to execute commands using sudo. When it comes to editing /etc/sudoers, the final validate line is critical, as you would rather the playbook fail due to an improper file than break your administrator capabilities.

We want to make sure the sudo package is installed as well.

- name: Install the `sudo` package
  package:
    name: sudo
    state: latest

Installing any package, whether it’s for CentOS, Ubuntu, or Debian, works this exact same way. That’s the beauty of Ansible—you can create one task that works anywhere due to the built-in logic.

Finally, we create the non-root user account that was specified in the variables in provision.yml.

- name: Create the non-root user account
  user:
    name: ""
    password: ""
    shell: /bin/bash
    update_password: on_create
    groups: wheel
    append: yes

This tasks sets up the user with the hashed password you created, and sets the shell to /bin/bash. Because we’re putting this user in the wheel group, we’ll be able to use sudo straightaway.

Step 4. Creating packages/tasks/main.yml

The packages task is really simple: we just want to update all packages so that we have the latest in security fixes, and then install any number of extra packages according to our specific needs.

- name: Upgrading all packages (Ubuntu/Debian)
  apt:
    upgrade: dist
  when: ansible_os_family == "Debian" or ansible_os_family == "Ubuntu"

- name: Upgrading all packages (CentOS)
  yum:
    name: '*'
    state: latest
  when: ansible_os_family == "RedHat"

The key to these two tasks is the when option—this allows you specify when to run certain commands depending on the OS you’ve chosen. This is necessary, because yum won’t work on Ubuntu, and apt won’t work on CentOS. In either case, we’re simply asking the respective package manager to update every installed package.

We can also install additional packages:

- name: Install a few more packages
  package:
    name: "{{item}}"
    state: installed
  with_items:
   - vim
   - htop

Essentially, we’re asking the package task to look through the list of items under with_items and install each of them in sequence. If you want some of your own packages, just customize that list to your heart’s content.

Step 5. Creating ssh/tasks/main.yml

Next up, we want to enable logging into the newly-create user with SSH keys rather than passwords—a simple-but-effective security measure. Beyond that, we want to use Ansible to make some configuration changes to the SSH daemon that will harden it against some basic attacks. It’s not foolproof, but it’s certainly a big step above the defaults.

- name: Add local public key for key-based SSH authentication
  authorized_key:
    user: ""
    state: present
    key: ""

This command looks for an SSH key on the local machine at the location specified in the vars section in provision.yml and then copies it to the server. Much easier than using ssh-copy-id, eh?

Next, let’s make SSH a little more secure.

- name: Harden sshd configuration
  lineinfile:
    dest: /etc/ssh/sshd_config
    regexp: "{{item.regexp}}"
    line: "{{item.line}}"
    state: present
  with_items:
    - regexp: "^#?PermitRootLogin"
      line: "PermitRootLogin no"
    - regexp: "^^#?PasswordAuthentication"
      line: "PasswordAuthentication no"
    - regexp: "^#?AllowAgentForwarding"
      line: "AllowAgentForwarding no"
    - regexp: "^#?AllowTcpForwarding"
      line: "AllowTcpForwarding no"
    - regexp: "^#?MaxAuthTries"
      line: "MaxAuthTries 2"
    - regexp: "^#?MaxSessions"
      line: "MaxSessions 2"
    - regexp: "^#?TCPKeepAlive"
      line: "TCPKeepAlive no"
    - regexp: "^#?UseDNS"
      line: "UseDNS no"
    - regexp: "^#?AllowAgentForwarding"
      line: "AllowAgentForwarding no"

The lineinfile and regexp should look familiar to you at this point—as with making changes to /etc/sudoers, we’re looking at /etc/ssh/sshd_config and replacing a number of existing lines with new ones. If the lines don’t currently exist, Ansible will create new lines at the bottom of the file containing our revisions. The ^#? regex allows us to replace lines whether or not they’re commented out, and thus begin with a #.

Finally, let’s have the SSD daemon to make sure our changes are applied.

- name: Restart sshd
  systemd:
    state: restarted
    daemon_reload: yes
    name: sshd

This systemd task allows us to run the equivalent of systemd restart sshd.

Final thoughts

As mentioned above, we’re hitting pause here for a moment and will return a week from now with the second half of this tutorial, which will walk through a basic iptables configuration, and install fail2ban.

But, in the meantime, you can run this playbook now and later. That’s the great thing about a correctly-configured Ansible playbook— they are idempotent, which means they can be run again and again without changing the result beyond the initial installation. You can run the playbook once, make a small change such as adding another package to be installed under the packages role, and then run the playbook again without error.

Once you get everything up and running, how do you actually run this playbook? It’s pretty straightforward.

Generate a hashed password. You first need to convert the password you want for your non-root user into a hashed password. This command should work on Linux and OS X, and be sure to replace password with your chosen password: python -c 'import crypt; print crypt.crypt("password", "$1$AnsibleSalt$")'.

Copy your new hash into provision.yml.

Run Ansible. Running the playbook itself is straightforward, with one simple command: $ ansible-playbook -k provision.yml

If you need to re-run the playbook after the first run, you’ll need to make some changes to provision.yml—look at the code above (and in the version you’ve created) for some basic instructions.

Stay tuned for next week’s conclusion, along with the full code you need to run this playbook on your own servers and get to work faster than ever.

Get 8GB RAM and 40GB SSD of the fastest cloud hosting for only $7.49/mo.

Get started

Our most popular posts:

VPS Comparison: Vultr vs. Digital Ocean vs. Linode vs. SSD Nodes Using Docker and Nginx to Host Multiple Websites Tutorial: Installing OpenVPN on Ubuntu 16.04