feat(svc-net-tor): route onion names through tor via dns, not per-app proxies

Reaching an onion address required each role to point its own http client
at the tor socks proxy, which then carried clearnet traffic too — fatal
in the test network, which has no exit, and the reason nextcloud needed a
per-host exclusion list.

The egress router already redirects tor's virtual range to the TransPort
and dnsmasq already forwards .onion to tor's DNSPort; the piece missing
was reachability. dnsmasq listened on loopback only, which docker
discards, so containers never saw it. It now also answers on the docker
bridge, the daemon points there with the clearnet resolver kept as
fallback, and the role's own onion resolves to a container-reachable
backend instead of loopback.

Nextcloud drops its proxy block and the exclusion list; every other role
gets the same behaviour without configuring anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-07-28 01:13:33 +02:00
1 parent bb1b4f7f96
commit 9d91fbea83
9 files changed
+169 -22

No files matched your search

+46
View File
@@ -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)]
@@ -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
+2 -1
View File
@@ -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 }}"
@@ -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"],
@@ -1,13 +1,2 @@
<?php
{% if NEXTCLOUD_SSO_ENABLED and (NEXTCLOUD_OIDC_HOST is search('\.onion$')) %}
return [
'proxy' => 'socks5h://{{ lookup('tor_socks', 'host.docker.internal') }}',
'proxyexclude' => [
{% for host in NEXTCLOUD_PROXY_EXCLUDED_HOSTS %}
'{{ host }}',
{% endfor %}
],
];
{% else %}
return [];
{% endif %}
-8
View File
@@ -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') }}"
@@ -0,0 +1,3 @@
from tests.integration.roles import PROJECT_ROOT
__all__ = ["PROJECT_ROOT"]
@@ -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()
@@ -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()