From cac25a8b54496f819ccb05ab453e596c97bd2421 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 28 Oct 2026 03:04:18 +0100 Subject: [PATCH] fix(web-svc-coturn): listen and relay only on the host's own public addresses With host networking and no --relay-ip, coturn put every non-loopback IPv4 of the host into its relay list, Docker bridge gateways included, and handed IPv6 and bridge clients relays on those addresses while advertising the public ip4, so their media went nowhere. It also listened on every bridge address. In compose mode with host networking, coturn now listens on networks.internet.ip4, 127.0.0.1 for the healthcheck and networks.internet.ip6 when set, and relays on ip4. It pins only when the host owns ip4, and owns ip6 too or leaves it unset; behind 1:1 NAT, without the ip binary or with a foreign ip6 it renders the previous command unchanged, because coturn exits when it cannot bind a listening address. A unit test renders the real template and vars for every cell. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-svc-coturn/README.md | 1 + roles/web-svc-coturn/templates/compose.yml.j2 | 8 +- roles/web-svc-coturn/vars/main.yml | 7 + .../python/roles/web-svc-coturn/__init__.py | 3 + .../web-svc-coturn/templates/__init__.py | 5 + .../templates/test_compose_addresses.py | 213 ++++++++++++++++++ 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 tests/unit/python/roles/web-svc-coturn/__init__.py create mode 100644 tests/unit/python/roles/web-svc-coturn/templates/__init__.py create mode 100644 tests/unit/python/roles/web-svc-coturn/templates/test_compose_addresses.py diff --git a/roles/web-svc-coturn/README.md b/roles/web-svc-coturn/README.md index 73134bbb5d..e3d7852c03 100644 --- a/roles/web-svc-coturn/README.md +++ b/roles/web-svc-coturn/README.md @@ -78,6 +78,7 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional ( - TURN and STUN support over TCP and UDP - Configurable relay port ranges for scaling - Configurable server-wide allocation quota (`services.coturn.total_quota`, default 2000); size it at about 2·N² for every concurrent Nextcloud Talk call with N participants, as each participant opens N peer connections and requests one allocation per TURN transport +- Pinned addresses on host networking in compose mode when the host carries `networks.internet.ip4` on an interface and carries `networks.internet.ip6` too or leaves it unset: listens on `networks.internet.ip4`, `127.0.0.1` for the healthcheck and the set `networks.internet.ip6`, and places every relay on `networks.internet.ip4` instead of on the Docker bridge addresses of the host. Any other host, such as one behind 1:1 NAT, keeps listening and relaying on every local address - Integration into Infinito.Nexus inventory/variable system ## Quick Setup diff --git a/roles/web-svc-coturn/templates/compose.yml.j2 b/roles/web-svc-coturn/templates/compose.yml.j2 index 53a6e26cdf..ec0164ec1a 100644 --- a/roles/web-svc-coturn/templates/compose.yml.j2 +++ b/roles/web-svc-coturn/templates/compose.yml.j2 @@ -51,6 +51,12 @@ {% if networks.internet.ip6 | default('') %} --external-ip={{ networks.internet.ip6 }} {% endif %} + {% if COTURN_ADDRESSES_PINNED | bool %} + {% for listening_ip in COTURN_LISTENING_IPS %} + --listening-ip={{ listening_ip }} + {% endfor %} + --relay-ip={{ COTURN_RELAY_IP }} + {% endif %} --realm="${COTURN_REALM}" --fingerprint --total-quota={{ COTURN_TOTAL_QUOTA }} @@ -67,7 +73,7 @@ {% endif %} --cipher-list=DEFAULT healthcheck: - test: ["CMD", "sh", "-c", "command -v turnutils_stunclient >/dev/null && turnutils_stunclient -p {{ COTURN_STUN_TURN_PORT }} 127.0.0.1 || nc -z 127.0.0.1 {{ COTURN_STUN_TURN_PORT }}"] + test: ["CMD", "sh", "-c", "command -v turnutils_stunclient >/dev/null && turnutils_stunclient -p {{ COTURN_STUN_TURN_PORT }} {{ COTURN_LOOPBACK_IP }} || nc -z {{ COTURN_LOOPBACK_IP }} {{ COTURN_STUN_TURN_PORT }}"] interval: 2m timeout: 5s retries: 3 diff --git a/roles/web-svc-coturn/vars/main.yml b/roles/web-svc-coturn/vars/main.yml index a198b9a548..79b3e699a5 100644 --- a/roles/web-svc-coturn/vars/main.yml +++ b/roles/web-svc-coturn/vars/main.yml @@ -6,6 +6,13 @@ domain: "{{ lookup('domain',application_id) }}" COTURN_CONTAINER: "{{ entity_name }}" COTURN_REALM: "{{ domain }}" COTURN_NETWORK_MODE: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.network_mode') }}" +COTURN_LOOPBACK_IP: "127.0.0.1" +COTURN_RELAY_IP: "{{ networks.internet.ip4 }}" +# nocheck: host-local-fact read only for host networking in compose mode, where the host rendering this file is the host coturn binds on; Ansible omits both keys on a host without the ip binary, which leaves coturn unpinned +COTURN_HOST_ADDRESSES: "{{ (ansible_facts['all_ipv4_addresses'] | default([])) + (ansible_facts['all_ipv6_addresses'] | default([])) }}" +COTURN_IP6_UNSET: "{{ networks.internet.ip6 in ['', defaults_networks.internet.ip6] }}" +COTURN_ADDRESSES_PINNED: "{{ COTURN_NETWORK_MODE == 'host' and DEPLOYMENT_MODE == 'compose' and COTURN_RELAY_IP in COTURN_HOST_ADDRESSES and (COTURN_IP6_UNSET or networks.internet.ip6 in COTURN_HOST_ADDRESSES) }}" +COTURN_LISTENING_IPS: "{{ [COTURN_RELAY_IP, COTURN_LOOPBACK_IP] + ([] if COTURN_IP6_UNSET else [networks.internet.ip6]) }}" COTURN_STUN_TURN_PORT: "{{ lookup('config', application_id, 'services.coturn.ports.public.stun_turn') }}" COTURN_STUN_TURN_PORT_TLS: "{{ lookup('config', application_id, 'services.coturn.ports.public.stun_turn_tls') }}" diff --git a/tests/unit/python/roles/web-svc-coturn/__init__.py b/tests/unit/python/roles/web-svc-coturn/__init__.py new file mode 100644 index 0000000000..8ee16d7567 --- /dev/null +++ b/tests/unit/python/roles/web-svc-coturn/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/python/roles/web-svc-coturn/templates/__init__.py b/tests/unit/python/roles/web-svc-coturn/templates/__init__.py new file mode 100644 index 0000000000..087c384b57 --- /dev/null +++ b/tests/unit/python/roles/web-svc-coturn/templates/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[6] + +__all__ = ["PROJECT_ROOT"] diff --git a/tests/unit/python/roles/web-svc-coturn/templates/test_compose_addresses.py b/tests/unit/python/roles/web-svc-coturn/templates/test_compose_addresses.py new file mode 100644 index 0000000000..f576286b1a --- /dev/null +++ b/tests/unit/python/roles/web-svc-coturn/templates/test_compose_addresses.py @@ -0,0 +1,213 @@ +"""coturn pins its addresses only on host networking in compose mode, and only addresses the host owns. + +Pinned, coturn listens on ``networks.internet.ip4``, on loopback for the +healthcheck and on ``networks.internet.ip6``, and relays on +``networks.internet.ip4`` instead of on whichever Docker bridge address the +allocation happens to land on. coturn exits when it cannot bind a listening +address, and a pinned coturn stops answering on every address it was not +given. So the command is pinned only when the host carries ip4 and either +carries ip6 as written or ip6 is unset (empty or the ``defaults_networks`` +placeholder); any other host keeps the unpinned command the role rendered +before, instead of losing its IPv6 listener or failing to bind. + +The real ``vars/main.yml`` is resolved by Ansible's templar and the real +``compose.yml.j2`` is rendered with the role's lookups stubbed, so a broken +ownership check or a gate that leaks into bridge or swarm mode fails here. +""" + +from __future__ import annotations + +import re +import unittest + +from ansible.parsing.dataloader import DataLoader +from ansible.plugins.filter.core import to_bool +from ansible.template import Templar +from jinja2 import DictLoader, Environment, StrictUndefined, select_autoescape + +from utils.cache.files import read_text +from utils.roles.mapping import ROLE_FILE_TEMPL_COMPOSE, ROLE_FILE_VARS_MAIN + +from . import PROJECT_ROOT + +ROLE = PROJECT_ROOT / "roles/web-svc-coturn" +TEMPLATE = ROLE / ROLE_FILE_TEMPL_COMPOSE +VARS = ROLE / ROLE_FILE_VARS_MAIN +NETWORK_DEFAULTS = DataLoader().load_from_file( + str(PROJECT_ROOT / "group_vars/all/08_networks.yml") +)["defaults_networks"] +PLACEHOLDER_IP6 = NETWORK_DEFAULTS["internet"]["ip6"] + +IP4 = "198.51.100.7" +IP6 = "2001:db8:1::2" +FOREIGN_IP4 = "203.0.113.7" +FOREIGN_IP6 = "2001:db8:ffff::9" +LOOPBACK = "127.0.0.1" + +OWNED = { + "all_ipv4_addresses": [IP4, "172.17.0.1", "192.168.101.1"], + "all_ipv6_addresses": [IP6, "fe80::1"], +} + +RESOLVED = ( + "COTURN_LOOPBACK_IP", + "COTURN_RELAY_IP", + "COTURN_ADDRESSES_PINNED", + "COTURN_LISTENING_IPS", +) + +STUBBED = { + "application_id": "web-svc-coturn", + "entity_name": "coturn", + "COTURN_CONTAINER": "coturn", + "COTURN_USER_AUTH_ENABLED": False, + "COTURN_TOTAL_QUOTA": 2000, + "COTURN_STUN_TURN_PORT": 3481, + "COTURN_STUN_TURN_PORT_TLS": 5351, + "COTURN_RELAY_PORT_START": 20000, + "COTURN_RELAY_PORT_END": 39999, + "COTURN_TLS_ENABLED": True, + "COTURN_TLS_CERT_PATH": "/etc/coturn/cert.pem", + "COTURN_TLS_KEY_PATH": "/etc/coturn/key.pem", + "DOCKER_BIND_HOST": LOOPBACK, +} + +INCLUDES = { + "roles/sys-svc-compose/templates/base.yml.j2": "services:\n", + "roles/sys-svc-container/templates/base.yml.j2": "", +} + +_HEALTHCHECK = re.compile(r"turnutils_stunclient -p \d+ (\S+) \|\| nc -z (\S+) \d+") + + +def render( + *, + ip4: str = IP4, + ip6: str = IP6, + facts: dict | None = None, + mode: str = "compose", + network: str = "host", +) -> str: + """The coturn compose file for one deployment. + + Args: + ip4: ``networks.internet.ip4``. + ip6: ``networks.internet.ip6``. + facts: the host's ``ansible_facts``; ``None`` means a host owning ``OWNED``. + mode: ``DEPLOYMENT_MODE``. + network: ``services.coturn.network_mode``. + """ + inputs = { + "networks": {"internet": {"ip4": ip4, "ip6": ip6, "dns": ""}}, + "defaults_networks": NETWORK_DEFAULTS, + "ansible_facts": OWNED if facts is None else facts, + "DEPLOYMENT_MODE": mode, + "COTURN_NETWORK_MODE": network, + } + declared = DataLoader().load_from_file(str(VARS), trusted_as_template=True) + templar = Templar(loader=DataLoader(), variables={**declared, **inputs}) + values = {name: templar.template(declared[name]) for name in RESOLVED} + + env = Environment( + autoescape=select_autoescape(default_for_string=False), + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + loader=DictLoader(INCLUDES), + ) + env.filters["bool"] = to_bool + env.globals["lookup"] = lambda *_args, **_kwargs: "" + return env.from_string(read_text(str(TEMPLATE))).render( + **STUBBED, **inputs, **values + ) + + +def listening(rendered: str) -> list[str]: + return re.findall(r"--listening-ip=(\S+)", rendered) + + +def relay(rendered: str) -> list[str]: + return re.findall(r"--relay-ip=(\S+)", rendered) + + +class TestOwnedAddressesArePinned(unittest.TestCase): + def test_listeners_cover_ip4_loopback_and_ip6(self) -> None: + self.assertEqual(listening(render()), [IP4, LOOPBACK, IP6]) + + def test_relays_stay_on_ip4(self) -> None: + self.assertEqual( + relay(render()), + [IP4], + "without the pin coturn may place a relay on a Docker bridge " + "address and still announce the external IP for it", + ) + + def test_the_healthcheck_probes_a_pinned_listener(self) -> None: + rendered = render() + probed = _HEALTHCHECK.search(rendered) + self.assertIsNotNone(probed, "the healthcheck command changed shape") + for address in probed.groups(): + self.assertIn( + address, + listening(rendered), + "a pinned coturn answers only on its listening addresses, so a " + "probe aimed elsewhere marks a healthy server unhealthy", + ) + + +class TestAnUnsetIpv6IsLeftOut(unittest.TestCase): + def test_the_placeholder_is_left_out(self) -> None: + rendered = render(ip6=PLACEHOLDER_IP6) + self.assertEqual( + (listening(rendered), relay(rendered)), ([IP4, LOOPBACK], [IP4]) + ) + + def test_an_empty_ipv6_is_left_out(self) -> None: + rendered = render(ip6="") + self.assertEqual( + (listening(rendered), relay(rendered)), ([IP4, LOOPBACK], [IP4]) + ) + + +class TestAddressesTheHostLacksKeepTheUnpinnedCommand(unittest.TestCase): + def test_a_foreign_ipv6(self) -> None: + rendered = render(ip6=FOREIGN_IP6) + self.assertEqual( + (listening(rendered), relay(rendered)), + ([], []), + "pinning ip4 alone would drop the IPv6 listener the unpinned " + "command still had", + ) + + def test_an_ipv6_written_unlike_the_interface_lists_it(self) -> None: + rendered = render(ip6=IP6.upper()) + self.assertEqual((listening(rendered), relay(rendered)), ([], [])) + + def test_a_foreign_ipv4_keeps_the_unpinned_command(self) -> None: + rendered = render(ip4=FOREIGN_IP4) + self.assertEqual( + (listening(rendered), relay(rendered)), + ([], []), + "a 1:1-NAT host carries its public IPv4 nowhere, so pinning it " + "stops coturn from binding at all", + ) + + def test_a_host_without_address_facts_keeps_the_unpinned_command(self) -> None: + rendered = render(facts={}) + self.assertEqual((listening(rendered), relay(rendered)), ([], [])) + + +class TestOtherModesAreUnchanged(unittest.TestCase): + def test_bridge_and_swarm_never_pin(self) -> None: + for mode, network in ( + ("compose", "bridge"), + ("swarm", "host"), + ("swarm", "bridge"), + ): + with self.subTest(mode=mode, network=network): + rendered = render(mode=mode, network=network) + self.assertEqual((listening(rendered), relay(rendered)), ([], [])) + + +if __name__ == "__main__": + unittest.main()