From 4c6f9d5cd7942e63da9c8bc5bcec46da1cea530a Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 18 Oct 2026 15:19:57 +0200 Subject: [PATCH] refactor(networks): decide a handover sibling in one lookup and proxy it as the canonical host The handover condition lived as the same inline Jinja in basic.conf.j2, ws_generic.conf.j2 and Baserow's proxy.conf.j2. Move it into lookup('network_handover', application_id, domain), which also refuses a non-boolean flag, and use it at all three places. A handover sibling still proxies its health, MCP and SSO endpoints to the backend, and those requests carried the onion Host, which single-URL apps reject: pretix answered /healthz/ready on the onion with 400 Unknown host and failed the Prometheus blackbox probe. forwarded.conf.j2 now sends the canonical domain as Host and X-Forwarded-Host on a handover sibling, so the onion probe keeps measuring the backend through the onion vhost. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/lookup/network_handover.py | 47 ++++++++++++++++ .../templates/headers/forwarded.conf.j2 | 5 +- .../templates/vhost/basic.conf.j2 | 2 +- .../templates/vhost/ws_generic.conf.j2 | 2 +- roles/web-app-baserow/templates/proxy.conf.j2 | 2 +- .../plugins/lookup/test_network_handover.py | 55 +++++++++++++++++++ 6 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 plugins/lookup/network_handover.py create mode 100644 tests/unit/python/plugins/lookup/test_network_handover.py diff --git a/plugins/lookup/network_handover.py b/plugins/lookup/network_handover.py new file mode 100644 index 0000000000..0e6812f775 --- /dev/null +++ b/plugins/lookup/network_handover.py @@ -0,0 +1,47 @@ +"""Whether a vhost is a network sibling its application hands over to clearnet.""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +from utils.networks.reachability import network_of + + +class LookupModule(LookupBase): + """ + Usage: + {{ lookup('network_handover', application_id, domain) }} + + Returns True when ``application_id`` opts into + ``networks.reachability.handover`` and ``domain`` lies in another network + than the application's canonical domain, so its vhost only redirects there. + """ + + def run(self, terms, variables: dict[str, Any] | None = None, **kwargs): + if not terms or len(terms) != 2 or not all(str(t or "").strip() for t in terms): + raise AnsibleError( + "lookup('network_handover', application_id, domain) expects two terms" + ) + variables = variables or getattr(self._templar, "available_variables", {}) or {} + templar = getattr(self, "_templar", None) + application_id, domain = (str(term).strip() for term in terms) + + def _run(name: str, *args: Any) -> Any: + return lookup_loader.get(name, loader=self._loader, templar=templar).run( + list(args), variables=variables + )[0] + + handover = _run( + "config", application_id, "networks.reachability.handover", False + ) + if not isinstance(handover, bool): + raise AnsibleError( + f"network_handover: {application_id}: networks.reachability.handover must be a boolean" + ) + if not handover: + return [False] + return [network_of(domain) != network_of(str(_run("domain", application_id)))] diff --git a/roles/sys-svc-proxy/templates/headers/forwarded.conf.j2 b/roles/sys-svc-proxy/templates/headers/forwarded.conf.j2 index 24301d755c..72a941c55c 100644 --- a/roles/sys-svc-proxy/templates/headers/forwarded.conf.j2 +++ b/roles/sys-svc-proxy/templates/headers/forwarded.conf.j2 @@ -1,7 +1,8 @@ -proxy_set_header Host $host; +{% set _forward_host = lookup('domain', application_id) if lookup('network_handover', application_id, domain) else '$host' %} +proxy_set_header Host {{ _forward_host }}; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Host {{ _forward_host }}; proxy_set_header X-Forwarded-Proto {{ proxy_x_forwarded_proto | default('$scheme') }}; proxy_set_header X-Forwarded-Port {{ lookup('tls', domain, 'ports.web') }}; proxy_set_header X-Forwarded-Ssl {{ lookup('tls', domain, 'enabled') | on_off }}; diff --git a/roles/sys-svc-proxy/templates/vhost/basic.conf.j2 b/roles/sys-svc-proxy/templates/vhost/basic.conf.j2 index 89ff23408d..14a1748657 100644 --- a/roles/sys-svc-proxy/templates/vhost/basic.conf.j2 +++ b/roles/sys-svc-proxy/templates/vhost/basic.conf.j2 @@ -23,7 +23,7 @@ server return 302 {{ OIDC.URL }}$request_uri; } - {% elif lookup('config', application_id, 'networks.reachability.handover', False) and (domain | network_of) != (lookup('domain', application_id) | network_of) %} + {% elif lookup('network_handover', application_id, domain) %} location / { return 302 {{ lookup('tls', lookup('domain', application_id), 'url.base') | regex_replace('/+$', '') }}$request_uri; } diff --git a/roles/sys-svc-proxy/templates/vhost/ws_generic.conf.j2 b/roles/sys-svc-proxy/templates/vhost/ws_generic.conf.j2 index 2b1fa221cf..5e5765ed3b 100644 --- a/roles/sys-svc-proxy/templates/vhost/ws_generic.conf.j2 +++ b/roles/sys-svc-proxy/templates/vhost/ws_generic.conf.j2 @@ -17,7 +17,7 @@ server { gzip_buffers 16 8k; gzip_http_version 1.1; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - {% if lookup('config', application_id, 'networks.reachability.handover', False) and (domain | network_of) != (lookup('domain', application_id) | network_of) %} + {% if lookup('network_handover', application_id, domain) %} location / { return 302 {{ lookup('tls', lookup('domain', application_id), 'url.base') | regex_replace('/+$', '') }}$request_uri; } diff --git a/roles/web-app-baserow/templates/proxy.conf.j2 b/roles/web-app-baserow/templates/proxy.conf.j2 index 273509f20a..e1eea227ae 100644 --- a/roles/web-app-baserow/templates/proxy.conf.j2 +++ b/roles/web-app-baserow/templates/proxy.conf.j2 @@ -13,7 +13,7 @@ server {% include 'roles/sys-front-tls/templates/nginx_ssl_header.j2' %} - {% if lookup('config', application_id, 'networks.reachability.handover', False) and (domain | network_of) != (lookup('domain', application_id) | network_of) %} + {% if lookup('network_handover', application_id, domain) %} location / { return 302 {{ lookup('tls', lookup('domain', application_id), 'url.base') | regex_replace('/+$', '') }}$request_uri; } diff --git a/tests/unit/python/plugins/lookup/test_network_handover.py b/tests/unit/python/plugins/lookup/test_network_handover.py new file mode 100644 index 0000000000..0187090313 --- /dev/null +++ b/tests/unit/python/plugins/lookup/test_network_handover.py @@ -0,0 +1,55 @@ +import unittest +import unittest.mock as mock + +from ansible.errors import AnsibleError + +import plugins.lookup.network_handover as network_handover_mod +from utils.domains.default_primary import default_domain_primary + +PRIMARY = default_domain_primary() +ONION = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvw.onion" +CANONICAL = { + "web-app-moodle": f"academy.{PRIMARY}", + "web-app-nextcloud": f"cloud.{PRIMARY}", +} +HANDOVER = {"web-app-moodle": True, "web-app-nextcloud": False} + + +def _loader(handover=HANDOVER): + def _get(name, **_k): + if name == "config": + return mock.MagicMock(run=lambda terms, **_kw: [handover[terms[0]]]) + return mock.MagicMock(run=lambda terms, **_kw: [CANONICAL[terms[0]]]) + + loader = mock.MagicMock() + loader.get.side_effect = _get + return mock.patch.object(network_handover_mod, "lookup_loader", loader) + + +class TestNetworkHandover(unittest.TestCase): + def _run(self, terms, handover=HANDOVER): + lookup = network_handover_mod.LookupModule() + lookup._loader = mock.MagicMock() + with _loader(handover): + return lookup.run(terms, variables={"domain": terms[-1]})[0] + + def test_the_onion_sibling_of_a_handover_app_hands_over(self): + self.assertIs(self._run(["web-app-moodle", f"academy.{ONION}"]), True) + + def test_the_canonical_vhost_of_a_handover_app_serves(self): + self.assertIs(self._run(["web-app-moodle", f"academy.{PRIMARY}"]), False) + + def test_an_app_without_handover_serves_every_sibling(self): + self.assertIs(self._run(["web-app-nextcloud", f"cloud.{ONION}"]), False) + + def test_a_non_boolean_flag_fails_loudly(self): + with self.assertRaises(AnsibleError): + self._run(["web-app-moodle", f"academy.{ONION}"], {"web-app-moodle": "yes"}) + + def test_both_terms_are_required(self): + with self.assertRaises(AnsibleError): + self._run(["web-app-moodle"]) + + +if __name__ == "__main__": + unittest.main()