Skip to main content

Ansible: Create Ansible Collection with Roles and Playbooks for User Management and SSH Daemon Configuration

1961 words·
Ansible Ansible-Playbook Ansible Galaxy
Ansible - This article is part of a series.
Part 2: This Article

Overview
#

I’m using the following servers;

192.168.30.10   # Ansible Server: Ubuntu 24.04
192.168.30.11   # Example host: Ubuntu 24.03
192.168.30.100  # Example host: Rocky Linux 9.4 (Blue Onyx

Prerequisites
#

Install Ansible
#

# Update package index & install dependencies
sudo apt update && sudo apt install software-properties-common wget gpg -y

# Downloads the GPG key to verify the authenticity of the Ansible packages
wget -O- "https://keyserver.ubuntu.com/pks/lookup?fingerprint=on&op=get&search=0x6125E2A8C77F2818FB7BD15B93C4A3FD7BB9C367" | sudo gpg --dearmour -o /usr/share/keyrings/ansible-archive-keyring.gpg

# Add the repository
echo "deb [signed-by=/usr/share/keyrings/ansible-archive-keyring.gpg] http://ppa.launchpad.net/ansible/ansible/ubuntu jammy main" | sudo tee /etc/apt/sources.list.d/ansible.list

# Install Ansible
sudo apt update && sudo apt install ansible -y

Verify Ansible Version
#

# Verify installation / check version
ansible --version

# Shell output:
ansible [core 2.17.5]
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/ubuntu/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = /home/ubuntu/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/bin/ansible
  python version = 3.12.3 (main, Sep 11 2024, 14:17:37) [GCC 13.2.0] (/usr/bin/python3)
  jinja version = 3.1.2
  libyaml = True
# Verify the .ansible directory
cd && ls -la

# Shell output:
total 40
drwxr-x--- 5 ubuntu ubuntu 4096 Oct 27 14:29 .
drwxr-xr-x 3 root   root   4096 Jun 19 17:19 ..
drwxrwxr-x 3 ubuntu ubuntu 4096 Oct 27 14:29 .ansible
-rw------- 1 ubuntu ubuntu  481 Oct 27 14:23 .bash_history
-rw-r--r-- 1 ubuntu ubuntu  220 Mar 31  2024 .bash_logout
-rw-r--r-- 1 ubuntu ubuntu 3771 Mar 31  2024 .bashrc
drwx------ 2 ubuntu ubuntu 4096 Jun 19 17:19 .cache
-rw-r--r-- 1 ubuntu ubuntu  807 Mar 31  2024 .profile
drwx------ 2 ubuntu ubuntu 4096 Jun 19 17:19 .ssh
-rw-r--r-- 1 ubuntu ubuntu    0 Jun 19 17:20 .sudo_as_admin_successful
-rw------- 1 ubuntu ubuntu   53 Oct 27 14:27 .Xauthority

Ansible Inventory
#

# Create an inventory for the example host
sudo vi /etc/ansible/hosts
[example_host]
192.168.30.11 ansible_user=ubuntu
192.168.30.100 ansible_user=ansible

Ansible Server SSH Key Pair
#

# Create a SSH key on the Ansible server
ssh-keygen -t rsa -b 4096
# Copy the public SSH key to the example host
ssh-copy-id ubuntu@192.168.30.11
ssh-copy-id ansible@192.168.30.100

Example SSH Key Pair
#

# Create an example SSH key pair tor the local_accounts Ansible role
ssh-keygen -t rsa -b 4096 -f ~/.ssh/examplekey

# Copy the public key
cat ~/.ssh/examplekey.pub

