fix(svc-net-tor): run the node onion natively in swarm

Pin tor to the manager (host-published SOCKS/DNS live on the stack
host), forward HiddenServicePorts and the dnsmasq onion fast path to
the node address instead of loopback (dead-ends in task netns),
tolerate an empty clearnet-upstream capture, detect foreign dnsmasq
drop-ins before claiming the listener, and transfer onion_self_reach
to remote targets via script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Fable 5 committed 2026-07-22 15:19:52 +02:00
1 parent a850735b78
commit 9ae0c7eff6
7 files changed
+39 -57

No files matched your search

+9 -9
View File
@@ -16,38 +16,38 @@ The diagram places Tor Onion Service in the Infinito.Nexus cosmos: the component
```mermaid
flowchart LR
subgraph role [svc-net-tor 🐳🐝]
subgraph role [svc-net-tor]
svc_mirror["mirror"]
svc_tor["tor"]
end
subgraph dependents [Dependents]
dpt_svc_db_elasticsearch["svc-db-elasticsearch 🐳🐝"]
dpt_svc_db_mariadb["svc-db-mariadb 🐳🐝"]
dpt_svc_db_memcached["svc-db-memcached 🐳🐝"]
dpt_svc_db_openldap["svc-db-openldap 🐳🐝"]
dpt_svc_db_postgres["svc-db-postgres 🐳🐝"]
dpt_svc_db_qdrant["svc-db-qdrant 🐳🐝"]
dpt_svc_db_rabbitmq["svc-db-rabbitmq 🐳🐝"]
dpt_svc_db_redis["svc-db-redis 🐳🐝"]
dpt_svc_db_typesense["svc-db-typesense 🐳🐝"]
dpt_svc_prx_openresty["svc-prx-openresty 🐳🐝"]
dpt_web_app_akaunting["web-app-akaunting 🐳🐝"]
dpt_web_app_baserow["web-app-baserow 🐳🐝"]
dpt_web_app_bluesky["web-app-bluesky 🐳🐝"]
dpt_web_app_bookwyrm["web-app-bookwyrm 🐳🐝"]
dpt_web_app_bridgy_fed["web-app-bridgy-fed 🐳🐝"]
dpt_web_app_checkmk["web-app-checkmk 🐳🐝"]
dpt_more["..."]
end
svc_mirror -- "1:1" --> dpt_more
svc_mirror -. "0..1" .-> dpt_svc_db_elasticsearch
svc_mirror -. "0..1" .-> dpt_svc_db_mariadb
svc_mirror -. "0..1" .-> dpt_svc_db_memcached
svc_mirror -. "0..1" .-> dpt_svc_db_openldap
svc_mirror -. "0..1" .-> dpt_svc_db_postgres
svc_mirror -. "0..1" .-> dpt_svc_db_qdrant
svc_mirror -. "0..1" .-> dpt_svc_db_rabbitmq
svc_mirror -. "0..1" .-> dpt_svc_db_redis
svc_mirror -. "0..1" .-> dpt_svc_db_typesense
svc_mirror -. "0..1" .-> dpt_svc_prx_openresty
svc_mirror -. "0..1" .-> dpt_web_app_akaunting
svc_mirror -. "0..1" .-> dpt_web_app_baserow
svc_mirror -. "0..1" .-> dpt_web_app_bluesky
svc_mirror -. "0..1" .-> dpt_web_app_bookwyrm
svc_mirror -. "0..1" .-> dpt_web_app_bridgy_fed
svc_mirror -. "0..1" .-> dpt_web_app_checkmk
```
Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments). Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in `meta/main.yml`.
+2 -3
View File
@@ -12,19 +12,18 @@ tor:
enabled: false
swarm:
enabled: false
# nocheck: default-placement-manager node onion ingress: host-published SOCKS/DNS/Trans ports and the HiddenService loopback forward must live on the stack host
placement: manager
bond: 1
enabled: true
shared: true
exclusive: true
primary: true
# Base image for the local Debian+tor build (files/Dockerfile).
image: debian
version: trixie-slim
custom: true
name: tor
node: ""
# Tor default (guard pinning). Test inventories disable it so one degraded
# entry guard cannot stall the whole node; anonymity is not under test there.
use_entry_guards: true
ports:
local:
+6 -13
View File
@@ -85,12 +85,8 @@
when: TOR_EGRESS_ROUTER_ENABLED | bool
- name: "⏳ Wait until the node onion answers internally (loopback fast path)"
ansible.builtin.command:
argv:
- python3
- "{{ role_path }}/files/onion_self_reach.py"
- internal
- "{{ TOR_NODE_ONION }}"
ansible.builtin.script:
cmd: "{{ role_path }}/files/onion_self_reach.py internal {{ TOR_NODE_ONION }}"
register: _tor_self_reach
retries: 90
delay: 5
@@ -101,13 +97,10 @@
- TOR_NODE_ONION | length > 0
- name: "🔎 Probe the node onion over the real Tor network (non-fatal)"
ansible.builtin.command:
argv:
- python3
- "{{ role_path }}/files/onion_self_reach.py"
- external
- "{{ TOR_NODE_ONION }}"
- "{{ lookup('tor_socks', '127.0.0.1') }}"
ansible.builtin.script:
cmd: >-
{{ role_path }}/files/onion_self_reach.py external
{{ TOR_NODE_ONION }} {{ lookup('tor_socks', '127.0.0.1') }}
register: _tor_self_reach_ext
retries: 90
delay: 5
+14 -4
View File
@@ -39,18 +39,27 @@
- name: "💾 Egress router | Persist clearnet upstreams (first setup only)"
ansible.builtin.copy:
dest: "{{ TOR_DNS_UPSTREAMS_FILE }}"
content: |
{% for _l in (_tor_resolv_now.content | b64decode).split('\n') if _l.startswith('nameserver ') and _l.split()[1] != '127.0.0.1' %}
{{ _l.split()[1] }}
{% endfor %}
content: "{{ (_tor_upstream_list | join('\n')) ~ '\n' }}"
owner: root
group: root
mode: "0644"
vars:
_tor_upstream_list: >-
{{ (_tor_resolv_now.content | b64decode).split('\n')
| select('match', '^nameserver ')
| map('split') | map(attribute=1)
| 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 | 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
@@ -60,6 +69,7 @@
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 }}"
register: _tor_dnsmasq_conf
- name: "🚀 Egress router | Enable + (re)start dnsmasq"
ansible.builtin.systemd:
@@ -1,17 +1,13 @@
{% if TOR_DNSMASQ_OWNS_LISTENER | default(true) %}
listen-address=127.0.0.1
bind-interfaces
{% endif %}
bind-dynamic
no-hosts
no-resolv
filter-AAAA
{% if TOR_NODE_ONION | length > 0 %}
# Fast path for this node's own onion: every app subdomain of it terminates on
# this node's OpenResty, so resolve the whole suffix to loopback and skip the
# Tor circuit for host-side access (health checks, injections, front-proxy
# probes). More specific than the generic `server=/onion/...` below, so only
# the node's own onion short-circuits — all other .onion still egress via Tor.
# Container/Playwright traffic is unaffected: it routes through the Tor SOCKS,
# not this resolver, so it still exercises the real onion service.
address=/{{ TOR_NODE_ONION }}/127.0.0.1
address=/{{ TOR_NODE_ONION }}/{{ TOR_HS_BACKEND_HOST }}
local=/{{ TOR_NODE_ONION }}/
{% endif %}
server=/onion/127.0.0.1#{{ TOR_DNS_PORT }}
{% for _ns in TOR_DNS_UPSTREAMS %}
+2 -9
View File
@@ -3,11 +3,6 @@ RunAsDaemon 0
Log notice stdout
DataDirectory /var/lib/tor
# SOCKS proxy — required to reach .onion services from the node (health checks,
# inter-onion federation, and the Playwright runner testing over Tor). Bound on
# all node interfaces so DiD sibling containers can reach it via
# host.docker.internal, but restricted by SocksPolicy to loopback + private/DiD
# ranges so it is never an open proxy.
SocksPort {{ lookup('tor_socks', '0.0.0.0') }}
SocksPolicy accept 127.0.0.0/8
SocksPolicy accept 10.0.0.0/8
@@ -28,11 +23,9 @@ VirtualAddrNetworkIPv4 {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }}
TransPort 127.0.0.1:{{ TOR_TRANS_PORT }}
DNSPort 127.0.0.1:{{ TOR_DNS_PORT }}
{% endif %}
# Node onion service: one hidden service, per-app subdomains routed by Host in
# OpenResty. All onion traffic terminates on the host-network OpenResty (:80).
HiddenServiceDir /var/lib/tor/infinito/node/
HiddenServiceVersion 3
HiddenServicePort 80 127.0.0.1:80
HiddenServicePort 80 {{ TOR_HS_BACKEND_HOST }}:80
{% for _p in TOR_ONION_EXTRA_PORTS %}
HiddenServicePort {{ _p.onion_port }} {{ _p.target }}
HiddenServicePort {{ _p.onion_port }} {{ TOR_HS_BACKEND_HOST }}:{{ _p.target.rsplit(':', 1) | last }}
{% endfor %}
+1 -10
View File
@@ -2,8 +2,6 @@
# General
application_id: "svc-net-tor"
# Tor upstream base image (a Debian release; the tor package is installed from it
# in files/Dockerfile for provenance + reproducible pinning).
TOR_IMAGE: "{{ lookup('config', application_id, 'services.tor.image') }}"
TOR_VERSION: "{{ lookup('config', application_id, 'services.tor.version') }}"
TOR_CONTAINER: "{{ lookup('config', application_id, 'services.tor.name') }}"
@@ -14,19 +12,12 @@ TOR_USE_ENTRY_GUARDS: "{{ lookup('config', application_id, 'services.tor.use_ent
TOR_TRANS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.trans') }}"
TOR_DNS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.dns') }}"
TOR_DNSMASQ_CONF: "/etc/dnsmasq.d/tor-onion.conf"
TOR_HS_BACKEND_HOST: "{{ ansible_facts['default_ipv4']['address'] if DEPLOYMENT_MODE == 'swarm' else '127.0.0.1' }}"
TOR_DNS_UPSTREAMS_FILE: "{{ [ DIR_VAR_LIB, application_id, 'dns-upstreams' ] | path_join }}"
# Host paths (under DIR_VAR_LIB); Tor's runtime DataDirectory + HiddenServiceDir.
TOR_DATA_DIR: "{{ [ DIR_VAR_LIB, application_id, 'data' ] | path_join }}"
TOR_CONFIG_DIR: "{{ [ DIR_VAR_LIB, application_id, 'config' ] | path_join }}"
TOR_TORRC_HOST: "{{ [ TOR_CONFIG_DIR, 'torrc' ] | path_join }}"
TOR_HS_DIR: "{{ [ TOR_DATA_DIR, 'infinito', 'node' ] | path_join }}"
# Authoritative hidden-service key files, minted by
# `cli.administration.inventory.onion init-env` at the repo root. Their address
# MUST equal DOMAIN_PRIMARY (== INFINITO_DOMAIN); copied into TOR_HS_DIR
# before Tor starts so the daemon serves exactly that onion.
TOR_HS_SRC_DIR: "{{ lookup('path_absolute', '.onion-identity', 'hs') }}"
# TOR_EGRESS_ENABLED and TOR_ONION_EXTRA_PORTS are defined globally in
# group_vars/all/19_tor.yml (they must not be redefined here — a role var that
# references itself triggers a Jinja recursion error).