Ansible tutorial: setup of user accounts, SSH and firewall with Ansible

In the first part of this tutorial series, I showed you how to install the operating system image on a Hetzner root server using Ansible. The second part of my Ansible tutorial is about “bootstrapping” the server. Under the term “bootstrapping”, I summarize all actions that will be the same for all servers, regardless of which task a server will be used for. In detail, I will:

  • apply patches,
  • define the hostname,
  • create a hosts file,
  • create another user so I don’t have to log in as root,
  • optimize the SSH configuration,
  • create some bash aliases,
  • install some helpful command line tools and
  • configure the firewall.

The article code can be found in my GitLab repository at https://gitlab.com/SvenWoltmann/happycoders-tutorial-server-setup.

In addition to the article, there is also a (German-only) video tutorial: Setup of user accounts, SSH and firewall with Ansible

The article series is divided into the following four parts:

  1. Setup of a root server with Ansible
  2. Setup of user accounts, SSH and firewall with Ansible
  3. Setup of Docker, MySQL and WordPress with Ansible
  4. Setup of HAProxy and a HTTPS certificate from Let’s Encrypt with Ansible

Bootstrap playbook

First I create the file bootstrap.yml in the project directory with the following content:

---
- hosts: "{{ target }}"
remote_user: root
roles:
- apt_upgrade
- hostnames
- users
- ssh

The difference from the playbooks in the previous article is that I omit the line gather_facts: false. Since the default value is true, Ansible will collect the facts of the target system (e.g. its operating system version) before executing the first role. This is only possible if Python is already installed on the target system. This was the last step I did in the previous article. I will explain the listed roles step by step in the following sections.

Applying patches

First of all, I want to bring the server up to date by installing all available patches. For this, I create the role apt_upgrade, for which I define only a single task in the file roles/apt_upgrade/tasks/main.yml:

---
- name: Update and upgrade apt packages
apt:
upgrade: yes
update_cache: yes

I’m using the Ansible module apt, where update_cache: yes is the equivalent of apt update and upgrade: yes stands for apt upgrade. This task is the equivalent to running apt update && apt-upgrade on the command line.

Defining the hostname

Now I want to make sure that the /etc/hostname file contains the correct hostname. This should be done by the installimage script, but it doesn’t hurt to check it again at this point and correct it if necessary. Finally, this Ansible playbook should also run on servers of other hosters on which the hostname file may not be set during provisioning. I create the role hostnames in the form of the file roles/hostnames/tasks/main.yml with the following task definition:

---
- name: Set hostname
hostname:
name: "{{ inventory_hostname }}"

The task uses the Ansible module hostname to execute the command of the same name to change the hostname, and to write the hostname to the file /etc/hostname, so that it is retained even if the server is restarted.

Creating the hosts file

I also want to write all relevant server names to the /etc/hosts file. At the moment, this is only one, but as soon as I have more than one server, it will be important. I copy the server’s /etc/hosts file to roles/hostnames/files/hosts in my local project directory and just adjust the comments slightly so that the file has the following content:

# IPv4
127.0.0.1 localhost.localdomain localhost
46.4.99.9 happy1.happycoders.eu happy1

# IPv6
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
ff02::3 ip6-allhosts
2a01:4f8:140:9090::2 happy1.happycoders.eu happy1

To copy the file to the server, I use the Ansible module copy and insert the following entry into the roles/hostnames/tasks/main.yml role file:

- name: Copy "hosts" file
copy:
src: hosts
dest: /etc/hosts
owner: root
group: root
mode: 0644

src specifies the source file relative to the files/ directory, while dest is the destination path on the server. The remaining three parameters define the owner and the group of the file, as well as the bits for the file mode.

Creating a user

For security reasons, I don’t want to connect to the server as a root user in the future, so I create a user sven, and add it to the group sudo. During the development phase, I don’t want to have to enter my password again and again for sudo. Therefore, I create an additional group, sudo-nopasswd, add the user sven and allow all users of this group to run sudo without password. All this I do as usual with Ansible, i.e. I create the role users and the file roles/hostnames/users/main.yml with the following tasks:

