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