Merging a list of IP addresses and ports for OpenSSH

OpenSSH server has separate configuration variables for IP addresses to listen on and ports to listen on, for example in /etc/ssh/sshd_config:

ListenAddress 192.168.1.2
ListenAddress 127.0.0.1
Port 22
Port 53
Port 443

A SSH role I have written and I’m updating has variables for ssh_ports and ssh_listen_addresses defined like this:

      ssh_listen_addresses:
        type: list
        elements: str
        required: false
        description: ListenAddress, IP addresses that SSH should listen for connections on.
      ssh_ports:
        type: list
        elements: int
        required: false
        description: Port, a list of ports for SSH to listen on.

So for this example:

ssh_listen_addresses:
  - 127.0.0.1
  - 192.168.1.2
ssh_ports:
  - 22
  - 53
  - 443

You can get the current SSHD configuration using sshd -T but this combines the two configuration variables:

sshd -T | grep ^listen
listenaddress 127.0.0.1:22
listenaddress 127.0.0.1:53
listenaddress 127.0.0.1:443
listenaddress 192.168.1.2:22
listenaddress 192.168.1.2:53
listenaddress 192.168.1.2:443

And you can parse this using jc, for example:

sshd -T | sort | jc -- sshd-conf -p -y | grep -A6 listenaddress
listenaddress:
  - 127.0.0.1:22
  - 127.0.0.1:53
  - 127.0.0.1:443
  - 192.168.1.2:22
  - 192.168.1.2:53
  - 192.168.1.2:443

Using Ansible:

        - name: Run sshd -T to get the current configuration
          ansible.builtin.command:
            cmd: /usr/sbin/sshd -T
          check_mode: false
          changed_when: false
          register: ssh_t

        - name: Set a fact for the current SSHD configuration
          ansible.builtin.set_fact:
            ssh_cnf: "{{ ssh_t.stdout | community.general.jc('sshd_conf') }}"

I want to check that the Ansible configuration for addresses and ports matches the configuration that the service has, if it does then some tasks can be skipped, in order to test this I need to combine the ssh_listen_addresses and the ssh_ports list, so far I have this in vars/main.yml:

# Generate a list of addresses and ports that match the sshd -T format of listenaddress
# in a not very elegant way...
ssh_listen_addr_port: |-
  {%- for ssh_listen_address in ssh_listen_addresses %}
  {%-     for ssh_port in ssh_ports %}
  {{ ssh_listen_address }}:{{ ssh_port }}
  {%     endfor -%}
  {% endfor -%}
ssh_listen_addr_port_list: >-
  {{- ssh_listen_addr_port |
     ansible.builtin.split('\n') |
     ansible.builtin.reject('regex', '^$') -}}

Tasks can be skipped when this condition is false:

  when: >-
    (ssh_cnf is not defined) or
    ((ssh_cnf is defined) and
    (ssh_cnf.listenaddress | ansible.builtin.difference(ssh_listen_addr_port_list) != []))

This works, but as you can see it looks fairly horrible, I realise that I could use set_fact with a loop to generate the ssh_listen_addr_port_list list but that also wouldn’t be very elegant, what am I missing, how can this be done more elegantly?

1 Like

I would solve this with product filter

---
- name: Multiply lists
  hosts: all
  gather_facts: false
  tasks:
    - name: Multiply lists
      ansible.builtin.assert:
        that:
          - sshd_config_lines == listenaddress_host_port
      vars:
        ssh_listen_addresses:
          - 127.0.0.1
          - 192.168.1.2
        ssh_ports:
          - 22
          - 53
          - 443
        ssh_ports_prefixed_colon: "{{ ssh_ports | map('regex_replace', '^(.*)$', ':' ~ '\\1') }}"
        host_port_list: "{{ ssh_listen_addresses | product (ssh_ports_prefixed_colon)  }}"
        host_port_string: "{{ host_port_list | map('join') }}"
        listenaddress_host_port: "{{ host_port_string | map('regex_replace', '^(.*)$', 'listenaddress ' ~ '\\1') }}"
        sshd_config_stdout: |-
          listenaddress 127.0.0.1:22
          listenaddress 127.0.0.1:53
          listenaddress 127.0.0.1:443
          listenaddress 192.168.1.2:22
          listenaddress 192.168.1.2:53
          listenaddress 192.168.1.2:443
        sshd_config_lines: "{{ sshd_config_stdout | split('\n') }}"