# Shell output:
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCbioQbz6ZkgMmAuwt8sNjQ4gULCk1VNM71YkiOKq7lwEuZdJns+/naCurn67A+UVRZ/lNmGVKocwoHofsmeAudvsDtDVa1cPoT5VF/1xM5EeyezjIKy4Esm29p4j3EIrvLbHay0j0d7lZo0JKgDJ+bD69rmOw5idOXwR+a1EF3hOup3BnqDE8bMXQMseKZsmhVsUPA/ciX6yY5p9cPwckSewSGOdy3PKou4YPo9Ka0bvBi0cCzkMh8R9nClhq3er9uocNZbucSkgOPgN9GrBWm1dhT/4UsZz755EpvmZcA/Cg9HtXJS7cwto6A4WUDshKKe4yXyUdWard8mv77R2XTNZF4iaVIMJWdYNUMIMr5u1jRPIf+wjYj5gGazHVAEXx/PzKbUBlXiTMQ+wzuj469WCFyNpfBFvvhV8VF0XQGqOXVR8cL5si7t7NNhGGP2CnSZIXrScde1NFO1Gb8K8yjGqtP7EG//kzAVMurLryqV2gLtLgNUUApIWr51D3UJx+JwiaOpuoZX29eNq3RReEF8Yb9NWfF+66YOctIH4NkR1JMnq1lahBEqHPFKrZ12jHAx/Bb9HzMsFYR8TxZ2VYpTHcrSPD4i9ugG+rUJ/9ffZwH02kb1j52dwPcrqmEDMN/ZG9i1g9CxXvbwWmel8eqUiKvefXa00kTu35KSIvHgw== ubuntu@ubuntu1

Test the Inventory / Hosts Connection
#

# Check connection to "example_host" host
ansible example_host -m ping

# Shell output:
192.168.30.11 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.12"
    },
    "changed": false,
    "ping": "pong"
}
192.168.30.100 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.9"
    },
    "changed": false,
    "ping": "pong"
}



Ansible Collection
#

Init Collection / Verify Folder Structure
#

# Create a example collection
ansible-galaxy collection init --init-path ~/ juergens_namespace.example_collection

# Shell output:
- Collection juergens_namespace.example_collection was created successfully

The folder and file structure should look like this:

juergens_namespace/
└── example_collection
    ├── docs
    ├── galaxy.yml # Main metadata file for the collection / dependencies to other Ansible collections
    ├── meta
    │   └── runtime.yml # Defines the minimum Ansible version and compatible Python versions
    ├── plugins # Custom plugins that extend Ansible’s functionality
    │   └── README.md
    ├── README.md
    └── roles

Ansible Collection Metadata
#

Optional adopt the Ansible collection metadata:

vi ~/juergens_namespace/example_collection/galaxy.yml
namespace: juergens_namespace
name: example_collection
version: 1.0.0
readme: README.md
authors:
- Juergen Klug juergen@jklug.work
description: Example collection
license:
- GPL-2.0-or-later
license_file: ''
tags: []
dependencies: {}
documentation: http://docs.example.com
homepage: https://jklug.work

Ansible Roles
#

Init Roles / Verify Folder Structure
#

# Create "ssh_config" role
ansible-galaxy init --init-path ~/juergens_namespace/example_collection/roles ssh_config

# Create "ocal_accounts" role
ansible-galaxy init --init-path ~/juergens_namespace/example_collection/roles local_accounts


# Shell output:
- Role ssh_config was created successfully
- Role local_accounts was created successfully
# Verify the folder structure
juergens_namespace/
└── example_collection
    ├── docs
    ├── galaxy.yml
    ├── meta
    │   └── runtime.yml
    ├── plugins
    │   └── README.md
    ├── README.md
    └── roles
        ├── local_accounts
        │   ├── defaults
        │   │   └── main.yml # Default variables for the role that can be overridden by variables defined at higher precedence levels / users won't need to change
        │   ├── files # Store for binary or configuration files that can by copied to a target machine
        │   ├── handlers
        │   │   └── main.yml # Used for operations like restarting a service or reloading a configuration
        │   ├── meta
        │   │   └── main.yml
        │   ├── README.md
        │   ├── tasks
        │   │   └── main.yml
        │   ├── templates # Templates can be rendered with variables and can adapt to different environments or host specifics. For example Nginx document roots
        │   ├── tests  # Testing setup for the role, which typically includes an inventory file with a list of test hosts & a basic playbook
        │   │   ├── inventory
        │   │   └── test.yml
        │   └── vars
        │       └── main.yml
        └── ssh_config
            ├── defaults
            │   └── main.yml
            ├── files
            ├── handlers
            │   └── main.yml
            ├── meta
            │   └── main.yml
            ├── README.md
            ├── tasks
            │   └── main.yml
            ├── templates
            ├── tests
            │   ├── inventory
            │   └── test.yml
            └── vars
                └── main.yml