Task 1 – Install sudo:

- name: Install sudo
apt:
name: sudo

Task 2 – Create user sven:

- name: Create "sven" user
user:
name: sven
shell: /bin/bash
uid: 1000
group: users
groups: sudo
append: true # --> user is not removed from any other group
password: $6$2i/vlnXuBj/m8A$y9xNcMAJQstggFMSWYbVeJStvvDpk4s4jScPv074Fb94jSfCbxyWSPz.tmMPQ6Qiy6mnpn07SQRTe6Sex4Pfi/

I create the user with the Ansible module user and use the following parameters:

  • With name I define the username.
  • The user shell is set with shell.
  • I use uid to set a fixed user ID.
  • With group, I define the main group of the user.
  • With groups, I specify that the user should also be included in the sudo group.
  • Important is append: true, otherwise the user would be removed from groups to which they might have been added in other playbooks.
  • With password, I specify my password in encrypted form. I generated it with the command mkpasswd -m sha-512.

Task 3 – Create group sudo-nopasswd:

- name: Create "sudo-nopasswd" group
group:
name: sudo-nopasswd

Here, I use the Ansible module group; this creates the group specified under name.

Task 4 – Add user sven to the sudo-nopasswd group:

- name: Add user "sven" to "sudo-nopasswd" group
user:
name: sven
groups: sudo-nopasswd
append: true # --> user is not removed from any other group
when: passwordless_sudo is defined and passwordless_sudo == true

I’m using the user module again, which I already explained above. With when, I define that the group assignment should only be done if the variable passwordless_sudo exists and is set to true. So after the development phase, I can very easily reactivate the password query for sudo by removing the variable or setting it to false. In this case, task 4 is no longer executed, but the user is not removed from the group. I will do this in task 5.

Task 5 – Remove user sven from the sudo-nopasswd group:

- name: Remove user "sven" from "sudo-nopasswd" group
shell: /usr/sbin/delgroup sven sudo-nopasswd
when: not (passwordless_sudo is defined and passwordless_sudo == true)
ignore_errors: yes

Unfortunately, this is not possible with the user module, so I use the shell module and the delgroup Linux command. It is important to specify the line ignore_errors: yes, otherwise the playbook will abort with an error message if the user is not in the group they should be removed from.

Task 6 – Specify that users of the sudo-nopasswd group may run sudo without a password:

- name: Add "sudo-nopasswd" group to "sudoers" file
lineinfile:
dest: /etc/sudoers
line: '%sudo-nopasswd ALL=(ALL:ALL) NOPASSWD:ALL'
regexp: '^%sudo-nopasswd'

For this, I use the Ansible module lineinfile with the following parameters:

  • With dest, I specify the file to be edited.
  • With line, I pass the line which is to be entered into the file.
  • If there is another line that matches the regular expression passed with regexp (i.e. one that starts with %sudo-nopasswd), it will be deleted.

In the last two lines, I have to put the strings in quotation marks according to the YAML Cookbook, because % is a special character.

With the remaining four tasks, I copy the public keys for the users root and sven to the server. The public key for the root user was already copied when the operating system was installed, but I want to be able to use this playbook to change the key if necessary or to install a key on a server that I did not set up using the installimage script from Hetzner.

Task 7 – Create .ssh directory for the root user

- name: Create root user's .ssh directory
file:
path: /root/.ssh
state: directory
owner: root
group: root
mode: 0700

Here, I use the Ansible module file to create the directory /root/.ssh/ and – like the copy module – to set owner, group and mode.

Task 8 – Copy public keys for the root user:

- name: Copy root user's authorized_keys
copy:
src: authorized_keys_root
dest: /root/.ssh/authorized_keys
owner: root
group: root
mode: 0600

