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)