# Merging a list of IP addresses and ports for OpenSSH

**URL:** <https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693>\
**Category:** Get Help\
**Tags:** ssh\
**Created:** [April 16, 2026, 1:43pm UTC](https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693 "2026-04-16T13:43:45Z")\
**Posts on this page:** 7\
**Page:** 1

<div class="post-metadata">

**Author:** ![chris](https://sea1.discourse-cdn.com/ansible/user_avatar/forum.ansible.com/chris/32/293_2.png) [@chris](https://forum.ansible.com/u/chris)\
**Post date:** [April 16, 2026, 1:43pm UTC](https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693/1 "2026-04-16T13:43:46Z")

</div>

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

```auto
ListenAddress 192.168.1.2
ListenAddress 127.0.0.1
Port 22
Port 53
Port 443

```

A [SSH role I have written](https://git.coop/webarch/ssh) and I’m updating has variables for `ssh_ports` and `ssh_listen_addresses` defined like this:

```yaml
      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:

```yaml
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:

```bash
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](https://github.com/kellyjonbrazil/jc/issues/282), for example:

```bash
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:

```yaml
        - 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`:

```yaml
# 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`:

```yaml
  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?

---

<div class="post-metadata">

**Author:** ![kks](https://sea1.discourse-cdn.com/ansible/user_avatar/forum.ansible.com/kks/32/10352_2.png) [@kks](https://forum.ansible.com/u/kks)\
**Post date:** [April 16, 2026, 3:07pm UTC](https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693/2 "2026-04-16T15:07:24Z")

</div>

I would solve this with product filter

> **[ansible.builtin.product filter – cartesian product of lists — Ansible...](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/product_filter.html)**

```yaml
---
- 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
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:

```yaml
---
- 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') }}"

```

---

<div class="post-metadata">

**Author:** ![utoddl](https://sea1.discourse-cdn.com/ansible/user_avatar/forum.ansible.com/utoddl/32/249_2.png) [@utoddl](https://forum.ansible.com/u/utoddl)\
**Post date:** [April 16, 2026, 4:05pm UTC](https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693/3 "2026-04-16T16:05:30Z")

</div>

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

```yaml
      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') }}"

```

---

<div class="post-metadata">

**Author:** ![kks](https://sea1.discourse-cdn.com/ansible/user_avatar/forum.ansible.com/kks/32/10352_2.png) [@kks](https://forum.ansible.com/u/kks)\
**Post date:** [April 16, 2026, 4:19pm UTC](https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693/4 "2026-04-16T16:19:17Z")

</div>

Thanks! That is indeed a good improvement!

---

<div class="post-metadata">

**Author:** ![chris](https://sea1.discourse-cdn.com/ansible/user_avatar/forum.ansible.com/chris/32/293_2.png) [@chris](https://forum.ansible.com/u/chris)\
**Post date:** [April 16, 2026, 4:25pm UTC](https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693/5 "2026-04-16T16:25:33Z")

</div>

Thanks @kks and @utoddl I didn’t know about the `ansible.builtin.product` filter… 🙄 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:

```yaml
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](https://kellyjonbrazil.github.io/jc/docs/parsers/sshd_conf) because it [was my idea](https://github.com/kellyjonbrazil/jc/issues/282) in the first place 😉 plus I use `jc` for lots of things so it isn’t an additional dependency for me.

---

<div class="post-metadata">

**Author:** ![kks](https://sea1.discourse-cdn.com/ansible/user_avatar/forum.ansible.com/kks/32/10352_2.png) [@kks](https://forum.ansible.com/u/kks)\
**Post date:** [April 16, 2026, 4:30pm UTC](https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693/6 "2026-04-16T16:30:54Z")

</div>

> [@chris](#):
>
> I don’t think there is a need to set any intermediate variables?

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

---

<div class="post-metadata">

**Author:** ![system](https://us1.discourse-cdn.com/ansible/original/1X/1882d9a01da5d8996a862359b0913cc568a39b84.svg) [@system](https://forum.ansible.com/u/system)\
**Post date:** [May 16, 2026, 4:31pm UTC](https://forum.ansible.com/t/merging-a-list-of-ip-addresses-and-ports-for-openssh/45693/7 "2026-05-16T16:31:49Z")

</div>

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.