I have already explained the copy module before. Tasks 9 and 10 create the directory /home/sven/.ssh/ and copy the source file authorized_keys_sven to /home/sven/.ssh/authorized_keys. The files authorized_keys_root and authorized_keys_sven are located in the source directory roles/users/files/ and contain the public keys of those persons who should have access to the server via SSH as users root or sven.

Distributing tasks across multiple files

The file roles/users/tasks/main.yml has become quite long and confusing, which I don’t like. So I will extract separate individual blocks into other files and import them into the main.yml with import_tasks. I split the file into three logical blocks:

  1. Installation and configuration of the sudo command,
  2. Creating the user and adding to/removing from groups,
  3. Copying the public keys.

The main.yml now looks like this:

---
- import_tasks: setup_sudo.yml
- import_tasks: setup_users.yml
- import_tasks: upload_pubkeys.yml

The three new files are located in the same directory as the main.yml, i.e. in roles/users/tasks/ and look like this:

File roles/users/tasks/setup_sudo.yml:

---
- name: Install sudo
apt:
name: sudo

- name: Create "sudo-nopasswd" group
group:
name: sudo-nopasswd

- name: Add "sudo-nopasswd" group to "sudoers" file
lineinfile:
dest: /etc/sudoers
line: '%sudo-nopasswd ALL=(ALL:ALL) NOPASSWD:ALL'
regexp: '^%sudo-nopasswd'

File roles/users/tasks/setup_users.yml:

---
- name: Create "sven" user
user:
name: sven
shell: /bin/bash
uid: 1000
group: users
groups: sudo
append: true # --> user is not removed from any other group
password: $6$2i/vlnXuBj/m8A$y9xNcMAJQstggFMSWYbVeJStvvDpk4s4jScPv074Fb94jSfCbxyWSPz.tmMPQ6Qiy6mnpn07SQRTe6Sex4Pfi/

- name: Add user "sven" to "sudo-nopasswd" group
user:
name: sven
groups: sudo-nopasswd
append: true # --> user is not removed from any other group
when: passwordless_sudo is defined and passwordless_sudo == true

- name: Remove user "sven" from "sudo-nopasswd" group
shell: /usr/sbin/delgroup sven sudo-nopasswd
when: not (passwordless_sudo is defined and passwordless_sudo == true)
ignore_errors: yes

File roles/users/tasks/upload_pubkeys.yml:

---
- name: Create root user's .ssh directory
file:
path: /root/.ssh
state: directory
owner: root
group: root
mode: 0700

- name: Copy root user's authorized_keys
copy:
src: authorized_keys_root
dest: /root/.ssh/authorized_keys
owner: root
group: root
mode: 0600

- name: Create sven user's .ssh directory
file:
path: /home/sven/.ssh
state: directory
owner: sven
group: users
mode: 0700

- name: Copy sven user's authorized_keys
copy:
src: authorized_keys_sven
dest: /home/sven/.ssh/authorized_keys
owner: sven
group: users
mode: 0600

Optimizing the SSH configuration

To better protect my server against attacks, I would like to make some changes to the standard SSH configuration. I’m not a security expert, so I won’t go into the details here – there’s plenty to read on the internet. I would like to change the following settings, among others:

  • Only allow public-key authentication with state-of-the-art encryption algorithms;
  • Disable connection as root user;
  • Connect via a port other than the default port 22 to become invisible to most malicious scans;
  • Disable agent forwarding;
  • Activate TCP forwarding only if required (via variable);
  • Minimize login and connection timeouts.

For the configuration, I create the role ssh and the SSH configuration file printed under this paragraph. This file contains the IP address of the server as a variable. To replace this variable when running the Ansible Playbook, the file must be placed in the templates/ directory and not in the files/ directory. The path of the file is therefore roles/ssh/templates/sshd_config.j2. The extension “.j2” is not mandatory, but I use it to enable Jinja2 syntax highlighting in my IDE (in IntelliJ IDEA, the “Python” plugin must be installed for this). The file looks like this:

