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) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-17 18:02:31 +02:00
1 parent dcb921c260
commit b09f2cbd82
3 files changed
+5 -14

No files matched your search

+3 -11
View File
@@ -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):
+1 -2
View File
@@ -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)
+1 -1
View File
@@ -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'))
}}"