David Moreau Simard

5 minute read

One of the first things I do when I get my hands on a new linux server is to change the SSH port.

It’s a basic, easy and efficient way of warding most brute force attempts. In a nutshell, you edit the Port parameter of /etc/ssh/sshd_config, restart sshd and you’re done.

If you have Selinux enabled, you’ll have to allow sshd to listen on that port too with semanage port -a -t ssh_port_t -p tcp <<port>>

So let’s translate all that into Ansible tasks :

role/tasks/main.yml

- name: Setup alternate SSH port
  lineinfile:
    dest: "/etc/ssh/sshd_config"
    regexp: "^Port"
    line: "Port 2222"
  notify: "Restart sshd"

- name: Setup selinux for alternate SSH port
  seport:
    ports: "2222"
    proto: "tcp"
    setype: "ssh_port_t"
    state: "present"

role/handlers/main.yml

- name: Restart sshd
  service:
    name: sshd
    state: restarted

Easy enough, we’re done. You think ?

The problem

The problem is about making the role idempotent without having to fiddle with your inventory file too much.

You want to be able to run it multiple times against the same hosts without failing and without needlessly reconfiguring things.

So, let’s pretend you have something like this for your inventory file:

hosts

[servers]
myserver ansible_host=192.168.0.10

If you run the above role against that machine, it will work. It will change the SSH port from 22 to 2222.

If you run it again, though, Ansible will fail to connect because it expects the host SSH port to be 22. So, what do you do ?

You can set the port either through your .ssh/config file or in your inventory, like so:

hosts

[servers]
myserver ansible_port=2222 ansible_host=192.168.0.10

So that requires a manual modification in your inventory file after your role has run. Not exactly awesome and bound to fail horribly if you have other roles running right after.

The solution

I found threads relevant to this use case on Google (ex: here and here) but I was not satisfied with their solutions.

What I’ve come up with is a fairly robust way of detecting if the server is running the default SSH port (22) or the port configured in the inventory (ansible_port) and use the right one.

This is what it looks like (comments and explanations inline):

role/tasks/main.yml

---
#   Copyright Red Hat, Inc. All Rights Reserved.
#
#   Licensed under the Apache License, Version 2.0 (the "License"); you may
#   not use this file except in compliance with the License. You may obtain
#   a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#   WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#   License for the specific language governing permissions and limitations
#   under the License.
#
#   Author: David Moreau Simard <dms@redhat.com>

# ansible_port can change throughout this role, keep a copy around
- name: Set configured port fact
  set_fact:
    configured_port: "{{ ansible_port }}"

# From localhost, check if we're able to reach {{ inventory_hostname }} on
# port 22
- name: Check if we're using the default SSH port
  wait_for:
    port: "22"
    state: "started"
    host: "{{ inventory_hostname }}"
    connect_timeout: "5"
    timeout: "10"
  delegate_to: "localhost"
  ignore_errors: "yes"
  register: default_ssh

# If reachable, continue the following tasks with this port
- name: Set inventory ansible_port to default
  set_fact:
    ansible_port: "22"
  when: default_ssh is defined and
        default_ssh.state == "started"
  register: ssh_port_set

# If unreachable on port 22, check if we're able to reach
# {{ inventory_hostname }} on {{ ansible_port }} provided by the inventory
# from localhost
- name: Check if we're using the inventory-provided SSH port
  wait_for:
    port: "{{ ansible_port }}"
    state: "started"
    host: "{{ inventory_hostname }}"
    connect_timeout: "5"
    timeout: "10"
  delegate_to: "localhost"
  ignore_errors: "yes"
  register: configured_ssh
  when: default_ssh is defined and
        default_ssh.state is undefined

# If {{ ansible_port }} is reachable, we don't need to do anything special
- name: SSH port is configured properly
  debug:
    msg: "SSH port is configured properly"
  when: configured_ssh is defined and
        configured_ssh.state is defined and
        configured_ssh.state == "started"
  register: ssh_port_set

# If the SSH port is neither the default or the configured, give up.
- name: Fail if SSH port was not auto-detected (unknown)
  fail:
    msg: "The SSH port is neither 22 or {{ ansible_port }}."
  when: ssh_port_set is undefined

# Sanity check, make sure Ansible is able to connect to the host
- name: Confirm host connection works
  ping:

- name: Setup alternate SSH port
  lineinfile:
    dest: "/etc/ssh/sshd_config"
    regexp: "^Port"
    line: "Port {{ configured_port }}"
  notify: "Restart sshd"

- name: Setup selinux for alternate SSH port
  seport:
    ports: "{{ configured_port }}"
    proto: "tcp"
    setype: "ssh_port_t"
    state: "present"

# We notified "Restart sshd" if we modified the sshd config.
# By calling flush_handlers, we make sure the handler is run *right now*
- name: Ensure SSH is reloaded if need be
  meta: flush_handlers

# We're done, make sure ansible_port is set properly so that any tasks
# after this use the right ansible_port.
- name: Ensure we use the configured SSH port for the remainder of the role
  set_fact:
    ansible_port: "{{ configured_port }}"

# Gather facts should be set to false when running this role since it will
# fail if the Ansible SSH port is not set correctly.
# We run setup to gather facts here once the SSH port is set up.
- name: Run deferred setup to gather facts
  setup:

role/handlers/main.yml

    - name: Restart sshd
      service:
        name: sshd
        state: restarted

There you go

With something like this in your role, you’ll be able to ensure the ansible_port configured in your inventory file is setup properly and it will be idempotent: you won’t need to change your inventory file.