diff --git a/README.md b/README.md index 6322429..5876f77 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [rustdesk](roles/rustdesk/README.md) - [sftp_share](roles/sftp_share/README.md) - [smartctl_exporter](roles/smartctl_exporter/README.md) +- [sshd](roles/sshd/README.md) - [swapfile](roles/swapfile/README.md) - [ufw](roles/ufw/README.md) - [users](roles/users/README.md) diff --git a/roles/sshd/README.md b/roles/sshd/README.md new file mode 100644 index 0000000..3f3962d --- /dev/null +++ b/roles/sshd/README.md @@ -0,0 +1,37 @@ +ansible-sshd +========= + +Deploy a hardened sshd server + +Requirements +------------ + +None + +Role Variables +-------------- + +None + +Dependencies +------------ + +None + +Example Playbook +---------------- + +```yaml +roles: + - role: genlab.common.sshd +``` + +License +------- + +BSD + +Author Information +------------------ + +corvus-migratorius@proton.me diff --git a/roles/sshd/defaults/main.yml b/roles/sshd/defaults/main.yml new file mode 100644 index 0000000..5354282 --- /dev/null +++ b/roles/sshd/defaults/main.yml @@ -0,0 +1,5 @@ +--- +sshd_disable_pam: false +sshd_password_auth: false +sshd_challenge_response_auth: false +sshd_gss_api_auth: false diff --git a/roles/sshd/handlers/main.yml b/roles/sshd/handlers/main.yml new file mode 100644 index 0000000..9f08055 --- /dev/null +++ b/roles/sshd/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: "Restart sshd" + ansible.builtin.service: + name: ssh + state: restarted + enabled: true + daemon_reload: true diff --git a/roles/sshd/meta/main.yml b/roles/sshd/meta/main.yml new file mode 100644 index 0000000..a365305 --- /dev/null +++ b/roles/sshd/meta/main.yml @@ -0,0 +1,17 @@ +--- +galaxy_info: + role_name: sshd + namespace: genlab + author: "Alexander Gorelyshev" + company: "Genlab, LLC" + description: "Deploy a hardened sshd server" + license: "MIT" + min_ansible_version: "2.1" + + platforms: + - name: "Ubuntu" + versions: ["jammy", "noble"] + + galaxy_tags: [] + +dependencies: [] diff --git a/roles/sshd/molecule/default/converge.yml b/roles/sshd/molecule/default/converge.yml new file mode 100644 index 0000000..bea06f8 --- /dev/null +++ b/roles/sshd/molecule/default/converge.yml @@ -0,0 +1,5 @@ +--- +- name: Converge + hosts: all + roles: + - role: "genlab.common.sshd" diff --git a/roles/sshd/molecule/default/molecule.yml b/roles/sshd/molecule/default/molecule.yml new file mode 100644 index 0000000..d82158e --- /dev/null +++ b/roles/sshd/molecule/default/molecule.yml @@ -0,0 +1,27 @@ +--- +dependency: + name: galaxy + +driver: + name: docker + +platforms: + - name: ubuntu + image: geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2404}-ansible:latest + pre_build_image: true + command: ${MOLECULE_DOCKER_COMMAND:-""} + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host + privileged: true + +provisioner: + name: ansible + +verifier: + name: ansible + +lint: | + set -e + yamllint . + ansible-lint . diff --git a/roles/sshd/molecule/default/verify.yml b/roles/sshd/molecule/default/verify.yml new file mode 100644 index 0000000..29b09d7 --- /dev/null +++ b/roles/sshd/molecule/default/verify.yml @@ -0,0 +1,76 @@ +--- + +- name: Verify + hosts: all + gather_facts: false + any_errors_fatal: true + + tasks: + - name: Gather service facts + ansible.builtin.service_facts: + + - name: Ensure sshd is running + ansible.builtin.assert: + that: + - ansible_facts.services['ssh.service'].state == 'running' + + - name: Ensure sshd_config syntax is OK + ansible.builtin.command: sshd -t -f /etc/ssh/sshd_config + changed_when: false + + - name: Ensure main parameteres are applied + ansible.builtin.shell: | + set -o pipefail ; + sshd -T | egrep -i ' + ^protocol 2| + ^permitrootlogin no| + ^passwordauthentication no| + ^pubkeyauthentication yes + ' + args: + executable: /bin/bash + changed_when: false + + - name: Get sshd_config stats + ansible.builtin.stat: + path: /etc/ssh/sshd_config + register: sshd_conf + + - name: Ensure sshd_config file is secure + ansible.builtin.assert: + that: + - sshd_conf.stat.uid == 0 + - sshd_conf.stat.gid == 0 + - sshd_conf.stat.mode == '0600' + + - name: Create test user + ansible.builtin.user: + name: test + create_home: true + shell: /bin/bash + + - name: Create .ssh directory + ansible.builtin.file: + path: /home/test/.ssh/ + state: directory + mode: '0700' + owner: test + group: test + + - name: Generate ssh keys + community.crypto.openssh_keypair: + path: /home/test/.ssh/id_rsa + owner: test + group: test + mode: '0600' + register: sshd_key_result + + - name: Put public key to test user + ansible.posix.authorized_key: + user: test + key: "{{ sshd_key_result.public_key }}" + state: present + + - name: Test ssh connection + ansible.builtin.command: ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /home/test/.ssh/id_rsa test@localhost hostname + changed_when: false diff --git a/roles/sshd/tasks/algorithms.yml b/roles/sshd/tasks/algorithms.yml new file mode 100644 index 0000000..a43581d --- /dev/null +++ b/roles/sshd/tasks/algorithms.yml @@ -0,0 +1,42 @@ +--- +# next task requires this directory to exist for sshd -t flag +- name: Ensure /run/sshd exists + ansible.builtin.file: + path: /run/sshd + state: directory + owner: root + group: root + mode: '0755' + +# NOTE: order of preference for openssh-server ed25519 -> rsa +- name: "Algorithms | enable ed25519 authentication algorithm" + notify: Restart sshd + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^HostKey /etc/ssh/ssh_host_ed25519_key' + line: 'HostKey /etc/ssh/ssh_host_ed25519_key' + validate: sshd -f %s -t + +- name: "Algorithms | enable the RSA authentication algorithm" + notify: Restart sshd + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^HostKey /etc/ssh/ssh_host_rsa_key' + line: 'HostKey /etc/ssh/ssh_host_rsa_key' + validate: sshd -f %s -t + +- name: "Algorithms | disable the ECDSA algorithm (deemed to be less safe)" + notify: Restart sshd + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^HostKey /etc/ssh/ssh_host_ecdsa_key' + state: absent + validate: sshd -f %s -t + +- name: "Algorithms | disable the DSA algorithm (considered to be defunct)" + notify: Restart sshd + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^HostKey /etc/ssh/ssh_host_dsa_key' + state: absent + validate: sshd -f %s -t diff --git a/roles/sshd/tasks/authentication.yml b/roles/sshd/tasks/authentication.yml new file mode 100644 index 0000000..8b4e63b --- /dev/null +++ b/roles/sshd/tasks/authentication.yml @@ -0,0 +1,31 @@ +--- +- name: "Authentication | Configure SSH authentication settings" + notify: Restart sshd + loop: + - { regexp: '^#?\s*PubkeyAuthentication\s+', line: 'PubkeyAuthentication yes' } + - { regexp: '^#?\s*PasswordAuthentication\s+', line: 'PasswordAuthentication {{ sshd_password_auth | ternary("yes", "no") }}' } + - { regexp: '^#?\s*PermitEmptyPasswords\s+', line: 'PermitEmptyPasswords no' } + - { regexp: '^#?\s*ChallengeResponseAuthentication\s+', line: 'ChallengeResponseAuthentication {{ sshd_challenge_response_auth | ternary("yes", "no") }}' } + - { regexp: '^#?\s*GSSAPIAuthentication\s+', line: 'GSSAPIAuthentication {{ sshd_gss_api_auth | ternary("yes", "no") }}' } + - { + regexp: '^#?\s*AuthenticationMethods\s+', + line: "{{ 'AuthenticationMethods publickey password' if sshd_password_auth else 'AuthenticationMethods publickey' }}" + } + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + validate: /usr/sbin/sshd -t -f %s + +- name: "Check if there is an SSH config forced by cloud-init" + register: sshd_cloud_init + ansible.builtin.stat: + path: "/etc/ssh/sshd_config.d/50-cloud-init.conf" + +- name: "Authentication | override password authentication by cloud-init to '{{ sshd_password_auth | ternary('yes', 'no') }}'" + when: sshd_cloud_init.stat.exists + notify: Restart sshd + ansible.builtin.lineinfile: + path: "/etc/ssh/sshd_config.d/50-cloud-init.conf" + regexp: '^#?PasswordAuthentication' + line: 'PasswordAuthentication {{ sshd_password_auth | ternary("yes", "no") }}' diff --git a/roles/sshd/tasks/encryption.yml b/roles/sshd/tasks/encryption.yml new file mode 100644 index 0000000..660a369 --- /dev/null +++ b/roles/sshd/tasks/encryption.yml @@ -0,0 +1,11 @@ +--- +- name: "Encryption | remove unsafe host keys" + loop: + - /etc/ssh/ssh_host_ecdsa_key + - /etc/ssh/ssh_host_ecdsa_key.pub + - /etc/ssh/ssh_host_dsa_key + - /etc/ssh/ssh_host_dsa_key.pub + notify: Restart sshd + ansible.builtin.file: + path: "{{ item }}" + state: absent diff --git a/roles/sshd/tasks/install.yml b/roles/sshd/tasks/install.yml new file mode 100644 index 0000000..ff03595 --- /dev/null +++ b/roles/sshd/tasks/install.yml @@ -0,0 +1,13 @@ +--- +- name: "Install | install OpenSSH (RHEL flavours)" + when: ansible_os_family == "RHEL" + ansible.builtin.dnf: + name: openssh + state: installed + +- name: "Install | Install OpenSSH (Debian flavours)" + when: ansible_os_family == "Debian" + ansible.builtin.apt: + name: openssh-server + state: present + update_cache: true diff --git a/roles/sshd/tasks/main.yml b/roles/sshd/tasks/main.yml new file mode 100644 index 0000000..d35222a --- /dev/null +++ b/roles/sshd/tasks/main.yml @@ -0,0 +1,23 @@ +--- +- name: "Install an OpenSSH server" + ansible.builtin.include_tasks: "install.yml" + +- name: "Configure SSH algorithms" + ansible.builtin.include_tasks: "algorithms.yml" + +- name: "Configure authentication methods" + ansible.builtin.include_tasks: "authentication.yml" + +- name: "Configure SSH encryption keys" + ansible.builtin.include_tasks: "encryption.yml" + +- name: "Configure additional restrictions" + ansible.builtin.include_tasks: "restrictions.yml" + +- name: "Log at VERBOSE level" + notify: Restart sshd + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^#?LogLevel' + line: 'LogLevel VERBOSE' + validate: sshd -f %s -t diff --git a/roles/sshd/tasks/restrictions.yml b/roles/sshd/tasks/restrictions.yml new file mode 100644 index 0000000..6d14920 --- /dev/null +++ b/roles/sshd/tasks/restrictions.yml @@ -0,0 +1,30 @@ +--- +- name: "Restrictions | Configure SSH security restrictions" + loop: + - { regexp: '^#?Protocol\s+', line: 'Protocol 2' } + - { regexp: '^PermitRootLogin yes', line: 'PermitRootLogin no' } + - { regexp: '^#?X11Forwarding', line: 'X11Forwarding no' } + - { regexp: '^#?IgnoreRhosts', line: 'IgnoreRhosts yes' } + - { regexp: '^#?DebianBanner\s+', line: 'DebianBanner no' } + notify: Restart sshd + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + validate: /usr/sbin/sshd -t -f %s + +- name: "Restrictions | toggle PAM" + notify: Restart sshd + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^#?UsePAM' + line: "UsePAM {{ sshd_disable_pam | ternary('no', 'yes') }}" + validate: sshd -f %s -t + +- name: "Restrictions | ensure the SSHD config is restricted to the root user" + notify: Restart sshd + ansible.builtin.file: + path: /etc/ssh/sshd_config + owner: root + group: root + mode: "0600" diff --git a/roles/sshd/vars/main.yml b/roles/sshd/vars/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/sshd/vars/main.yml @@ -0,0 +1 @@ +---