AcceptEnv LANG LC_*
AllowAgentForwarding no
{# using a boolean here and not a string, as a string might end up as 'True' if you set the variable to yes (without quote marks) #}
AllowTcpForwarding {{ 'yes' if allow_tcp_forwarding is defined and allow_tcp_forwarding == true else 'no' }}
AllowGroups sshers
ChallengeResponseAuthentication no
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes256-ctr
ClientAliveCountMax 3
ClientAliveInterval 15
Compression delayed

GatewayPorts no

HostbasedAuthentication no
IgnoreRhosts yes
KexAlgorithms curve25519-sha256@libssh.org
ListenAddress {{ ansible_default_ipv4.address }}:{{ ssh_port | default('22') }}
LoginGraceTime 10
LogLevel INFO
MaxAuthTries 3
PasswordAuthentication no
PermitEmptyPasswords no
PermitRootLogin no
Protocol 2
PubkeyAuthentication yes
StrictModes yes
Subsystem sftp /usr/lib/openssh/sftp-server
SyslogFacility AUTH
UsePAM yes
UsePrivilegeSeparation yes

X11Forwarding no

The explanation of all parameters would go beyond the scope of this article. I refer you to the sshd_config manual. I will limit myself to the two parameters where I used variables:

  • AllowTcpForwarding: as mentioned in the introduction, I want to be able to enable this as needed and introduce the variable allow_tcp_forwarding, which I set to false in my host configuration file for now.
  • ListenAddress: I set this to the IP address of the server followed by the port number, which I can define in the variable ssh_port. If the variable is not defined, the default port is 22. I choose a random port – port 5930 and enter the following in my host configuration file host_vars/happy1.happycoders.eu: ssh_port: 5930.

To copy the SSH configuration file and restart the SSH service, I create the task definition file roles/ssh/tasks/main.yml, which contains the following three tasks:

Task 1 – Create group sshers:

---
- name: Create "sshers" group
group:
name: sshers

Due to the AllowGroups sshers configuration entry in sshd_config, only users of this group may connect via SSH.

Task 2 – Add user sven to group sshers:

- name: Add user "sven" to "sshers" group
user:
name: sven
groups: sshers
append: true # --> user is not removed from any other group

Task 3 – Copy SSH configuration file:

- name: Configure SSH server
template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: 0644
notify:
- Restart SSH service

Here I use the Ansible module template, which copies a template file and replaces the variables it contains. The parameters are the same as in the previously used copy module.

The last two lines are also new: These ensure that the handler Restart SSH service is executed if the sshd_config is changed (and only then). A handler is a task that is executed in response to a particular event. The handler is defined in the subdirectory handlers/ of the role, again in a main.yml, i.e. in roles/ssh/handlers/main.yml:

---
- name: Restart SSH service
service:
name: ssh
state: restarted

I am using the Ansible module service to restart the SSH service. It is important to know that the handler is not executed immediately after the task Configure SSH server, but at the end of the playbook, together with all other handlers activated by notify. This can also be preferred with the task meta: flush_handlers, but this is not necessary at this point.

Executing the playbook

The bootstrap playbook isn’t quite finished yet (the configuration of the aliases and the installation of some tools are still missing), but now it’s time to run it for the first time. I do this with the following command:

ansible-playbook --extra-vars "target=happy1.happycoders.eu" bootstrap.yml
Running the Bootstrap Ansible playbook
Running the Bootstrap Ansible playbook

The playbook ran successfully to my satisfaction. The error when removing the user sven from the group sudo-nopasswd was expected and is being ignored correctly. Since the playbook has changed the SSH settings of the server, I have to connect to the server via the new port 5930 and as user sven:

ssh -p 5930 sven@happy1.happycoders.eu
Login as user "sven"
Login as user “sven”

Creating Bash aliases

What bothers me about the standard Debian installation is that different bash aliases are defined for the root user and regular users. For the root user, the file /root/.bashrc contains the following aliases by default:

export LS_OPTIONS='--color=auto'
[...]
alias ls='ls $LS_OPTIONS'
alias ll='ls $LS_OPTIONS -l'
alias l='ls $LS_OPTIONS -lA'

For regular users, i.e. the user sven, only a single alias is defined in the file /home/sven/.bashrc – the rest is commented out and differs from those of the root user:

alias ls='ls --color=auto'
#alias dir='dir --color=auto' #alias vdir='vdir --color=auto'
#alias grep='grep --color=auto'
#alias fgrep='fgrep --color=auto'
#alias egrep='egrep --color=auto'
[...]
#alias ll='ls -l'
#alias la='ls -A'
#alias l='ls -CF'

I want to use the ls aliases of the root user for the sven user and additionally activate the commented aliases for grep, fgrep, egrep. But I don’t want to overwrite /home/sven/.bashrc because my playbook should also be compatible with future Debian releases, which might install another .bashrc file. Instead, I will write the aliases to /home/sven/.bash_aliases. For this, I create a role aliases and create the file roles/aliases/files/.bash_aliases with the following content:

# enable color support of ls and also add handy aliases
if [ -x /usr/bin/dircolors ]; then
test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias fgrep='fgrep --color=auto'
alias egrep='egrep --color=auto'
fi

# ls aliases from root user
alias l='ls -lA'
alias ll='ls -l'

The file is copied to the server with the following task definition in the file roles/aliases/tasks/main.yml:

---
- name: Copy sven's .bash_aliases
copy:
src: .bash_aliases
dest: /home/sven/.bash_aliases
owner: sven
group: users
mode: 0644

Installing tools

Finally, I install some tools that I will need on all servers. I create a role tools and define which packages should be installed in the tasks file roles/tools/tasks/main.yml:

- name: Install Midnight Commander, NTP daemon
apt:
name:
- mc
- ntp
cache_valid_time: 3600

I already described the module apt above. New is the parameter cache_valid_time: 3600. I use this parameter to specify that the package list should be updated with apt update before installation if it is older than 3,600 seconds (1 hour).

Running the playbook again

I add the two new roles, aliases and tools to the bootstrap playbook bootstrap.yml, which now looks like this:

---
- hosts: "{{ target }}"
remote_user: root
roles:
- apt_upgrade
- hostnames
- users
- ssh
- aliases
- tools

I restart the playbook:

ansible-playbook --extra-vars "target=happy1.happycoders.eu" bootstrap.yml

But this time, Ansible aborts with the following error message: “Failed to connect to the host via ssh: ssh: connect to host happy1.happycoders.eu port 22: Connection refused”.

"Connection refused" when re-running the bootstrap playbook
“Connection refused” when re-running the bootstrap playbook

The reason is quite simple: The bootstrap playbook tries to connect as root user to the standard SSH port 22. Due to the hardened SSH configuration, however, the connection must be made as user sven on port 5930.

Happy Ansible playbook

If I want to change the configuration, I can’t do that via the bootstrap playbook. Not only can it no longer connect to the server with the previous configuration – it also updates all packages, which I don’t want to let happen uncontrolled in production mode whenever I change something in the server configuration.

The solution is: I create a new playbook, happy1.yml, which connects as user sven on port 5930 and executes all roles that the bootstrap playbook would execute – except the role apt_upgrade (for above mentioned reason):

---
- hosts: happy1.happycoders.eu
vars:
ansible_port: "{{ ssh_port | default('22') }}"
remote_user: sven
become: yes
roles:
- hostnames
- users
- ssh
- aliases
- tools

The following distinguishes this playbook from the bootstrap playbook:

  • This playbook is not a generic one, but a concrete one for my server happy1.happycoders.eu. So I can specify it directly via the parameter hosts and omit the parameter --extra-vars "target=happy1.happycoders.eu" when running the playbook.
  • Using vars I, set the variable ansible_port to the SSH port I set in the host configuration file in the variable ssh_port, so Ansible connects to the server via this port.
  • By specifying remote_user: sven, Ansible connects as user sven.
  • If become: yes is specified, Ansible executes all commands on the server as root user.

When running this playbook, I now have to add the -K parameter so that Ansible asks me for the sudo password:

ansible-playbook -K happy1.yml
Running the Ansible playbook "happy1.yml"
Running the Ansible playbook “happy1.yml”

You can see that the playbook runs completely and reports “ok” on almost all tasks, which means that no changes have been made. Only for the two new tasks does Ansible report “changed”, which means that the Bash aliases and tools have been successfully installed.

sudo without password

As you may remember, in the bootstrap playbook in the users role, I created the option of not having to enter the password for sudo during the development phase. I want to activate this now. Therefore, I have to add the line passwordless_sudo: true to the host configuration file host_vars/happy1.happycoders.eu and run the playbook again:

ansible-playbook -K happy1.yml
User "sven" is added to the group "sudo-nopasswd
User “sven” is added to the group “sudo-nopasswd

For the task Add user "sven" to "sudo-nopasswd" group, Ansible reports “changed”, which means that the task was executed, i.e. the user was added to the group. And the task Remove user "sven" from "sudo-nopasswd" group is acknowledged by Ansible with “skipping”, i.e. this step was skipped, so the user was not removed from the group again. If I now set passwordless_sudo to false and run the playbook again, the first task would be skipped and the second would be executed.

In the future executions of the playbook, the parameter -K can now be omitted (until I set passwordless_sudo to false again):

ansible-playbook happy1.yml

Firewall from the Ansible Galaxy

The last step in this article is to install a firewall that only allows requests on ports 80, 443 and 5930. Since this could be quite complex, I’ll see if someone else has already taken the trouble to write a firewall role. The Ansible developer community releases roles on Ansible Galaxy. There, I search for the keyword “firewall”:

Searching for "firewall" in the Ansible Galaxy
Searching for “firewall” in the Ansible Galaxy

I also search Google and find some GitHub projects, of which only the firewall role of geerlingguy, which I found in the Ansible Galaxy, having more than 200 stars is relevant; all other Ansible firewall projects have less than five stars.

The developer writes about his role: “This firewall aims for simplicity over complexity”, which is confirmed later on: I can unlock individual ports, and if I want to get more into detail, I can add arbitrarily complex iptables commands.

The role is simply installed on the local computer as follows:

ansible-galaxy install geerlingguy.firewall
Installation of the role "geerlingguy.firewall" from Ansible Galaxy
Installation of the role “geerlingguy.firewall” from Ansible Galaxy

I add the role geerlingguy.firewall to the playbook happy1.yml:

---
- hosts: happy1.happycoders.eu
vars:
ansible_port: "{{ ssh_port | default('22') }}"
remote_user: sven
become: yes
roles:
- hostnames
- users
- ssh
- aliases
- tools
- geerlingguy.firewall

The role is configured using variables that I add to the host configuration file host_vars/happy1.happycoders.eu. All I have to do is specify the three ports I want opened, the rest is done by the role. I add the following to the host configuration file:

firewall_allowed_tcp_ports:
- 80
- 443
- 5930

I’m running the playbook:

ansible-playbook happy1.yml
Firewall configuration with Ansible
Firewall configuration with Ansible

After running the playbook, I check whether the firewall settings are active:

ssh -p 5930 sven@happy1.happycoders.eu
sudo iptables -L
sudo ip6tables -L
Displaying the firewall configuration with "iptables -L" and "ip6tables -L"
Displaying the firewall configuration with “iptables -L” and “ip6tables -L”

You see: The firewall has been configured as desired.

Summary and outlook

In this part of the series, I have shown you the “bootstrapping” of the server, i.e. all those installations and configurations I will need for future servers with possibly different application areas.

In the third part of the series, I will extend the happy1 playbook and show you how to install Docker, MySQL and WordPress with Ansible.

I thank you for reading this article and would be happy if you left a comment to tell me how you liked the tutorial, what I could have done better and what topics you are interested in. Thanks a lot and happy coding!

I would also be very happy if you would share the article via one of the following share buttons.