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)