mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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:
1 parent
bb1b4f7f96
commit
9d91fbea83
9 files changed
+169
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user