--- - 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.