managing sshd with Ansible

My environment has two common tasks when managing OpenSSH servers: copying user’s authorized_keys files to the server, and changing the sshd configuration file /etc/ssh/sshd_config. I use Ansible for both, using a single playbook. Running the playbook updates all the authorized_keys files on every host and verifies that sshd is properly configured. (Not that any of my minions would reconfigure sshd without going through change control, or anything like that.)

I’ll start with authorized_keys management.

My servers are grouped by functions. Not all users have access to all servers, and I only want to copy a user’s authorized_keys file to a server if that user can access that server. Start by creating a group_vars directory at the top level of your Ansible install. Create a file for each group that you manage authorized_keys on. Within that file, create a YAML list of all users who can access your server. For example, my DNS servers are all in the Ansible group “dns.” I’ve created the file /etc/ansible/group_vars/dns, and it contains:

---

#users who get SSH access to these machines
sshusers:
  - mwlucas
  - ansible
  - john
  - harry
  - mandy

I have a similar file for my LDAP servers, in the group “ldap.” The file is /etc/ansible/group_vars/ldap.

---

#users who get SSH access to these machines
sshusers:
  - mwlucas
  - ansible
  - jake
  - harry

I think you see the trend here. Remember, YAML lists are space-sensitive, and tabs are forbidden.

You cannot define these lists in /etc/ansible/hosts: you must use a variables file.

Then there’s the SSH configuration part. I run an artisan network. (“Artisan network” means “each server is set up uniquely, managed uniquely, and has little in common with anything else, really frustrating any hopes of easy or consistent systems administration.” While I’m slowly dragging everything towards mass manageability, it’s not there yet.) Each host has multiple IP addresses. I want sshd to only listen on a single IP address. I can’t consistently pull the IP from the host itself, so I need to hard-code it in Ansible.

Fortunately, Ansible should already have this information. A host’s ssh daemon probably isn’t listening on the IP address that DNS gives for the host — that is, the IP referred to by the hostname ldap.michaelwlucas.com probably doesn’t have an SSH server on it. It might be ldap-ssh or ldap-mgmt or ldap-pleasekillmenow. So I’ve had to tell Ansible the address to connect to.

The directory /etc/ansible/host_vars contains a file for each host. The file /etc/ansible/host_vars/ldap.michaelwlucas.com contains something like this:

---

ansible_ssh_host: 192.0.2.88

The ansible_ssh_host is a dedicated Ansible variable, giving the hostname or IP address that Ansible should use to SSH into this host. I’m going to piggyback this for my SSH configuration file. (If you use hostnames in your ansible_ssh_host, you’ll need to either create a separate variable with the IP or to convert the hostname to an IP address somewhere in this process. If you figure out an easy way to do this, do let me know.)

So, how do I want sshd configured?

As I’m mass-managing these machines’ sshd service, I neither need nor want all the various default options in sshd_config. Having those options in the configuration file is great when you’re manually configuring sshd, but I don’t want anyone on any of these servers to change the sshd configuration without going through change control.

So, let’s go to a machine that has a properly configured sshd and get the non-default options.

$ grep -v ^# /etc/ssh/sshd_config > sshd_config.j2

The .j2 indicates this is a Jinja2 template, which is what Ansible uses for creating files. Look at what’s left after I remove the blank lines.

ListenAddress 192.0.2.88
PermitRootLogin without-password
AuthorizedKeysFile /etc/ssh/authorized_keys/%u
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes
Subsystem sftp /usr/libexec/sftp-server

The only problem here is the ListenAddress setting, which needs to be different on each host. I make that a variable. I also add a revision tag and an Ansible management statement.

#$Id$
#{{ ansible_managed }}
ListenAddress {{ ansible_ssh_host }}
PermitRootLogin without-password
AuthorizedKeysFile /etc/ssh/authorized_keys/%u
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes
Subsystem sftp /usr/libexec/sftp-server

The {{ ansible_ssh_host }} is a variable statement, pulling in that variable from Ansible itself.

So, how to use all this in a playbook?

---
- hosts: freebsd
  user: ansible
  sudo: yes

  tasks:
  - name: create key directory
    action: file path=/etc/ssh/authorized_keys state=directory
      owner=0 group=0 mode=0755

  - name: upload user key
    action: copy src=/home/ansible/crossplatform/etc/ssh/authorized_keys/{{ item }}
      dest=/etc/ssh/authorized_keys/
      owner=0 group=0 mode=644
    with_items: sshusers

  - name: sshd configuration file update
    template: src=/etc/ansible/configs/etc/ssh/sshd_config.j2
      dest=/etc/ssh/sshd_config
      backup=yes
      owner=0 group=0 mode=0644
      validate='/usr/sbin/sshd -T -f %s'
    notify:
    - restart sshd

  handlers:
    - name: restart sshd
      service: name=sshd state=restarted

The first task creates the directory for key storage. I do not allow users to upload authorized_keys files for their own account. We don’t want an intruder to add their own key to a user account. Instead, each user’s authorized keys are in a file named after the username, in the directory /etc/ssh/authorized_keys, and our sshd_config tells sshd to look in that directory.

The second task copies the user keys listed in the sshusers variable defined in the group_vars file. While Ansible has an authorized_keys module specifically for handling these files, it has problems with quotes in restricted keys. Until that’s fixed, I’ll fall back to the perfectly adequate “copy” module.

The third task reads the jinja2 template for sshd_config, adds the necessary information, and copies the file to the server. It also validates that the configuration is legitimate — not that it will do what you want, mind you, but it will verify that sshd understands this sshd_config file.

Last, we restart sshd.

My next steps with this will be to update the template so it works on non-FreeBSD hosts. But that’ll be a topic for another day.

Want more on configuring SSH? Check out my book SSH Mastery.

11 Replies to “managing sshd with Ansible”

  1. Few questions… (1) how does ansible get in there in the first place if ssh isn’t configured yet? is there always a manual first step then, to setup the ansible account & key? (2) have you figured out a way to add new entries to known_hosts without deleting existing entries from the “artisan” network? i have a playbook that does this but it’s a mess.

  2. What’s up, I just seen that occasionally this webpage renders a 404 error. I figured you would be keen to know. All the best

  3. Hello Michael, great information you have. What happens when you remove a sshuser? They key still stays in the remote servers right?

  4. Andrew, yes, the key file remains on the remote user. You need a separate job to remove dead keys. Lots of ways to approach that depending on how you want it to work.

  5. To delete the blank lines with bash, just add to the end of your line: ” | awk ‘NF > 0′”! via stackoverflow our friends.

Comments are closed.