diff --git a/plugins/lookup/container_dns.py b/plugins/lookup/container_dns.py new file mode 100644 index 0000000000..615a60e90b --- /dev/null +++ b/plugins/lookup/container_dns.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +from ansible.plugins.lookup import LookupBase + +_TOR_ROLE = "svc-net-tor" + + +def resolve_container_dns(variables: dict[str, Any]) -> list[str]: + """Return the resolver list for the container runtime, most specific first. + + On a Tor node the node dnsmasq resolves ``.onion`` through Tor's DNSPort + and everything else through the clearnet upstream, which is what makes + per-app proxy settings unnecessary. It is only reachable on the docker + bridge address because docker discards loopback resolvers, and it is + absent before docker itself is installed, hence the guard. + + The clearnet resolver stays as a second entry so that losing dnsmasq costs + onion names rather than all name resolution. + """ + facts = variables.get("ansible_facts") or {} + bridge = ((facts.get("docker0") or {}).get("ipv4") or {}).get("address") or "" + on_tor_node = _TOR_ROLE in (variables.get("group_names") or []) + + clearnet = ( + ((variables.get("networks") or {}).get("internet") or {}).get("dns") or "" + ) + + resolvers = [str(r) for r in (bridge if on_tor_node else "", clearnet) if r] + return resolvers + + +class LookupModule(LookupBase): + """ + Usage: + {{ lookup('container_dns') }} + + Resolver list for the docker daemon's ``dns`` key. Takes no terms. + """ + + def run(self, terms, variables: dict[str, Any] | None = None, **kwargs): + if terms: + raise ValueError("lookup('container_dns') takes no positional terms.") + variables = variables or getattr(self._templar, "available_variables", {}) or {} + return [resolve_container_dns(variables)] diff --git a/roles/svc-net-tor/templates/dnsmasq-tor-onion.conf.j2 b/roles/svc-net-tor/templates/dnsmasq-tor-onion.conf.j2 index 3e3f89eb4c..79b268ab02 100644 --- a/roles/svc-net-tor/templates/dnsmasq-tor-onion.conf.j2 +++ b/roles/svc-net-tor/templates/dnsmasq-tor-onion.conf.j2 @@ -1,5 +1,8 @@ {% if TOR_DNSMASQ_OWNS_LISTENER | default(true) %} listen-address=127.0.0.1 +{% if TOR_CONTAINER_DNS_HOST | length > 0 %} +listen-address={{ TOR_CONTAINER_DNS_HOST }} +{% endif %} {% endif %} bind-dynamic no-hosts diff --git a/roles/svc-net-tor/vars/main.yml b/roles/svc-net-tor/vars/main.yml index d1572fac22..e858e13cf5 100644 --- a/roles/svc-net-tor/vars/main.yml +++ b/roles/svc-net-tor/vars/main.yml @@ -13,7 +13,8 @@ TOR_FLAVOR: "{{ lookup('config', application_id, 'services.tor.flavor', 'public' TOR_TRANS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.trans') }}" TOR_DNS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.dns') }}" TOR_DNSMASQ_CONF: "/etc/dnsmasq.d/tor-onion.conf" -TOR_HS_BACKEND_HOST: "{{ ansible_facts['default_ipv4']['address'] if DEPLOYMENT_MODE == 'swarm' else '127.0.0.1' }}" +TOR_CONTAINER_DNS_HOST: "{{ ansible_facts['docker0']['ipv4']['address'] | default('', true) if DEPLOYMENT_MODE == 'compose' else '' }}" +TOR_HS_BACKEND_HOST: "{{ ansible_facts['default_ipv4']['address'] if DEPLOYMENT_MODE == 'swarm' else (TOR_CONTAINER_DNS_HOST | ternary(TOR_CONTAINER_DNS_HOST, '127.0.0.1')) }}" TOR_DNS_UPSTREAMS_FILE: "{{ [ DIR_VAR_LIB, application_id, 'dns-upstreams' ] | path_join }}" TOR_DATA_DIR: "{{ [ DIR_VAR_LIB, application_id, 'data' ] | path_join }}" diff --git a/roles/sys-svc-container/templates/daemon.json.j2 b/roles/sys-svc-container/templates/daemon.json.j2 index 409a0d73c3..4e4b92516d 100644 --- a/roles/sys-svc-container/templates/daemon.json.j2 +++ b/roles/sys-svc-container/templates/daemon.json.j2 @@ -10,8 +10,8 @@ {% set _ = _insecure.append(_cache_hosts[0] ~ ':' ~ _cache_port) %} {% endif %} { -{% if (networks.internet.dns | default('') | length) > 0 %} - "dns": ["{{ networks.internet.dns }}"], +{% if lookup('container_dns') %} + "dns": {{ lookup('container_dns') | to_json }}, {% endif %} "dns-search": ["{{ DOMAIN_PRIMARY }}"], "dns-opts": ["ndots:0"], diff --git a/roles/web-app-nextcloud/templates/config/proxy.config.php.j2 b/roles/web-app-nextcloud/templates/config/proxy.config.php.j2 index 759c34978b..881ab67d03 100644 --- a/roles/web-app-nextcloud/templates/config/proxy.config.php.j2 +++ b/roles/web-app-nextcloud/templates/config/proxy.config.php.j2 @@ -1,13 +1,2 @@ 'socks5h://{{ lookup('tor_socks', 'host.docker.internal') }}', - 'proxyexclude' => [ -{% for host in NEXTCLOUD_PROXY_EXCLUDED_HOSTS %} - '{{ host }}', -{% endfor %} - ], -]; -{% else %} return []; -{% endif %} diff --git a/roles/web-app-nextcloud/vars/main.yml b/roles/web-app-nextcloud/vars/main.yml index c10b64c32f..dd9f259afa 100644 --- a/roles/web-app-nextcloud/vars/main.yml +++ b/roles/web-app-nextcloud/vars/main.yml @@ -14,7 +14,6 @@ NEXTCLOUD_PROTOCOL: "{{ lookup('tls', application_id, 'protocols.web') }}" NEXTCLOUD_TRUSTED_PROXIES: "{{ networks.internet.values() | select }}" NEXTCLOUD_SSO_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') | bool }}" -NEXTCLOUD_OIDC_HOST: "{{ lookup('tls', 'web-app-keycloak', 'domain') }}" NEXTCLOUD_PLUGIN_ITEMS: "{{ lookup('config', application_id, 'addons') | dict2items }}" NEXTCLOUD_PLUGINS_ENABLED: "{{ lookup('config', application_id, 'services.nextcloud.plugins_enabled') }}" @@ -44,13 +43,6 @@ NEXTCLOUD_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, NEX NEXTCLOUD_OPENPROJECT_CONTAINER_ADDRESS: "{{ lookup('container_address', 'web-app-openproject', 'web') }}" NEXTCLOUD_ZAMMAD_CONTAINER_ADDRESS: "{{ lookup('container_address', 'web-app-zammad', 'railsserver') }}" -NEXTCLOUD_PROXY_EXCLUDED_HOSTS: - - apps.nextcloud.com - - github.com - - .githubusercontent.com - - codeberg.org - - gitlab.com - NEXTCLOUD_PROXY_SERVICE: "proxy" NEXTCLOUD_PROXY_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_PROXY_SERVICE ~ '.name') }}" diff --git a/tests/integration/roles/svc-net-tor/__init__.py b/tests/integration/roles/svc-net-tor/__init__.py new file mode 100644 index 0000000000..039b52c789 --- /dev/null +++ b/tests/integration/roles/svc-net-tor/__init__.py @@ -0,0 +1,3 @@ +from tests.integration.roles import PROJECT_ROOT + +__all__ = ["PROJECT_ROOT"] diff --git a/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py b/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py new file mode 100644 index 0000000000..f11425f151 --- /dev/null +++ b/tests/integration/roles/svc-net-tor/test_container_dns_wiring.py @@ -0,0 +1,51 @@ +import unittest + +from jinja2 import Environment, FileSystemLoader + +from tests.integration.roles import PROJECT_ROOT + + +def _render_dnsmasq(**overrides): + variables = { + "TOR_DNSMASQ_OWNS_LISTENER": True, + "TOR_CONTAINER_DNS_HOST": "172.17.0.1", + "TOR_NODE_ONION": "example.onion", + "TOR_HS_BACKEND_HOST": "172.17.0.1", + "TOR_DNS_PORT": 9053, + "TOR_DNS_UPSTREAMS": ["127.0.0.11"], + } + variables.update(overrides) + env = Environment(loader=FileSystemLoader(str(PROJECT_ROOT))) + template = env.get_template( + "roles/svc-net-tor/templates/dnsmasq-tor-onion.conf.j2" + ) + return template.render(**variables) + + +class TestDnsmasqListeners(unittest.TestCase): + """dnsmasq must answer on the docker bridge as well as on loopback: docker + discards loopback resolvers, so a 127.0.0.1-only listener never reaches the + containers that need .onion resolved.""" + + def test_listens_on_bridge_in_addition_to_loopback(self): + rendered = _render_dnsmasq() + self.assertIn("listen-address=127.0.0.1", rendered) + self.assertIn("listen-address=172.17.0.1", rendered) + + def test_bridge_listener_omitted_when_unset(self): + rendered = _render_dnsmasq(TOR_CONTAINER_DNS_HOST="") + self.assertIn("listen-address=127.0.0.1", rendered) + self.assertNotIn("listen-address=172.17", rendered) + + def test_onion_queries_go_to_the_tor_resolver(self): + self.assertIn("server=/onion/127.0.0.1#9053", _render_dnsmasq()) + + def test_clearnet_upstreams_are_kept(self): + self.assertIn("server=127.0.0.11", _render_dnsmasq()) + + def test_own_onion_resolves_to_a_container_reachable_backend(self): + self.assertIn("address=/example.onion/172.17.0.1", _render_dnsmasq()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/plugins/lookup/test_container_dns.py b/tests/unit/plugins/lookup/test_container_dns.py new file mode 100644 index 0000000000..31ec0f4bf0 --- /dev/null +++ b/tests/unit/plugins/lookup/test_container_dns.py @@ -0,0 +1,62 @@ +import sys +import unittest + +from . import PROJECT_ROOT + + +def _ensure_repo_root_on_syspath(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +_ensure_repo_root_on_syspath() + +from plugins.lookup.container_dns import resolve_container_dns # noqa: E402 + +_BRIDGE = {"docker0": {"ipv4": {"address": "172.17.0.1"}}} +_CLEARNET = {"internet": {"dns": "172.30.0.53"}} + + +def _vars(**overrides): + variables = { + "ansible_facts": _BRIDGE, + "group_names": ["svc-net-tor"], + "networks": _CLEARNET, + } + variables.update(overrides) + return variables + + +class TestResolveContainerDns(unittest.TestCase): + """The bridge resolver must come first on a Tor node so .onion resolves, + with the clearnet resolver kept as fallback.""" + + def test_tor_node_prefers_the_bridge_resolver(self): + self.assertEqual( + resolve_container_dns(_vars()), ["172.17.0.1", "172.30.0.53"] + ) + + def test_without_tor_only_clearnet(self): + self.assertEqual( + resolve_container_dns(_vars(group_names=[])), ["172.30.0.53"] + ) + + def test_without_bridge_only_clearnet(self): + self.assertEqual( + resolve_container_dns(_vars(ansible_facts={})), ["172.30.0.53"] + ) + + def test_without_clearnet_only_bridge(self): + self.assertEqual(resolve_container_dns(_vars(networks={})), ["172.17.0.1"]) + + def test_nothing_configured_yields_empty_list(self): + self.assertEqual( + resolve_container_dns(_vars(ansible_facts={}, networks={})), [] + ) + + def test_missing_keys_do_not_raise(self): + self.assertEqual(resolve_container_dns({}), []) + + +if __name__ == "__main__": + unittest.main()