fix(svc-net-tor): stop imposing a bind policy on a foreign dnsmasq

bind-dynamic sat outside the {% if TOR_DNSMASQ_OWNS_LISTENER %} guard that
gates the listen-address lines, and router.yml:72 forces that flag false in
swarm. So on a stack host the drop-in declared it does not own the listener
and then dictated its bind mode anyway -- naming no address at all.

That converts a wildcard-bound instance's sockets into enumeration-derived
ones. When an IPv6 netlink dump aborts, dnsmasq skips the AF_INET pass and
its GC closes every IPv4 listener, silently: the message is LOG_DEBUG and
bind-dynamic bind failures are non-fatal. The node was left with 10 IPv6
listeners and zero IPv4 while four IPv4 addresses existed, and router.yml
had already pointed /etc/resolv.conf at 127.0.0.1 -- so dockerd got ICMP
port-unreachable and could not resolve ghcr.io. 33 failures in 11 groups,
none before the drop-in landed. The two worker nodes, which never received
it, kept 0.0.0.0:53 bound throughout.

Ownership is the right axis, not deployment mode. Where the role owns the
listener, bind-dynamic is load-bearing: a listen-address naming docker0 can
disappear across a docker restart, and without OPT_CLEVERBIND that bind is
fatal. Where it does not own the instance, the directive is pure collateral
-- and next to a base config carrying bind-interfaces it aborts startup
outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-10 10:10:12 +02:00
1 parent 4b10d6b937
commit 4739b2de99
2 files changed
+8 -1

No files matched your search

@@ -3,8 +3,8 @@ listen-address=127.0.0.1
{% if TOR_CONTAINER_DNS_HOST | length > 0 %}
listen-address={{ TOR_CONTAINER_DNS_HOST }}
{% endif %}
{% endif %}
bind-dynamic
{% endif %}
no-hosts
no-resolv
filter-AAAA
@@ -38,6 +38,13 @@ class TestDnsmasqListeners(unittest.TestCase):
self.assertIn("listen-address=127.0.0.1", rendered)
self.assertNotIn("listen-address=172.17", rendered)
def test_no_bind_policy_when_the_listener_is_not_ours(self):
rendered = _render_dnsmasq(
TOR_DNSMASQ_OWNS_LISTENER=False, TOR_CONTAINER_DNS_HOST=""
)
self.assertNotIn("bind-dynamic", rendered)
self.assertNotIn("listen-address", rendered)
def test_onion_queries_go_to_the_tor_resolver(self):
self.assertIn("server=/onion/127.0.0.1#9053", _render_dnsmasq())