Quick explanation:
sshd_config_stdout - this is your text output from sshd configuration - pay attention that new lines are preserved (‘|’) and last new line is removed (‘-’)
ssh_ports_prefixed_colon - just adds ‘:’ to each port
host_port_list - multiplication of two lists - hosts and ports
host_port_string - converts list of lists to list of strings
listenaddress_host_port - add 'listenaddress ’ as prefix to each element of a list

And if we run this playbook assertion is true:

bash-5.2$ uv run --with ansible-core==2.19 ansible-playbook -i localhost, playbook.yml 
[WARNING]: Skipping plugin (/opt/homebrew/lib/python3.14/site-packages/ara/plugins/callback/ara_default.py), cannot load: No module named 'ara'

PLAY [Multiply lists] ****************************************************************************************************************************************************************************************************************

TASK [Multiply lists] ****************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "changed": false,
    "msg": "All assertions passed"
}

PLAY RECAP ***************************************************************************************************************************************************************************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Meaning we made sure that our two configuration lists (hosts and ports) give as exactly what is in text output of configuration. I prefer to avoid jq - this is unnecessary dependency from my point of view.

Easiest way to understand what is happening is to print all the variables in the order they are defined:

---
- name: Multiply lists
  hosts: all
  gather_facts: false
  tasks:
    - name: Multiply lists
      ansible.builtin.debug:
        var: ssh_ports_prefixed_colon
        # var: host_port_list
        # var: host_port_string
        # var: listenaddress_host_port
        # var: sshd_config_stdout
        # var: sshd_config_lines
      vars:
        ssh_listen_addresses:
          - 127.0.0.1
          - 192.168.1.2
        ssh_ports:
          - 22
          - 53
          - 443
        ssh_ports_prefixed_colon: "{{ ssh_ports | map('regex_replace', '^(.*)$', ':' ~ '\\1') }}"
        host_port_list: "{{ ssh_listen_addresses | product (ssh_ports_prefixed_colon)  }}"
        host_port_string: "{{ host_port_list | map('join') }}"
        listenaddress_host_port: "{{ host_port_string | map('regex_replace', '^(.*)$', 'listenaddress ' ~ '\\1') }}"
        sshd_config_stdout: |-
          listenaddress 127.0.0.1:22
          listenaddress 127.0.0.1:53
          listenaddress 127.0.0.1:443
          listenaddress 192.168.1.2:22
          listenaddress 192.168.1.2:53
          listenaddress 192.168.1.2:443
        sshd_config_lines: "{{ sshd_config_stdout | split('\n') }}"

2 Likes

Very nice.
You can simplify a bit by moving the colon insertion to the join:

      vars:
        ssh_listen_addresses:
          - 127.0.0.1
          - 192.168.1.2
        ssh_ports:
          - 22
          - 53
          - 443
        # ssh_ports_prefixed_colon: "{{ ssh_ports | map('regex_replace', '^(.*)$', ':' ~ '\\1') }}"
        # host_port_list: "{{ ssh_listen_addresses | product (ssh_ports_prefixed_colon)  }}"
        host_port_list: "{{ ssh_listen_addresses | product(ssh_ports)  }}"
        host_port_string: "{{ host_port_list | map('join', ':') }}"
        listenaddress_host_port: "{{ host_port_string | map('regex_replace', '^(.*)$', 'listenaddress ' ~ '\\1') }}"
2 Likes

Thanks! That is indeed a good improvement!

1 Like

Thanks @kks and @utoddl I didn’t know about the ansible.builtin.product filter… :roll_eyes: and since this is the thing I was missing I’ve marked that as the solution.

However the solution I think I’ll use is this:

ssh_listen_addr_port_list: >-
  {{ ssh_listen_addresses |
     ansible.builtin.product(ssh_ports) |
     ansible.builtin.map('ansible.builtin.join', ':') }}

I don’t think there is a need to set any intermediate variables?

@kks I’m somewhat obliged to use the jc sshd-conf filter because it was my idea in the first place :wink: plus I use jc for lots of things so it isn’t an additional dependency for me.

2 Likes

Yes, they are only to make it easily understandable and I personally using them for troubleshooting during development.

2 Likes