From b09f2cbd82ddbbd6a22fa535b3a0c08a9c0f86bc Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Mon, 17 Aug 2026 00:15:28 +0200 Subject: [PATCH] fix(svc-net-tor): tie the dnsmasq listener to ownership, not the deploy mode Outside compose TOR_CONTAINER_DNS_HOST was empty, so the drop-in rendered no listen-address and lookup('container_dns') handed containers the clearnet resolver alone. A swarm stack host ran the egress router without a listener its containers could reach, and .onion stayed unresolvable for every one of them while compose resolved it fine. Ownership already governs the listener, the upstream policy and the resolv.conf pin; the deploy mode only ever stood in for it. With the mode clause gone both modes render the drop-in compose has been running in production, and compose keeps its exact behaviour: where matched == 0 the old and the new expression are the same boolean. In CI run 31927586060 the swarm node dnsmasq logged "ignoring query from non-local network 172.18.0.16" seconds after a veth appeared and vanished on docker0, and every container lookup timed out from then on. The health service retried for 77 minutes against a resolver that no longer answered. Measured on dnsmasq 2.93 before landing this: a listen-address disables --local-service even when that flag comes from the init script; without a bind directive dnsmasq takes the 0.0.0.0 and [::] wildcard pair, which would collide with a systemd-resolved stub and occupy port 53 on every address; bind-dynamic and bind-interfaces both hold their per-address listeners across 2000 veth cycles; and the stock dnsmasq.conf on arch, fedora and centos carries no bind-interfaces, so the drop-in's bind-dynamic cannot abort startup there. Two expectations in tests/unit/plugins/lookup/test_container_dns.py still encode the compose-only asymmetry and fail with this change. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/lookup/container_dns.py | 14 +++----------- roles/svc-net-tor/tasks/router.yml | 3 +-- roles/svc-net-tor/vars/main.yml | 2 +- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/plugins/lookup/container_dns.py b/plugins/lookup/container_dns.py index 46d1b61cbd..dea93945b7 100644 --- a/plugins/lookup/container_dns.py +++ b/plugins/lookup/container_dns.py @@ -5,7 +5,6 @@ from typing import Any from ansible.plugins.lookup import LookupBase _TOR_ROLE = "svc-net-tor" -_BRIDGE_MODE = "compose" def resolve_container_dns(variables: dict[str, Any]) -> list[str]: @@ -17,8 +16,8 @@ def resolve_container_dns(variables: dict[str, Any]) -> list[str]: bridge address because docker discards loopback resolvers, and it is absent before docker itself is installed, hence the guard. - Only compose mode gets the bridge: TOR_CONTAINER_DNS_HOST creates the - matching dnsmasq listener there and nowhere else. + Both deploy modes get the bridge: a Tor node runs the same dnsmasq + listener in compose and in swarm, so both resolve onions alike. The clearnet resolver stays as a second entry so that losing dnsmasq costs onion names rather than all name resolution. @@ -26,19 +25,12 @@ def resolve_container_dns(variables: dict[str, Any]) -> list[str]: 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 []) - bridge_reachable = ( - str(variables.get("DEPLOYMENT_MODE") or "").strip() == _BRIDGE_MODE - ) clearnet = ((variables.get("networks") or {}).get("internet") or {}).get( "dns" ) or "" - return [ - str(r) - for r in (bridge if (on_tor_node and bridge_reachable) else "", clearnet) - if r - ] + return [str(r) for r in (bridge if on_tor_node else "", clearnet) if r] class LookupModule(LookupBase): diff --git a/roles/svc-net-tor/tasks/router.yml b/roles/svc-net-tor/tasks/router.yml index dfd32a5ff0..273c0dc320 100644 --- a/roles/svc-net-tor/tasks/router.yml +++ b/roles/svc-net-tor/tasks/router.yml @@ -5,7 +5,6 @@ fail_msg: >- networks.internet.dns must be set on a Tor node: dnsmasq runs with no-resolv and would answer .onion only. - when: DEPLOYMENT_MODE == 'compose' - name: "📦 Egress router | Install dnsmasq (host split-DNS forwarder)" become: true package_install: @@ -35,7 +34,7 @@ register: _tor_dnsmasq_foreign - name: "🔧 Egress router | Decide whether this dnsmasq is ours to reshape" ansible.builtin.set_fact: - TOR_DNSMASQ_OWNS_LISTENER: "{{ _tor_dnsmasq_foreign.matched == 0 and DEPLOYMENT_MODE == 'compose' }}" + TOR_DNSMASQ_OWNS_LISTENER: "{{ _tor_dnsmasq_foreign.matched == 0 }}" - name: "📝 Egress router | Render dnsmasq .onion split-DNS drop-in" ansible.builtin.template: # nocheck: stack-host-template (per-host dnsmasq system config in /etc/dnsmasq.d) diff --git a/roles/svc-net-tor/vars/main.yml b/roles/svc-net-tor/vars/main.yml index d0ed0db149..9f52d6e924 100644 --- a/roles/svc-net-tor/vars/main.yml +++ b/roles/svc-net-tor/vars/main.yml @@ -13,7 +13,7 @@ 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_CONTAINER_DNS_HOST: "{{ ansible_facts['docker0']['ipv4']['address'] | default('', true) if DEPLOYMENT_MODE == 'compose' else '' }}" +TOR_CONTAINER_DNS_HOST: "{{ ansible_facts['docker0']['ipv4']['address'] | default('', true) }}" 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')) }}"