Files
computer-playbook/roles/svc-net-tor/tasks/router.yml
kevinveenbirkenbachandClaude Opus 5 b12ff8d0c4 fix(svc-net-tor): tie the dnsmasq reload to the branch that writes its drop-in
The systemd drop-in is written inside a block gated on TOR_DNSMASQ_OWNS_LISTENER, which turns false as soon as a foreign file sits in /etc/dnsmasq.d. The start task read that block's register unconditionally, so the expression's correctness depended on ansible defining a register for a task it skipped. Short-circuiting on the same condition means the register is never reached when the block did not run, and the question stops mattering.

The test asserted the literal declaration string, which exercises nothing. It now renders the expression read from the role with ansible's own bool filter and a changed test, in both states: owning the listener with a rewritten drop-in yields True, not owning it yields False without touching the register. The previous form raises UndefinedError under the second case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 01:27:04 +02:00

110 lines
4.2 KiB
YAML

---
- 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.
- name: "📦 Egress router | Install dnsmasq (host split-DNS forwarder)"
become: true
package_install:
id: dnsmasq
- name: "📁 Egress router | Ensure the dnsmasq drop-in directory exists"
ansible.builtin.file:
path: "{{ TOR_DNSMASQ_CONF | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: "🔧 Egress router | Ensure dnsmasq reads the drop-in directory"
ansible.builtin.lineinfile:
path: /etc/dnsmasq.conf
regexp: "^conf-dir="
line: "conf-dir={{ TOR_DNSMASQ_CONF | dirname }}/,*.conf"
create: true
owner: root
group: root
mode: "0644"
register: _tor_dnsmasq_confdir
- 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 }}"
- 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)
src: dnsmasq-tor-onion.conf.j2
dest: "{{ TOR_DNSMASQ_CONF }}"
owner: root
group: root
mode: "0644"
vars:
TOR_DNS_UPSTREAMS: "{{ [ networks.internet.dns ] }}"
register: _tor_dnsmasq_conf
- name: "🔁 Egress router | Retry dnsmasq until docker0 exists after a boot"
when: TOR_DNSMASQ_OWNS_LISTENER | bool
block:
- name: "📁 Egress router | Ensure the dnsmasq unit drop-in directory exists"
ansible.builtin.file:
path: "{{ TOR_DNSMASQ_DROPIN | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: "📝 Egress router | Restart dnsmasq on failure"
ansible.builtin.copy:
src: dnsmasq-restart.conf
dest: "{{ TOR_DNSMASQ_DROPIN }}"
owner: root
group: root
mode: "0644"
register: _tor_dnsmasq_dropin
- name: "🚀 Egress router | Enable + (re)start dnsmasq"
ansible.builtin.systemd:
name: dnsmasq
enabled: true
daemon_reload: "{{ TOR_DNSMASQ_OWNS_LISTENER | bool and _tor_dnsmasq_dropin is changed }}"
state: "{{ 'restarted' if (_tor_dnsmasq_conf is changed or _tor_dnsmasq_confdir is changed) else 'started' }}"
- name: "🔀 Egress router | Route host name resolution through dnsmasq"
when: TOR_DNSMASQ_OWNS_LISTENER | bool
block:
- name: "🔀 Egress router | Point the host resolver at dnsmasq (127.0.0.1)"
ansible.builtin.template: # nocheck: stack-host-template (per-host resolver config in /etc/resolv.conf)
src: resolv.conf.j2
dest: /etc/resolv.conf
owner: root
group: root
mode: "0644"
unsafe_writes: true
- name: "🔀 Egress router | Let a name systemd-resolved refuses reach dnsmasq"
become: true
ansible.builtin.lineinfile:
path: /etc/nsswitch.conf
regexp: "^(hosts:.*\\bresolve)\\s*\\[!UNAVAIL=return\\](.*)$"
line: "\\1 [SUCCESS=return NOTFOUND=continue TRYAGAIN=return]\\2"
backrefs: true
- name: "🔀 Egress router | Route .onion traffic and bound the ports that carry no policy"
ansible.builtin.include_role:
name: svc-net-firewall
- name: "🛡️ Egress guard | Read back the chain that bounds the ports"
become: true
ansible.builtin.command:
argv: [nft, list, chain, inet, "{{ firewall_loaded_table }}", guard]
register: tor_egress_guard_chain
changed_when: false
- name: "🛡️ Egress guard | Refuse a guard that drops nothing"
ansible.builtin.assert:
that:
- "'drop' in tor_egress_guard_chain.stdout"
fail_msg: >-
The guard chain in {{ firewall_loaded_table }} carries no drop, so
TransPort and DNSPort are reachable from every source. These two ports
have no access policy of their own; this chain is the only boundary.