By the end of this article, you will be able to harden the security of a remote OpenSSH server using an Ansible GitHub action. Basic security measures will be applied to the SSH server.
Change the SSH port to a custom one
Disable root login
Set an idle timeout interval
Change the maximum login attempts
Disable password authentication
Disable X11 forwarding
Update UFW rules
Feel free to add more after. All the source code is available here: https://github.com/jackkweyunga/ssh-hardening-with-ansible-and-gh-actions
Let's get started!
Prerequisites
Basic knowledge of Ansible
Basic knowledge of GitHub Actions
A remote Ubuntu server with OpenSSH server installed
Project Structure
.
├── .github
│ └── workflows
│ ├── ssh.yml
│ └── ufw.yml
├── ssh
│ └── tasks
│ └── main.yml
├── ufw
│ └── tasks
│ └── main.yml
├── create-sudo-password-ansible-secret.sh
├── ssh.yml
└── ufw.yml
6 directories, 7 files
Ansible playbooks
The SSH ansible playbook
Let's start by creating an Ansible role. This will perform the hardening tasks for us.
ssh/tasks/main.yml
- name: Harden SSH security
become: true
block:
- name: Install / Update openssh-server (Debian-based systems)
ansible.builtin.package:
name: openssh-server
state: latest
when: ansible_os_family == 'Debian'
- name: Check SSH configuration syntax
command: sshd -t
register: sshd_config_check
ignore_errors: true
- name: Ensure SSH service is running
ansible.builtin.service:
name: ssh
state: started
enabled: yes
when: sshd_config_check.rc != 0
- name: Disable root login
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
state: present
backup: yes
- name: Disable password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
state: present
backup: yes
- name: Disable X11 forwarding
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?X11Forwarding'
line: 'X11Forwarding no'
state: present
- name: Set idle timeout interval
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?ClientAliveInterval'
line: 'ClientAliveInterval {{ ssh_alive_interval }}'
state: present
- name: Set maximum number of login attempts
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?MaxAuthTries'
line: 'MaxAuthTries {{ ssh_max_auth_tries }}'
state: present
- name: Ensure UFW is installed and enabled (Debian-based systems)
ansible.builtin.service:
name: ufw
state: started
when: ansible_os_family == 'Debian'
- name: Add firewall rule for new SSH port
ansible.builtin.ufw:
rule: allow
port: '{{ ssh_new_port }}'
proto: tcp
- name: Enable UFW if not already enabled
ansible.builtin.ufw:
state: enabled
- name: Change SSH port to {{ ssh_new_port }}
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?Port'
line: 'Port {{ ssh_new_port }}'
state: present
- name: Check SSH configuration syntax
command: sshd -t
register: sshd_config_check_before_restart
ignore_errors: true
- name: Restart SSH service to apply changes
ansible.builtin.service:
name: ssh
state: restarted
when: sshd_config_check_before_restart.rc == 0
- name: Reconnect to server using new SSH port
become: true
local_action:
module: wait_for
host: "{{ inventory_hostname }}"
port: '{{ ssh_new_port }}'
delay: 10
timeout: 300
state: started
Now, let's define the actual playbook and reference the SSH role within.
ssh.yml
- name: SSH Hardening
hosts: all
become: yes
vars_files:
- secret
vars:
ssh_new_port: "{{ lookup('env', 'SSH_NEW_PORT') }}"
ssh_alive_interval: "{{ lookup('env', 'SSH_ALIVE_INTERVAL') }}"
ssh_max_auth_tries: "{{ lookup('env', 'SSH_MAX_AUTH_TRIES') }}"
roles:
- ssh
The UFW ansible playbook
Let's start by creating an Ansible role to configure UFW and add minimal port rules.
ufw/tasks/main.yml
---
- name: Ensure UFW is installed
apt:
name: ufw
state: present
- name: Set logging
community.general.ufw:
logging: 'on'
- name: Limit SSH attempts
community.general.ufw:
rule: limit
port: 22
proto: tcp
- name: Limit SSH attempts
community.general.ufw:
rule: limit
port: 2222
proto: tcp
- name: Allow SSH
ufw:
rule: allow
port: 22
proto: tcp
- name: Allow SSH
ufw:
rule: allow
port: 2222
proto: tcp
- name: Allow HTTP
ufw:
rule: allow
port: 80
proto: tcp
- name: Allow HTTPS
ufw:
rule: allow
port: 443
proto: tcp
# - name: Allow custom port (e.g., 8080)
# ufw:
# rule: allow
# port: 8080
# proto: tcp
- name: Set default incoming policy to deny
ufw:
default: deny
direction: incoming
- name: Set default outgoing policy to allow
ufw:
default: allow
direction: outgoing
- name: Enable UFW
ufw:
state: enabled
And of course, the playbook.
ufw.yml
---
- name: Configure UFW Firewall on Ubuntu
hosts: all
become: yes
vars_files:
- secret
roles:
- ufw
Helper files
Let add a helper file which helps us create a sudo password Ansible secret for the remote server. This allows Ansible to run sudo commands in the automation without exposing the password in logs or source code.
create-sudo-password-ansible-secret.sh
#!/bin/bash
# variables
VAULT_PASSWORD=$(openssl rand -base64 12)
VAULT_PASSWORD_FILE="ansible/vault.txt"
VAULT_FILE="ansible/secret"
SUDO_PASSWORD="$1"
SUDO_PASSWORD_FILE="/tmp/sudo-password"
# sudo passord is required
if [ -z "${SUDO_PASSWORD}" ]; then
echo "Usage: $0 <sudo-password>"
exit 1
fi
# create vault password file
echo "${VAULT_PASSWORD}" > "${VAULT_PASSWORD_FILE}"
# create a sudo password file
echo "ansible_sudo_pass: \"${SUDO_PASSWORD}\"" > "${SUDO_PASSWORD_FILE}"
# encrypt sudo password
ansible-vault encrypt --vault-password-file "${VAULT_PASSWORD_FILE}" "${SUDO_PASSWORD_FILE}" --output "${VAULT_FILE}"
GitHub Actions
After creating the Ansible plays, let's move on to creating the GitHub workflows we’ll be running.
ssh workflow
First, there is the SSH workflow, which will run the SSH playbook when triggered.
.github/workflows/ssh.yml
name: ssh hardening
on:
workflow_dispatch:
inputs:
REMOTE_USER:
type: string
description: 'Remote User'
required: true
HOME_DIR:
type: string
description: 'Home Directory'
required: true
TARGET_HOST:
description: 'Target Host'
required: true
SSH_PORT:
description: 'SSH Port'
required: true
SSH_NEW_PORT:
description: 'SSH new Port'
required: true
default: "2222"
SSH_ALIVE_INTERVAL:
description: 'SSH Alive Interval'
required: true
default: "300"
SSH_MAX_AUTH_TRIES:
description: 'SSH Max Auth Tries'
required: true
default: "3"
jobs:
ansible:
runs-on: ubuntu-latest
env:
SSH_NEW_PORT: "${{ inputs.SSH_NEW_PORT }}"
SSH_ALIVE_INTERVAL: "${{ inputs.SSH_ALIVE_INTERVAL }}"
SSH_MAX_AUTH_TRIES: "${{ inputs.SSH_MAX_AUTH_TRIES }}"
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Add SSH Keys
run: |
cat << EOF > ansible/ssh-key
${{ secrets.SSH_PRIVATE_KEY }}
EOF
- name: Update ssh private key permissions
run: |
chmod 400 ansible/ssh-key
- name: Install Ansible
run: |
pip install ansible
- name: Adding or Override Ansible inventory File
run: |
cat << EOF > ansible/inventory.ini
[servers]
${{ inputs.TARGET_HOST }}
EOF
- name: Adding or Override Ansible Config File
run: |
cat << EOF > ./ansible/ansible.cfg
[defaults]
ansible_python_interpreter='/usr/bin/python3'
deprecation_warnings=False
inventory=./inventory.ini
remote_tmp="/tmp"
remote_user="${{ inputs.REMOTE_USER }}"
remote_port=${{ inputs.SSH_PORT }}
host_key_checking=False
private_key_file = ./ssh-key
retries=2
EOF
- name: Run main playbook
run: |
sh create-sudo-password-ansible-secret.sh "${{ secrets.SUDO_PASSWORD }}"
ANSIBLE_CONFIG=ansible/ansible.cfg ansible-playbook ssh.yml --vault-password-file=ansible/vault.txt
ufw workflow
Next, the UFW workflow will run the UFW playbook when triggered.
.github/workflows/ufw.yml
name: minimal UFW
on:
workflow_dispatch:
inputs:
REMOTE_USER:
type: string
description: 'Remote User'
required: true
HOME_DIR:
type: string
description: 'Home Directory'
required: true
TARGET_HOST:
description: 'Target Host'
required: true
SSH_PORT:
description: 'SSH Port'
required: true
jobs:
ansible:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Add SSH Keys
run: |
cat << EOF > ansible/ssh-key
${{ secrets.SSH_PRIVATE_KEY }}
EOF
- name: Update ssh private key permissions
run: |
chmod 400 ansible/ssh-key
- name: Install Ansible
run: |
pip install ansible
- name: Adding or Override Ansible inventory File
run: |
cat << EOF > ansible/inventory.ini
[servers]
${{ inputs.TARGET_HOST }}
EOF
- name: Adding or Override Ansible Config File
run: |
cat << EOF > ./ansible/ansible.cfg
[defaults]
ansible_python_interpreter='/usr/bin/python3'
deprecation_warnings=False
inventory=./inventory.ini
remote_tmp="/tmp"
remote_user="${{ inputs.REMOTE_USER }}"
remote_port=${{ inputs.SSH_PORT }}
host_key_checking=False
private_key_file = ./ssh-key
retries=2
EOF
- name: Run main playbook
run: |
sh create-sudo-password-ansible-secret.sh "${{ secrets.SUDO_PASSWORD }}"
ANSIBLE_CONFIG=ansible/ansible.cfg ansible-playbook ufw.yml --vault-password-file=ansible/vault.txt
Now that that's done, let's push the repository to GitHub. I assume you have already created a remote GitHub repository for this project.
git init
git commit -m "initial commit"
git push
GitHub secrets
Navigate to Settings, then Secrets and Variables, and finally Actions in your repository. Add the following GitHub secrets:
SSH_PRIVATE_KEY: A private key whose public key is added to the authorized_keys file on the server.
SUDO_PASSWORD: The password of the remote sudo user
Operation
On GitHub, go to the Actions tab of the repository to verify that the two workflows are available.
Select the one you want to start with, click the "Run workflow" button, fill in the form, and click "Run workflow" again. Monitor the progress to debug any errors if they occur and try again.
Congratulations! You can now change the settings to harden OpenSSH servers for any other remote hosts you have.
Seeking expert guidance in Ops, DevOps, or DevSecOps? I provide customized consultancy services for personal projects, small teams, and organizations. Whether you require assistance in optimizing operations, improving your CI/CD pipelines, or implementing strong security practices, I am here to support you. Let's collaborate to elevate your projects. Contact me today | LinkedIn | GitHub
Top comments (0)