Local Accounts Role
#

Variables
#

# Convert the date into unix format (number of seconds from 1.1.1970 to 31.12.2024)
date -d "2024-12-31" +%s

# Shell output:
1735603200
vi ~/juergens_namespace/example_collection/roles/local_accounts/vars/main.yml
---
# vars file for local_accounts
local_accounts:
  - name: local_adm # Define user name
    shell: /bin/bash # Define shell
    userid: 1010 # Define user ID
    expires: -1  # No expiration
    home: /home/local_adm # Define home directory
    groups: [] # Default group, no additional group memberships
    ssh_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCbioQbz6ZkgMmAuwt8sNjQ4gULCk1VNM71YkiOKq7lwEuZdJns+/naCurn67A+UVRZ/lNmGVKocwoHofsmeAudvsDtDVa1cPoT5VF/1xM5EeyezjIKy4Esm29p4j3EIrvLbHay0j0d7lZo0JKgDJ+bD69rmOw5idOXwR+a1EF3hOup3BnqDE8bMXQMseKZsmhVsUPA/ciX6yY5p9cPwckSewSGOdy3PKou4YPo9Ka0bvBi0cCzkMh8R9nClhq3er9uocNZbucSkgOPgN9GrBWm1dhT/4UsZz755EpvmZcA/Cg9HtXJS7cwto6A4WUDshKKe4yXyUdWard8mv77R2XTNZF4iaVIMJWdYNUMIMr5u1jRPIf+wjYj5gGazHVAEXx/PzKbUBlXiTMQ+wzuj469WCFyNpfBFvvhV8VF0XQGqOXVR8cL5si7t7NNhGGP2CnSZIXrScde1NFO1Gb8K8yjGqtP7EG//kzAVMurLryqV2gLtLgNUUApIWr51D3UJx+JwiaOpuoZX29eNq3RReEF8Yb9NWfF+66YOctIH4NkR1JMnq1lahBEqHPFKrZ12jHAx/Bb9HzMsFYR8TxZ2VYpTHcrSPD4i9ugG+rUJ/9ffZwH02kb1j52dwPcrqmEDMN/ZG9i1g9CxXvbwWmel8eqUiKvefXa00kTu35KSIvHgw== ubuntu@ubuntu" # Optional, add public SSH key

  - name: local_log
    shell: /bin/sh
    userid: 1011
    expires: 1735603200 # Expiration date in unix format
    home: /home/local_log
    groups: []
    ssh_key: "" # Optional, add public SSH key

Ansible Tasks
#

The Ansible user module is used to create users based on the variables defined in vars/main.yml:

vi ~/juergens_namespace/example_collection/roles/local_accounts/tasks/main.yml
---
# tasks file for local_accounts
- name: Create user accounts
  ansible.builtin.user: # Use Fully Qualified Collection Name FQCN for Ansible module specification
    name: "{{ item.name }}"
    shell: "{{ item.shell }}"
    uid: "{{ item.userid }}"
    home: "{{ item.home }}"
    groups: "{{ item.groups | default(item.name) }}"
    expires: "{{ item.expires }}"
  loop: "{{ local_accounts }}"

- name: Configure passwordless SSH for users with specified ssh_key
  ansible.builtin.authorized_key: # Use Fully Qualified Collection Name FQCN for Ansible module specification
    user: "{{ item.name }}"
    key: "{{ item.ssh_key }}"
    state: present
  loop: "{{ local_accounts }}"
  when: item.ssh_key is defined and item.ssh_key != ""

Ansible Playbook
#

This Ansible playbook runs the local_accounts role:

