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()