From 375c567dac831a90525f85504c4468fd447955c9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:51:19 -0300 Subject: [PATCH 1/2] feat(proxy): prepare opt-in stream port mapping for Talk TURN Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/validate-compose.yml | 12 +++++++ README.md | 40 ++++++++++++++++++++++ docker-compose.yml | 7 ++-- tests/test_compose_stream.py | 46 ++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/validate-compose.yml create mode 100644 tests/test_compose_stream.py diff --git a/.github/workflows/validate-compose.yml b/.github/workflows/validate-compose.yml new file mode 100644 index 0000000..d10f3e8 --- /dev/null +++ b/.github/workflows/validate-compose.yml @@ -0,0 +1,12 @@ +name: Validate Docker Compose (direct and stream) +on: + pull_request: + push: + branches: [main] +jobs: + compose: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Test direct and opt-in stream port mappings + run: python3 -m unittest discover -s tests -p 'test_*.py' -v diff --git a/README.md b/README.md index 4fbb8ba..36df39f 100644 --- a/README.md +++ b/README.md @@ -64,3 +64,43 @@ For pgadmin to work on the /pgadmin path you will need to add these rules to ngi proxy_set_header X-Script-Name /pgadmin; proxy_set_header Host $host; ``` + + +## Optional Nextcloud Talk TURNS/TCP443 sharing (LibreCode) + +The image already includes Nginx stream and ssl_preread. The container +can run both the TCP/TLS entry listeners and its existing dynamically generated +HTTP/HTTPS virtual hosts in the same Nginx process. The Compose defaults +keep the current behaviour (80:80 and 443:443, without PROXY protocol). + +When explicitly enabled by the Ansible role from +LibreCodeCoop/infrastructure-operations: + +- NGINX_PROXY_HTTP_CONTAINER_PORT=10080 maps host TCP/80 to the + stream entry, which forwards to the generated HTTP listener on internal 80. +- NGINX_PROXY_HTTPS_CONTAINER_PORT=10443 maps host TCP/443 to the + stream entry; ssl_preread routes the TURN hostname to the Eturnal + private TLS listener, everything else to the generated HTTPS listener + on internal 443. +- ENABLE_PROXY_PROTOCOL=true, configured in the Ansible override only + on the opted-in host, makes the generated HTTP/HTTPS listeners + require PROXY protocol. Stream must always supply + it for both HTTP and HTTPS. A direct connection to the inner HTTP/HTTPS + port without PROXY protocol will fail. +- The Ansible-managed Compose override mounts the top-level stream + configuration, enables PROXY protocol only for the opted-in host and + pins the current running Nginx/ACME image digests. It also configures + real-IP trust for the local stream loopback. + +Do not enable the three settings without the matching generated Ansible +files and Eturnal TLS listener. Changes to the published target ports +require a controlled Docker Compose service recreation, not a reload. + +This relies on clients supplying valid SNI for talk-turn.librecode.coop. +Without SNI the connection defaults to HTTPS. Direct TURN/UDP3478, +TURN/UDP443 and TURNS/TCP5349 remain available independently. + +The nginx-proxy ACME companion is unchanged. Its HTTP-01 and certificate +renewal paths must be tested in staging before a production cutover. +The shared-port solution does not address unrelated signaling or +network-path timeouts. diff --git a/docker-compose.yml b/docker-compose.yml index 589bbbd..4e0629d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,12 @@ services: container_name: nginx-proxy image: nginxproxy/nginx-proxy:alpine restart: unless-stopped + # Defaults keep 80/443 bound directly to the HTTP listeners. + # For TLS SNI multiplexing, the host still exposes 80/443 but these + # container ports become dedicated stream listeners. ports: - - 80:80 - - 443:443 + - "80:${NGINX_PROXY_HTTP_CONTAINER_PORT:-80}" + - "443:${NGINX_PROXY_HTTPS_CONTAINER_PORT:-443}" volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - ./volumes/vhost.d/:/etc/nginx/vhost.d diff --git a/tests/test_compose_stream.py b/tests/test_compose_stream.py new file mode 100644 index 0000000..072d7cc --- /dev/null +++ b/tests/test_compose_stream.py @@ -0,0 +1,46 @@ +"""Verify optional single-Nginx stream port remapping is backward compatible.""" +import json +import os +import subprocess +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +MUX_ENV={ + "NGINX_PROXY_HTTP_CONTAINER_PORT":"10080", + "NGINX_PROXY_HTTPS_CONTAINER_PORT":"10443", + "NGINX_PROXY_ENABLE_PROXY_PROTOCOL":"true", + "NGINX_PROXY_TRUST_DOWNSTREAM_PROXY":"false", +} +KEYS=tuple(MUX_ENV) +def config(overrides): + env=os.environ.copy() + for key in KEYS:env.pop(key,None) + env.update(overrides) + p=subprocess.run( + ["docker","compose","config","--format","json"], + cwd=ROOT,env=env,check=True,text=True,capture_output=True,timeout=30, + ) + return json.loads(p.stdout) + +class StreamComposeTest(unittest.TestCase): + def test_default_no_mux_does_not_change_the_public_80_443_ports(self): + data=config({}) + nginx=data["services"]["nginx-proxy"] + self.assertEqual({(p["published"],p["target"]) for p in nginx["ports"]}, + {("80",80),("443",443)}) + self.assertIn("nginx-proxy-acme",data["services"]) + + def test_optional_mux_maps_public_to_private_stream_listeners(self): + data=config(MUX_ENV) + nginx=data["services"]["nginx-proxy"] + self.assertEqual({(p["published"],p["target"]) for p in nginx["ports"]}, + {("80",10080),("443",10443)}) + self.assertEqual(len(nginx["ports"]),2) + # PROXY header, mount, image pins are deliberately host-specific + # and supplied by the Ansible docker-compose.override.yml. + self.assertFalse(any(v["target"]=="/etc/nginx/toplevel.conf.d" + for v in nginx["volumes"])) + +if __name__=="__main__": + unittest.main() From c4286b3f5fa77eb9944b4d9e4e1d07cd9acc84be Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:59:13 -0300 Subject: [PATCH 2/2] refactor(proxy): limit change to configurable container ports Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/validate-compose.yml | 12 ------- README.md | 40 ---------------------- docker-compose.yml | 3 -- tests/test_compose_stream.py | 46 -------------------------- 4 files changed, 101 deletions(-) delete mode 100644 .github/workflows/validate-compose.yml delete mode 100644 tests/test_compose_stream.py diff --git a/.github/workflows/validate-compose.yml b/.github/workflows/validate-compose.yml deleted file mode 100644 index d10f3e8..0000000 --- a/.github/workflows/validate-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Validate Docker Compose (direct and stream) -on: - pull_request: - push: - branches: [main] -jobs: - compose: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Test direct and opt-in stream port mappings - run: python3 -m unittest discover -s tests -p 'test_*.py' -v diff --git a/README.md b/README.md index 36df39f..4fbb8ba 100644 --- a/README.md +++ b/README.md @@ -64,43 +64,3 @@ For pgadmin to work on the /pgadmin path you will need to add these rules to ngi proxy_set_header X-Script-Name /pgadmin; proxy_set_header Host $host; ``` - - -## Optional Nextcloud Talk TURNS/TCP443 sharing (LibreCode) - -The image already includes Nginx stream and ssl_preread. The container -can run both the TCP/TLS entry listeners and its existing dynamically generated -HTTP/HTTPS virtual hosts in the same Nginx process. The Compose defaults -keep the current behaviour (80:80 and 443:443, without PROXY protocol). - -When explicitly enabled by the Ansible role from -LibreCodeCoop/infrastructure-operations: - -- NGINX_PROXY_HTTP_CONTAINER_PORT=10080 maps host TCP/80 to the - stream entry, which forwards to the generated HTTP listener on internal 80. -- NGINX_PROXY_HTTPS_CONTAINER_PORT=10443 maps host TCP/443 to the - stream entry; ssl_preread routes the TURN hostname to the Eturnal - private TLS listener, everything else to the generated HTTPS listener - on internal 443. -- ENABLE_PROXY_PROTOCOL=true, configured in the Ansible override only - on the opted-in host, makes the generated HTTP/HTTPS listeners - require PROXY protocol. Stream must always supply - it for both HTTP and HTTPS. A direct connection to the inner HTTP/HTTPS - port without PROXY protocol will fail. -- The Ansible-managed Compose override mounts the top-level stream - configuration, enables PROXY protocol only for the opted-in host and - pins the current running Nginx/ACME image digests. It also configures - real-IP trust for the local stream loopback. - -Do not enable the three settings without the matching generated Ansible -files and Eturnal TLS listener. Changes to the published target ports -require a controlled Docker Compose service recreation, not a reload. - -This relies on clients supplying valid SNI for talk-turn.librecode.coop. -Without SNI the connection defaults to HTTPS. Direct TURN/UDP3478, -TURN/UDP443 and TURNS/TCP5349 remain available independently. - -The nginx-proxy ACME companion is unchanged. Its HTTP-01 and certificate -renewal paths must be tested in staging before a production cutover. -The shared-port solution does not address unrelated signaling or -network-path timeouts. diff --git a/docker-compose.yml b/docker-compose.yml index 4e0629d..8c09b8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,6 @@ services: container_name: nginx-proxy image: nginxproxy/nginx-proxy:alpine restart: unless-stopped - # Defaults keep 80/443 bound directly to the HTTP listeners. - # For TLS SNI multiplexing, the host still exposes 80/443 but these - # container ports become dedicated stream listeners. ports: - "80:${NGINX_PROXY_HTTP_CONTAINER_PORT:-80}" - "443:${NGINX_PROXY_HTTPS_CONTAINER_PORT:-443}" diff --git a/tests/test_compose_stream.py b/tests/test_compose_stream.py deleted file mode 100644 index 072d7cc..0000000 --- a/tests/test_compose_stream.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Verify optional single-Nginx stream port remapping is backward compatible.""" -import json -import os -import subprocess -import unittest -from pathlib import Path - -ROOT=Path(__file__).resolve().parents[1] -MUX_ENV={ - "NGINX_PROXY_HTTP_CONTAINER_PORT":"10080", - "NGINX_PROXY_HTTPS_CONTAINER_PORT":"10443", - "NGINX_PROXY_ENABLE_PROXY_PROTOCOL":"true", - "NGINX_PROXY_TRUST_DOWNSTREAM_PROXY":"false", -} -KEYS=tuple(MUX_ENV) -def config(overrides): - env=os.environ.copy() - for key in KEYS:env.pop(key,None) - env.update(overrides) - p=subprocess.run( - ["docker","compose","config","--format","json"], - cwd=ROOT,env=env,check=True,text=True,capture_output=True,timeout=30, - ) - return json.loads(p.stdout) - -class StreamComposeTest(unittest.TestCase): - def test_default_no_mux_does_not_change_the_public_80_443_ports(self): - data=config({}) - nginx=data["services"]["nginx-proxy"] - self.assertEqual({(p["published"],p["target"]) for p in nginx["ports"]}, - {("80",80),("443",443)}) - self.assertIn("nginx-proxy-acme",data["services"]) - - def test_optional_mux_maps_public_to_private_stream_listeners(self): - data=config(MUX_ENV) - nginx=data["services"]["nginx-proxy"] - self.assertEqual({(p["published"],p["target"]) for p in nginx["ports"]}, - {("80",10080),("443",10443)}) - self.assertEqual(len(nginx["ports"]),2) - # PROXY header, mount, image pins are deliberately host-specific - # and supplied by the Ansible docker-compose.override.yml. - self.assertFalse(any(v["target"]=="/etc/nginx/toplevel.conf.d" - for v in nginx["volumes"])) - -if __name__=="__main__": - unittest.main()