vi ~/juergens_namespace/example_collection/create_local_accounts.yml
---
- name: Create local user accounts
  hosts: example_host  # Define host group from inventory file
  become: yes
  roles:
    - local_accounts # Define Role



SSH Config Role
#

Ansible Tasks
#

vi ~/juergens_namespace/example_collection/roles/ssh_config/tasks/main.yml
---
# Ubuntu Settings: Uncomment PasswordAuthentication
- name: Check if 50-cloud-init.conf exists
  ansible.builtin.stat:
    path: /etc/ssh/sshd_config.d/50-cloud-init.conf
  register: ubuntu_cloud_init_conf

- name: Uncomment PasswordAuthentication
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config.d/50-cloud-init.conf
    regexp: '^(#\s*)?PasswordAuthentication '
    line: '#PasswordAuthentication'
  when: ubuntu_cloud_init_conf.stat.exists  # Only run if the file exists


# RHEL Settings: Uncomment PermitRootLogin
- name: Check if 01-permitrootlogin.conf exists
  ansible.builtin.stat:
    path: /etc/ssh/sshd_config.d/01-permitrootlogin.conf
  register: rhel_cloud_init_conf

- name: Uncomment PermitRootLogin
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config.d/01-permitrootlogin.conf
    regexp: '^(#\s*)?PermitRootLogin '
    line: '#PermitRootLogin'
  when: rhel_cloud_init_conf.stat.exists  # Only run if the file exists


# SSH Daemon Configuration: Create new configuration file
- name: Configure SSH Daemon
  ansible.builtin.blockinfile:
    path: /etc/ssh/sshd_config.d/90-ansible-ssh.conf  # Create a new config file
    block: |
      # Ansible managed
      PasswordAuthentication yes
      PermitEmptyPasswords no
      PermitRootLogin no
    create: yes
    state: present
    marker: "# {mark} ANSIBLE MANAGED BLOCK"
  register: sshd_config_changed  # Register task result

# Restart SSH Daemon
- name: Restart SSH Daemon
  ansible.builtin.service:
    name: "{{ 'sshd' if ansible_os_family == 'RedHat' else 'ssh' }}"
    state: restarted
  when: sshd_config_changed.changed  # Use the registered task variable to check if changes occurred

Ansible Playbook
#

The playbook runs the /ssh_config role:

vi ~/juergens_namespace/example_collection/ssh_daemon_config.yml
---
- name: Configure SSH
  hosts: example_host  # Define host group from inventory file
  become: yes
  roles:
    - ssh_config # Define Role



Run Ansible Playbooks
#

# Run the Ansible playbooks
cd ~/juergens_namespace/example_collection
ansible-playbook create_local_accounts.yml
ansible-playbook ssh_daemon_config.yml

Verify Ansible Roles
#

Verify the Ansible playbooks did successfully run.

Verify User Details
#

Group Membership
#

# List groups of user: local_log
id local_adm

# Shell output:
uid=1010(local_adm) gid=1010(local_adm) groups=1010(local_adm)
# List groups of user: local_log
id local_log

