fix(svc-net-tor): bind the loopback listener statically when no bridge is expected

9ae0c7eff6 replaced bind-interfaces with bind-dynamic when it taught the role to bind the node address for swarm, an address that appears after dnsmasq starts. Without a bridge address there is nothing to wait for, and the rebuild bind-dynamic performs on every netlink event can then only drop the listener: on the DiD host of run 33698844864 job 100479883964 the sampler lost dnsmasq's 127.0.0.1:53 for four consecutive samples one second after a veth unregistered.

interface=lo is deliberately not added. dnsmasq unions interface= and listen-address= rather than intersecting them, measured on 2.93: naming lo additionally seizes 127.0.0.53 and ::1, and against a distro stub dnsmasq exits 2, which any_errors_fatal turns into a failed deploy.

The resolv.conf keeps both nameservers. bb0e098a9f added the second one from a measured total outage where dnsmasq held no socket at all, and that failure mode is untouched by this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-03 12:44:05 +02:00
1 parent b0ca8784e2
commit 11c98825aa
2 files changed
+47 -1

No files matched your search

@@ -2,8 +2,10 @@
listen-address=127.0.0.1
{% if TOR_CONTAINER_DNS_HOST | length > 0 %}
listen-address={{ TOR_CONTAINER_DNS_HOST }}
{% endif %}
bind-dynamic
{% else %}
bind-interfaces
{% endif %}
no-resolv
{% endif %}
no-hosts
@@ -3,6 +3,11 @@ import unittest
from jinja2 import Environment, FileSystemLoader
from tests.integration.roles import PROJECT_ROOT
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_str
ROUTER = PROJECT_ROOT / "roles/svc-net-tor/tasks/router.yml"
RESOLV_TASK = "🔀 Egress router | Point the host resolver at dnsmasq (127.0.0.1)"
def _render_dnsmasq(**overrides):
@@ -38,6 +43,24 @@ class TestDnsmasqListeners(unittest.TestCase):
self.assertIn("listen-address=127.0.0.1", rendered)
self.assertNotIn("listen-address=172.17", rendered)
def test_loopback_only_binds_statically(self):
"""A loopback-only listener has no address to wait for, and the rebuild
that bind-dynamic performs on every netlink event can then only drop it."""
rendered = _render_dnsmasq(TOR_CONTAINER_DNS_HOST="")
self.assertIn("bind-interfaces", rendered)
self.assertNotIn("bind-dynamic", rendered)
def test_the_static_bind_claims_no_address_it_was_not_given(self):
"""interface= and listen-address= are a union in dnsmasq, so naming lo
would additionally seize 127.0.0.53 and ::1, where a distro stub sits."""
rendered = _render_dnsmasq(TOR_CONTAINER_DNS_HOST="")
self.assertNotIn("interface=", rendered)
def test_a_bridge_listener_still_binds_dynamically(self):
rendered = _render_dnsmasq()
self.assertIn("bind-dynamic", rendered)
self.assertNotIn("bind-interfaces", 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=""
@@ -69,5 +92,26 @@ class TestDnsmasqListeners(unittest.TestCase):
self.assertIn("address=/example.onion/172.17.0.1", _render_dnsmasq())
class TestHostResolver(unittest.TestCase):
"""glibc reads resolv.conf per query and keeps no server state, so the
clearnet entry costs nothing while dnsmasq answers and carries the host when
it stops. systemd-resolved does keep state, which is why the role has to
stay out of its way rather than drop the entry."""
def _resolv_conf(self) -> str:
tasks = load_yaml_str(read_text(str(ROUTER)))
task = next(t for t in tasks if t.get("name") == RESOLV_TASK)
return task["ansible.builtin.copy"]["content"]
def test_dnsmasq_answers_first_and_the_upstream_carries_the_host(self):
servers = [
line.split()[1]
for line in self._resolv_conf().splitlines()
if line.startswith("nameserver ")
]
self.assertEqual(len(servers), 2)
self.assertEqual(servers[0], "127.0.0.1")
if __name__ == "__main__":
unittest.main()