fix(svc-net-tor): name the clearnet upstream

The egress router scraped /etc/resolv.conf once and persisted whatever it found. Inside a container that is 127.0.0.11, docker's embedded resolver, which answered REFUSED to recursive queries in CI run 31927586060 and left the node with no clearnet DNS. The upstream now comes from networks.internet.dns, the same source lookup('container_dns') already gives the docker daemon, and an assert demands it before the role touches the host.

no-resolv, the clearnet server= lines and the /etc/resolv.conf pin now hang on TOR_DNSMASQ_OWNS_LISTENER. A dnsmasq the role does not own keeps its upstreams and the node keeps its resolver; only the onion routing is added there. Without that split a swarm node would end up with no-resolv, no upstream, and its resolver pinned at itself.

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:30 +02:00
1 parent 7125cb9d2c
commit 54aeba7e06
4 files changed
+32 -39

No files matched your search

+12 -35
View File
@@ -1,4 +1,11 @@
---
- name: "✅ Egress router | Assert a clearnet resolver is configured"
ansible.builtin.assert:
that: networks.internet.dns | length > 0
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:
@@ -20,45 +27,15 @@
group: root
mode: "0644"
register: _tor_dnsmasq_confdir
- name: "📁 Egress router | Ensure state directory exists"
ansible.builtin.file:
path: "{{ [ DIR_VAR_LIB, application_id ] | path_join }}"
state: directory
owner: root
group: root
mode: "0755"
- name: "🔎 Egress router | Check for persisted clearnet upstreams"
ansible.builtin.stat:
path: "{{ TOR_DNS_UPSTREAMS_FILE }}"
register: _tor_upstreams_stat
- name: "📖 Egress router | Read the current resolver (to capture upstreams once)"
ansible.builtin.slurp:
src: /etc/resolv.conf
register: _tor_resolv_now
when: not _tor_upstreams_stat.stat.exists
- name: "💾 Egress router | Persist clearnet upstreams (first setup only)"
ansible.builtin.copy:
dest: "{{ TOR_DNS_UPSTREAMS_FILE }}"
content: "{{ (_tor_upstream_list | join('\n')) ~ '\n' }}"
owner: root
group: root
mode: "0644"
vars:
_tor_upstream_list: >-
{{ (_tor_resolv_now.content | b64decode)
| regex_findall('(?m)^nameserver\s+(\S+)')
| reject('equalto', '127.0.0.1') | list }}
when: not _tor_upstreams_stat.stat.exists
- name: "📥 Egress router | Load captured upstreams"
ansible.builtin.slurp:
src: "{{ TOR_DNS_UPSTREAMS_FILE }}"
register: _tor_dns_upstreams_raw
- name: "🔎 Egress router | Detect foreign dnsmasq drop-ins (shared instance)"
ansible.builtin.find:
paths: "{{ TOR_DNSMASQ_CONF | dirname }}"
patterns: "*.conf"
excludes: "{{ TOR_DNSMASQ_CONF | basename }}"
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' }}"
- 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)
@@ -68,8 +45,7 @@
group: root
mode: "0644"
vars:
TOR_DNS_UPSTREAMS: "{{ (_tor_dns_upstreams_raw.content | b64decode).split('\n') | map('trim') | reject('equalto', '') | list }}"
TOR_DNSMASQ_OWNS_LISTENER: "{{ _tor_dnsmasq_foreign.matched == 0 and DEPLOYMENT_MODE == 'compose' }}"
TOR_DNS_UPSTREAMS: "{{ [ networks.internet.dns ] }}"
register: _tor_dnsmasq_conf
- name: "🚀 Egress router | Enable + (re)start dnsmasq"
ansible.builtin.systemd:
@@ -86,6 +62,7 @@
group: root
mode: "0644"
unsafe_writes: true
when: TOR_DNSMASQ_OWNS_LISTENER | bool
- name: "🔀 Egress router | Redirect .onion virtual-IP TCP to Tor TransPort"
ansible.builtin.iptables:
table: nat
@@ -4,15 +4,17 @@ listen-address=127.0.0.1
listen-address={{ TOR_CONTAINER_DNS_HOST }}
{% endif %}
bind-dynamic
no-resolv
{% endif %}
no-hosts
no-resolv
filter-AAAA
{% if TOR_NODE_ONION | length > 0 %}
address=/{{ TOR_NODE_ONION }}/{{ TOR_HS_BACKEND_HOST }}
local=/{{ TOR_NODE_ONION }}/
{% endif %}
server=/onion/127.0.0.1#{{ TOR_DNS_PORT }}
{% if TOR_DNSMASQ_OWNS_LISTENER | default(true) %}
{% for _ns in TOR_DNS_UPSTREAMS %}
server={{ _ns }}
{% endfor %}
{% endif %}
-1
View File
@@ -16,7 +16,6 @@ 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_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 }}"
TOR_CONFIG_DIR: "{{ [ DIR_VAR_LIB, application_id, 'config' ] | path_join }}"
@@ -12,7 +12,7 @@ def _render_dnsmasq(**overrides):
"TOR_NODE_ONION": "example.onion",
"TOR_HS_BACKEND_HOST": "172.17.0.1",
"TOR_DNS_PORT": 9053,
"TOR_DNS_UPSTREAMS": ["127.0.0.11"],
"TOR_DNS_UPSTREAMS": ["172.30.0.53"],
}
variables.update(overrides)
env = Environment(
@@ -49,7 +49,22 @@ class TestDnsmasqListeners(unittest.TestCase):
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())
self.assertIn("server=172.30.0.53", _render_dnsmasq())
def test_upstream_policy_stays_off_a_listener_we_do_not_own(self):
"""A shared dnsmasq brings its own upstreams, and in swarm
networks.internet.dns is the docker bridge that very dnsmasq answers
on. Writing a server= there points the resolver at itself, and
no-resolv would cut the upstreams its owner configured."""
rendered = _render_dnsmasq(TOR_DNSMASQ_OWNS_LISTENER=False)
self.assertNotIn("server=172.30.0.53", rendered)
self.assertNotIn("no-resolv", rendered)
self.assertIn("server=/onion/127.0.0.1#9053", rendered)
def test_upstream_policy_applies_to_our_own_listener(self):
rendered = _render_dnsmasq()
self.assertIn("no-resolv", rendered)
self.assertIn("server=172.30.0.53", rendered)
def test_own_onion_resolves_to_a_container_reachable_backend(self):
self.assertIn("address=/example.onion/172.17.0.1", _render_dnsmasq())