# Shell output:
uid=1011(local_log) gid=1011(local_log) groups=1011(local_log

Expiry Date
#

# List account details: local_adm
sudo chage -l local_adm

# Shell output:
Last password change                                    : Oct 27, 2024
Password expires                                        : never
Password inactive                                       : never
Account expires                                         : never
Minimum number of days between password change          : 0
Maximum number of days between password change          : 99999
Number of days of warning before password expires       : 7
# List account details: local_log
sudo chage -l local_log

# Shell output:
Last password change                                    : Oct 27, 2024
Password expires                                        : never
Password inactive                                       : never
Account expires                                         : Dec 31, 2024
Minimum number of days between password change          : 0
Maximum number of days between password change          : 99999
Number of days of warning before password expires       : 7

Home Directory
#

# List home directory: local_adm
getent passwd local_adm | cut -d: -f6

# Shell output:
/home/local_adm
# List home directory: local_log
getent passwd local_log | cut -d: -f6

# Shell output:
/home/local_log

Shell
#

# List shell: local_adm
getent passwd local_adm | cut -d: -f7

# Shell output:
/bin/bash
# List shell: local_log
getent passwd local_log | cut -d: -f7

# Shell output:
/bin/sh

Verify Passwordless SSH Authentication
#

# Open SSH connection
ssh -i ~/.ssh/examplekey local_adm@192.168.30.11
ssh -i ~/.ssh/examplekey local_adm@192.168.30.100


# List home directory of current user
pwd

# Shell output:
/home/local_adm



Verify SSH Daemon Configuration
#

# Check the effective configuration of the SSH daemon
sudo sshd -T | grep passwordauthentication
sudo sshd -T | grep permitemptypasswords
sudo sshd -T | grep permitrootlogin

# Shell output:
passwordauthentication yes
permitemptypasswords no
permitrootlogin no



Idempotency Test
#

  • Idempotent Ansible roles only make changes when necessary.

  • If the system is already in the desired state, re-running the roles should not produce additional changes.

Run the Ansible playbooks a second time:

# Run the Ansible playbooks
cd ~/juergens_namespace/example_collection
ansible-playbook create_local_accounts.yml
ansible-playbook ssh_daemon_config.yml

Shell output:

PLAY [Create local user accounts] **************************************************************************************************************************************************************

TASK [Gathering Facts] *************************************************************************************************************************************************************************
[WARNING]: Platform linux on host 192.168.30.11 is using the discovered Python interpreter at /usr/bin/python3.12, but future installation of another Python interpreter could change the
meaning of that path. See https://docs.ansible.com/ansible-core/2.17/reference_appendices/interpreter_discovery.html for more information.
ok: [192.168.30.11]

TASK [local_accounts : Create user accounts] ***************************************************************************************************************************************************
ok: [192.168.30.11] => (item={'name': 'local_adm', 'shell': '/bin/bash', 'userid': 38000087, 'expires': -1, 'home': '/home/local_adm', 'groups': [], 'ssh_key': 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCbioQbz6ZkgMmAuwt8sNjQ4gULCk1VNM71YkiOKq7lwEuZdJns+/naCurn67A+UVRZ/lNmGVKocwoHofsmeAudvsDtDVa1cPoT5VF/1xM5EeyezjIKy4Esm29p4j3EIrvLbHay0j0d7lZo0JKgDJ+bD69rmOw5idOXwR+a1EF3hOup3BnqDE8bMXQMseKZsmhVsUPA/ciX6yY5p9cPwckSewSGOdy3PKou4YPo9Ka0bvBi0cCzkMh8R9nClhq3er9uocNZbucSkgOPgN9GrBWm1dhT/4UsZz755EpvmZcA/Cg9HtXJS7cwto6A4WUDshKKe4yXyUdWard8mv77R2XTNZF4iaVIMJWdYNUMIMr5u1jRPIf+wjYj5gGazHVAEXx/PzKbUBlXiTMQ+wzuj469WCFyNpfBFvvhV8VF0XQGqOXVR8cL5si7t7NNhGGP2CnSZIXrScde1NFO1Gb8K8yjGqtP7EG//kzAVMurLryqV2gLtLgNUUApIWr51D3UJx+JwiaOpuoZX29eNq3RReEF8Yb9NWfF+66YOctIH4NkR1JMnq1lahBEqHPFKrZ12jHAx/Bb9HzMsFYR8TxZ2VYpTHcrSPD4i9ugG+rUJ/9ffZwH02kb1j52dwPcrqmEDMN/ZG9i1g9CxXvbwWmel8eqUiKvefXa00kTu35KSIvHgw== ubuntu@ubuntu'})
ok: [192.168.30.11] => (item={'name': 'local_log', 'shell': '/bin/sh', 'userid': 38000088, 'expires': 1735603200, 'home': '/home/local_log', 'groups': [], 'ssh_key': ''})

TASK [local_accounts : Configure passwordless SSH for users with specified ssh_key] ************************************************************************************************************
ok: [192.168.30.11] => (item={'name': 'local_adm', 'shell': '/bin/bash', 'userid': 38000087, 'expires': -1, 'home': '/home/local_adm', 'groups': [], 'ssh_key': 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCbioQbz6ZkgMmAuwt8sNjQ4gULCk1VNM71YkiOKq7lwEuZdJns+/naCurn67A+UVRZ/lNmGVKocwoHofsmeAudvsDtDVa1cPoT5VF/1xM5EeyezjIKy4Esm29p4j3EIrvLbHay0j0d7lZo0JKgDJ+bD69rmOw5idOXwR+a1EF3hOup3BnqDE8bMXQMseKZsmhVsUPA/ciX6yY5p9cPwckSewSGOdy3PKou4YPo9Ka0bvBi0cCzkMh8R9nClhq3er9uocNZbucSkgOPgN9GrBWm1dhT/4UsZz755EpvmZcA/Cg9HtXJS7cwto6A4WUDshKKe4yXyUdWard8mv77R2XTNZF4iaVIMJWdYNUMIMr5u1jRPIf+wjYj5gGazHVAEXx/PzKbUBlXiTMQ+wzuj469WCFyNpfBFvvhV8VF0XQGqOXVR8cL5si7t7NNhGGP2CnSZIXrScde1NFO1Gb8K8yjGqtP7EG//kzAVMurLryqV2gLtLgNUUApIWr51D3UJx+JwiaOpuoZX29eNq3RReEF8Yb9NWfF+66YOctIH4NkR1JMnq1lahBEqHPFKrZ12jHAx/Bb9HzMsFYR8TxZ2VYpTHcrSPD4i9ugG+rUJ/9ffZwH02kb1j52dwPcrqmEDMN/ZG9i1g9CxXvbwWmel8eqUiKvefXa00kTu35KSIvHgw== ubuntu@ubuntu'})
skipping: [192.168.30.11] => (item={'name': 'local_log', 'shell': '/bin/sh', 'userid': 38000088, 'expires': 1735603200, 'home': '/home/local_log', 'groups': [], 'ssh_key': ''})

PLAY RECAP *************************************************************************************************************************************************************************************
192.168.30.11              : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0


PLAY [Configure SSH] ***************************************************************************************************************************************************************************

TASK [Gathering Facts] *************************************************************************************************************************************************************************
[WARNING]: Platform linux on host 192.168.30.11 is using the discovered Python interpreter at /usr/bin/python3.12, but future installation of another Python interpreter could change the
meaning of that path. See https://docs.ansible.com/ansible-core/2.17/reference_appendices/interpreter_discovery.html for more information.
ok: [192.168.30.11]

TASK [ssh_config : Configure SSH Daemon] *******************************************************************************************************************************************************
ok: [192.168.30.11]

TASK [ssh_config : Restart SSH Daemon] *********************************************************************************************************************************************************
skipping: [192.168.30.11]

PLAY RECAP *************************************************************************************************************************************************************************************
192.168.30.11              : ok=2    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
  • changed=0 Both playbooks made no changes to the system on the second run

  • skipped=1 Restart SSH Daemon was not executed



Ansible Galaxy
#

Build the Collection
#

# Build the Ansible collection
cd ~/juergens_namespace/example_collection
ansible-galaxy collection build

# Shell output:
ansible-galaxy collection build
Created collection for juergens_namespace.example_collection at /home/ubuntu/juergens_namespace/example_collection/juergens_namespace-example_collection-1.0.0.tar.gz

Install the Collection
#

# Install the collection
ansible-galaxy collection install /home/ubuntu/juergens_namespace/example_collection/juergens_namespace-example_collection-1.0.0.tar.gz

# Shell output:
Starting galaxy collection install process
Process install dependency map
Starting collection install process
Installing 'juergens_namespace.example_collection:1.0.0' to '/home/ubuntu/.ansible/collections/ansible_collections/juergens_namespace/example_collection'
juergens_namespace.example_collection:1.0.0 was installed successfully

Verify Collection Installation Path
#

# List Ansible collections
ls ~/.ansible/collections/ansible_collections/

# Shell output:
juergens_namespace
Ansible - This article is part of a series.
Part 2: This Article