Creating user with Ansible
I usually write code. Occasionally, I use tools that run in the background. From time to time, I have to process data that takes a lot of resources.
To do all of those things, I need to have some specific environment. While some of this stuff can be done on my personal machine, there are cases when one needs to set up a remote machine.
And since this is mostly for personal needs, I don’t want to leverage any complicated tools and stay minimalistic.
The goal is to use Ansbile to create a user account on a remote machine with sudo capabilities and set up a secure connection to it, with no passwords, only SSH keys generated during the process.
Please, keep in mind this is an opinionated approach for my use case: I use 1password to generate and store root keys, and I upload the public key of the root to the VPS provider (I’m going to use Ubuntu on Vultr) so that I can deploy a server with root key already there.
Bootstrapping VPS connection
Assuming the VPS is up (thus the root key is uploaded too) one can create an inventory file.
Before doing so, I’m going to create an alias in ~/.ssh/config:
Host caipirinha
Hostname 65.123.123.123
And here’s the inventory I called “hosts”:
[servers]
caipirinha
When all the above is complete, we can try to connect to the server.
Since our VPS provider already uploaded the root key, we connect as a root:
$ ansible all -i hosts -u root -m ping
caipirinha | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
What this command does is to use “hosts” file as an inventory, “root” as a user and “ping” as a module.
“all” means that we execute the module on all entries in the inventory.
For this example, instead of “all” one can use “servers” or “caipirinha” too.
By the way, “ping” might be confusing at first because what it really does is to perform a test SSH connection, not to send an ICMP packet.
Creating a playbook
Let’s start with a file named playbook.yml.
We begin with the header: a name of our playbook, hosts it targets, and become directive which elevates the privileges (to root, by default).
---
- name: Create user
hosts: servers
become: true
We define a series of variables that will be used along the use of playbook.
“username” is the name of the user that will be created on the server.
“local_” prefixed variables will be used for generating SSH key pairs on our machine.
Lookup plugin is used to access data from sources outside of the playbook like environment or filesystem.
vars:
username: "seblw"
local_home_path: "{{ lookup('env','HOME') }}"
local_key_path: "{{ local_home_path }}/.ssh/\
{{ username }}_{{ inventory_hostname }}"
local_pubkey_path: "{{ local_key_path }}.pub"
local_pubkey_file: "{{ lookup('file', \
local_pubkey_path) }}"
The next step is to make sure “wheel” group exists and let group members use sudo command without a password.
tasks:
- name: ensure 'wheel' group
group:
name: wheel
state: present
- name: allow 'wheel' group to have passwordless sudo
lineinfile:
path: /etc/sudoers
state: present
regexp: '^%wheel'
line: '%wheel ALL=(ALL) NOPASSWD: ALL'
validate: '/usr/sbin/visudo -cf %s'
Here’s the interesting piece. We use “local_action” module which let us act as the host computer i.e. the machine we run ansible command from.
Since we’ve run ansible with -u root
flag, we need to switch back to the user we logged to our host machine (we get the username from $USER
env. variable).
Later on, we generate SSH key pair in a location pointed by “local_key_path” variable, for this example, it’s “~/.ssh/seblw_caipirinha”.
- name: generate an OpenSSH keypair on localhost
become_user: "{{ lookup('env', 'USER') }}"
local_action:
module: openssh_keypair
path: "{{ local_key_path }}"
type: ed25519
We then switch back to the remote machine and finally create a new user there which belongs to the “wheel” group and set the authorized key to the one we generated in the previous step.
- name: create user '{{ username }}'
user:
name: "{{ username }}"
state: present
groups: ["wheel"]
append: yes
shell: /bin/bash
- name: set authorized key for user '{{ username }}'
authorized_key:
user: "{{ username }}"
state: present
key: "{{ local_pubkey_file }}"
We also do not forget about security tweaks to SSH server configuration - disabling password authentication and (optionally) root login.
Mind the notify directive, it calls a “handler”.
Handler is Ansible concept that allows us to run some predefined task on every state change, i.e. call a task to restart service after each configuration update.
In our example, we trigger SSH server restart.
- name: disable password authentication
lineinfile:
path: /etc/ssh/sshd_config
state: present
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
validate: '/usr/sbin/sshd -T -f %s'
notify: restart sshd
- name: disable root login
lineinfile:
path: /etc/ssh/sshd_config
state: present
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
validate: '/usr/sbin/sshd -T -f %s'
notify: restart sshd
And at the end of the playbook, we define the handler.
handlers:
- name: restart sshd
service:
name: sshd
state: restarted
Running the playbook
Now we are ready to run the playbook.
$ ansible-playbook -i hosts -u root playbook.yml
PLAY [Create user] ****************************************************
TASK [Gathering Facts] ************************************************
[...]
When it is finished, we can finally log in using the SSH key and username.
$ ssh -i ~/.ssh/seblw_caipirinha seblw@caipirinha
Welcome to Ubuntu 22.04.1 LTS (GNU/Linux 5.15.0-40-generic x86_64)
[...]
Success!
PS. Keeping proper indentation in YAML files is left as an exercise for the reader ;)