DEV Community

Cover image for Strengthen OpenSSH Security through Ansible and GitHub Actions
Jack Kweyunga
Jack Kweyunga

Posted on

Strengthen OpenSSH Security through Ansible and GitHub Actions

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

And of course, the playbook.

ufw.yml

--- - name: Configure UFW Firewall on Ubuntu hosts: all become: yes vars_files: - secret roles: - ufw 
Enter fullscreen mode Exit fullscreen mode

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}" 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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)